From d2cd655c1155ad91ab1f72c4baafcadf06c008e7 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 6 Apr 2026 21:28:22 +0300 Subject: [PATCH] fix(team): harden provisioning and team UI state --- src/main/ipc/teams.ts | 15 +- .../services/infrastructure/FileWatcher.ts | 113 ++++++++--- .../services/team/TeamProvisioningService.ts | 182 +++++++++++++++--- .../components/chat/DisplayItemList.tsx | 60 ++++++ .../components/chat/items/BaseItem.tsx | 15 +- .../components/chat/items/LinkedToolItem.tsx | 6 + .../components/chat/items/SlashItem.tsx | 6 + .../components/chat/items/TextItem.tsx | 6 + .../components/chat/items/ThinkingItem.tsx | 6 + .../components/team/ClaudeLogsPanel.tsx | 3 + .../components/team/ClaudeLogsSection.tsx | 3 +- .../components/team/CliLogsRichView.tsx | 13 ++ .../components/team/TeamDetailView.tsx | 17 +- .../team/TeamProvisioningBanner.tsx | 51 +++-- .../team/dialogs/CreateTeamDialog.tsx | 4 +- .../team/dialogs/LaunchTeamDialog.tsx | 4 +- .../team/kanban/KanbanGridLayout.tsx | 16 +- .../team/messages/MessagesPanel.tsx | 15 +- src/renderer/index.css | 16 +- src/renderer/store/slices/teamSlice.ts | 149 ++++++++++++-- 20 files changed, 601 insertions(+), 99 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index e0e17945..747c92b5 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1454,6 +1454,7 @@ function buildMessageDeliveryText( opts: { actionMode?: AgentActionMode; isLeadRecipient: boolean; + replyRecipient?: string; } ): string { const hiddenBlocks: string[] = []; @@ -1462,11 +1463,20 @@ function buildMessageDeliveryText( hiddenBlocks.push(actionModeBlock); } if (!opts.isLeadRecipient) { + const replyRecipient = + typeof opts.replyRecipient === 'string' && opts.replyRecipient.trim().length > 0 + ? opts.replyRecipient.trim() + : 'user'; + const senderDescriptor = replyRecipient === 'user' ? 'the human user' : `"${replyRecipient}"`; hiddenBlocks.push( [ AGENT_BLOCK_OPEN, - 'You received a direct message from the human user via the UI.', - 'Please reply back to recipient "user" with a short, human-readable answer.', + `You received a direct message from ${senderDescriptor} via the UI.`, + 'CRITICAL: Reply using the SendMessage tool, not plain assistant text.', + `CRITICAL: The destination must be exactly to="${replyRecipient}".`, + 'CRITICAL: The SendMessage tool input must use the exact field names `to`, `summary`, and `message`.', + 'Do NOT answer only with normal assistant text because that will not appear in the UI message thread.', + `Please reply back to recipient "${replyRecipient}" with a short, human-readable answer.`, 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', AGENT_BLOCK_CLOSE, ].join('\n') @@ -1702,6 +1712,7 @@ async function handleSendMessage( const memberDeliveryText = buildMessageDeliveryText(baseText, { actionMode, isLeadRecipient, + replyRecipient: typeof payload.from === 'string' ? payload.from : 'user', }); const result = await getTeamDataService().sendMessage(tn, { member: memberName, diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index cd03f751..dab24266 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -45,6 +45,12 @@ const WATCHER_RETRY_MS = 2000; const CATCH_UP_INTERVAL_MS = 30_000; /** Only catch-up scan files modified within this window */ const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour +/** Retire quiet top-level sessions from best-effort catch-up after this long. */ +const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes +/** Subagent logs are much noisier; retire them sooner from catch-up tracking. */ +const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes +/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */ +const CATCH_UP_SCAN_BUDGET = 24; interface AppendedParseResult { messages: ParsedMessage[]; @@ -56,6 +62,7 @@ interface ActiveSessionFile { projectId: string; sessionId: string; subagentId?: string; + lastObservedAt: number; } export class FileWatcher extends EventEmitter { @@ -81,6 +88,10 @@ export class FileWatcher extends EventEmitter { private activeSessionFiles = new Map(); /** Timer for periodic catch-up scan */ private catchUpTimer: NodeJS.Timeout | null = null; + /** Prevent overlapping catch-up scans when a previous pass is still running. */ + private catchUpInProgress = false; + /** Round-robin cursor so catch-up work is spread across tracked files. */ + private catchUpCursor = 0; /** Timer for SSH polling mode (replaces fs.watch) */ private pollingTimer: NodeJS.Timeout | null = null; /** Polling interval for SSH mode */ @@ -167,6 +178,8 @@ export class FileWatcher extends EventEmitter { */ stop(): void { this.isWatching = false; + this.catchUpInProgress = false; + this.catchUpCursor = 0; if (this.retryTimer) { clearTimeout(this.retryTimer); @@ -702,7 +715,7 @@ export class FileWatcher extends EventEmitter { if (config.notifications.includeSubagentErrors) { const subagentFilename = path.basename(parts[3], '.jsonl'); const subagentId = subagentFilename.replace(/^agent-/, ''); - this.activeSessionFiles.set(fullPath, { projectId, sessionId, subagentId }); + this.rememberActiveSessionFile(fullPath, { projectId, sessionId, subagentId }); this.detectErrorsInSessionFile(projectId, sessionId, fullPath, subagentId).catch( (err) => { logger.error('Error detecting errors in subagent file:', err); @@ -710,7 +723,7 @@ export class FileWatcher extends EventEmitter { ); } } else { - this.activeSessionFiles.set(fullPath, { projectId, sessionId }); + this.rememberActiveSessionFile(fullPath, { projectId, sessionId }); this.detectErrorsInSessionFile(projectId, sessionId, fullPath).catch((err) => { logger.error('Error detecting errors in session file:', err); }); @@ -857,6 +870,22 @@ export class FileWatcher extends EventEmitter { this.lastProcessedLineCount.clear(); this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); + this.catchUpCursor = 0; + this.catchUpInProgress = false; + } + + private rememberActiveSessionFile( + filePath: string, + info: Omit + ): void { + this.activeSessionFiles.set(filePath, { + ...info, + lastObservedAt: Date.now(), + }); + } + + private getCatchUpRetentionMs(info: ActiveSessionFile): number { + return info.subagentId ? CATCH_UP_SUBAGENT_RETENTION_MS : CATCH_UP_SESSION_RETENTION_MS; } /** @@ -1084,41 +1113,71 @@ export class FileWatcher extends EventEmitter { * Only checks files modified within the last hour. */ private async runCatchUpScan(): Promise { - if (!this.notificationManager || this.activeSessionFiles.size === 0) { + if (!this.notificationManager || this.activeSessionFiles.size === 0 || this.catchUpInProgress) { return; } - const now = Date.now(); + this.catchUpInProgress = true; + try { + const now = Date.now(); + const entries = [...this.activeSessionFiles.entries()]; + if (entries.length === 0) { + return; + } - for (const [filePath, info] of this.activeSessionFiles) { - try { - const stats = await this.fsProvider.stat(filePath); + const budget = Math.min(CATCH_UP_SCAN_BUDGET, entries.length); + const startIndex = this.catchUpCursor % entries.length; - // Skip files not modified recently - if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) { - this.activeSessionFiles.delete(filePath); + for (let offset = 0; offset < budget; offset += 1) { + if (!this.isWatching) { + break; + } + const [filePath] = entries[(startIndex + offset) % entries.length]; + const info = this.activeSessionFiles.get(filePath); + if (!info) { continue; } + try { + if (now - info.lastObservedAt > this.getCatchUpRetentionMs(info)) { + this.clearErrorTracking(filePath); + continue; + } - const lastSize = this.lastProcessedSize.get(filePath) ?? 0; - if (stats.size > lastSize) { - logger.info(`FileWatcher: Catch-up scan detected growth in ${filePath}`); - await this.detectErrorsInSessionFile( - info.projectId, - info.sessionId, - filePath, - info.subagentId - ); - } - } catch (err) { - // File may have been deleted between iterations - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - this.activeSessionFiles.delete(filePath); - this.clearErrorTracking(filePath); - } else { - logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err); + const stats = await this.fsProvider.stat(filePath); + + // Skip files not modified recently + if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) { + this.clearErrorTracking(filePath); + continue; + } + + const lastSize = this.lastProcessedSize.get(filePath) ?? 0; + if (stats.size > lastSize) { + logger.info(`FileWatcher: Catch-up scan detected growth in ${filePath}`); + this.rememberActiveSessionFile(filePath, { + projectId: info.projectId, + sessionId: info.sessionId, + ...(info.subagentId ? { subagentId: info.subagentId } : {}), + }); + await this.detectErrorsInSessionFile( + info.projectId, + info.sessionId, + filePath, + info.subagentId + ); + } + } catch (err) { + // File may have been deleted between iterations + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.clearErrorTracking(filePath); + } else { + logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err); + } } } + this.catchUpCursor = (startIndex + budget) % Math.max(entries.length, 1); + } finally { + this.catchUpInProgress = false; } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index da04959c..2f8bdfcc 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -162,9 +162,12 @@ const FS_MONITOR_POLL_MS = 2000; const TASK_WAIT_FALLBACK_MS = 15_000; const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_WARNING_THRESHOLD_MS = 20_000; +const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; +const MEMBER_SPAWN_AUDIT_MIN_INTERVAL_MS = 1_500; +const MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS = 10_000; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ 'cross_team_send', 'cross_team_list_targets', @@ -603,6 +606,12 @@ interface ProvisioningRun { >; /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ memberSpawnToolUseIds: Map; + /** Throttles config/inbox audit work triggered by frequent status polling. */ + lastMemberSpawnAuditAt: number; + /** Throttles repeated audit warnings when config.json is temporarily unreadable. */ + lastMemberSpawnAuditConfigReadWarningAt: number; + /** Per-member warning throttle for repeated "missing from config" logs. */ + lastMemberSpawnAuditMissingWarningAt: Map; } type LeadActivityState = 'active' | 'idle' | 'offline'; @@ -1290,7 +1299,7 @@ Constraints: - In a non-solo team, your default first move is delegation, NOT personal investigation. Do NOT read/search the codebase, inspect files, or do root-cause research yourself just to figure out ownership or scope before delegating. - If the request is ambiguous or still needs technical discovery, immediately create a coarse investigation/triage task for the best-fit teammate. That teammate owns the code inspection, scope refinement, and creation of any follow-up tasks needed for execution. - Only do lead-side research first if the human explicitly asked YOU for analysis/planning, or if there is genuinely no appropriate teammate to own the investigation. -- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +- Do NOT use the built-in TaskCreate tool for team-board tasks. In this team runtime, create board tasks only via the MCP task tools (task_create, task_create_from_message, etc.). - When messaging "user" (the human): write plain human language. If a task needs a status update, do it yourself via the board MCP tools; never ask the user to run a command.${soloConstraint} ${teamCtlOps} @@ -1534,6 +1543,9 @@ ${isSolo ? '3' : '4'}) After all steps, output a short summary. CRITICAL: If any Agent teammate spawn returns an error, that teammate is NOT online. Do NOT claim they were spawned successfully. In your final summary, explicitly list which teammate names failed to start. CRITICAL: Do NOT call a teammate "online", "ready", "confirmed alive", or "without launch errors" solely because Agent returned "Spawned successfully". Use that wording only after the teammate has actually completed bootstrap or sent a real post-bootstrap confirmation message. If a teammate runtime is alive but bootstrap is still pending, say exactly that. CRITICAL: If a teammate reports that member_briefing is unavailable, do NOT tell them to skip bootstrap and do NOT improvise a workaround. Treat that as a real bootstrap error, ask them to send the exact error text, and keep that teammate in bootstrap-pending or failed state until the error is resolved. +CRITICAL: In the user-facing final summary, do NOT mention internal tool names like "member_briefing" unless that tool actually failed. Describe pending bootstrap in plain language such as "runtime started, waiting for bootstrap confirmation" or "waiting for first heartbeat". +CRITICAL: If no teammates failed to start, say that plainly. Do NOT write awkward forms like "failed: none", "Не удалось запустить: нет", or similar pseudo-error phrasing. +CRITICAL: If zero teammates have confirmed bootstrap yet and there are no launch errors, keep the summary brief. Say that runtimes started and initialization is continuing. Do NOT add a second sentence just to restate that no bootstrap confirmations arrived yet. `; } @@ -1641,6 +1653,9 @@ ${step2And3Block} 5) After all steps, output a short summary of reconnected members and what happens next. CRITICAL: If any Agent teammate spawn returns an error, that teammate is NOT online. Do NOT claim they were restored/spawned successfully. In your final summary, explicitly list which teammate names failed to start. +CRITICAL: In the user-facing final summary, do NOT mention internal tool names like "member_briefing" unless that tool actually failed. Describe pending bootstrap in plain language such as "runtime started, waiting for bootstrap confirmation" or "waiting for first heartbeat". +CRITICAL: If no teammates failed to start, say that plainly. Do NOT write awkward forms like "failed: none", "Не удалось запустить: нет", or similar pseudo-error phrasing. +CRITICAL: If zero teammates have confirmed bootstrap yet and there are no launch errors, keep the summary brief. Say that runtimes started and initialization is continuing. Do NOT add a second sentence just to restate that no bootstrap confirmations arrived yet. `; } @@ -1697,7 +1712,10 @@ ${isSolo ? '3' : '3'}) Do NOT create tasks, do NOT review code, do NOT inspect f - "runtime started, bootstrap pending" - "bootstrap confirmed" - "failed to start" -Do NOT call a teammate online/ready/confirmed alive unless the teammate has actually completed bootstrap or sent a real post-bootstrap confirmation message.`; +Do NOT call a teammate online/ready/confirmed alive unless the teammate has actually completed bootstrap or sent a real post-bootstrap confirmation message. +In the user-facing summary, do NOT mention internal tool names like "member_briefing" unless that tool actually failed. +If no teammates failed to start, say that plainly. Do NOT write awkward forms like "failed: none" or "Не удалось запустить: нет". +If zero teammates have confirmed bootstrap yet and there are no launch errors, keep the summary brief. Say that runtimes started and initialization is continuing. Do NOT add a second sentence just to restate that no bootstrap confirmations arrived yet.`; } function buildGeminiLaunchPrompt( @@ -1753,7 +1771,10 @@ ${spawnBlock} - "runtime started, bootstrap pending" - "bootstrap confirmed" - "failed to start" -Do NOT call a teammate online/ready/confirmed alive unless the teammate has actually completed bootstrap or sent a real post-bootstrap confirmation message.`; +Do NOT call a teammate online/ready/confirmed alive unless the teammate has actually completed bootstrap or sent a real post-bootstrap confirmation message. +In the user-facing summary, do NOT mention internal tool names like "member_briefing" unless that tool actually failed. +If no teammates failed to start, say that plainly. Do NOT write awkward forms like "failed: none" or "Не удалось запустить: нет". +If zero teammates have confirmed bootstrap yet and there are no launch errors, keep the summary brief. Say that runtimes started and initialization is continuing. Do NOT add a second sentence just to restate that no bootstrap confirmations arrived yet.`; } function buildGeminiPostLaunchHydrationPrompt( @@ -3252,7 +3273,7 @@ export class TeamProvisioningService { } await this.refreshMemberSpawnStatusesFromLeadInbox(run); - await this.auditMemberSpawnStatuses(run); + await this.maybeAuditMemberSpawnStatuses(run); await this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); const persisted = await this.launchStateStore.read(teamName); @@ -3333,7 +3354,7 @@ export class TeamProvisioningService { return; } await this.refreshMemberSpawnStatusesFromLeadInbox(run); - await this.auditMemberSpawnStatuses(run); + await this.maybeAuditMemberSpawnStatuses(run, { force: true }); const refreshed = run.memberSpawnStatuses.get(memberName); if (!refreshed) return; if ( @@ -3351,6 +3372,35 @@ export class TeamProvisioningService { ); } + private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean { + if (!run.expectedMembers || run.expectedMembers.length === 0) { + return true; + } + return run.expectedMembers.every((memberName) => { + const entry = run.memberSpawnStatuses.get(memberName); + return entry?.launchState === 'failed_to_start' || entry?.launchState === 'confirmed_alive'; + }); + } + + private async maybeAuditMemberSpawnStatuses( + run: ProvisioningRun, + options?: { force?: boolean } + ): Promise { + if (this.shouldSkipMemberSpawnAudit(run)) { + return; + } + const now = Date.now(); + if ( + !options?.force && + run.lastMemberSpawnAuditAt > 0 && + now - run.lastMemberSpawnAuditAt < MEMBER_SPAWN_AUDIT_MIN_INTERVAL_MS + ) { + return; + } + run.lastMemberSpawnAuditAt = now; + await this.auditMemberSpawnStatuses(run); + } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; @@ -4335,6 +4385,9 @@ export class TeamProvisioningService { request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + lastMemberSpawnAuditAt: 0, + lastMemberSpawnAuditConfigReadWarningAt: 0, + lastMemberSpawnAuditMissingWarningAt: new Map(), progress: { runId, teamName: request.teamName, @@ -4382,7 +4435,7 @@ export class TeamProvisioningService { '--mcp-config', mcpConfigPath, '--disallowedTools', - 'TeamDelete,TodoWrite', + APP_TEAM_RUNTIME_DISALLOWED_TOOLS, // Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json // (e.g. "acceptEdits") which otherwise takes precedence over CLI flags ...(request.skipPermissions !== false @@ -4821,6 +4874,9 @@ export class TeamProvisioningService { expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + lastMemberSpawnAuditAt: 0, + lastMemberSpawnAuditConfigReadWarningAt: 0, + lastMemberSpawnAuditMissingWarningAt: new Map(), progress: { runId, teamName: request.teamName, @@ -4890,7 +4946,7 @@ export class TeamProvisioningService { '--mcp-config', mcpConfigPath, '--disallowedTools', - 'TeamDelete,TodoWrite', + APP_TEAM_RUNTIME_DISALLOWED_TOOLS, // Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json // (e.g. "acceptEdits") which otherwise takes precedence over CLI flags ...(request.skipPermissions !== false @@ -5992,31 +6048,43 @@ export class TeamProvisioningService { * was incorrect (e.g., missing team_name/name params) and the agent ran as a * one-shot subagent instead of a persistent teammate. */ - private async auditMemberSpawnStatuses(run: ProvisioningRun): Promise { - if (!run.expectedMembers || run.expectedMembers.length === 0) return; - - // Read config.json to get the actual registered members - const configPath = path.join(getTeamsBasePath(), run.teamName, 'config.json'); - let registeredNames: Set; + private async getRegisteredTeamMemberNames(teamName: string): Promise | null> { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { const raw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, maxBytes: TEAM_CONFIG_MAX_BYTES, }); if (!raw) { - logger.warn(`[${run.teamName}] auditMemberSpawnStatuses: config.json not readable`); - return; + return null; } const config = JSON.parse(raw) as { members?: { name?: string; agentType?: string }[]; }; - registeredNames = new Set( + return new Set( (config.members ?? []) .map((m) => (typeof m.name === 'string' ? m.name.trim() : '')) .filter(Boolean) ); } catch { - logger.warn(`[${run.teamName}] auditMemberSpawnStatuses: failed to parse config.json`); + return null; + } + } + + private async auditMemberSpawnStatuses(run: ProvisioningRun): Promise { + if (!run.expectedMembers || run.expectedMembers.length === 0) return; + + // Read config.json to get the actual registered members + const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName); + if (!registeredNames) { + const now = Date.now(); + if ( + now - run.lastMemberSpawnAuditConfigReadWarningAt >= + MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS + ) { + run.lastMemberSpawnAuditConfigReadWarningAt = now; + logger.warn(`[${run.teamName}] auditMemberSpawnStatuses: config.json not readable`); + } return; } @@ -6069,9 +6137,14 @@ export class TeamProvisioningService { continue; } - logger.warn( - `[${run.teamName}] Member "${expected}" not found in config.json members after provisioning` - ); + const now = Date.now(); + const lastWarnAt = run.lastMemberSpawnAuditMissingWarningAt.get(expected) ?? 0; + if (now - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) { + run.lastMemberSpawnAuditMissingWarningAt.set(expected, now); + logger.warn( + `[${run.teamName}] Member "${expected}" not found in config.json members after provisioning` + ); + } if (graceExpired) { this.setMemberSpawnStatus( run, @@ -6083,6 +6156,38 @@ export class TeamProvisioningService { } } + private async finalizeMissingRegisteredMembersAsFailed(run: ProvisioningRun): Promise { + if (!run.expectedMembers || run.expectedMembers.length === 0) return; + const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName); + if (!registeredNames) { + return; + } + + for (const expected of run.expectedMembers) { + const matchedRuntimeNames = [...registeredNames].filter((name) => { + if (name === expected) return true; + const parsed = parseNumericSuffixName(name); + return parsed !== null && parsed.suffix >= 2 && parsed.base === expected; + }); + + if (matchedRuntimeNames.length > 0) { + continue; + } + + const current = run.memberSpawnStatuses.get(expected); + if (current?.launchState === 'failed_to_start') { + continue; + } + + this.setMemberSpawnStatus( + run, + expected, + 'error', + 'Teammate was not registered in config.json during launch. Persistent spawn failed.' + ); + } + } + private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean { return this.getLiveTeamAgentNames(teamName).has(memberName); } @@ -6162,6 +6267,33 @@ export class TeamProvisioningService { return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; } + private buildPendingBootstrapStatusMessage( + prefix: string, + run: ProvisioningRun, + launchSummary: { + confirmedCount: number; + pendingCount: number; + runtimeAlivePendingCount: number; + } + ): string { + const stillStartingCount = Math.max( + 0, + launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount + ); + if (launchSummary.confirmedCount === 0) { + const allRuntimeAlive = + launchSummary.runtimeAlivePendingCount > 0 && + launchSummary.runtimeAlivePendingCount === run.expectedMembers.length; + return allRuntimeAlive + ? `${prefix} — teammate runtimes started, waiting for bootstrap confirmation` + : launchSummary.runtimeAlivePendingCount > 0 + ? `${prefix} — ${launchSummary.runtimeAlivePendingCount}/${run.expectedMembers.length} teammate runtime${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} started${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}, waiting for bootstrap confirmation` + : `${prefix} — teammates are still starting, waiting for bootstrap confirmation`; + } + + return `${prefix} — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} runtime${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} waiting for bootstrap confirmation` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining` : ''}`; + } + private buildRuntimeSpawnStatusRecord( run: ProvisioningRun ): Record { @@ -8591,7 +8723,8 @@ export class TeamProvisioningService { // Audit: flag any expected member not registered in config.json after launch. await this.refreshMemberSpawnStatusesFromLeadInbox(run); - await this.auditMemberSpawnStatuses(run); + await this.maybeAuditMemberSpawnStatuses(run, { force: true }); + await this.finalizeMissingRegisteredMembersAsFailed(run); await this.persistLaunchStateSnapshot(run, 'finished'); const failedSpawnMembers = this.getFailedSpawnMembers(run); const launchSummary = this.getMemberLaunchSummary(run); @@ -8603,7 +8736,7 @@ export class TeamProvisioningService { .map((member) => member.name) .join(', ')} failed to start` : hasPendingBootstrap - ? `Launch completed — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive, bootstrap still pending` + ? this.buildPendingBootstrapStatusMessage('Launch completed', run, launchSummary) : 'Team launched — process alive and ready'; const progress = updateProgress(run, 'ready', readyMessage, { cliLogsTail: extractCliLogsFromRun(run), @@ -8750,7 +8883,8 @@ export class TeamProvisioningService { // Audit: flag any expected member not registered in config.json after provisioning. await this.refreshMemberSpawnStatusesFromLeadInbox(run); - await this.auditMemberSpawnStatuses(run); + await this.maybeAuditMemberSpawnStatuses(run, { force: true }); + await this.finalizeMissingRegisteredMembersAsFailed(run); await this.persistLaunchStateSnapshot(run, 'finished'); const failedSpawnMembers = this.getFailedSpawnMembers(run); const launchSummary = this.getMemberLaunchSummary(run); @@ -8765,7 +8899,7 @@ export class TeamProvisioningService { .map((member) => member.name) .join(', ')} failed to start` : hasPendingBootstrap - ? `Team provisioned — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive, bootstrap still pending` + ? this.buildPendingBootstrapStatusMessage('Team provisioned', run, launchSummary) : 'Team provisioned — process alive and ready', { cliLogsTail: extractCliLogsFromRun(run), diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index f0281c8d..ec0412d5 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -9,6 +9,7 @@ import { TOOL_CALL_TEXT, } from '@renderer/constants/cssVariables'; import { formatTokensCompact } from '@renderer/utils/formatters'; +import { getToolContextTokens } from '@renderer/utils/toolRendering'; import { format } from 'date-fns'; import { ChevronRight, Layers, MailOpen } from 'lucide-react'; @@ -43,6 +44,25 @@ interface DisplayItemListProps { registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; /** Max characters for preview text in item headers (default: 150 for thinking/output, 80 for input) */ previewMaxLength?: number; + /** Optional timestamp format override for all items in this list. */ + timestampFormat?: string; + /** Whether to include compact item metadata in a hover tooltip. */ + showItemMetaTooltip?: boolean; +} + +function buildItemMetaTooltip( + timestamp: Date | undefined, + tokenCount: number | undefined, + tokenLabel = 'tokens' +): string | undefined { + const parts: string[] = []; + if (timestamp) { + parts.push(`Time: ${format(timestamp, 'HH:mm')}`); + } + if (tokenCount != null && tokenCount > 0) { + parts.push(`Tokens: ~${formatTokensCompact(tokenCount)} ${tokenLabel}`); + } + return parts.length > 0 ? parts.join(' • ') : undefined; } /** @@ -79,6 +99,8 @@ export const DisplayItemList = ({ notificationColorMap, registerToolRef, previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, }: Readonly): React.JSX.Element => { // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair const [replyLinkToolId, setReplyLinkToolId] = useState(null); @@ -134,6 +156,12 @@ export const DisplayItemList = ({ onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') + : undefined + } markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} searchQueryOverride={searchQueryOverride} /> @@ -160,6 +188,12 @@ export const DisplayItemList = ({ onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') + : undefined + } markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} searchQueryOverride={searchQueryOverride} /> @@ -175,6 +209,16 @@ export const DisplayItemList = ({ onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} timestamp={item.tool.startTime} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip( + item.tool.startTime, + getToolContextTokens(item.tool), + 'tokens' + ) + : undefined + } searchQueryOverride={searchQueryOverride} isHighlighted={highlightToolUseId === item.tool.id} highlightColor={highlightColor} @@ -226,6 +270,16 @@ export const DisplayItemList = ({ onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} timestamp={item.slash.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip( + item.slash.timestamp, + item.slash.instructionsTokenCount, + 'tokens' + ) + : undefined + } /> ); break; @@ -255,6 +309,12 @@ export const DisplayItemList = ({ summary={truncateText(inputContent, previewMaxLength ?? 80)} tokenCount={inputTokenCount} timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') + : undefined + } onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} > diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index ade71014..f6c51440 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -30,6 +30,10 @@ interface BaseItemProps { durationMs?: number; /** Timestamp to display (compact HH:mm:ss) */ timestamp?: Date; + /** Optional date-fns format for the timestamp. Defaults to HH:mm:ss. */ + timestampFormat?: string; + /** Optional tooltip text for the header row. */ + titleText?: string; /** Click handler for toggling */ onClick: () => void; /** Whether the item is expanded */ @@ -56,7 +60,7 @@ interface BaseItemProps { export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { return ( ); @@ -84,6 +88,8 @@ export const BaseItem: React.FC = ({ status, durationMs, timestamp, + timestampFormat = 'HH:mm:ss', + titleText, onClick, isExpanded, hasExpandableContent = true, @@ -101,6 +107,7 @@ export const BaseItem: React.FC = ({
{ if (e.key === 'Enter' || e.key === ' ') { @@ -145,7 +152,7 @@ export const BaseItem: React.FC = ({ {/* Token count badge */} {tokenCount != null && tokenCount > 0 && ( = ({ {/* Notification dot (replaces status dot when present) */} {notificationDotColor && ( )} @@ -179,7 +186,7 @@ export const BaseItem: React.FC = ({ className="base-item-timestamp shrink-0 text-[11px] tabular-nums" style={{ color: TOOL_ITEM_MUTED }} > - {format(timestamp, 'HH:mm:ss')} + {format(timestamp, timestampFormat)} )} diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 590bde21..e9707e40 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -50,6 +50,7 @@ interface LinkedToolItemProps { isExpanded: boolean; /** Timestamp for display */ timestamp?: Date; + timestampFormat?: string; /** Optional local search query override for inline highlighting */ searchQueryOverride?: string; /** Whether this item should be highlighted for error deep linking */ @@ -60,6 +61,7 @@ interface LinkedToolItemProps { notificationDotColor?: TriggerColor; /** Optional ref registration callback for external scroll control */ registerRef?: (el: HTMLDivElement | null) => void; + titleText?: string; } export const LinkedToolItem: React.FC = ({ @@ -67,11 +69,13 @@ export const LinkedToolItem: React.FC = ({ onClick, isExpanded, timestamp, + timestampFormat, searchQueryOverride, isHighlighted, highlightColor, notificationDotColor, registerRef, + titleText, }) => { const status = getToolStatus(linkedTool); const { isLight } = useTheme(); @@ -181,6 +185,8 @@ export const LinkedToolItem: React.FC = ({ status={status} durationMs={linkedTool.durationMs} timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} onClick={onClick} isExpanded={isExpanded} highlightClasses={highlightClasses} diff --git a/src/renderer/components/chat/items/SlashItem.tsx b/src/renderer/components/chat/items/SlashItem.tsx index a5741796..c48ece0b 100644 --- a/src/renderer/components/chat/items/SlashItem.tsx +++ b/src/renderer/components/chat/items/SlashItem.tsx @@ -15,12 +15,14 @@ interface SlashItemProps { isExpanded: boolean; /** Timestamp for display */ timestamp?: Date; + timestampFormat?: string; /** Additional classes for highlighting (e.g., error deep linking) */ highlightClasses?: string; /** Inline styles for highlighting (used by custom hex colors) */ highlightStyle?: React.CSSProperties; /** Notification dot color for custom triggers */ notificationDotColor?: TriggerColor; + titleText?: string; } /** @@ -37,9 +39,11 @@ export const SlashItem: React.FC = ({ onClick, isExpanded, timestamp, + timestampFormat, highlightClasses, highlightStyle, notificationDotColor, + titleText, }) => { const hasInstructions = !!slash.instructions; @@ -55,6 +59,8 @@ export const SlashItem: React.FC = ({ tokenLabel="tokens" status={hasInstructions ? 'ok' : undefined} timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} onClick={onClick} isExpanded={isExpanded} hasExpandableContent={hasInstructions} diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index e5aeadc1..9e94e566 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -17,6 +17,7 @@ interface TextItemProps { isExpanded: boolean; /** Timestamp for display */ timestamp?: Date; + timestampFormat?: string; /** Optional local search query for inline highlighting */ searchQueryOverride?: string; /** Optional stable item id for search highlighting */ @@ -27,6 +28,7 @@ interface TextItemProps { highlightStyle?: React.CSSProperties; /** Notification dot color for custom triggers */ notificationDotColor?: TriggerColor; + titleText?: string; } export const TextItem: React.FC = ({ @@ -35,11 +37,13 @@ export const TextItem: React.FC = ({ onClick, isExpanded, timestamp, + timestampFormat, searchQueryOverride, markdownItemId, highlightClasses, highlightStyle, notificationDotColor, + titleText, }) => { const fullContent = step.content.outputText ?? preview; const summary = searchQueryOverride @@ -58,6 +62,8 @@ export const TextItem: React.FC = ({ summary={summary} tokenCount={tokenCount} timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} onClick={onClick} isExpanded={isExpanded} highlightClasses={highlightClasses} diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 1e3306f1..116a9680 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -17,6 +17,7 @@ interface ThinkingItemProps { isExpanded: boolean; /** Timestamp for display */ timestamp?: Date; + timestampFormat?: string; /** Optional local search query for inline highlighting */ searchQueryOverride?: string; /** Optional stable item id for search highlighting */ @@ -27,6 +28,7 @@ interface ThinkingItemProps { highlightStyle?: React.CSSProperties; /** Notification dot color for custom triggers */ notificationDotColor?: TriggerColor; + titleText?: string; } export const ThinkingItem: React.FC = ({ @@ -35,11 +37,13 @@ export const ThinkingItem: React.FC = ({ onClick, isExpanded, timestamp, + timestampFormat, searchQueryOverride, markdownItemId, highlightClasses, highlightStyle, notificationDotColor, + titleText, }) => { const fullContent = step.content.thinkingText ?? preview; const summary = searchQueryOverride @@ -58,6 +62,8 @@ export const ThinkingItem: React.FC = ({ summary={summary} tokenCount={tokenCount} timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} onClick={onClick} isExpanded={isExpanded} highlightClasses={highlightClasses} diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx index 98ee4546..74e969be 100644 --- a/src/renderer/components/team/ClaudeLogsPanel.tsx +++ b/src/renderer/components/team/ClaudeLogsPanel.tsx @@ -28,6 +28,7 @@ interface ClaudeLogsPanelProps { viewerMaxHeight?: number; /** Extra className for the panel wrapper. */ className?: string; + compactMetaInTooltip?: boolean; } // ============================================================================= @@ -39,6 +40,7 @@ export const ClaudeLogsPanel = ({ viewerClassName, viewerMaxHeight, className, + compactMetaInTooltip = false, }: ClaudeLogsPanelProps): React.JSX.Element => { const { data, @@ -135,6 +137,7 @@ export const ClaudeLogsPanel = ({ style={viewerMaxHeight ? { maxHeight: `${viewerMaxHeight}px` } : undefined} containerRefCallback={containerRefCallback} onScroll={handleScroll} + compactMetaInTooltip={compactMetaInTooltip} viewerState={viewerState} onViewerStateChange={onViewerStateChange} footer={ diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index f476b175..8c9a6d27 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -141,8 +141,9 @@ export const ClaudeLogsSection = ({ ) : ( )} diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 58ad832c..a72e5fb3 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -52,6 +52,8 @@ interface CliLogsRichViewProps { style?: React.CSSProperties; /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ footer?: React.ReactNode; + /** When true, hide compact inline metadata and expose it via hover tooltip instead. */ + compactMetaInTooltip?: boolean; // ── Controlled mode (optional — all-or-nothing) ────────────────────── /** When provided, the component uses external expansion state. */ @@ -136,11 +138,13 @@ const FlatGroupItem = ({ expandedItemIds, onItemClick, searchQueryOverride, + compactMetaInTooltip, }: { group: StreamJsonGroup; expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; + compactMetaInTooltip?: boolean; }): React.JSX.Element => { const groupItemIds = useMemo( () => scopedItemIds(expandedItemIds, group.id), @@ -160,6 +164,8 @@ const FlatGroupItem = ({ aiGroupId={group.id} searchQueryOverride={searchQueryOverride} previewMaxLength={500} + timestampFormat="HH:mm" + showItemMetaTooltip={compactMetaInTooltip} />
); @@ -175,6 +181,7 @@ const StreamGroup = ({ expandedItemIds, onItemClick, searchQueryOverride, + compactMetaInTooltip, }: { group: StreamJsonGroup; isExpanded: boolean; @@ -182,6 +189,7 @@ const StreamGroup = ({ expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; + compactMetaInTooltip?: boolean; }): React.JSX.Element => { // Scope item IDs to this group to avoid cross-group collisions const groupItemIds = useMemo( @@ -233,6 +241,8 @@ const StreamGroup = ({ aiGroupId={group.id} searchQueryOverride={searchQueryOverride} previewMaxLength={500} + timestampFormat="HH:mm" + showItemMetaTooltip={compactMetaInTooltip} /> )} @@ -344,6 +354,7 @@ export const CliLogsRichView = ({ className, style, footer, + compactMetaInTooltip = false, viewerState: controlledState, onViewerStateChange, }: CliLogsRichViewProps): React.JSX.Element => { @@ -608,6 +619,7 @@ export const CliLogsRichView = ({ expandedItemIds={expandedItemIds} onItemClick={handleItemClick} searchQueryOverride={searchQueryOverride} + compactMetaInTooltip={compactMetaInTooltip} /> ) : ( ) )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e63a2d6d..e679cc04 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -565,10 +565,23 @@ export const TeamDetailView = ({ // Fetch initial spawn statuses when provisioning starts useEffect(() => { - if (teamName && (isTeamProvisioning || memberSpawnStatuses == null)) { + const leadActivity = teamName ? leadActivityByTeam[teamName] : undefined; + const shouldFetchSpawnStatuses = + Boolean(teamName) && + (isTeamProvisioning || + (memberSpawnStatuses == null && + (data?.isAlive === true || leadActivity === 'active' || leadActivity === 'idle'))); + if (teamName && shouldFetchSpawnStatuses) { void fetchMemberSpawnStatuses(teamName); } - }, [isTeamProvisioning, memberSpawnStatuses, teamName, fetchMemberSpawnStatuses]); + }, [ + data?.isAlive, + fetchMemberSpawnStatuses, + isTeamProvisioning, + leadActivityByTeam, + memberSpawnStatuses, + teamName, + ]); // Convert Record → Map const memberSpawnStatusMap = useMemo(() => { diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 0aed6e48..36684490 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -126,21 +126,42 @@ export const TeamProvisioningBanner = ({ const entry = memberSpawnStatuses?.[member.name]; return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true; }).length; - const pendingSpawnCount = - snapshotSummary?.pendingCount ?? - teammates.filter((member) => { - const entry = memberSpawnStatuses?.[member.name]; - return ( - entry?.launchState === 'starting' || - (entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive !== true) - ); - }).length; + const pendingSpawnCount = snapshotSummary + ? Math.max(0, snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount) + : teammates.filter((member) => { + const entry = memberSpawnStatuses?.[member.name]; + return ( + entry?.launchState === 'starting' || + (entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive !== true) + ); + }).length; const allTeammatesConfirmedAlive = fallbackTeammateCount > 0 && failedSpawnCount === 0 && heartbeatConfirmedCount === fallbackTeammateCount; + const allPendingRuntimesStarted = + fallbackTeammateCount > 0 && + heartbeatConfirmedCount === 0 && + processOnlyAliveCount === fallbackTeammateCount && + pendingSpawnCount === 0; if (isReady) { + const readyDetailMessage = + failedSpawnCount > 0 + ? progress.message + : fallbackTeammateCount === 0 + ? 'Team provisioned — lead online' + : allTeammatesConfirmedAlive + ? `Team provisioned — all ${fallbackTeammateCount} teammates confirmed alive` + : allPendingRuntimesStarted + ? 'Team provisioned — teammate runtimes started, waiting for bootstrap confirmation' + : processOnlyAliveCount > 0 || pendingSpawnCount > 0 + ? `Team provisioned — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` + : 'Team provisioned — teammate liveness is still being confirmed'; + const readyDetailSeverity = + failedSpawnCount > 0 || processOnlyAliveCount > 0 || pendingSpawnCount > 0 + ? 'warning' + : undefined; const readyMessage = failedSpawnCount > 0 ? `Launch finished with errors — ${failedSpawnCount}/${Math.max(fallbackTeammateCount, failedSpawnCount)} teammates failed to start` @@ -148,17 +169,19 @@ export const TeamProvisioningBanner = ({ ? 'Team launched — lead online' : allTeammatesConfirmedAlive ? `Team launched — all ${fallbackTeammateCount} teammates confirmed alive` - : processOnlyAliveCount > 0 || pendingSpawnCount > 0 - ? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` - : 'Team launched — teammate liveness is still being confirmed'; + : allPendingRuntimesStarted + ? 'Team launched — teammate runtimes started, waiting for bootstrap confirmation' + : processOnlyAliveCount > 0 || pendingSpawnCount > 0 + ? `Team launched — ${heartbeatConfirmedCount}/${fallbackTeammateCount} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` + : 'Team launched — teammate liveness is still being confirmed'; return (
= 0 ? progressStepIndex : -1} startedAt={progress.startedAt} pid={progress.pid} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 6c4c1e6f..c4abf77a 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -68,6 +68,8 @@ const TEAM_COLOR_NAMES = [ 'pink', ] as const; +const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; + import type { EffortLevel, Project, @@ -861,7 +863,7 @@ export const CreateTeamDialog = ({ const args: string[] = []; args.push('--input-format', 'stream-json', '--output-format', 'stream-json'); args.push('--verbose', '--setting-sources', 'user,project,local'); - args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); + args.push('--mcp-config', '', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS); if (skipPermissions) args.push('--dangerously-skip-permissions'); if (effectiveModel) args.push('--model', effectiveModel); if (selectedEffort) args.push('--effort', selectedEffort); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index f464def8..c8f9c98c 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -124,6 +124,8 @@ interface LaunchDialogScheduleMode { export type LaunchTeamDialogProps = LaunchDialogLaunchMode | LaunchDialogScheduleMode; +const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate'; + // ============================================================================= // Helpers // ============================================================================= @@ -1004,7 +1006,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const args: string[] = []; args.push('--input-format', 'stream-json', '--output-format', 'stream-json'); args.push('--verbose', '--setting-sources', 'user,project,local'); - args.push('--mcp-config', '', '--disallowedTools', 'TeamDelete,TodoWrite'); + args.push('--mcp-config', '', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS); if (skipPermissions) args.push('--dangerously-skip-permissions'); const model = computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId); if (model) args.push('--model', model); diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index 75c3b944..ff595d54 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -65,7 +65,15 @@ function buildDefaultItems(itemIds: string[]): PersistedGridLayoutItem[] { ? (index - ITEMS_PER_FIRST_ROW) * SECOND_ROW_ITEM_WIDTH : index * DEFAULT_ITEM_WIDTH; const y = isSecondRow ? DEFAULT_ITEM_HEIGHT : 0; - return { id, x, y, w, h: DEFAULT_ITEM_HEIGHT, minW: DEFAULT_MIN_WIDTH, minH: DEFAULT_MIN_HEIGHT }; + return { + id, + x, + y, + w, + h: DEFAULT_ITEM_HEIGHT, + minW: DEFAULT_MIN_WIDTH, + minH: DEFAULT_MIN_HEIGHT, + }; }); } @@ -243,7 +251,7 @@ export const KanbanGridLayout = ({ const [showResolvedLayout, setShowResolvedLayout] = useState(false); useEffect(() => { - if (!isLoaded || showResolvedLayout) return; + if (showResolvedLayout) return; const timeoutId = window.setTimeout(() => { setShowResolvedLayout(true); @@ -252,7 +260,7 @@ export const KanbanGridLayout = ({ return () => { window.clearTimeout(timeoutId); }; - }, [isLoaded, showResolvedLayout]); + }, [showResolvedLayout]); const applyReactGridLayout = useCallback( (layout: Layout, options?: { persist?: boolean }) => { @@ -263,7 +271,7 @@ export const KanbanGridLayout = ({ [applyVisibleItems] ); - if (!isLoaded || !showResolvedLayout) { + if (!showResolvedLayout && !isLoaded) { return ; } diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index b5954ca2..073f21f0 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -9,6 +9,7 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { CheckCheck, ChevronsDownUp, @@ -189,6 +190,16 @@ export const MessagesPanel = memo(function MessagesPanel({ }); }, [messages, timeWindow, messagesFilter, messagesSearchQuery]); + const replyCandidateMessages = useMemo( + () => + messages.filter( + (m) => + m.messageKind !== 'task_comment_notification' && + !isInboxNoiseMessage(typeof m.text === 'string' ? m.text : '') + ), + [messages] + ); + // Resolve the expanded item from filtered messages const expandedItem = useMemo(() => { if (!expandedItemKey) return null; @@ -250,7 +261,7 @@ export const MessagesPanel = memo(function MessagesPanel({ const next = { ...pendingRepliesByMember }; let changed = false; for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) { - const hasReply = messages.some((m) => { + const hasReply = replyCandidateMessages.some((m) => { if (m.from !== memberName) return false; const ts = Date.parse(m.timestamp); return Number.isFinite(ts) && ts > sentAtMs; @@ -261,7 +272,7 @@ export const MessagesPanel = memo(function MessagesPanel({ } } if (changed) onPendingReplyChange(() => next); - }, [messages, pendingRepliesByMember, onPendingReplyChange]); + }, [replyCandidateMessages, pendingRepliesByMember, onPendingReplyChange]); const handleSend = useCallback( ( diff --git a/src/renderer/index.css b/src/renderer/index.css index 2ae08931..8ae0ad0b 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1134,12 +1134,24 @@ body { line-height: 0.875rem; } -/* Hide per-item timestamps and chevrons in compact CLI logs */ -.cli-logs-compact .base-item-timestamp, +/* Hide chevrons in compact CLI logs, but keep per-item timestamps visible */ .cli-logs-compact .base-item-chevron { display: none; } +@media (max-width: 980px) { + .cli-logs-compact .base-item-timestamp { + display: none; + } +} + +.cli-logs-sidebar .base-item-timestamp, +.cli-logs-sidebar .base-item-tokens, +.cli-logs-sidebar .base-item-status-dot, +.cli-logs-sidebar .base-item-notification-dot { + display: none; +} + /* Extension list zebra rows */ .mcp-servers-grid > *, diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index a5a0675e..6a7c5c6b 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -18,6 +18,13 @@ const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; const TEAM_FETCH_TIMEOUT_MS = 30_000; +const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000; +const inFlightTeamDataRequests = new Map>(); +const pendingFreshTeamDataRefreshes = new Set(); +const memberSpawnStatusesIpcBackoffUntilByTeam = new Map(); +type RefreshTeamDataOptions = { + withDedup?: boolean; +}; function nowIso(): string { return new Date().toISOString(); } @@ -57,6 +64,76 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } +function fetchTeamDataDeduped(teamName: string): Promise { + const existing = inFlightTeamDataRequests.get(teamName); + if (existing) { + return existing; + } + + const request = withTimeout( + unwrapIpc('team:getData', () => api.teams.getData(teamName)), + TEAM_GET_DATA_TIMEOUT_MS, + `team:getData(${teamName})` + ).finally(() => { + if (inFlightTeamDataRequests.get(teamName) === request) { + inFlightTeamDataRequests.delete(teamName); + } + }); + + inFlightTeamDataRequests.set(teamName, request); + return request; +} + +function fetchTeamDataFresh(teamName: string): Promise { + return withTimeout( + unwrapIpc('team:getData', () => api.teams.getData(teamName)), + TEAM_GET_DATA_TIMEOUT_MS, + `team:getData(${teamName})` + ); +} + +function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { + const aTime = Date.parse(a.timestamp); + const bTime = Date.parse(b.timestamp); + const aValid = Number.isFinite(aTime); + const bValid = Number.isFinite(bTime); + if (aValid && bValid && aTime !== bTime) { + return aTime - bTime; + } + if (aValid !== bValid) { + return aValid ? -1 : 1; + } + const aId = typeof a.messageId === 'string' ? a.messageId : ''; + const bId = typeof b.messageId === 'string' ? b.messageId : ''; + return aId.localeCompare(bId); +} + +function upsertLocalSentMessage(data: TeamData, message: InboxMessage): TeamData { + const nextMessages = [...data.messages]; + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + const existingIndex = nextMessages.findIndex( + (candidate) => + typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId + ); + if (existingIndex >= 0) { + nextMessages[existingIndex] = { + ...nextMessages[existingIndex], + ...message, + }; + } else { + nextMessages.push(message); + } + } else { + nextMessages.push(message); + } + nextMessages.sort(compareInboxMessagesByTimestamp); + return { + ...data, + messages: nextMessages, + }; +} + async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, @@ -136,6 +213,7 @@ import type { CrossTeamSendRequest, EffortLevel, GlobalTask, + InboxMessage, KanbanColumnId, LeadActivityState, LeadContextUsage, @@ -650,7 +728,7 @@ export interface TeamSlice { teamName: string, opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } ) => Promise; - refreshTeamData: (teamName: string) => Promise; + refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { teamName: string; @@ -931,8 +1009,13 @@ export const createTeamSlice: StateCreator = (set, launchParamsByTeam: loadAllLaunchParams(), fetchMemberSpawnStatuses: async (teamName: string) => { if (!api.teams?.getMemberSpawnStatuses) return; + const backoffUntil = memberSpawnStatusesIpcBackoffUntilByTeam.get(teamName) ?? 0; + if (backoffUntil > Date.now()) { + return; + } try { const snapshot = await api.teams.getMemberSpawnStatuses(teamName); + memberSpawnStatusesIpcBackoffUntilByTeam.delete(teamName); set((prev) => { if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { return {}; @@ -980,7 +1063,14 @@ export const createTeamSlice: StateCreator = (set, }, }; }); - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("No handler registered for 'team:memberSpawnStatuses'")) { + memberSpawnStatusesIpcBackoffUntilByTeam.set( + teamName, + Date.now() + MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS + ); + } // ignore — spawn statuses are best-effort } }, @@ -1364,11 +1454,7 @@ export const createTeamSlice: StateCreator = (set, }); try { - const data = await withTimeout( - unwrapIpc('team:getData', () => api.teams.getData(teamName)), - TEAM_GET_DATA_TIMEOUT_MS, - `team:getData(${teamName})` - ); + const data = await fetchTeamDataDeduped(teamName); // Stale check: user may have switched to another team during the async call if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { return; @@ -1502,20 +1588,23 @@ export const createTeamSlice: StateCreator = (set, } }, - refreshTeamData: async (teamName: string) => { + refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { const state = get(); if (state.selectedTeamName !== teamName) { return; } // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). + const reusedInFlightRequest = + opts?.withDedup === true && inFlightTeamDataRequests.has(teamName); + if (reusedInFlightRequest) { + pendingFreshTeamDataRefreshes.add(teamName); + } try { const previousData = get().selectedTeamData; - const data = await withTimeout( - unwrapIpc('team:getData', () => api.teams.getData(teamName)), - TEAM_GET_DATA_TIMEOUT_MS, - `refreshTeamData(${teamName})` - ); + const data = opts?.withDedup + ? await fetchTeamDataDeduped(teamName) + : await fetchTeamDataFresh(teamName); // Re-check after async: the user might have navigated away. if (get().selectedTeamName !== teamName) { return; @@ -1576,6 +1665,10 @@ export const createTeamSlice: StateCreator = (set, return; } set({ selectedTeamError: msg }); + } finally { + if (reusedInFlightRequest && pendingFreshTeamDataRefreshes.delete(teamName)) { + void get().refreshTeamData(teamName); + } } }, @@ -1609,11 +1702,37 @@ export const createTeamSlice: StateCreator = (set, const result = await unwrapIpc('team:sendMessage', () => api.teams.sendMessage(teamName, request) ); - set({ + const optimisticMessage: InboxMessage = { + from: request.from ?? 'user', + to: request.to ?? request.member, + text: request.text, + timestamp: request.timestamp ?? nowIso(), + read: true, + taskRefs: request.taskRefs?.length ? request.taskRefs : undefined, + summary: request.summary, + color: request.color, + messageId: result.messageId, + relayOfMessageId: request.relayOfMessageId, + source: request.source ?? 'user_sent', + attachments: request.attachments?.length ? request.attachments : undefined, + leadSessionId: request.leadSessionId, + conversationId: request.conversationId, + replyToConversationId: request.replyToConversationId, + toolSummary: request.toolSummary, + toolCalls: request.toolCalls, + messageKind: request.messageKind, + slashCommand: request.slashCommand, + commandOutput: request.commandOutput, + }; + set((state) => ({ sendingMessage: false, sendMessageError: null, lastSendMessageResult: result, - }); + selectedTeamData: + state.selectedTeamName === teamName && state.selectedTeamData + ? upsertLocalSentMessage(state.selectedTeamData, optimisticMessage) + : state.selectedTeamData, + })); await get().refreshTeamData(teamName); } catch (error) { set({