diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 950bf628..03eab37d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -62,7 +62,7 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024; const SHELL_ENV_TIMEOUT_MS = 12000; // const CLI_PREPARE_TIMEOUT_MS = 10000; const PROBE_CACHE_TTL_MS = 10 * 60_000; -const PREFLIGHT_TIMEOUT_MS = 30000; +const PREFLIGHT_TIMEOUT_MS = 60000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; const FS_MONITOR_POLL_MS = 2000; @@ -70,8 +70,20 @@ const TASK_WAIT_FALLBACK_MS = 15_000; 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 PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else'; -const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const; +const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.'; +const PREFLIGHT_PING_ARGS = [ + '-p', + PREFLIGHT_PING_PROMPT, + '--output-format', + 'text', + '--model', + 'haiku', + '--max-turns', + '1', + '--max-budget-usd', + '0.05', + '--no-session-persistence', +] as const; const PREFLIGHT_EXPECTED = 'PONG'; type TeamsBaseLocation = 'configured' | 'default'; @@ -1008,8 +1020,23 @@ interface CachedProbeResult { } let cachedProbeResult: CachedProbeResult | null = null; -let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null = - null; +let probeInFlight: Promise<{ + claudePath: string; + authSource: ProvisioningAuthSource; + warning?: string; +} | null> | null = null; + +function isTransientProbeWarning(warning: string): boolean { + const lower = warning.toLowerCase(); + return ( + lower.includes('timeout running:') || + lower.includes('did not complete') || + lower.includes('timed out') || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('eai_again') + ); +} export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; @@ -1046,7 +1073,9 @@ export class TeamProvisioningService { 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 limit = Number.isFinite(limitRaw) + ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) + : 100; const total = run.claudeLogLines.length; if (total === 0) { @@ -1057,8 +1086,10 @@ export class TeamProvisioningService { const oldestInclusive = Math.max(0, newestExclusive - limit); const normalizeLine = (line: string): string => { // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] " - if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length); - if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length); + if (line.startsWith('[stdout] ') && line !== '[stdout]') + return line.slice('[stdout] '.length); + if (line.startsWith('[stderr] ') && line !== '[stderr]') + return line.slice('[stderr] '.length); return line; }; @@ -1201,7 +1232,8 @@ export class TeamProvisioningService { async warmup(): Promise { try { - if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return; + if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) + return; const result = await this.getCachedOrProbeResult(process.cwd()); if (!result) return; logger.info('CLI warmup completed'); @@ -1226,7 +1258,11 @@ export class TeamProvisioningService { const ready = !warning || authSource !== 'none' || !isAuthFailure; return { ready, - message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready', + message: ready + ? warnings.length > 0 + ? 'CLI is ready to launch (see notes)' + : 'CLI is warmed up and ready to launch' + : warning || 'CLI is not ready', warnings: warnings.length > 0 ? warnings : undefined, }; } @@ -1267,7 +1303,10 @@ export class TeamProvisioningService { return { ready: true, - message: 'CLI is warmed up and ready to launch', + message: + warnings.length > 0 + ? 'CLI is ready to launch (see notes)' + : 'CLI is warmed up and ready to launch', warnings: warnings.length > 0 ? warnings : undefined, }; } @@ -1287,7 +1326,11 @@ export class TeamProvisioningService { ): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> { const cached = this.getFreshCachedProbeResult(); if (cached) { - return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning }; + return { + claudePath: cached.claudePath, + authSource: cached.authSource, + warning: cached.warning, + }; } if (probeInFlight) { @@ -1300,12 +1343,20 @@ export class TeamProvisioningService { const { env, authSource } = await this.buildProvisioningEnv(); const probe = await this.probeClaudeRuntime(claudePath, cwd, env); - const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) }; + const result = { + claudePath, + authSource, + ...(probe.warning ? { warning: probe.warning } : {}), + }; - if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) { + const shouldCache = + !probe.warning || + (!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning)); + + if (shouldCache) { cachedProbeResult = { ...result, cachedAtMs: Date.now() }; } else { - // Don't pin auth failures in cache — user may log in externally and retry. + // Don't pin auth failures / transient failures in cache — user may fix and retry. cachedProbeResult = null; } @@ -2819,7 +2870,6 @@ export class TeamProvisioningService { 'team-lead'; const leadMsg: InboxMessage = { from: leadName, - to: 'user', text: cleanText, timestamp: nowIso(), read: true, @@ -2831,7 +2881,10 @@ export class TeamProvisioningService { run.leadTextPushedInCurrentTurn = true; const now = Date.now(); - if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) { + if ( + now - run.lastLeadTextEmitMs >= + TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS + ) { run.lastLeadTextEmitMs = now; this.teamChangeEmitter?.({ type: 'inbox', @@ -3126,9 +3179,7 @@ export class TeamProvisioningService { | 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 tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : ''; const compactMsg: InboxMessage = { from: 'system', @@ -3160,7 +3211,12 @@ 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 || run.processKilled || run.progress.state === 'failed') + 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 @@ -3172,7 +3228,11 @@ export class TeamProvisioningService { .filter(Boolean) .join('\n') .trim(); - if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) { + if ( + preCompleteText && + this.hasApiError(preCompleteText) && + !this.isAuthFailureWarning(preCompleteText) + ) { this.failProvisioningWithApiError(run, preCompleteText); return; } @@ -3976,7 +4036,9 @@ export class TeamProvisioningService { if (membersRaw.length > 0) { const teammateNames = membersRaw .map((m) => (typeof m.name === 'string' ? m.name.trim() : '')) - .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'); + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ); const keepName = createCliAutoSuffixNameGuard(teammateNames); const nextMembers: Record[] = []; @@ -4015,7 +4077,9 @@ export class TeamProvisioningService { const activeNames = metaMembers .filter((m) => !m.removedAt) .map((m) => m.name.trim()) - .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'); + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ); const keepName = createCliAutoSuffixNameGuard(activeNames); const removedFromMeta: string[] = []; @@ -4042,7 +4106,9 @@ export class TeamProvisioningService { nextMeta .filter((m) => !m.removedAt) .map((m) => m.name.trim()) - .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user') + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ) ); } } catch { @@ -4658,7 +4724,13 @@ export class TeamProvisioningService { [...PREFLIGHT_PING_ARGS], cwd, env, - PREFLIGHT_TIMEOUT_MS + PREFLIGHT_TIMEOUT_MS, + { + resolveOnOutputMatch: ({ stdout, stderr }) => { + const combined = `${stdout}\n${stderr}`.trim(); + return /\bPONG\b/i.test(combined); + }, + } ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -4705,7 +4777,7 @@ export class TeamProvisioningService { } const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim(); - const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED; + const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate); if (!isPong) { return { warning: @@ -4730,7 +4802,15 @@ export class TeamProvisioningService { args: string[], cwd: string, env: NodeJS.ProcessEnv, - timeoutMs: number + timeoutMs: number, + options?: { + /** + * Optional early success predicate. If this returns true based on + * buffered stdout/stderr, the probe resolves immediately (and the process + * is best-effort terminated) instead of waiting for `close`. + */ + resolveOnOutputMatch?: (ctx: { stdout: string; stderr: string }) => boolean; + } ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawnCli(claudePath, args, { @@ -4738,26 +4818,52 @@ export class TeamProvisioningService { env, stdio: ['ignore', 'pipe', 'pipe'], }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; + let stdoutText = ''; + let stderrText = ''; + let settled = false; const timeoutHandle = setTimeout(() => { + settled = true; killProcessTree(child); reject(new Error(`Timeout running: claude ${args.join(' ')}`)); }, timeoutMs); - child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); - child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + const maybeResolveEarly = (): void => { + if (settled) return; + if (!options?.resolveOnOutputMatch) return; + const ctx = { stdout: stdoutText.trim(), stderr: stderrText.trim() }; + if (!options.resolveOnOutputMatch(ctx)) return; + + settled = true; + clearTimeout(timeoutHandle); + // If the process printed the match but hangs during teardown, don't + // block the UI; terminate best-effort and resolve. + killProcessTree(child); + resolve({ exitCode: 0, stdout: ctx.stdout, stderr: ctx.stderr }); + }; + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutText += chunk.toString('utf8'); + maybeResolveEarly(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderrText += chunk.toString('utf8'); + maybeResolveEarly(); + }); child.once('error', (error) => { + if (settled) return; + settled = true; clearTimeout(timeoutHandle); reject(error); }); child.once('close', (exitCode) => { + if (settled) return; + settled = true; clearTimeout(timeoutHandle); resolve({ exitCode, - stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(), - stderr: Buffer.concat(stderrChunks).toString('utf8').trim(), + stdout: stdoutText.trim(), + stderr: stderrText.trim(), }); }); }); diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 39dc5742..0a20eaa5 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -3,14 +3,12 @@ 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 { useStore } from '@renderer/store'; import { Search, Terminal, X } from 'lucide-react'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CliLogsRichView } from './CliLogsRichView'; -import { - ClaudeLogsFilterPopover, - DEFAULT_CLAUDE_LOGS_FILTER, -} from './ClaudeLogsFilterPopover'; +import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import type { TeamClaudeLogsResponse } from '@shared/types'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; @@ -108,7 +106,10 @@ function filterStreamJsonText( return null; }; - const writeBlocks = (parsed: Record, blocks: AssistantContentBlock[]): Record => { + const writeBlocks = ( + parsed: Record, + blocks: AssistantContentBlock[] + ): Record => { if (Array.isArray(parsed.content)) { return { ...parsed, content: blocks }; } @@ -188,6 +189,7 @@ function filterStreamJsonText( } export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { + const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); const [pending, setPending] = useState(null); @@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J useEffect(() => { let cancelled = false; - const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => { + const computeNewCount = ( + committed: TeamClaudeLogsResponse, + latest: TeamClaudeLogsResponse + ): number => { if (committed.lines.length === 0) return latest.lines.length; const marker = committed.lines[0]; const idx = latest.lines.indexOf(marker); if (idx >= 0) return idx; - const diff = (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); + const diff = + (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); return Math.max(0, diff); }; @@ -331,8 +337,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J Showing {Math.min(data.total, visibleCount)} of{' '} {data.total} - ) : ( + ) : isAlive ? ( 'No logs yet.' + ) : ( + 'Team is not running.' )}
@@ -366,7 +374,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
-
+
{error ?

{error}

: null} {!error && filteredText.trim().length > 0 ? ( - {loading ? 'Loading…' : 'No logs captured.'} + {loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}

) : null} {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( -

- No matching logs. -

+

No matching logs.

) : null}
); }; - diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 45d8ad17..ef1c24dc 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -26,7 +26,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -129,7 +128,16 @@ export const SendMessageDialog = ({ } } prevOpenRef.current = open; - }, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]); + }, [ + open, + defaultRecipient, + defaultText, + defaultChip, + quotedMessage, + lastResult, + textDraft, + chipDraft, + ]); // Track whether auto-close is needed (avoid setState in render) useEffect(() => { @@ -381,7 +389,7 @@ export const SendMessageDialog = ({ ) : null} {textDraft.isSaved ? ( - Draft saved + + Draft saved + ) : null}
} @@ -442,7 +452,6 @@ export const SendMessageDialog = ({ Shown as notification preview. Team lead also sees this for peer messages.

- diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index a25757a3..5bc12d5e 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -7,7 +7,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useStore } from '@renderer/store'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; @@ -71,50 +70,45 @@ export const TaskCommentInput = ({ trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; - const addFiles = useCallback( - (files: FileList | File[]) => { - setAttachError(null); - const fileArray = Array.from(files); - for (const file of fileArray) { - if (!ACCEPTED_TYPES.has(file.type)) { - setAttachError(`Unsupported type: ${file.type}`); - continue; - } - if (file.size > MAX_FILE_SIZE) { - setAttachError( - `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` - ); - continue; - } - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result as string; - const base64 = result.split(',')[1]; - if (!base64) return; - const id = crypto.randomUUID(); - setPendingAttachments((prev) => { - if (prev.length >= MAX_ATTACHMENTS) { - setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); - return prev; - } - return [ - ...prev, - { - id, - filename: file.name, - mimeType: file.type, - base64Data: base64, - previewUrl: result, - size: file.size, - }, - ]; - }); - }; - reader.readAsDataURL(file); + const addFiles = useCallback((files: FileList | File[]) => { + setAttachError(null); + const fileArray = Array.from(files); + for (const file of fileArray) { + if (!ACCEPTED_TYPES.has(file.type)) { + setAttachError(`Unsupported type: ${file.type}`); + continue; } - }, - [] - ); + if (file.size > MAX_FILE_SIZE) { + setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`); + continue; + } + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + if (!base64) return; + const id = crypto.randomUUID(); + setPendingAttachments((prev) => { + if (prev.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + return prev; + } + return [ + ...prev, + { + id, + filename: file.name, + mimeType: file.type, + base64Data: base64, + previewUrl: result, + size: file.size, + }, + ]; + }); + }; + reader.readAsDataURL(file); + } + }, []); const removeAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); @@ -250,7 +244,7 @@ export const TaskCommentInput = ({ /> - {visibleComments.map((comment, index) => ( -
-
- - {comment.type === 'review_approved' ? ( - - - Approved + {visibleComments.map((comment, index) => ( +
+
+ + {comment.type === 'review_approved' ? ( + + + Approved + + ) : comment.type === 'review_request' ? ( + + + Review requested + + ) : null} + + {(() => { + const date = new Date(comment.createdAt); + return isNaN(date.getTime()) + ? 'unknown time' + : formatDistanceToNow(date, { addSuffix: true }); + })()} - ) : comment.type === 'review_request' ? ( - - - Review requested - - ) : null} - - {(() => { - const date = new Date(comment.createdAt); - return isNaN(date.getTime()) - ? 'unknown time' - : formatDistanceToNow(date, { addSuffix: true }); - })()} - - - - - - Reply to comment - -
- {(() => { - const reply = parseMessageReply(comment.text); - const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); - return ( - - {reply ? ( - + + + + Reply to comment + +
+ {(() => { + const reply = parseMessageReply(comment.text); + const rawForDisplay = reply ? reply.replyText : comment.text; + const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); + return ( + + {reply ? ( + -
- )} - - ); - })()} - {comment.attachments && comment.attachments.length > 0 ? ( - - ) : null} -
- ))} + ) : ( + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const id = link.getAttribute('href')?.replace('task://', ''); + if (id) onTaskIdClick(id); + } + } + : undefined + } + > + { + let t = linkifyTaskIdsInMarkdown(displayText); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight="max-h-none" + bare + /> + + )} + + ); + })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null} +
+ ))} {sortedComments.length > visibleComments.length ? ( @@ -347,7 +349,7 @@ export const TaskCommentsSection = ({
s.updateTaskFields); const [logsRefreshing, setLogsRefreshing] = useState(false); + const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); // Inline editing: subject const [editingSubject, setEditingSubject] = useState(false); @@ -289,6 +290,11 @@ export const TaskDetailDialog = ({ .map((t) => t.id); const isTodo = status === 'pending' && !kanbanColumn; const canReassign = isTodo && onOwnerChange; + const leadName = + members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead'; + const isLeadOwnedTask = + (currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() || + (currentTask.owner ?? '').trim().toLowerCase() === 'team-lead'; return ( !v && onClose()}> @@ -647,10 +653,23 @@ export const TaskDetailDialog = ({ title="Execution Logs" icon={} headerExtra={ - logsRefreshing ? ( - - - Updating... + logsRefreshing || executionPreviewOnline ? ( + + {executionPreviewOnline ? ( + + + + + ) : null} + {logsRefreshing ? ( + + + Updating... + + ) : null} ) : null } @@ -667,6 +686,11 @@ export const TaskDetailDialog = ({ taskStatus={currentTask.status} taskWorkIntervals={currentTask.workIntervals} onRefreshingChange={setLogsRefreshing} + // Only show a "latest messages" preview when this task is owned by a subagent. + // For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents), + // so filtering to "just the member messages" is unreliable and easy to mislead. + showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask} + onPreviewOnlineChange={setExecutionPreviewOnline} />
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index e8f2c1a9..1265b682 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { + SubagentRecentMessagesPreview, + type SubagentPreviewMessage, +} from '@renderer/components/team/members/SubagentRecentMessagesPreview'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { formatDuration } from '@renderer/utils/formatters'; import { @@ -14,6 +18,9 @@ import { MessageSquare, } from 'lucide-react'; +import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; +import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; + import type { EnhancedChunk } from '@renderer/types/data'; import type { MemberLogSummary } from '@shared/types'; @@ -28,6 +35,10 @@ interface MemberLogsTabProps { taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; /** Notifies parent when a background refresh starts/ends. */ onRefreshingChange?: (isRefreshing: boolean) => void; + /** Show last few subagent messages as a quick "where are we?" preview (task view only). */ + showSubagentPreview?: boolean; + /** Notifies parent when preview looks "online" (recent output). */ + onPreviewOnlineChange?: (isOnline: boolean) => void; } export const MemberLogsTab = ({ @@ -38,6 +49,8 @@ export const MemberLogsTab = ({ taskStatus, taskWorkIntervals, onRefreshingChange, + showSubagentPreview = false, + onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { const intervalsKey = useMemo( () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), @@ -52,6 +65,7 @@ export const MemberLogsTab = ({ const [expandedId, setExpandedId] = useState(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [previewChunks, setPreviewChunks] = useState(null); const getRowId = useCallback((log: MemberLogSummary): string => { return log.kind === 'subagent' @@ -82,6 +96,35 @@ export const MemberLogsTab = ({ return withIndex.map((x) => x.log); }, [logs]); + const previewLog = useMemo((): MemberLogSummary | null => { + if (!showSubagentPreview || taskId == null) return null; + + const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); + if (candidates.length === 0) return null; + + if (taskOwner) { + const target = taskOwner.trim().toLowerCase(); + const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target); + // When viewing task logs, this preview is intended to show the assigned owner's progress. + // If we can't confidently match a subagent log to the owner, don't show anything + // rather than risk showing a different member's activity (or a lead-attributed log). + return match ?? null; + } + + return candidates[0] ?? null; + }, [showSubagentPreview, sortedLogs, taskId, taskOwner]); + + const previewMessages = useMemo((): SubagentPreviewMessage[] => { + if (!previewChunks || previewChunks.length === 0) return []; + return extractSubagentPreviewMessages(previewChunks, 4); + }, [previewChunks]); + + const previewOnline = useMemo((): boolean => { + const newest = previewMessages[0]; + if (!newest) return false; + return Date.now() - newest.timestamp.getTime() <= 10_000; + }, [previewMessages]); + const expandedLogSummary = useMemo(() => { if (!expandedId) return null; return logs.find((log) => getRowId(log) === expandedId) ?? null; @@ -92,6 +135,14 @@ export const MemberLogsTab = ({ return () => onRefreshingChange?.(false); }, [refreshing, onRefreshingChange]); + useEffect(() => { + onPreviewOnlineChange?.(previewOnline); + }, [onPreviewOnlineChange, previewOnline]); + + useEffect(() => { + return () => onPreviewOnlineChange?.(false); + }, [onPreviewOnlineChange]); + useEffect(() => { if (!expandedId) return; if (expandedLogSummary) return; @@ -152,14 +203,68 @@ export const MemberLogsTab = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); - const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise => { - if (log.kind === 'subagent') { - const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); - return (d?.chunks ?? null) as EnhancedChunk[] | null; + const fetchDetailForLog = useCallback( + async (log: MemberLogSummary): Promise => { + if (log.kind === 'subagent') { + const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); + return (d?.chunks ?? null) as EnhancedChunk[] | null; + } + const d = await api.getSessionDetail(log.projectId, log.sessionId); + return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; + }, + [] + ); + + useEffect(() => { + if (!showSubagentPreview || taskId == null) { + setPreviewChunks(null); + return; } - const d = await api.getSessionDetail(log.projectId, log.sessionId); - return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; - }, []); + if (!previewLog) { + setPreviewChunks(null); + return; + } + + let cancelled = false; + const run = async (): Promise => { + try { + const next = await fetchDetailForLog(previewLog); + if (cancelled) return; + setPreviewChunks(next ? [...next] : null); + } catch { + if (cancelled) return; + setPreviewChunks(null); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [fetchDetailForLog, previewLog, showSubagentPreview, taskId]); + + useEffect(() => { + if (!showSubagentPreview || taskId == null) return; + if (!previewLog) return; + + const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing; + if (!shouldAutoRefreshPreview) return; + + let cancelled = false; + const interval = setInterval(async () => { + try { + const next = await fetchDetailForLog(previewLog); + if (cancelled) return; + setPreviewChunks(next ? [...next] : null); + } catch { + // keep last successful preview + } + }, 5000); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [fetchDetailForLog, previewLog, showSubagentPreview, taskId, taskStatus]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; @@ -245,17 +350,19 @@ export const MemberLogsTab = ({ return (
+ {showSubagentPreview && previewLog && previewMessages.length > 0 ? ( + + ) : null} {sortedLogs.map((log) => ( void handleExpand(log)} /> ))} @@ -358,3 +465,54 @@ function formatRelativeTime(isoString: string): string { if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } + +function extractSubagentPreviewMessages( + chunks: EnhancedChunk[], + limit: number +): SubagentPreviewMessage[] { + const conversation = transformChunksToConversation(chunks, [], false); + + const out: SubagentPreviewMessage[] = []; + + // Collect newest-first and stop as soon as we have enough. + for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) { + const item = conversation.items[i]; + if (item.type === 'ai') { + const enhanced = enhanceAIGroup(item.group); + const items = enhanced.displayItems ?? []; + for (let j = items.length - 1; j >= 0 && out.length < limit; j--) { + const di = items[j]; + if (di.type === 'output' && di.content.trim()) { + out.push({ + id: `${item.group.id}:output:${di.timestamp.toISOString()}:${j}`, + timestamp: di.timestamp, + kind: 'output', + label: 'Output', + content: di.content, + }); + } else if (di.type === 'teammate_message') { + out.push({ + id: `${item.group.id}:teammate:${di.teammateMessage.id}`, + timestamp: di.teammateMessage.timestamp, + kind: 'teammate_message', + label: `Message — ${di.teammateMessage.teammateId}`, + content: di.teammateMessage.content || di.teammateMessage.summary, + }); + } + } + } else if (item.type === 'user') { + const text = item.group.content.rawText ?? item.group.content.text ?? ''; + if (text.trim()) { + out.push({ + id: `${item.group.id}:user:${item.group.timestamp.toISOString()}`, + timestamp: item.group.timestamp, + kind: 'user', + label: 'User', + content: text, + }); + } + } + } + + return out; +} diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx new file mode 100644 index 00000000..fba5b10a --- /dev/null +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -0,0 +1,80 @@ +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { format } from 'date-fns'; + +export type SubagentPreviewMessageKind = + | 'output' + | 'text' + | 'tool_result' + | 'interruption' + | 'plan_exit' + | 'teammate_message' + | 'user' + | 'unknown'; + +export interface SubagentPreviewMessage { + id: string; + timestamp: Date; + kind: SubagentPreviewMessageKind; + /** Optional short label (e.g. tool name). */ + label?: string; + content: string; +} + +interface SubagentRecentMessagesPreviewProps { + messages: SubagentPreviewMessage[]; + memberName?: string; +} + +export const SubagentRecentMessagesPreview = ({ + messages, + memberName, +}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => { + if (!messages.length) return null; + + return ( +
+
+
+ Latest messages{memberName ? ` — ${memberName}` : ''} +
+
+ {format(messages[0].timestamp, 'h:mm:ss a')} +
+
+ +
+ {messages.map((m) => ( +
+
+
+ {m.label ? ( + + {m.label} + + ) : ( + {m.kind} + )} +
+
+ {format(m.timestamp, 'h:mm:ss a')} +
+
+ + {m.kind === 'tool_result' ? ( +
+                {m.content}
+              
+ ) : ( +
+ +
+ )} +
+ ))} +
+
+ ); +}; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2c638d4f..07fda10f 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -13,7 +13,6 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; @@ -49,7 +48,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { return ( -
+
{ - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); handleSend(); @@ -290,7 +292,10 @@ export const MessageComposer = ({ > {members.length > 5 && (
- + Team offline + + Team offline + ) : null}
@@ -406,7 +413,7 @@ export const MessageComposer = ({ void; - /** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */ + /** Called when Enter (without Shift) is pressed. */ onModEnter?: () => void; } @@ -503,8 +503,8 @@ export const MentionableTextarea = React.forwardRef) => { - // Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) { + // Enter (without Shift) → submit; Shift+Enter → newline + if (e.key === 'Enter' && !e.shiftKey && onModEnter) { e.preventDefault(); onModEnter(); return;