diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 2133abdb..db3142f6 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -14,6 +14,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, @@ -108,6 +109,8 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -195,6 +198,7 @@ export function initializeTeamHandlers( export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); @@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_LAUNCH); @@ -634,6 +639,39 @@ async function validateProvisioningRequest( }; } +async function handleGetClaudeLogs( + _event: IpcMainInvokeEvent, + teamName: unknown, + query?: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + + let parsed: TeamClaudeLogsQuery | undefined; + if (query !== undefined) { + if (!query || typeof query !== 'object') { + return { success: false, error: 'query must be an object' }; + } + const q = query as Record; + parsed = { + offset: typeof q.offset === 'number' ? q.offset : undefined, + limit: typeof q.limit === 'number' ? q.limit : undefined, + }; + } + + return wrapTeamHandler('getClaudeLogs', async () => { + const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed); + return { + lines: data.lines, + total: data.total, + hasMore: data.hasMore, + updatedAt: data.updatedAt, + }; + }); +} + async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8f7a1fe6..7d30a616 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -114,6 +114,14 @@ interface ProvisioningRun { progress: TeamProvisioningProgress; stdoutBuffer: string; stderrBuffer: string; + /** Rolling buffer of CLI log lines (oldest -> newest). */ + claudeLogLines: string[]; + /** Carry buffer for stdout line splitting (CLI output). */ + stdoutLogLineBuf: string; + /** Carry buffer for stderr line splitting (CLI output). */ + stderrLogLineBuf: string; + /** ISO timestamp when the last CLI line was recorded. */ + claudeLogsUpdatedAt?: string; processKilled: boolean; finalizingByTimeout: boolean; cancelRequested: boolean; @@ -719,7 +727,8 @@ ${membersFooter} function buildLaunchPrompt( request: TeamLaunchRequest, members: TeamCreateRequest['members'], - tasks: TeamTask[] + tasks: TeamTask[], + isResume: boolean ): string { const membersBlock = buildMembersPrompt(members); const userPromptBlock = request.prompt?.trim() @@ -828,7 +837,9 @@ ${memberSpawnInstructions} ? `Members:\n${membersBlock}` : 'Members: (none — solo team lead)'; - return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] + const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; + + return `${startLabel} [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. @@ -967,6 +978,8 @@ interface CachedProbeResult { let cachedProbeResult: CachedProbeResult | null = null; export class TeamProvisioningService { + private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private readonly runs = new Map(); private readonly activeByTeam = new Map(); private readonly teamOpLocks = new Map>(); @@ -983,6 +996,71 @@ export class TeamProvisioningService { private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore() ) {} + getClaudeLogs( + teamName: string, + query?: { offset?: number; limit?: number } + ): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + return { lines: [], total: 0, hasMore: false }; + } + const run = this.runs.get(runId); + if (!run) { + return { lines: [], total: 0, hasMore: false }; + } + + const offsetRaw = query?.offset ?? 0; + const limitRaw = query?.limit ?? 100; + const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; + + const total = run.claudeLogLines.length; + if (total === 0) { + return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt }; + } + + const newestExclusive = Math.max(0, total - offset); + const oldestInclusive = Math.max(0, newestExclusive - limit); + const windowOldestToNewest = run.claudeLogLines.slice(oldestInclusive, newestExclusive); + const lines = windowOldestToNewest.reverse(); + return { + lines, + total, + hasMore: oldestInclusive > 0, + updatedAt: run.claudeLogsUpdatedAt, + }; + } + + private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void { + const nowMs = Date.now(); + run.claudeLogsUpdatedAt = new Date(nowMs).toISOString(); + + const prefix = stream === 'stdout' ? '[stdout] ' : '[stderr] '; + if (stream === 'stdout') { + run.stdoutLogLineBuf += text; + const parts = run.stdoutLogLineBuf.split('\n'); + run.stdoutLogLineBuf = parts.pop() ?? ''; + for (const part of parts) { + const normalized = part.endsWith('\r') ? part.slice(0, -1) : part; + run.claudeLogLines.push(prefix + normalized); + } + } else { + run.stderrLogLineBuf += text; + const parts = run.stderrLogLineBuf.split('\n'); + run.stderrLogLineBuf = parts.pop() ?? ''; + for (const part of parts) { + const normalized = part.endsWith('\r') ? part.slice(0, -1) : part; + run.claudeLogLines.push(prefix + normalized); + } + } + if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) { + run.claudeLogLines.splice( + 0, + run.claudeLogLines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT + ); + } + } + /** * Serializes operations per team name using promise-chaining. * Same pattern as withInboxLock / withTaskLock. @@ -1189,6 +1267,67 @@ export class TeamProvisioningService { ); } + private hasApiError(text: string): boolean { + return /api error:\s*\d{3}\b/i.test(text) || /invalid_request_error/i.test(text); + } + + private sanitizeCliSnippet(text: string): string { + // Remove control characters that often show up as binary noise in CLI error payloads. + // Preserve newlines/tabs for readability. + return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); + } + + private extractApiErrorSnippet(text: string): string | null { + const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); + if (!match || match.index === undefined) return null; + const start = Math.max(0, match.index - 200); + const end = Math.min(text.length, match.index + 4000); + const raw = text.slice(start, end).trim(); + if (!raw) return null; + // Avoid breaking markdown fences if the payload contains ``` accidentally. + return this.sanitizeCliSnippet(raw).replace(/```/g, '``\\`'); + } + + private failProvisioningWithApiError(run: ProvisioningRun, source: string): void { + if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return; + if (run.progress.state === 'failed' || run.cancelRequested) return; + + const combined = [ + buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), + run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', + ] + .filter(Boolean) + .join('\n') + .trim(); + + const snippet = + this.extractApiErrorSnippet(combined) ?? this.extractApiErrorSnippet(source) ?? null; + const status = + /api error:\s*(\d{3})\b/i.exec(combined)?.[1] ?? /api error:\s*(\d{3})\b/i.exec(source)?.[1]; + + const hint = run.isLaunch ? 'Launch' : 'Provisioning'; + const statusLabel = status ? `API Error ${status}` : 'API Error'; + if (snippet) { + run.provisioningOutputParts.push( + `**${hint} failed: ${statusLabel} detected**\n\n\`\`\`\n${snippet}\n\`\`\`` + ); + } else { + run.provisioningOutputParts.push(`**${hint} failed: ${statusLabel} detected**`); + } + + const progress = updateProgress(run, 'failed', `${hint} failed — ${statusLabel}`, { + error: `Claude CLI reported ${statusLabel} during startup. The team was not started.`, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + + run.processKilled = true; + run.cancelRequested = true; + run.child?.stdin?.end(); + killProcessTree(run.child); + this.cleanupRun(run); + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -1250,6 +1389,10 @@ export class TeamProvisioningService { // Reset buffers for fresh attempt run.stdoutBuffer = ''; run.stderrBuffer = ''; + run.claudeLogLines = []; + run.stdoutLogLineBuf = ''; + run.stderrLogLineBuf = ''; + run.claudeLogsUpdatedAt = undefined; run.authFailureRetried = true; updateProgress(run, 'spawning', 'Auth failed — retrying after short delay'); @@ -1362,6 +1505,7 @@ export class TeamProvisioningService { let stdoutLineBuf = ''; child.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); + this.appendCliLogs(run, 'stdout', text); run.stdoutBuffer += text; if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) { run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT); @@ -1380,6 +1524,9 @@ export class TeamProvisioningService { } catch { // Not valid JSON — check for auth failure in raw text output this.handleAuthFailureInOutput(run, trimmed, 'stdout'); + if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) { + this.failProvisioningWithApiError(run, trimmed); + } } } @@ -1398,6 +1545,7 @@ export class TeamProvisioningService { child.stderr.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); + this.appendCliLogs(run, 'stderr', text); run.stderrBuffer += text; if (run.stderrBuffer.length > STDERR_RING_LIMIT) { run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT); @@ -1405,6 +1553,9 @@ export class TeamProvisioningService { // Detect auth failure early instead of waiting for 5-minute timeout this.handleAuthFailureInOutput(run, text, 'stderr'); + if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) { + this.failProvisioningWithApiError(run, text); + } const currentTs = Date.now(); if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { @@ -1460,6 +1611,10 @@ export class TeamProvisioningService { startedAt, stdoutBuffer: '', stderrBuffer: '', + claudeLogLines: [], + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + claudeLogsUpdatedAt: undefined, processKilled: false, finalizingByTimeout: false, cancelRequested: false, @@ -1744,6 +1899,10 @@ export class TeamProvisioningService { startedAt, stdoutBuffer: '', stderrBuffer: '', + claudeLogLines: [], + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + claudeLogsUpdatedAt: undefined, processKilled: false, finalizingByTimeout: false, cancelRequested: false, @@ -1800,7 +1959,12 @@ export class TeamProvisioningService { ); } - const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks); + const prompt = buildLaunchPrompt( + request, + expectedMemberSpecs, + existingTasks, + Boolean(previousSessionId) + ); let child: ReturnType; const { env: shellEnv } = await this.buildProvisioningEnv(); const launchArgs = [ @@ -2480,6 +2644,10 @@ export class TeamProvisioningService { // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); + if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) { + this.failProvisioningWithApiError(run, text); + return; + } logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); // During provisioning (before provisioningComplete), accumulate for live UI preview. // Emission is handled by the throttled emitLogsProgress() in the stdout data handler. @@ -2735,9 +2903,39 @@ export class TeamProvisioningService { // Handle compact_boundary — context was compacted, next assistant message will carry fresh usage if (msg.type === 'system') { const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined; - if (sub === 'compact_boundary' && run.leadContextUsage) { - run.leadContextUsage.lastUsageMessageId = null; - logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`); + if (sub === 'compact_boundary') { + if (run.leadContextUsage) { + run.leadContextUsage.lastUsageMessageId = null; + } + + // Extract compact metadata for the system message + const meta = (msg as Record).compact_metadata as + | Record + | undefined; + const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto'; + const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null; + const tokenInfo = preTokens + ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` + : ''; + + const compactMsg: InboxMessage = { + from: 'system', + text: `Context compacted${tokenInfo}, trigger: ${trigger}`, + timestamp: nowIso(), + read: true, + summary: `Context compacted (${trigger})`, + messageId: `compact-${run.runId}-${Date.now()}`, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(run.teamName, compactMsg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'compact_boundary', + }); + logger.info( + `[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}` + ); } } } @@ -2750,19 +2948,24 @@ export class TeamProvisioningService { private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { // Guard: must be set synchronously BEFORE any await to prevent // double-invocation from filesystem monitor + stream-json racing. - if (run.provisioningComplete || run.cancelRequested) return; + if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed') + return; // Prevent false "ready" when auth failure was printed as assistant text or logs // but the filesystem monitor observed files on disk. - const authFailureText = [ + const preCompleteText = [ buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', ] .filter(Boolean) .join('\n') .trim(); - if (authFailureText && this.isAuthFailureWarning(authFailureText)) { - this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete'); + if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) { + this.failProvisioningWithApiError(run, preCompleteText); + return; + } + if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) { + this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete'); return; } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a3ca64c3..72722ee0 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list'; /** Get detailed team data */ export const TEAM_GET_DATA = 'team:getData'; +/** Get buffered Claude CLI logs (paged, newest-first) */ +export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; + /** Update team kanban state */ export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 8361d241..863c48d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -70,6 +70,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, @@ -197,6 +198,8 @@ import type { TaskChangeSetV2, TaskComment, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, @@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = { getData: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, + getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { + return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); + }, deleteTeam: async (teamName: string) => { return invokeIpcWithResult(TEAM_DELETE_TEAM, teamName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b2c69fa8..7fc64c7c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -45,6 +45,8 @@ import type { SshLastConnection, SubagentDetail, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, TeamData, @@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI { getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, + getClaudeLogs: async ( + _teamName: string, + _query?: TeamClaudeLogsQuery + ): Promise => { + console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode'); + return { lines: [], total: 0, hasMore: false }; + }, deleteTeam: async (_teamName: string): Promise => { throw new Error('Team deletion is not available in browser mode'); }, diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx new file mode 100644 index 00000000..7bb46cd0 --- /dev/null +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Button } from '@renderer/components/ui/button'; +import { cn } from '@renderer/lib/utils'; +import { Terminal } from 'lucide-react'; + +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; + +import type { TeamClaudeLogsResponse } from '@shared/types'; + +const PAGE_SIZE = 100; +const POLL_MS = 2000; +const ONLINE_WINDOW_MS = 10_000; + +interface ClaudeLogsSectionProps { + teamName: string; +} + +function isRecent(updatedAt: string | undefined): boolean { + if (!updatedAt) return false; + const t = Date.parse(updatedAt); + if (Number.isNaN(t)) return false; + return Date.now() - t <= ONLINE_WINDOW_MS; +} + +export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + setData({ lines: [], total: 0, hasMore: false }); + setError(null); + }, [teamName]); + + useEffect(() => { + let cancelled = false; + + const fetchLogs = async (): Promise => { + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + setLoading(true); + const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); + if (cancelled) return; + setData(next); + setError(null); + } catch (e) { + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + inFlightRef.current = false; + if (!cancelled) setLoading(false); + } + }; + + void fetchLogs(); + const id = window.setInterval(() => void fetchLogs(), POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [teamName, visibleCount]); + + const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); + const badge = data.total > 0 ? data.total : undefined; + const showMoreVisible = data.hasMore; + + const headerExtra = online ? ( + + + + + ) : null; + + return ( + } + badge={badge} + headerExtra={headerExtra} + defaultOpen + contentClassName="pt-0" + > +
+ + {data.total > 0 ? ( + <> + Showing {Math.min(data.total, visibleCount)} of{' '} + {data.total} + + ) : ( + 'No logs yet.' + )} + + {showMoreVisible && ( + + )} +
+ +
+ {error ? ( +

{error}

+ ) : data.lines.length > 0 ? ( +
+            {data.lines.join('\n')}
+          
+ ) : ( +

+ {loading ? 'Loading…' : 'No logs captured.'} +

+ )} +
+
+ ); +}; + diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 29c7e229..70450e2a 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -44,27 +44,66 @@ function formatElapsed(seconds: number): string { return `${m}:${String(s).padStart(2, '0')}`; } -function useElapsedTimer(startedAt?: string): string | null { - const [elapsed, setElapsed] = useState(null); +function useElapsedTimer(startedAt?: string, isRunning = true): string | null { + const [elapsedSeconds, setElapsedSeconds] = useState(null); useEffect(() => { - if (!startedAt) return () => setElapsed(null); + if (!startedAt) { + setElapsedSeconds(null); + return; + } + const startMs = Date.parse(startedAt); - if (isNaN(startMs)) return () => setElapsed(null); + if (isNaN(startMs)) { + setElapsedSeconds(null); + return; + } + + const computeElapsedSeconds = (): number => + Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + + if (!isRunning) { + // Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick. + setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev)); + return; + } const tick = (): void => { - const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); - setElapsed(formatElapsed(seconds)); + setElapsedSeconds(computeElapsedSeconds()); }; + tick(); const id = window.setInterval(tick, 1000); return () => { window.clearInterval(id); }; - }, [startedAt]); + }, [startedAt, isRunning]); if (!startedAt) return null; - return elapsed; + if (elapsedSeconds === null) return null; + return formatElapsed(elapsedSeconds); +} + +function sanitizeAssistantOutput(raw?: string, isError = false): string | null { + if (!raw) return null; + if (!isError) return raw; + + const looksLikeRawApiEnvelope = + raw.includes('API Error: 400') && + (raw.includes('"_requests"') || + raw.includes('"session_id"') || + raw.includes('"parent_tool_use_id"') || + raw.includes('\\u000')); + + if (!looksLikeRawApiEnvelope) { + return raw; + } + + return ( + 'API Error: 400\n\n' + + 'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' + + 'Open **CLI logs** below for readable diagnostics.' + ); } export const ProvisioningProgressBlock = ({ @@ -81,11 +120,12 @@ export const ProvisioningProgressBlock = ({ assistantOutput, className, }: ProvisioningProgressBlockProps): React.JSX.Element => { - const elapsed = useElapsedTimer(startedAt); - const [logsOpen, setLogsOpen] = useState(false); + const elapsed = useElapsedTimer(startedAt, loading); + const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail)); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; + const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); // Auto-scroll assistant output useEffect(() => { @@ -99,6 +139,14 @@ export const ProvisioningProgressBlock = ({ setLiveOutputOpen(defaultLiveOutputOpen); }, [defaultLiveOutputOpen]); + // On error with logs available, prioritize logs view over noisy live stream payload. + useEffect(() => { + if (isError && cliLogsTail) { + setLogsOpen(true); + setLiveOutputOpen(false); + } + }, [isError, cliLogsTail]); + return (
- {assistantOutput ? ( - + {displayAssistantOutput ? ( + ) : (

)} + + { }; }, [electronMode, teams]); + // Refresh alive teams when opening the create dialog so conflict warning is accurate. + useEffect(() => { + if (!electronMode || !showCreateDialog) return; + let cancelled = false; + void api.teams + .aliveList() + .then((list) => { + if (!cancelled) setAliveTeams(list); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [electronMode, showCreateDialog]); + const currentProjectPath = useMemo(() => { if (viewMode === 'grouped') { const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 292194ae..41768955 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -491,10 +491,11 @@ export const CreateTeamDialog = ({ activeError?.includes('Team already exists') === true && request.teamName.length > 0; const conflictingTeam = useMemo(() => { + if (!launchTeam) return null; if (!activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; - }, [activeTeams, effectiveCwd]); + }, [activeTeams, effectiveCwd, launchTeam]); // Reset dismiss when conflict target changes useEffect(() => { @@ -554,6 +555,18 @@ export const CreateTeamDialog = ({ })(); }; + const handleTeamNameChange = (value: string): void => { + setTeamName(value); + setFieldErrors((prev) => { + if (!prev.teamName) return prev; + const { teamName: _teamName, ...rest } = prev; + if (!rest.members && !rest.cwd && localError === 'Check form fields') { + setLocalError(null); + } + return rest; + }); + }; + return (

- Team “{conflictingTeam.displayName}” is already running in this - project + Another team “{conflictingTeam.displayName}” is already running for + this working directory

Running two teams in the same directory is risky — they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.

+

+ Working directory: {effectiveCwd} +

)} - {leadContext && leadContext.percent > 0 && ( - - -
-
90 - ? 'bg-red-500' - : leadContext.percent > 70 - ? 'bg-amber-500' - : 'bg-blue-500' - }`} - style={{ width: `${Math.min(leadContext.percent, 100)}%` }} - /> -
- - - Context: {Math.round(leadContext.percent)}% ( - {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} - {(leadContext.contextWindow / 1000).toFixed(0)}k tokens) - - - )} + {/* TODO: lead context bar disabled — usage formula is inaccurate */}
{!isRemoved && (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 5dc422ae..8ac7e03c 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { useStore } from '@renderer/store'; +// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { Pencil } from 'lucide-react'; @@ -31,20 +31,15 @@ export const MemberDetailHeader = ({ }: MemberDetailHeaderProps): React.JSX.Element => { const [editing, setEditing] = useState(false); - const teamName = useStore((s) => s.selectedTeamName); - const leadContext = useStore((s) => - member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - ); + // TODO: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); const colors = getTeamColorSet(member.color ?? ''); const role = member.role || formatAgentRole(member.agentType); - const presenceLabel = getPresenceLabel( - member, - isTeamAlive, - isTeamProvisioning, - leadActivity, - leadContext?.percent - ); + const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const canEditRole = @@ -107,12 +102,7 @@ export const MemberDetailHeader = ({ > {presenceLabel} - {leadContext && leadContext.percent > 0 && ( - - {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} - {(leadContext.contextWindow / 1000).toFixed(0)}k - - )} + {/* TODO: lead context token display disabled — usage formula is inaccurate */} )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index fd3a903a..23d68d0b 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -148,10 +148,11 @@ export const MessageComposer = ({ const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; - const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; - const leadContext = useStore((s) => - isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined - ); + // TODO: lead context ring disabled — usage formula is inaccurate + // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; + // const leadContext = useStore((s) => + // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined + // ); const supportsAttachments = isLeadRecipient && !!isTeamAlive; const canAttach = supportsAttachments && canAddMore; const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; @@ -420,7 +421,7 @@ export const MessageComposer = ({ disabled={sending} cornerAction={
- {leadContext && leadContext.percent > 0 && } + {/* TODO: ContextRing disabled — usage formula is inaccurate */}