diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 73c3653a..a7206341 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -129,7 +129,7 @@ export class TeamMemberLogsFinder { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); try { await fs.access(leadJsonl); - if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId)) { + if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId, true)) { const leadSummary = await this.parseLeadSessionSummary( leadJsonl, projectId, @@ -470,7 +470,8 @@ export class TeamMemberLogsFinder { private async fileMentionsTaskId( filePath: string, teamName: string, - taskId: string + taskId: string, + assumeTeam: boolean = false ): Promise { const teamLower = teamName.trim().toLowerCase(); const taskIdStr = taskId.trim(); @@ -505,17 +506,84 @@ export class TeamMemberLogsFinder { return Boolean(cmdTaskId && cmdTaskId === taskIdStr); }; + const matchesTeamMentionText = (text: string): boolean => { + const t = text.toLowerCase(); + if (!t.includes(teamLower)) return false; + // Strongest signal: spawn/system prompt format includes: on team "X" (X) + // Use substring checks to avoid regex word-boundary issues with kebab-case names. + if (t.includes(`on team "${teamLower}"`)) return true; + if (t.includes(`on team '${teamLower}'`)) return true; + if (t.includes(`on team ${teamLower}`)) return true; + if (t.includes(`(${teamLower})`)) return true; + return false; + }; + + const extractTeamFromProcess = (entry: Record): string | null => { + const init = entry.init as Record | undefined; + const process = (entry.process ?? init?.process) as Record | undefined; + const team = process?.team as Record | undefined; + const raw = + typeof team?.teamName === 'string' + ? team.teamName + : typeof team?.team_name === 'string' + ? team.team_name + : typeof team?.name === 'string' + ? team.name + : null; + return typeof raw === 'string' ? raw.trim() : null; + }; + try { const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let teamSeen = assumeTeam; + let taskSeenWithoutTeam = false; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; try { const entry = JSON.parse(trimmed) as Record; + // Team detection (for TaskUpdate without team_name): accept only if we can + // confidently attribute the file to this team. + if (!teamSeen) { + const procTeam = extractTeamFromProcess(entry); + if (procTeam?.toLowerCase() === teamLower) { + teamSeen = true; + } + } + if (!teamSeen) { + const msg = entry.message as Record | undefined; + const rawContent = msg?.content ?? entry.content; + if (typeof rawContent === 'string' && matchesTeamMentionText(rawContent)) { + teamSeen = true; + } + } + const content = this.extractEntryContent(entry); if (!Array.isArray(content)) continue; + if (!teamSeen) { + // Check message text blocks for team mention (common in Solo spawn prompts) + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if ( + b.type === 'text' && + typeof b.text === 'string' && + matchesTeamMentionText(b.text) + ) { + teamSeen = true; + break; + } + } + } + + if (teamSeen && taskSeenWithoutTeam) { + rl.close(); + stream.destroy(); + return true; + } + for (const block of content) { if (!block || typeof block !== 'object') continue; const b = block as Record; @@ -530,14 +598,24 @@ export class TeamMemberLogsFinder { const inputTeam = extractTeamFromInput(input); const rawTaskId = input.taskId ?? input.task_id; const inputTaskId = extractTaskIdFromUnknown(rawTaskId); - if ( - inputTeam?.toLowerCase() === teamLower && - inputTaskId && - inputTaskId === taskIdStr - ) { - rl.close(); - stream.destroy(); - return true; + if (inputTaskId && inputTaskId === taskIdStr) { + // If team is present in the input, require exact match. + if (inputTeam) { + if (inputTeam.toLowerCase() === teamLower) { + rl.close(); + stream.destroy(); + return true; + } + } else { + // Some agents use TaskUpdate without team_name (common in Solo). + // Only accept when we have a separate team marker for this file. + if (teamSeen) { + rl.close(); + stream.destroy(); + return true; + } + taskSeenWithoutTeam = true; + } } // Deterministic CLI match: teamctl command line (Bash tool). @@ -550,6 +628,12 @@ export class TeamMemberLogsFinder { } } } + + if (teamSeen && taskSeenWithoutTeam) { + rl.close(); + stream.destroy(); + return true; + } } catch { // ignore parse errors } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c4278739..6023c409 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -760,14 +760,22 @@ function buildLaunchPrompt( if (isSolo) { step2And3Block = `2) Skip — solo team, no teammates to spawn. -3) Execute tasks sequentially and keep the board + user updated: +3) SOLO TASK EXECUTION (IMPORTANT — timing matters): + - Do NOT start executing tasks in THIS reconnect turn. + - This turn is ONLY to reconnect and confirm you are ready. + - After the reconnect is marked ready, you will receive a follow-up message telling you to begin work. + + When you receive that follow-up message: + - Execute tasks sequentially and keep the board + user updated: - Identify the next READY task (pending, not blocked by incomplete dependencies). - If the task is unassigned, set yourself ("${leadName}") as owner. - BEFORE doing any work on a task: mark it started (in_progress). - Immediately SendMessage "user" that you started task # (what you're doing + next step). - While working: after each meaningful milestone/decision/blocker, add a task comment on #. If the milestone is user-relevant, also SendMessage "user". - On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task # is complete and what you will do next. - - Do NOT start the next task until the current task is completed (default: one task in_progress at a time).`; + - Do NOT start the next task until the current task is completed (default: one task in_progress at a time). + + For this reconnect turn: review the task board snapshot above and output a short summary (1–2 sentences) confirming reconnect is complete and you are ready.`; } else { // Build per-member task snapshots to include in each teammate's spawn prompt const memberTaskBlocks = new Map(); @@ -859,7 +867,7 @@ Steps (execute in this exact order): ${step2And3Block} -4) After all steps, output a short summary of reconnected members and resumed tasks. +4) After all steps, output a short summary of reconnected members and what happens next. ${membersFooter} `; @@ -2613,6 +2621,48 @@ export class TeamProvisioningService { void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`) ); + + // Solo teams have no teammate processes to resume work; kick off task execution + // as a separate turn AFTER the launch is marked ready so the UI doesn't mix + // long-running task output into the "Launching team" live output stream. + if (run.request.members.length === 0) { + void (async () => { + try { + const taskReader = new TeamTaskReader(); + const tasks = await taskReader.getTasks(run.teamName); + const active = tasks.filter( + (t) => + (t.status === 'pending' || t.status === 'in_progress') && + !t.id.startsWith('_internal') + ); + if (active.length === 0) return; + + const board = buildTaskBoardSnapshot(tasks); + const message = [ + `Reconnected and ready. Begin executing tasks now.`, + `Execute tasks sequentially and keep the board + user updated:`, + `- Identify the next READY task (pending, not blocked by incomplete dependencies).`, + `- If the task is unassigned, set yourself as owner.`, + `- BEFORE doing any work on a task: mark it started (in_progress).`, + `- Immediately SendMessage "user" that you started task # (what you're doing + next step).`, + `- While working: after each meaningful milestone/decision/blocker, add a task comment on #. If user-relevant, also SendMessage "user".`, + `- On completion: add a final task comment (what changed + how to verify), mark the task completed, then SendMessage "user" that task # is complete and what you will do next.`, + `- Do NOT start the next task until the current task is completed (default: one task in_progress at a time).`, + board.trim(), + ] + .filter(Boolean) + .join('\n\n'); + + await this.sendMessageToTeam(run.teamName, message); + } catch (error) { + logger.warn( + `[${run.teamName}] Failed to kick off solo task resumption: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + })(); + } return; } diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 557f4ced..6e72fe9e 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -83,13 +83,14 @@ export const SidebarTaskItem = ({ const displaySubject = getDisplaySubject?.(task) ?? task.subject; const [editValue, setEditValue] = useState(displaySubject); const inputRef = useRef(null); - // Focus input when rename starts useEffect(() => { - if (isRenaming && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); }, [isRenaming]); // Reset edit value when renaming starts @@ -175,11 +176,8 @@ export const SidebarTaskItem = ({ onRenameCancel?.(); } }} - className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0 text-[13px] font-medium leading-tight text-text focus:outline-none" - style={{ - borderColor: 'var(--color-border-emphasis)', - backgroundColor: 'var(--color-surface-raised)', - }} + className="min-w-0 flex-1 border-none bg-transparent p-0 text-[13px] font-medium leading-tight focus:outline-none" + style={{ color: 'var(--color-text-muted)' }} onClick={(e) => e.stopPropagation()} /> ) : ( diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx index 64ce34f4..3036adbb 100644 --- a/src/renderer/components/sidebar/TaskContextMenu.tsx +++ b/src/renderer/components/sidebar/TaskContextMenu.tsx @@ -33,7 +33,7 @@ export const TaskContextMenu = ({
{children}
- + e.preventDefault()}> {isPinned ? ( <> diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 8f1a405c..1e411933 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -97,7 +97,7 @@ export const CollapsibleTeamSection = ({ {action &&
{action}
} {isOpen && ( -
+
{children}
)} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 6888a2cb..af5ca430 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -545,7 +545,7 @@ export const TaskDetailDialog = ({ } }} > - + -
+
+
{logs.map((log) => ( +
)} {!detailLoading && detailChunks && ( -
+
(activeChangeSet?.files ?? []).map((f) => f.filePath), + // Sort files to match the visual order of the file tree (directories first, then alphabetical) + const sortedFiles = useMemo( + () => sortItemsAsTree(activeChangeSet?.files ?? [], (f) => f.relativePath), [activeChangeSet] ); + // File paths for viewed tracking + const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]); + const pathChangeLabels = useMemo(() => { if (!activeChangeSet) return {} as Record< @@ -599,7 +603,7 @@ export const ChangeReviewDialog = ({ ); const diffNav = useDiffNavigation( - activeChangeSet?.files ?? [], + sortedFiles, activeFilePath, scrollToFile, activeEditorViewRef, @@ -1104,7 +1108,7 @@ export const ChangeReviewDialog = ({ className="relative flex min-h-0 flex-1 flex-col overflow-hidden" > (nodes: TreeNode[]): TreeNode[] { return a.name.localeCompare(b.name); }); } + +/** + * Flatten a sorted tree into a list of leaf items in display order. + * Mirrors the visual order of ReviewFileTree (directories first, then alphabetical at each level). + */ +function collectLeaves(nodes: TreeNode[], out: T[]): void { + for (const node of sortTreeNodes(nodes)) { + if (node.isFile && node.data != null) { + out.push(node.data); + } else { + collectLeaves(node.children, out); + } + } +} + +/** + * Sort a flat list of items to match the visual order of the file tree + * (directories first, then alphabetical at each level). + */ +export function sortItemsAsTree(items: T[], getPath: (item: T) => string): T[] { + if (items.length <= 1) return items; + const tree = buildTree(items, getPath); + const result: T[] = []; + collectLeaves(tree, result); + return result; +} diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 15f259d8..c468ffb0 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -521,7 +521,7 @@ describe('TeamMemberLogsFinder', () => { const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); - // Team A subagent referencing taskId 9 for team-a + // Team A subagent referencing taskId 9 (no team_name in tool input, as in Solo/older runs) await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-a1.jsonl'), [ @@ -542,7 +542,7 @@ describe('TeamMemberLogsFinder', () => { { type: 'tool_use', name: 'TaskUpdate', - input: { team_name: teamA, taskId: '9', status: 'in_progress' }, + input: { taskId: '9', status: 'in_progress' }, }, ], }, @@ -551,7 +551,7 @@ describe('TeamMemberLogsFinder', () => { 'utf8' ); - // Team B subagent referencing taskId 9 for team-b (must NOT be included when querying team-a) + // Team B subagent referencing taskId 9 (must NOT be included when querying team-a) await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-b1.jsonl'), [ @@ -569,7 +569,7 @@ describe('TeamMemberLogsFinder', () => { { type: 'tool_use', name: 'TaskUpdate', - input: { team_name: teamB, taskId: '9', status: 'in_progress' }, + input: { taskId: '9', status: 'in_progress' }, }, ], },