feat: enhance session and subagent routes with cache bypass functionality

- Updated session and subagent route handlers to support an optional `bypassCache` query parameter, allowing clients to bypass cached responses.
- Enhanced README to include a Discord link for community engagement.
- Improved CSS for lightbox toolbar buttons to address macOS hit-testing issues.
- Refactored task ID linkification in markdown to ensure accurate matching and improved functionality in various components.
This commit is contained in:
iliya 2026-03-07 00:05:38 +02:00
parent 9bfcbb182c
commit dba2d98923
14 changed files with 548 additions and 309 deletions

View file

@ -10,7 +10,8 @@
<p align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://discord.gg/m5gszZKG"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
</p>
<p align="center">

View file

@ -137,78 +137,76 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
);
// Session detail
app.get<{ Params: { projectId: string; sessionId: string } }>(
'/api/projects/:projectId/sessions/:sessionId',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
// Check cache first
let sessionDetail = services.dataCache.get(cacheKey);
if (sessionDetail) {
return sessionDetail;
}
const fsType = services.projectScanner.getFileSystemProvider().type;
// In SSH mode, avoid an extra deep metadata scan before full parse.
const session = await services.projectScanner.getSessionWithOptions(
safeProjectId,
safeSessionId,
{
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
}
);
if (!session) {
logger.error(`Session not found: ${safeSessionId}`);
return null;
}
// Parse session messages
const parsedSession = await services.sessionParser.parseSession(
safeProjectId,
safeSessionId
);
// Resolve subagents
const subagents = await services.subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
session.hasSubagents = subagents.length > 0;
// Build session detail with chunks
sessionDetail = services.chunkBuilder.buildSessionDetail(
session,
parsedSession.messages,
subagents
);
// Cache the result
services.dataCache.set(cacheKey, sessionDetail);
return sessionDetail;
} catch (error) {
app.get<{
Params: { projectId: string; sessionId: string };
Querystring: { bypassCache?: string };
}>('/api/projects/:projectId/sessions/:sessionId', async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
error
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
const bypassCache = request.query?.bypassCache === 'true';
// Check cache first
let sessionDetail = services.dataCache.get(cacheKey);
if (sessionDetail && !bypassCache) {
return sessionDetail;
}
const fsType = services.projectScanner.getFileSystemProvider().type;
// In SSH mode, avoid an extra deep metadata scan before full parse.
const session = await services.projectScanner.getSessionWithOptions(
safeProjectId,
safeSessionId,
{
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
}
);
if (!session) {
logger.error(`Session not found: ${safeSessionId}`);
return null;
}
// Parse session messages
const parsedSession = await services.sessionParser.parseSession(safeProjectId, safeSessionId);
// Resolve subagents
const subagents = await services.subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
session.hasSubagents = subagents.length > 0;
// Build session detail with chunks
sessionDetail = services.chunkBuilder.buildSessionDetail(
session,
parsedSession.messages,
subagents
);
// Cache the result
services.dataCache.set(cacheKey, sessionDetail);
return sessionDetail;
} catch (error) {
logger.error(
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
error
);
return null;
}
);
});
// Conversation groups
app.get<{ Params: { projectId: string; sessionId: string } }>(

View file

@ -15,63 +15,64 @@ import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:subagents');
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
const validatedSubagent = validateSubagentId(request.params.subagentId);
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
logger.error(
`GET subagent-detail rejected: ${
validatedProject.error ??
validatedSession.error ??
validatedSubagent.error ??
'Invalid parameters'
}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const safeSubagentId = validatedSubagent.value!;
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
// Check cache first
let subagentDetail = services.dataCache.getSubagent(cacheKey);
if (subagentDetail) {
return subagentDetail;
}
const fsProvider = services.projectScanner.getFileSystemProvider();
const projectsDir = services.projectScanner.getProjectsDir();
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
safeProjectId,
safeSessionId,
safeSubagentId,
services.sessionParser,
services.subagentResolver,
fsProvider,
projectsDir
app.get<{
Params: { projectId: string; sessionId: string; subagentId: string };
Querystring: { bypassCache?: string };
}>('/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
const validatedSubagent = validateSubagentId(request.params.subagentId);
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
logger.error(
`GET subagent-detail rejected: ${
validatedProject.error ??
validatedSession.error ??
validatedSubagent.error ??
'Invalid parameters'
}`
);
if (!builtDetail) {
logger.error(`Subagent not found: ${safeSubagentId}`);
return null;
}
subagentDetail = builtDetail;
services.dataCache.setSubagent(cacheKey, subagentDetail);
return subagentDetail;
} catch (error) {
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const safeSubagentId = validatedSubagent.value!;
const bypassCache = request.query?.bypassCache === 'true';
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
// Check cache first
let subagentDetail = services.dataCache.getSubagent(cacheKey);
if (subagentDetail && !bypassCache) {
return subagentDetail;
}
const fsProvider = services.projectScanner.getFileSystemProvider();
const projectsDir = services.projectScanner.getProjectsDir();
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
safeProjectId,
safeSessionId,
safeSubagentId,
services.sessionParser,
services.subagentResolver,
fsProvider,
projectsDir
);
if (!builtDetail) {
logger.error(`Subagent not found: ${safeSubagentId}`);
return null;
}
subagentDetail = builtDetail;
services.dataCache.setSubagent(cacheKey, subagentDetail);
return subagentDetail;
} catch (error) {
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
return null;
}
);
});
}

View file

@ -205,6 +205,16 @@ interface ProvisioningRun {
} | null;
/** Pending tool approval requests awaiting user response (control_request protocol). */
pendingApprovals: Map<string, ToolApprovalRequest>;
/**
* Post-compact context reinjection lifecycle.
* - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject.
* - postCompactReminderInFlight: the reminder turn has been injected via stdin, waiting for result.
* - suppressPostCompactReminderOutput: true while processing a reminder turn suppress
* low-value acknowledgement text so the user doesn't see "OK, I'll remember that."
*/
pendingPostCompactReminder: boolean;
postCompactReminderInFlight: boolean;
suppressPostCompactReminderOutput: boolean;
}
type LeadActivityState = 'active' | 'idle' | 'offline';
@ -553,6 +563,76 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
);
}
/**
* Builds the durable lead context constraints, communication protocol, teamctl ops,
* and agent block policy that must survive context compaction.
*
* Used by: buildProvisioningPrompt, buildLaunchPrompt, and post-compact reinjection.
*/
function buildPersistentLeadContext(opts: {
teamName: string;
leadName: string;
isSolo: boolean;
members: TeamCreateRequest['members'];
}): string {
const { teamName, leadName, isSolo, members } = opts;
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
const soloConstraint = isSolo
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
`\n - Only move a task to in_progress when you are actively starting work on it.` +
`\n - Only move a task to completed when it is truly finished.` +
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
: '';
const membersBlock = buildMembersPrompt(members);
const membersFooter = membersBlock
? `Members:\n${membersBlock}`
: 'Members: (none — solo team lead)';
return `${languageInstruction}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator it is NOT a teammate.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work.
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
${teamCtlOps}
Communication protocol (CRITICAL you are running headless, no one sees your text output):
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates they are separate processes and can only read their inbox.
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting:
${agentBlockPolicy}
${membersFooter}`;
}
function buildAgentBlockUsagePolicy(): string {
return `Agent-only formatting policy (applies to ALL messages you write):
- Humans can see teammate inbox messages and coordination text in the UI.
@ -638,42 +718,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
const isSolo = request.members.length === 0;
const soloConstraint = isSolo
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
`\n - Only move a task to in_progress when you are actively starting work on it.` +
`\n - Only move a task to completed when it is truly finished.` +
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
: '';
const step3Block = isSolo
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself ("${leadName}") as owner.\n` +
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
` - Prefer fewer, broader tasks over many micro-tasks.\n` +
` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` +
` - The tasks will be executed after the team is launched separately.`
@ -689,7 +747,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
- When tasks have natural ordering (e.g. setup implementation testing), use --blocked-by.
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
- Review guidance:
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- Prefer NOT creating a separate review task. Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
- Use --related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
@ -703,12 +761,12 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
// below, even though the text is identical across members. This duplicates ~4K chars per member
// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool.
// Extracting them once and telling the lead to "insert the protocol block" risks hallucination
// Extracting them once and telling the lead to “insert the protocol block” risks hallucination
// or omission — the lead may rephrase rules, skip items, or forget to include them.
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
${request.members
.map(
(m) => ` For "${m.name}":
(m) => ` For ${m.name}:
- prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
.split('\n')
@ -717,53 +775,32 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
)
.join('\n\n')}`;
const membersFooter = members ? `Members:\n${members}` : 'Members: (none — solo team lead)';
const persistentContext = buildPersistentLeadContext({
teamName: request.teamName,
leadName,
isSolo,
members: request.members,
});
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
return `Team Start [Agent Team: ${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
You are ${leadName}, the team lead.
Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
${userPromptBlock}
${languageInstruction}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator it is NOT a teammate.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work.
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
${teamCtlOps}
Communication protocol (CRITICAL you are running headless, no one sees your text output):
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates they are separate processes and can only read their inbox.
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting:
${agentBlockPolicy}
${persistentContext}
Steps (execute in this exact order):
1) TeamCreate create team "${request.teamName}":
- description: "${description}"
1) TeamCreate create team ${request.teamName}:
- description: ${description}
${step2Block}
${step3Block}
4) After all steps, output a short summary.
${membersFooter}
`;
}
@ -773,39 +810,18 @@ function buildLaunchPrompt(
tasks: TeamTask[],
isResume: boolean
): string {
const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
const isSolo = members.length === 0;
const soloConstraint = isSolo
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
`\n - Only move a task to in_progress when you are actively starting work on it.` +
`\n - Only move a task to completed when it is truly finished.` +
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
: '';
let step2And3Block: string;
if (isSolo) {
@ -876,9 +892,12 @@ ${memberSpawnInstructions}
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
}
const membersFooter = membersBlock
? `Members:\n${membersBlock}`
: 'Members: (none — solo team lead)';
const persistentContext = buildPersistentLeadContext({
teamName: request.teamName,
leadName,
isSolo,
members,
});
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
@ -889,31 +908,8 @@ You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
${userPromptBlock}
${languageInstruction}
${taskBoardSnapshot}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator it is NOT a teammate.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work.
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
${teamCtlOps}
Communication protocol (CRITICAL you are running headless, no one sees your text output):
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates they are separate processes and can only read their inbox.
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting:
${agentBlockPolicy}
${persistentContext}
Steps (execute in this exact order):
@ -922,11 +918,19 @@ Steps (execute in this exact order):
${step2And3Block}
4) After all steps, output a short summary of reconnected members and what happens next.
${membersFooter}
`;
}
/**
* Unconditionally clears all post-compact reminder state on a run.
* Called from cleanupRun, cancel, and error paths.
*/
function clearPostCompactReminderState(run: ProvisioningRun): void {
run.pendingPostCompactReminder = false;
run.postCompactReminderInFlight = false;
run.suppressPostCompactReminderOutput = false;
}
function updateProgress(
run: ProvisioningRun,
state: Exclude<TeamProvisioningState, 'idle'>,
@ -1774,6 +1778,9 @@ export class TeamProvisioningService {
authRetryInProgress: false,
spawnContext: null,
pendingApprovals: new Map(),
pendingPostCompactReminder: false,
postCompactReminderInFlight: false,
suppressPostCompactReminderOutput: false,
progress: {
runId,
teamName: request.teamName,
@ -2077,6 +2084,9 @@ export class TeamProvisioningService {
authRetryInProgress: false,
spawnContext: null,
pendingApprovals: new Map(),
pendingPostCompactReminder: false,
postCompactReminderInFlight: false,
suppressPostCompactReminderOutput: false,
progress: {
runId,
teamName: request.teamName,
@ -2938,7 +2948,11 @@ export class TeamProvisioningService {
// Push each assistant text block as a separate live message (per-message pattern).
// When the same assistant message includes SendMessage(to:"user"), skip text —
// captureSendMessageToUser() handles it separately.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
if (
!run.silentUserDmForward &&
!run.suppressPostCompactReminderOutput &&
!hasSendMessageToUser
) {
const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length > 0) {
run.leadMsgSeq += 1;
@ -3004,7 +3018,11 @@ export class TeamProvisioningService {
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
if (run.provisioningComplete && !run.silentUserDmForward) {
if (
run.provisioningComplete &&
!run.silentUserDmForward &&
!run.suppressPostCompactReminderOutput
) {
this.captureSendMessageToUser(run, content ?? []);
}
@ -3136,7 +3154,18 @@ export class TeamProvisioningService {
}
if (run.provisioningComplete) {
// If this was a post-compact reminder turn completing, clear in-flight and suppress flags.
if (run.postCompactReminderInFlight) {
clearPostCompactReminderState(run);
logger.info(`[${run.teamName}] post-compact reminder turn completed`);
}
this.setLeadActivity(run, 'idle');
// Deferred post-compact context reinjection: inject durable rules on first idle after compact.
if (run.pendingPostCompactReminder && !run.postCompactReminderInFlight) {
void this.injectPostCompactReminder(run);
}
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
@ -3182,7 +3211,14 @@ export class TeamProvisioningService {
killProcessTree(run.child);
this.cleanupRun(run);
} else if (run.provisioningComplete) {
// Post-provisioning error: process alive, waiting for input
// Post-provisioning error: process alive, waiting for input.
// Drop post-compact reminder on error (strict drop-after-attempt policy).
if (run.postCompactReminderInFlight) {
clearPostCompactReminderState(run);
logger.warn(
`[${run.teamName}] post-compact reminder turn errored — dropping (strict policy)`
);
}
this.setLeadActivity(run, 'idle');
}
}
@ -3220,10 +3256,152 @@ export class TeamProvisioningService {
logger.info(
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
);
// Schedule post-compact context reinjection on next idle.
// Guard: only set if provisioning is complete and no reminder is already pending/in-flight.
if (
run.provisioningComplete &&
!run.pendingPostCompactReminder &&
!run.postCompactReminderInFlight
) {
run.pendingPostCompactReminder = true;
logger.info(`[${run.teamName}] post-compact reminder scheduled for next idle`);
}
}
}
}
/**
* Injects a post-compact context reminder into the lead process via stdin.
* Reinjects durable lead rules (constraints, communication protocol, teamctl ops)
* plus a fresh task board snapshot so the lead recovers full operational context
* after context compaction.
*
* Policy: strict drop-after-attempt one compact cycle gives at most one reminder turn.
* If the injection fails (stdin not writable, process killed), we do not retry.
*/
private async injectPostCompactReminder(run: ProvisioningRun): Promise<void> {
// Consume the pending flag immediately — strict one-shot policy.
run.pendingPostCompactReminder = false;
// Guard: process must be alive and writable.
if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
logger.warn(
`[${run.teamName}] post-compact reminder skipped — process not writable or killed`
);
return;
}
// Guard: don't inject if another turn is actively processing (race with user send / inbox relay).
if (run.leadActivityState !== 'idle') {
logger.info(
`[${run.teamName}] post-compact reminder deferred — lead is ${run.leadActivityState}, not idle`
);
// Re-arm so it triggers on next idle.
run.pendingPostCompactReminder = true;
return;
}
// Guard: don't inject while a relay capture is in-flight.
if (run.leadRelayCapture) {
logger.info(`[${run.teamName}] post-compact reminder deferred — relay capture in-flight`);
run.pendingPostCompactReminder = true;
return;
}
// Guard: don't inject while a silent DM forward is in progress.
if (run.silentUserDmForward) {
logger.info(
`[${run.teamName}] post-compact reminder deferred — silent DM forward in progress`
);
run.pendingPostCompactReminder = true;
return;
}
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const isSolo = run.request.members.length === 0;
// Build persistent lead context.
const persistentContext = buildPersistentLeadContext({
teamName: run.teamName,
leadName,
isSolo,
members: run.request.members,
});
// Best-effort: fetch fresh task board snapshot.
let taskBoardBlock = '';
try {
const taskReader = new TeamTaskReader();
const tasks = await taskReader.getTasks(run.teamName);
taskBoardBlock = buildTaskBoardSnapshot(tasks);
} catch {
// If tasks can't be read, inject without the snapshot.
logger.warn(`[${run.teamName}] post-compact reminder: task board snapshot unavailable`);
}
// Re-check guards after async work.
if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
logger.warn(
`[${run.teamName}] post-compact reminder aborted — process state changed during preparation`
);
return;
}
if (run.leadActivityState !== 'idle') {
logger.info(
`[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState}`
);
return;
}
const message = [
`Context reminder (post-compaction) — your context was compacted. Here are your standing rules and current state:`,
``,
`You are "${leadName}", the team lead of team "${run.teamName}".`,
`You are running in a non-interactive CLI session. Do not ask questions.`,
``,
persistentContext,
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
``,
`Acknowledge briefly (1 sentence max) and continue with any pending work.`,
]
.filter(Boolean)
.join('\n');
const payload = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: message }],
},
});
run.postCompactReminderInFlight = true;
run.suppressPostCompactReminderOutput = true;
this.setLeadActivity(run, 'active');
try {
const stdin = run.child.stdin;
await new Promise<void>((resolve, reject) => {
stdin.write(payload + '\n', (err) => {
if (err) reject(err);
else resolve();
});
});
logger.info(`[${run.teamName}] post-compact reminder injected`);
} catch (error) {
// Strict drop-after-attempt — do not re-arm.
clearPostCompactReminderState(run);
this.setLeadActivity(run, 'idle');
logger.warn(
`[${run.teamName}] post-compact reminder injection failed: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Handles a control_request message from CLI stream-json output.
* `can_use_tool` emits to renderer for manual approval.
@ -3552,6 +3730,7 @@ export class TeamProvisioningService {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
clearPostCompactReminderState(run);
this.stopFilesystemMonitor(run);
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
if (run.child) {

View file

@ -249,11 +249,15 @@ export class HttpAPIClient implements ElectronAPI {
getSessionDetail = (
projectId: string,
sessionId: string,
_options?: { bypassCache?: boolean }
): Promise<SessionDetail | null> =>
this.get<SessionDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
options?: { bypassCache?: boolean }
): Promise<SessionDetail | null> => {
const params = new URLSearchParams();
if (options?.bypassCache) params.set('bypassCache', 'true');
const qs = params.toString();
return this.get<SessionDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`
);
};
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
this.get<SessionMetrics | null>(
@ -269,11 +273,15 @@ export class HttpAPIClient implements ElectronAPI {
projectId: string,
sessionId: string,
subagentId: string,
_options?: { bypassCache?: boolean }
): Promise<SubagentDetail | null> =>
this.get<SubagentDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
options?: { bypassCache?: boolean }
): Promise<SubagentDetail | null> => {
const params = new URLSearchParams();
if (options?.bypassCache) params.set('bypassCache', 'true');
const qs = params.toString();
return this.get<SubagentDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}`
);
};
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
this.get<ConversationGroup[]>(

View file

@ -157,8 +157,8 @@ const AUTH_ERROR_PATTERNS = [
// ---------------------------------------------------------------------------
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
export function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
}
/**
@ -166,7 +166,10 @@ function linkifyTaskIdsInMarkdown(text: string): string {
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
* Greedy match: longer names are tried first to avoid partial matches.
*/
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
export function linkifyMentionsInMarkdown(
text: string,
memberColorMap: Map<string, string>
): string {
if (memberColorMap.size === 0) return text;
// Sort by name length descending for greedy matching
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
@ -182,7 +185,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
}
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#\d+)/g).map((part, i) => {
return text.split(/(#\d+\b)/g).map((part, i) => {
const match = /^#(\d+)$/.exec(part);
if (!match) return <span key={i}>{part}</span>;
const taskId = match[1];

View file

@ -352,6 +352,8 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
collapseState={collapseState}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
/>
);
})()}
@ -397,6 +399,8 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(realIndex)}
collapseState={collapseState}
onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap}
/>
</React.Fragment>
);

View file

@ -16,6 +16,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
import { isManagedCollapseState } from './collapseState';
import type { ActivityCollapseState } from './collapseState';
@ -97,6 +98,10 @@ interface LeadThoughtsGroupRowProps {
zebraShade?: boolean;
/** Explicit collapse state for timeline-controlled collapsed mode. */
collapseState?: ActivityCollapseState;
/** Called when a task ID link (e.g. #10) is clicked in thought text. */
onTaskIdClick?: (taskId: string) => void;
/** Map of member name → color name for @mention badge rendering. */
memberColorMap?: Map<string, string>;
}
function formatTime(timestamp: string): string {
@ -179,12 +184,16 @@ interface LeadThoughtItemProps {
thought: InboxMessage;
showDivider: boolean;
shouldAnimate: boolean;
onTaskIdClick?: (taskId: string) => void;
memberColorMap?: Map<string, string>;
}
const LeadThoughtItem = ({
thought,
showDivider,
shouldAnimate,
onTaskIdClick,
memberColorMap,
}: LeadThoughtItemProps): JSX.Element => {
const wrapperRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@ -192,6 +201,15 @@ const LeadThoughtItem = ({
const animationFrameRef = useRef<number | null>(null);
const cleanupTimerRef = useRef<number | null>(null);
const displayContent = useMemo(() => {
let text = thought.text.replace(/\n/g, ' \n');
text = linkifyTaskIdsInMarkdown(text);
if (memberColorMap && memberColorMap.size > 0) {
text = linkifyMentionsInMarkdown(text, memberColorMap);
}
return text;
}, [thought.text, memberColorMap]);
const clearPendingAnimation = useCallback(() => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
@ -313,11 +331,25 @@ const LeadThoughtItem = ({
)}
<div className="flex text-[11px]">
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
<MarkdownViewer
content={thought.text.replace(/\n/g, ' \n')}
maxHeight="max-h-none"
bare
/>
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'a[href^="task://"]'
);
if (link) {
e.preventDefault();
e.stopPropagation();
const taskId = link.getAttribute('href')?.replace('task://', '');
if (taskId) onTaskIdClick(taskId);
}
}
: undefined
}
>
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
</span>
</div>
</div>
{thought.toolSummary && (
@ -355,6 +387,8 @@ export const LeadThoughtsGroupRow = ({
canBeLive,
zebraShade,
collapseState,
onTaskIdClick,
memberColorMap,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -676,6 +710,8 @@ export const LeadThoughtsGroupRow = ({
thought={thought}
showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
onTaskIdClick={onTaskIdClick}
memberColorMap={memberColorMap}
/>
))}
</div>

View file

@ -8,12 +8,14 @@ import type { AttachmentPayload } from '@shared/types';
interface AttachmentPreviewItemProps {
attachment: AttachmentPayload;
onRemove: (id: string) => void;
onPreview?: () => void;
disabled?: boolean;
}
export const AttachmentPreviewItem = ({
attachment,
onRemove,
onPreview,
disabled,
}: AttachmentPreviewItemProps): React.JSX.Element => {
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
@ -25,7 +27,7 @@ export const AttachmentPreviewItem = ({
<Ban size={18} className="text-red-400" />
</div>
) : null}
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" onClick={onPreview} />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
{attachment.filename}

View file

@ -1,6 +1,9 @@
import { useState } from 'react';
import { AlertCircle, X } from 'lucide-react';
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
import { ImageLightbox } from './ImageLightbox';
import type { AttachmentPayload } from '@shared/types';
@ -23,17 +26,25 @@ export const AttachmentPreviewList = ({
disabled,
disabledHint,
}: AttachmentPreviewListProps): React.JSX.Element | null => {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
if (attachments.length === 0 && !error) return null;
const lightboxSlides = attachments.map((att) => ({
src: `data:${att.mimeType};base64,${att.data}`,
alt: att.filename,
}));
return (
<div className="space-y-1.5 px-1">
{attachments.length > 0 ? (
<div className="flex gap-2 overflow-x-auto py-1">
{attachments.map((att) => (
{attachments.map((att, i) => (
<AttachmentPreviewItem
key={att.id}
attachment={att}
onRemove={onRemove}
onPreview={() => setLightboxIndex(i)}
disabled={disabled}
/>
))}
@ -63,6 +74,14 @@ export const AttachmentPreviewList = ({
) : null}
</div>
) : null}
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
<ImageLightbox
open
onClose={() => setLightboxIndex(null)}
slides={lightboxSlides}
index={lightboxIndex}
/>
) : null}
</div>
);
};

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
@ -54,7 +55,7 @@ interface TaskCommentsSectionProps {
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
function linkifyTaskIdsInMarkdown(text: string): string {
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
}
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
@ -190,7 +191,11 @@ export const TaskCommentsSection = ({
}
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
<MemberBadge
name={comment.author}
color={colorMap.get(comment.author)}
hideAvatar={comment.author === 'user'}
/>
{comment.type === 'review_approved' ? (
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
<CheckCircle2 size={10} />
@ -232,6 +237,9 @@ export const TaskCommentsSection = ({
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
<span className="opacity-0 transition-opacity group-hover:opacity-100">
<CopyButton text={comment.text} inline />
</span>
</div>
{(() => {
const reply = parseMessageReply(comment.text);

View file

@ -76,7 +76,6 @@ export const MemberLogsTab = ({
const refreshHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const expandedIdRef = useRef<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
@ -92,10 +91,6 @@ export const MemberLogsTab = ({
};
}, []);
useEffect(() => {
expandedIdRef.current = expandedId;
}, [expandedId]);
const beginRefreshing = useCallback((): void => {
if (refreshCountRef.current === 0) {
refreshBeganAtRef.current = Date.now();
@ -282,16 +277,6 @@ export const MemberLogsTab = ({
setLogs(nextLogs);
hasLoadedRef.current = true;
}
// Keep expanded session details in sync with the same refresh
// cadence as the summary (counts/titles) while "Updating..." is shown.
if (!cancelled && didBeginRefreshing) {
try {
await refreshExpandedDetailFromLogs(nextLogs);
} catch {
// Keep last successful detail view; avoid flicker on transient failures.
}
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unknown error');
@ -335,26 +320,6 @@ export const MemberLogsTab = ({
[]
);
const refreshExpandedDetailFromLogs = useCallback(
async (nextLogs: MemberLogSummary[]): Promise<void> => {
const rowId = expandedIdRef.current;
if (!rowId) return;
if (!isMountedRef.current) return;
const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId);
if (!nextExpanded) return;
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
const next = await fetchDetailForLog(nextExpanded, { bypassCache: true });
if (!isMountedRef.current) return;
// Ensure new reference so memoized transforms update.
setDetailChunks(next ? [...next] : null);
},
[fetchDetailForLog, getRowId, taskId, taskStatus]
);
useEffect(() => {
if (!shouldShowPreview) {
setPreviewChunks(null);
@ -419,10 +384,7 @@ export const MemberLogsTab = ({
useEffect(() => {
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!expandedLogSummary) return;
// When task logs are auto-refreshing, the summary refresh loop also refreshes
// expanded details to keep everything in sync (and avoid duplicate requests).
if (shouldAutoRefreshSummary) return;
if (!expandedLogSummary.isOngoing) return;
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
let cancelled = false;
@ -440,6 +402,7 @@ export const MemberLogsTab = ({
}
};
void refreshDetail();
const interval = setInterval(() => void refreshDetail(), 5000);
return () => {

View file

@ -250,7 +250,7 @@ export const MessageComposer = ({
>
<DropZoneOverlay active={isDragOver && !!canAttach} />
<div className="mb-2 flex items-center gap-2">
<div className="mb-1 flex items-center gap-2">
{isLeadRecipient ? (
<>
<input
@ -266,7 +266,7 @@ export const MessageComposer = ({
<button
type="button"
className={cn(
'inline-flex items-center gap-1 rounded p-1 transition-colors',
'inline-flex shrink-0 items-center gap-1 rounded p-1 transition-colors',
canAttach
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] opacity-40'
@ -285,10 +285,29 @@ export const MessageComposer = ({
: 'Attach images (paste or drag & drop)'}
</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1">
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
</div>
</>
) : null}
) : (
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
)}
<div className="ml-auto flex items-center gap-2">
<div className="ml-auto flex shrink-0 items-center gap-2">
{!isTeamAlive ? (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline
@ -395,15 +414,6 @@ export const MessageComposer = ({
</div>
</div>
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
<MentionableTextarea
id={`compose-${teamName}`}
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"

View file

@ -774,13 +774,20 @@ body {
background-color: #ffffff;
}
/* Lightbox toolbar buttons — enlarge hit targets and fix SVG dead zones */
/* Lightbox toolbar buttons — enlarge hit targets and fix macOS hit-testing */
.yarl__toolbar {
z-index: 1;
}
.yarl__toolbar .yarl__button {
min-width: 44px;
min-height: 44px;
position: relative;
/* filter: drop-shadow() causes hit-test glitches on macOS/Electron compositing layers */
filter: none;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8);
}
.yarl__toolbar .yarl__button > svg {
pointer-events: none;
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8));
}