diff --git a/README.md b/README.md index 32936bd9..e3be5726 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@

Latest Release  - CI Status + CI Status  + Discord

diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index 9697d43c..a3bc267c 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -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 } }>( diff --git a/src/main/http/subagents.ts b/src/main/http/subagents.ts index 8b66c3d4..27d4241d 100644 --- a/src/main/http/subagents.ts +++ b/src/main/http/subagents.ts @@ -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; } - ); + }); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 292ce178..02894f3d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -205,6 +205,16 @@ interface ProvisioningRun { } | null; /** Pending tool approval requests awaiting user response (control_request protocol). */ pendingApprovals: Map; + /** + * 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 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 ..., 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 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 ..., 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 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 ..., 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, @@ -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 { + // 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((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) { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 58bb724e..30cb0f65 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -249,11 +249,15 @@ export class HttpAPIClient implements ElectronAPI { getSessionDetail = ( projectId: string, sessionId: string, - _options?: { bypassCache?: boolean } - ): Promise => - this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` + options?: { bypassCache?: boolean } + ): Promise => { + const params = new URLSearchParams(); + if (options?.bypassCache) params.set('bypassCache', 'true'); + const qs = params.toString(); + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}` ); + }; getSessionMetrics = (projectId: string, sessionId: string): Promise => this.get( @@ -269,11 +273,15 @@ export class HttpAPIClient implements ElectronAPI { projectId: string, sessionId: string, subagentId: string, - _options?: { bypassCache?: boolean } - ): Promise => - this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` + options?: { bypassCache?: boolean } + ): Promise => { + const params = new URLSearchParams(); + if (options?.bypassCache) params.set('bypassCache', 'true'); + const qs = params.toString(); + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${qs ? `?${qs}` : ''}` ); + }; getSessionGroups = (projectId: string, sessionId: string): Promise => this.get( diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2d935969..b8631861 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -157,8 +157,8 @@ const AUTH_ERROR_PATTERNS = [ // --------------------------------------------------------------------------- /** Convert `#` 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 { +export function linkifyMentionsInMarkdown( + text: string, + memberColorMap: Map +): 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` 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 {part}; const taskId = match[1]; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index bc8eeff3..81a9492e 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index e8c964dd..02fdc485 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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; } function formatTime(timestamp: string): string { @@ -179,12 +184,16 @@ interface LeadThoughtItemProps { thought: InboxMessage; showDivider: boolean; shouldAnimate: boolean; + onTaskIdClick?: (taskId: string) => void; + memberColorMap?: Map; } const LeadThoughtItem = ({ thought, showDivider, shouldAnimate, + onTaskIdClick, + memberColorMap, }: LeadThoughtItemProps): JSX.Element => { const wrapperRef = useRef(null); const contentRef = useRef(null); @@ -192,6 +201,15 @@ const LeadThoughtItem = ({ const animationFrameRef = useRef(null); const cleanupTimerRef = useRef(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 = ({ )}

- + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const taskId = link.getAttribute('href')?.replace('task://', ''); + if (taskId) onTaskIdClick(taskId); + } + } + : undefined + } + > + +
{thought.toolSummary && ( @@ -355,6 +387,8 @@ export const LeadThoughtsGroupRow = ({ canBeLive, zebraShade, collapseState, + onTaskIdClick, + memberColorMap, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -676,6 +710,8 @@ export const LeadThoughtsGroupRow = ({ thought={thought} showDivider={idx > 0} shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + onTaskIdClick={onTaskIdClick} + memberColorMap={memberColorMap} /> ))} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 819ebaf6..345e7a1c 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -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 = ({ ) : null} - +
{attachment.filename} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index 362fe301..42c0043f 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -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(null); + if (attachments.length === 0 && !error) return null; + const lightboxSlides = attachments.map((att) => ({ + src: `data:${att.mimeType};base64,${att.data}`, + alt: att.filename, + })); + return (
{attachments.length > 0 ? (
- {attachments.map((att) => ( + {attachments.map((att, i) => ( setLightboxIndex(i)} disabled={disabled} /> ))} @@ -63,6 +74,14 @@ export const AttachmentPreviewList = ({ ) : null}
) : null} + {lightboxIndex !== null && lightboxSlides[lightboxIndex] ? ( + setLightboxIndex(null)} + slides={lightboxSlides} + index={lightboxIndex} + /> + ) : null}
); }; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 1415949b..13a9ef00 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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 `#` 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 = ({ } >
- + {comment.type === 'review_approved' ? ( @@ -232,6 +237,9 @@ export const TaskCommentsSection = ({ Reply to comment + + +
{(() => { const reply = parseMessageReply(comment.text); diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index fed6403f..ebe6d36b 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -76,7 +76,6 @@ export const MemberLogsTab = ({ const refreshHideTimeoutRef = useRef | null>(null); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); - const expandedIdRef = useRef(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(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 => { - 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 () => { diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index a624b777..88757314 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -250,7 +250,7 @@ export const MessageComposer = ({ > -
+
{isLeadRecipient ? ( <> +
+ +
- ) : null} + ) : ( + + )} -
+
{!isTeamAlive ? ( Team offline @@ -395,15 +414,6 @@ export const MessageComposer = ({
- - svg { pointer-events: none; + filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8)); }