From a59e6575206790d7c48fb06055e1f12e26b8a683 Mon Sep 17 00:00:00 2001 From: Luchong Date: Sun, 20 Sep 2026 23:44:37 +0800 Subject: [PATCH] feat(sidebar): show live Codex subagent progress Rebased onto upstream/main (d5dc7b9cf8): the original 11 branch commits are folded into this one. Adaptations the new base required: - agent-hook-listener split (#16187): hasCodexParentTranscript moved to providers/codex-state.ts and refreshCodexSubagentTranscriptStatus to providers/codex-events.ts. - server.ts split into agent-hooks/server/*: the poll loop now lives in server-status-retries.ts on top of CodexSubagentPollScheduler, which gained an optional per-entry delayMs so the quiet-roster backoff (1s -> 5s) coexists with the shared coalesced timer. - renderer store split: the codex-subagent-progress modal type moved to slices/ui/ui-slice-contract-contextual.ts. - compact row: canonical-label tooltip now composes with upstream's monitoring ordering; row activation moved to use-codex-subagent-row-activation.ts to stay under max-lines. - tests adapted: TooltipProvider wraps (AgentStateDot tooltip), useNow-based freshness (mocked in the sheet test), scheduler-backed poll assertions, pane-agent-identity inventory entry. --- .../codex-subagent-poll-policy.test.ts | 32 ++ .../agent-hooks/codex-subagent-poll-policy.ts | 23 ++ ...r-codex-subagent-dynamic-lifecycle.test.ts | 301 +++++++++++++++ .../server-codex-subagent-transcript.test.ts | 79 ++++ .../agent-hooks/server/server-lifecycle.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 1 - .../server/server-status-retries.ts | 53 ++- .../native-chat/transcript-reader.test.ts | 41 +++ src/renderer/src/assets/main.css | 14 +- .../agent-child-disclosure-label.ts | 16 + .../codex-subagent-display-label.ts | 10 + .../DashboardAgentChildDisclosure.tsx | 12 +- .../dashboard/DashboardAgentRow.test.tsx | 3 + .../dashboard/DashboardAgentRow.tsx | 16 +- .../components/dashboard/useDashboardData.ts | 9 +- .../CodexSubagentProgressSheet.test.tsx | 302 +++++++++++++++ .../sidebar/CodexSubagentProgressSheet.tsx | 266 ++++++++++++++ .../WorktreeCardAgents.activation.test.tsx | 109 +++++- .../components/sidebar/WorktreeCardAgents.tsx | 31 +- ...x-subagent-progress-host-authority.test.ts | 191 ++++++++++ .../codex-subagent-progress-host-authority.ts | 102 ++++++ .../codex-subagent-progress-route.test.ts | 45 +++ .../sidebar/codex-subagent-progress-route.ts | 20 + .../codex-subagent-progress-selection.test.ts | 25 ++ .../codex-subagent-progress-selection.ts | 20 + .../codex-subagent-progress-target.test.ts | 69 ++++ .../sidebar/codex-subagent-progress-target.ts | 112 ++++++ .../codex-subagent-transcript-scope.test.ts | 55 +++ .../codex-subagent-transcript-scope.ts | 27 ++ src/renderer/src/components/sidebar/index.tsx | 2 + .../use-codex-subagent-row-activation.ts | 46 +++ .../sidebar/useWorktreeAgentRows.test.ts | 25 +- ...e-card-compact-agent-row.subagent.test.tsx | 101 +++++ .../worktree-card-compact-agent-row.tsx | 70 ++-- .../sidebar/worktree-subagent-child-rows.ts | 18 +- src/renderer/src/components/ui/sheet.test.tsx | 23 ++ src/renderer/src/components/ui/sheet.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 28 ++ src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + .../slices/ui/ui-slice-contract-contextual.ts | 1 + .../providers/codex-events.ts | 27 +- .../providers/codex-state.ts | 5 + src/shared/codex-subagent-poll-scheduler.ts | 11 +- src/shared/codex-subagent-roster.test.ts | 14 + src/shared/codex-subagent-roster.ts | 10 +- .../codex-subagent-transcript-lifecycle.ts | 44 +++ src/shared/codex-subagent-transcript-model.ts | 22 ++ src/shared/codex-subagent-transcript.test.ts | 70 +++- src/shared/codex-subagent-transcript.ts | 75 ++-- .../pane-agent-identity-inventory.test.ts | 1 + ...codex-subagent-concurrent-progress.spec.ts | 147 ++++++++ .../codex-subagent-sidebar-lifecycle.spec.ts | 344 ++++++++++++++++++ .../codex-subagent-state-and-session.spec.ts | 276 ++++++++++++++ .../helpers/codex-subagent-rollout-fixture.ts | 277 ++++++++++++++ 57 files changed, 3518 insertions(+), 133 deletions(-) create mode 100644 src/main/agent-hooks/codex-subagent-poll-policy.test.ts create mode 100644 src/main/agent-hooks/codex-subagent-poll-policy.ts create mode 100644 src/main/agent-hooks/server-codex-subagent-dynamic-lifecycle.test.ts create mode 100644 src/renderer/src/components/agent-child-disclosure-label.ts create mode 100644 src/renderer/src/components/codex-subagent-display-label.ts create mode 100644 src/renderer/src/components/sidebar/CodexSubagentProgressSheet.test.tsx create mode 100644 src/renderer/src/components/sidebar/CodexSubagentProgressSheet.tsx create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.test.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-route.test.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-route.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-selection.test.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-selection.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-target.test.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-progress-target.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-transcript-scope.test.ts create mode 100644 src/renderer/src/components/sidebar/codex-subagent-transcript-scope.ts create mode 100644 src/renderer/src/components/sidebar/use-codex-subagent-row-activation.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-row.subagent.test.tsx create mode 100644 src/renderer/src/components/ui/sheet.test.tsx create mode 100644 src/shared/codex-subagent-transcript-lifecycle.ts create mode 100644 src/shared/codex-subagent-transcript-model.ts create mode 100644 tests/e2e/codex-subagent-concurrent-progress.spec.ts create mode 100644 tests/e2e/codex-subagent-sidebar-lifecycle.spec.ts create mode 100644 tests/e2e/codex-subagent-state-and-session.spec.ts create mode 100644 tests/e2e/helpers/codex-subagent-rollout-fixture.ts diff --git a/src/main/agent-hooks/codex-subagent-poll-policy.test.ts b/src/main/agent-hooks/codex-subagent-poll-policy.test.ts new file mode 100644 index 000000000000..a557fe4470b0 --- /dev/null +++ b/src/main/agent-hooks/codex-subagent-poll-policy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' + +import { + advanceCodexSubagentPollPlan, + CODEX_SUBAGENT_POLL_ACTIVE_MS, + CODEX_SUBAGENT_POLL_QUIET_MAX_MS, + INITIAL_CODEX_SUBAGENT_POLL_PLAN +} from './codex-subagent-poll-policy' + +describe('Codex subagent poll policy', () => { + it('backs off quiet polls to a bounded cadence', () => { + const first = advanceCodexSubagentPollPlan(INITIAL_CODEX_SUBAGENT_POLL_PLAN, true) + expect(first).toEqual({ delayMs: 2_000 }) + + const second = advanceCodexSubagentPollPlan(first, true) + expect(second).toEqual({ delayMs: 4_000 }) + + const capped = advanceCodexSubagentPollPlan(second, true) + expect(capped).toEqual({ delayMs: CODEX_SUBAGENT_POLL_QUIET_MAX_MS }) + expect(advanceCodexSubagentPollPlan(capped, true)).toEqual({ + delayMs: CODEX_SUBAGENT_POLL_QUIET_MAX_MS + }) + }) + + it('restores the active cadence when activity returns', () => { + const quiet = advanceCodexSubagentPollPlan(INITIAL_CODEX_SUBAGENT_POLL_PLAN, true) + + expect(advanceCodexSubagentPollPlan(quiet, false)).toEqual({ + delayMs: CODEX_SUBAGENT_POLL_ACTIVE_MS + }) + }) +}) diff --git a/src/main/agent-hooks/codex-subagent-poll-policy.ts b/src/main/agent-hooks/codex-subagent-poll-policy.ts new file mode 100644 index 000000000000..8ecdfe16a75a --- /dev/null +++ b/src/main/agent-hooks/codex-subagent-poll-policy.ts @@ -0,0 +1,23 @@ +export const CODEX_SUBAGENT_POLL_ACTIVE_MS = 1_000 +// Why: cap delayed-child discovery lag while cutting steady quiet polling by 80%. +export const CODEX_SUBAGENT_POLL_QUIET_MAX_MS = 5_000 + +export type CodexSubagentPollPlan = Readonly<{ + delayMs: number +}> + +export const INITIAL_CODEX_SUBAGENT_POLL_PLAN: CodexSubagentPollPlan = Object.freeze({ + delayMs: CODEX_SUBAGENT_POLL_ACTIVE_MS +}) + +export function advanceCodexSubagentPollPlan( + current: CodexSubagentPollPlan, + quiet: boolean +): CodexSubagentPollPlan { + if (!quiet) { + return INITIAL_CODEX_SUBAGENT_POLL_PLAN + } + return { + delayMs: Math.min(current.delayMs * 2, CODEX_SUBAGENT_POLL_QUIET_MAX_MS) + } +} diff --git a/src/main/agent-hooks/server-codex-subagent-dynamic-lifecycle.test.ts b/src/main/agent-hooks/server-codex-subagent-dynamic-lifecycle.test.ts new file mode 100644 index 000000000000..bcfb0ea11f7b --- /dev/null +++ b/src/main/agent-hooks/server-codex-subagent-dynamic-lifecycle.test.ts @@ -0,0 +1,301 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AgentHookServer } from './server' +import { + CODEX_SUBAGENT_POLL_ACTIVE_MS, + CODEX_SUBAGENT_POLL_QUIET_MAX_MS +} from './codex-subagent-poll-policy' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' +const SECOND_CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f3170' + +type AgentHookServerPollInternals = { + codexSubagentPollScheduler: { has(paneKey: string): boolean } +} + +function line(record: unknown): string { + return `${JSON.stringify(record)}\n` +} + +function activityLine( + threadId: string, + agentPath: string, + kind: 'started' | 'interrupted' +): string { + return line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: threadId, + agent_path: agentPath, + kind + } + }) +} + +function spawnLine(threadId: string, agentPath: string): string { + return activityLine(threadId, agentPath, 'started') +} + +async function postCodexHook( + server: AgentHookServer, + payload: Record +): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', payload }) + }) +} + +describe('AgentHookServer Codex dynamic subagent lifecycle', () => { + const dirs: string[] = [] + + afterEach(() => { + vi.useRealTimers() + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('keeps watching when the roster empties before another child starts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + const secondChildPath = join(dir, `rollout-child-${SECOND_CHILD_ID}.jsonl`) + const started = line({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(childPath, started) + writeFileSync(secondChildPath, started) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postCodexHook(server, { + hook_event_name: 'Stop', + session_id: 'root-session', + transcript_path: parentPath + }) + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: CHILD_ID }) + ]) + + appendFileSync(parentPath, activityLine(CHILD_ID, '/root/pr_review', 'interrupted')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 3_000, interval: 50 } + ) + + appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/perf_audit')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: SECOND_CHILD_ID, description: '/root/perf_audit' }) + ]) + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + it('discovers the first child after binding an initially empty parent transcript', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postCodexHook(server, { + hook_event_name: 'Stop', + session_id: 'root-session', + transcript_path: parentPath + }) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + + appendFileSync(parentPath, spawnLine(CHILD_ID, '/root/late_spawn')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: CHILD_ID, description: '/root/late_spawn' }) + ]) + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + it('keeps a quiet poll bounded and rearms it on the next Codex turn', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + const secondChildPath = join(dir, `rollout-child-${SECOND_CHILD_ID}.jsonl`) + const started = line({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(parentPath, started) + writeFileSync(childPath, started) + writeFileSync(secondChildPath, started) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + vi.useFakeTimers() + vi.setSystemTime(0) + const internals = server as unknown as AgentHookServerPollInternals + try { + await postCodexHook(server, { + hook_event_name: 'Stop', + session_id: 'root-session', + transcript_path: parentPath + }) + expect(internals.codexSubagentPollScheduler.has(PANE_KEY)).toBe(true) + + await vi.advanceTimersByTimeAsync(60_000 + CODEX_SUBAGENT_POLL_QUIET_MAX_MS) + expect(internals.codexSubagentPollScheduler.has(PANE_KEY)).toBe(true) + + appendFileSync(parentPath, spawnLine(CHILD_ID, '/root/long_quiet')) + await vi.advanceTimersByTimeAsync(CODEX_SUBAGENT_POLL_QUIET_MAX_MS) + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: CHILD_ID, description: '/root/long_quiet' }) + ]) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.advanceTimersByTimeAsync(CODEX_SUBAGENT_POLL_ACTIVE_MS) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + + await postCodexHook(server, { + hook_event_name: 'UserPromptSubmit', + session_id: 'root-session', + prompt: 'continue' + }) + expect(internals.codexSubagentPollScheduler.has(PANE_KEY)).toBe(true) + + appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/rearmed')) + await vi.advanceTimersByTimeAsync(CODEX_SUBAGENT_POLL_ACTIVE_MS) + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: SECOND_CHILD_ID, description: '/root/rearmed' }) + ]) + } finally { + server.stop() + vi.useRealTimers() + } + }) + + it('keeps parent discovery active across child hook lifecycle events', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + const secondChildPath = join(dir, `rollout-child-${SECOND_CHILD_ID}.jsonl`) + const started = line({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(childPath, started) + writeFileSync(secondChildPath, started) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postCodexHook(server, { + hook_event_name: 'Stop', + session_id: 'root-session', + transcript_path: parentPath + }) + await postCodexHook(server, { + hook_event_name: 'PreToolUse', + agent_id: CHILD_ID, + agent_type: 'reviewer', + tool_name: 'exec_command' + }) + + appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/perf_audit')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents?.map(({ id }) => id)).toEqual([ + CHILD_ID, + SECOND_CHILD_ID + ]) + }, + { timeout: 3_000, interval: 50 } + ) + + await postCodexHook(server, { hook_event_name: 'SubagentStop', agent_id: CHILD_ID }) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + expect(server.getStatusSnapshot()[0]?.subagents?.map(({ id }) => id)).toEqual([ + SECOND_CHILD_ID + ]) + + appendFileSync( + secondChildPath, + line({ type: 'event_msg', payload: { type: 'task_complete' } }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + it('switches the watcher when the pane starts a new Codex session', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const firstParentPath = join(dir, 'rollout-first-parent.jsonl') + const nextParentPath = join(dir, 'rollout-next-parent.jsonl') + const firstChildPath = join(dir, `rollout-first-child-${CHILD_ID}.jsonl`) + const nextChildPath = join(dir, `rollout-next-child-${SECOND_CHILD_ID}.jsonl`) + const started = line({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(firstParentPath, started) + writeFileSync(nextParentPath, started) + writeFileSync(firstChildPath, started) + writeFileSync(nextChildPath, started) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + await postCodexHook(server, { + hook_event_name: 'Stop', + session_id: 'first-root-session', + transcript_path: firstParentPath + }) + await postCodexHook(server, { + hook_event_name: 'SessionStart', + session_id: 'next-root-session', + transcript_path: nextParentPath + }) + + appendFileSync(firstParentPath, spawnLine(CHILD_ID, '/root/stale_child')) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + + appendFileSync(nextParentPath, spawnLine(SECOND_CHILD_ID, '/root/current_child')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: SECOND_CHILD_ID, description: '/root/current_child' }) + ]) + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts index dd771d8b2bed..950d3b02ac3a 100644 --- a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -204,4 +204,83 @@ describe('AgentHookServer Codex subagent transcript polling', () => { server.stop() } }) + + it('parks a waiting child without keeping a completed lead working', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/test_agent')) + writeFileSync( + childPath, + line({ type: 'event_msg', payload: { type: 'task_started' } }) + + line({ + type: 'response_item', + payload: { type: 'function_call', name: 'wait_agent', call_id: 'call-wait-1' } + }) + ) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'Stop', + session_id: 'root-session', + transcript_path: parentPath + } + }) + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + subagents: [expect.objectContaining({ id: CHILD_ID, state: 'idle' })] + }) + + appendFileSync( + childPath, + line({ + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'call-wait-1', output: '' } + }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + subagents: [expect.objectContaining({ id: CHILD_ID, state: 'working' })] + }) + }, + { timeout: 3_000, interval: 50 } + ) + + appendFileSync( + childPath, + line({ + type: 'response_item', + payload: { type: 'function_call', name: 'wait_agent', call_id: 'call-wait-2' } + }) + ) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + subagents: [expect.objectContaining({ id: CHILD_ID, state: 'idle' })] + }) + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) }) diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 5b8bf908d552..a450e2308866 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -132,7 +132,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, enriched) } } res.writeHead(204) diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index b637e8082b8e..23f17f4b69cd 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -217,7 +217,6 @@ export abstract class AgentHookServerState { protected abstract clearAllCodexSubagentPolls(): void protected abstract scheduleCodexSubagentPoll( source: AgentHookSource, - body: unknown, original: EnrichedAgentHookEventPayload ): void protected abstract scheduleAssistantMessageRetry( diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 4620506b210e..9143900e7c94 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -1,5 +1,8 @@ -import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' -import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { + hasCodexParentTranscript, + hasCodexTranscriptSubagents +} from '../../../shared/agent-hook-listener/providers/codex-state' +import { refreshCodexSubagentTranscriptStatus } from '../../../shared/agent-hook-listener/providers/codex-events' import { hasPendingAgentResultText, preparePendingGrokResultDiscovery @@ -12,12 +15,17 @@ import { ASSISTANT_MESSAGE_RETRY_MS, CODEX_SUBAGENT_POLL_MS } from './server-constants' +import { + advanceCodexSubagentPollPlan, + INITIAL_CODEX_SUBAGENT_POLL_PLAN, + type CodexSubagentPollPlan +} from '../codex-subagent-poll-policy' import { AgentHookServerStatusUpdate } from './server-status-update' type CodexSubagentPoll = { source: AgentHookSource - body: unknown original: EnrichedAgentHookEventPayload + plan: CodexSubagentPollPlan } export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { @@ -45,22 +53,27 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus protected scheduleCodexSubagentPoll( source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload + original: EnrichedAgentHookEventPayload, + plan: CodexSubagentPollPlan = INITIAL_CODEX_SUBAGENT_POLL_PLAN ): void { // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. if (source !== 'codex') { return } this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + // Why: poll while a parent rollout is tracked even with an empty roster, so late-spawned children are discovered. + if (!hasCodexParentTranscript(this.state, original.paneKey)) { return } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + this.codexSubagentPollScheduler.schedule( + original.paneKey, + { source, original, plan }, + plan.delayMs + ) } private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { - const { source, body, original } = poll + const { source, original, plan } = poll // Keep the identity check at callback time: a newer event supersedes this // payload even when its pane still has transcript children. if ( @@ -70,15 +83,29 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus ) { return } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized) { + const payload = refreshCodexSubagentTranscriptStatus(this.state, original.paneKey) + if (!payload) { return } const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + JSON.stringify(payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged + ? this.applyNormalizedStatus({ + paneKey: original.paneKey, + launchToken: original.launchToken, + tabId: original.tabId, + worktreeId: original.worktreeId, + connectionId: original.connectionId, + providerSession: original.providerSession, + payload + }) + : original + // Why: inactive empty rosters only need late-rollout grace; the next Codex hook restores active polling. + const quiet = + !hasCodexTranscriptSubagents(this.state, original.paneKey) && + (payload.state !== 'working' || original.hookEventName === 'SessionStart') if (next) { - this.scheduleCodexSubagentPoll(source, body, next) + this.scheduleCodexSubagentPoll(source, next, advanceCodexSubagentPollPlan(plan, quiet)) } } diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index eb333cd8fdaf..30fcde0e3f96 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -259,6 +259,47 @@ describe('readNativeChatTranscript (codex)', () => { const reasoning = result.messages.find((m) => m.role === 'reasoning') expect(reasoning?.blocks[0]).toEqual({ type: 'text', text: 'I will run it' }) }) + + it('maps custom tool activity emitted by Codex subagents', async () => { + const filePath = await writeFixture('orca-native-chat-codex-custom-tool-', [ + { + type: 'response_item', + timestamp: '2026-06-01T10:00:00.000Z', + payload: { + type: 'custom_tool_call', + name: 'exec', + input: 'const result = await tools.exec_command({ cmd: "git status" })' + } + }, + { + type: 'response_item', + timestamp: '2026-06-01T10:00:01.000Z', + payload: { + type: 'custom_tool_call_output', + output: [ + { type: 'input_text', text: 'Script completed\n' }, + { type: 'input_text', text: '## main...origin/main\n' } + ] + } + } + ]) + + const result = await readNativeChatTranscript('codex', 'codex-sess', { filePath }) + if (!('messages' in result)) { + throw new Error('expected messages') + } + + expect(result.messages.map((message) => message.role)).toEqual(['assistant', 'tool']) + expect(result.messages[0]?.blocks[0]).toEqual({ + type: 'tool-call', + name: 'exec', + input: 'const result = await tools.exec_command({ cmd: "git status" })' + }) + expect(result.messages[1]?.blocks[0]).toEqual({ + type: 'tool-result', + output: 'Script completed\n## main...origin/main' + }) + }) }) describe('readNativeChatTranscript (errors)', () => { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index df9c8e733973..8d0414c859e1 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1322,7 +1322,8 @@ html.native-shell .app-layout { background: color-mix(in srgb, var(--sidebar-foreground) 1.25%, transparent); } -.worktree-agent-row-hover[data-focused-agent-pane='true'] { +.worktree-agent-row-hover[data-focused-agent-pane='true'], +.worktree-agent-row-hover[data-current='true'] { /* Why: a borderless fill. --sidebar-accent ≈ --sidebar in light mode, so the selection wash mixes sidebar-foreground into the accent to stay legible without going so dark that muted text (timestamp) drops out. */ @@ -1333,7 +1334,8 @@ html.native-shell .app-layout { background: color-mix(in srgb, var(--accent) 18%, transparent); } -.dark .worktree-agent-row-hover[data-focused-agent-pane='true'] { +.dark .worktree-agent-row-hover[data-focused-agent-pane='true'], +.dark .worktree-agent-row-hover[data-current='true'] { background: color-mix(in srgb, var(--accent) 70%, transparent); } @@ -1660,13 +1662,17 @@ html.native-shell .app-layout { } [data-compact-agent-list='true'] - .compact-agent-row.worktree-agent-row-hover[data-focused-agent-pane='true'] { + .compact-agent-row.worktree-agent-row-hover[data-focused-agent-pane='true'], +[data-compact-agent-list='true'] .compact-agent-row.worktree-agent-row-hover[data-current='true'] { background: color-mix(in srgb, var(--sidebar-foreground) 12%, var(--sidebar-accent)); } .dark [data-compact-agent-list='true'] - .compact-agent-row.worktree-agent-row-hover[data-focused-agent-pane='true'] { + .compact-agent-row.worktree-agent-row-hover[data-focused-agent-pane='true'], +.dark + [data-compact-agent-list='true'] + .compact-agent-row.worktree-agent-row-hover[data-current='true'] { background: color-mix(in srgb, var(--accent) 70%, transparent); } diff --git a/src/renderer/src/components/agent-child-disclosure-label.ts b/src/renderer/src/components/agent-child-disclosure-label.ts new file mode 100644 index 000000000000..c70b36d9c84a --- /dev/null +++ b/src/renderer/src/components/agent-child-disclosure-label.ts @@ -0,0 +1,16 @@ +import { translate } from '@/i18n/i18n' + +export function agentChildDisclosureLabel(expanded: boolean, count: number): string { + if (expanded) { + return count === 1 + ? translate('components.agent-child-disclosure.hide-one', 'Hide 1 child agent') + : translate('components.agent-child-disclosure.hide-other', 'Hide {{count}} child agents', { + count + }) + } + return count === 1 + ? translate('components.agent-child-disclosure.show-one', 'Show 1 child agent') + : translate('components.agent-child-disclosure.show-other', 'Show {{count}} child agents', { + count + }) +} diff --git a/src/renderer/src/components/codex-subagent-display-label.ts b/src/renderer/src/components/codex-subagent-display-label.ts new file mode 100644 index 000000000000..774c403791a9 --- /dev/null +++ b/src/renderer/src/components/codex-subagent-display-label.ts @@ -0,0 +1,10 @@ +const CODEX_ROOT_AGENT_PATH = '/root/' + +export function formatCodexSubagentDisplayLabel(value: string): string { + const label = value.trim() + if (!label.startsWith(CODEX_ROOT_AGENT_PATH)) { + return label + } + const pathSegments = label.slice(CODEX_ROOT_AGENT_PATH.length).split('/').filter(Boolean) + return pathSegments.at(-1) ?? label +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx index 82cd87fe47c0..fa5531152d39 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentChildDisclosure.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import { ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' +import { agentChildDisclosureLabel } from '@/components/agent-child-disclosure-label' type Props = { childAgentCount?: number @@ -52,15 +52,7 @@ export function DashboardAgentChildDisclosure({ onMouseDown={stopMouseDown} onKeyDown={stopKeyDown} className="-ml-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-sidebar-border/80 bg-sidebar text-foreground/80 shadow-xs hover:bg-sidebar-accent hover:text-foreground" - aria-label={translate( - 'auto.components.dashboard.DashboardAgentChildDisclosure.1b57ce9fa4', - '{{value0}} {{value1}} child {{value2}}', - { - value0: childAgentsExpanded ? 'Hide' : 'Show', - value1: childAgentCount, - value2: childAgentCount === 1 ? 'agent' : 'agents' - } - )} + aria-label={agentChildDisclosureLabel(childAgentsExpanded, childAgentCount)} aria-expanded={childAgentsExpanded} > { it('renders orchestration child rows with a connector and tree level', () => { const markup = renderRow( makeAgent({ + rowSource: 'subagent', + subagentSession: { id: 'child-1', provider: 'codex', parentPaneKey: 'tab-1:leaf-1' }, lineage: { depth: 1, isFirstSibling: true, @@ -421,6 +423,7 @@ describe('DashboardAgentRow', () => { ) expect(markup).toContain('data-agent-lineage-connector="last"') + expect(markup).toContain('data-subagent-id="child-1"') expect(markup).toContain('role="treeitem"') expect(markup).toContain('aria-level="2"') expect(classTokens(markup)).toContain('pl-5') diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 3f2b571d01cb..37b0d5bbd920 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react' import { cn } from '@/lib/utils' import { AgentStateDot, agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' import { AgentIcon } from '@/lib/agent-catalog' +import { formatCodexSubagentDisplayLabel } from '@/components/codex-subagent-display-label' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { DashboardAgentChildDisclosure } from './DashboardAgentChildDisclosure' @@ -55,6 +56,8 @@ type Props = { hideExpand?: boolean /** Reuse the row's hover tint to show the focused terminal pane's agent. */ isFocusedPane?: boolean + /** Mark a synthetic child row whose inspector is currently open. */ + isCurrentAgent?: boolean // Why: inline-card orchestration rows fold children under a leading chevron. childAgentCount?: number childAgentsExpanded?: boolean @@ -79,6 +82,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ hideIdentityIcon = false, hideExpand = false, isFocusedPane = false, + isCurrentAgent = false, childAgentCount, childAgentsExpanded = false, onToggleChildAgents, @@ -92,6 +96,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ typeof childAgentCount === 'number' && childAgentCount > 0 && typeof onToggleChildAgents === 'function' + const isHighlighted = isFocusedPane || isCurrentAgent const [expanded, setExpanded] = useState(false) const handleToggleExpanded = useCallback(() => { setExpanded((prev) => !prev) @@ -130,7 +135,9 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const conversationName = useAgentRowConversationName(agent) const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. - const displayLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) + const sourceLabel = prompt || agentStateLabel(asDotState(agent.state, agent.entry.workingMode)) + const isCodexSubagent = agent.subagentSession?.provider === 'codex' + const displayLabel = isCodexSubagent ? formatCodexSubagentDisplayLabel(sourceLabel) : sourceLabel const model = agent.entry.model?.trim() ?? '' const isMonitoring = agent.state === 'working' && agent.entry.workingMode === 'monitoring' const isWorking = agent.state === 'working' && !isMonitoring @@ -194,9 +201,12 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ )} data-focused-agent-pane={isFocusedPane ? 'true' : undefined} data-agent-send-target={sendTargetStatus} + data-current={isCurrentAgent ? 'true' : undefined} + data-subagent-id={agent.subagentSession?.id} title={titleParts.length > 0 ? titleParts.join(' • ') : undefined} role={participatesInLineage ? 'treeitem' : undefined} aria-level={participatesInLineage ? (lineage?.depth ?? 0) + 1 : undefined} + aria-selected={participatesInLineage ? isCurrentAgent : undefined} > {lineageChildCount > 0 && !hideLineageConnectors ? ( {displayLabel} diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 02d4010edcdf..450bd29199d8 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -12,8 +12,15 @@ export type DashboardAgentRow = { rowSource?: 'live' | 'retained' | 'subagent' state: AgentRowState /** Pane to focus when the row is activated, when it differs from paneKey. - * Subagent rows have no pane of their own and activate their parent's. */ + * Subagent rows use this as the parent-terminal fallback. */ activationPaneKey?: string + /** Explicit provider session identity for an in-process child. The child has + * no PTY, so consumers must not derive this from the synthetic pane key. */ + subagentSession?: { + id: string + provider: AgentType + parentPaneKey: string + } /** When this agent first began reporting status. Derived from the oldest * stateHistory entry, falling back to updatedAt when no history exists yet. * Used to sort agents by when they started. */ diff --git a/src/renderer/src/components/sidebar/CodexSubagentProgressSheet.test.tsx b/src/renderer/src/components/sidebar/CodexSubagentProgressSheet.test.tsx new file mode 100644 index 000000000000..1f91ee24bcc1 --- /dev/null +++ b/src/renderer/src/components/sidebar/CodexSubagentProgressSheet.test.tsx @@ -0,0 +1,302 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' + +const mocks = vi.hoisted(() => ({ + state: { + activeModal: 'codex-subagent-progress', + modalData: {} as Record, + closeModal: vi.fn(), + agentStatusByPaneKey: {} as Record, + agentStatusEpoch: 0 + }, + liveSession: vi.fn() +})) + +// Why: the sheet samples freshness from useNow's shared clock; route it through the +// per-test Date.now mock so static renders see the test's controlled time. +vi.mock('@/hooks/use-now', () => ({ + useNow: () => Date.now() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) + +vi.mock('@/components/native-chat/use-native-chat-live-session', () => ({ + useNativeChatLiveSession: mocks.liveSession +})) + +vi.mock('@/components/native-chat/NativeChatMessageList', () => ({ + NativeChatMessageList: ({ + session, + isWorking + }: { + session: { messages: { id: string }[]; hasMore: boolean } + isWorking: boolean + }) => ( +
message.id).join(',')} + data-has-more={String(session.hasMore)} + /> + ) +})) + +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children, modal }: { children: ReactNode; modal?: boolean }) => ( +
{children}
+ ), + SheetContent: ({ children, showOverlay }: { children: ReactNode; showOverlay?: boolean }) => ( +
{children}
+ ), + SheetHeader: ({ children }: { children: ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children: ReactNode }) =>

{children}

, + SheetDescription: ({ children }: { children: ReactNode }) =>

{children}

+})) + +function target( + hostAuthority: Record = { kind: 'local' } +): Record { + return { + sessionId: 'child-1', + startedAt: 1_000, + paneKey: 'parent\u0000subagent:child-1', + parentPaneKey: 'parent-pane', + terminalTabId: 'tab-1', + worktreeId: 'wt-1', + label: 'Review files', + model: 'gpt-5.4-mini', + hostAuthority + } +} + +function liveSession(): Record { + return { + sessionId: 'child-1', + agent: 'codex', + status: 'working', + messages: [ + { + id: 'message-1', + role: 'assistant', + timestamp: 1_000, + source: 'transcript', + blocks: [{ type: 'text', text: 'Child progress' }] + } + ], + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase: 'ready' + } +} + +describe('CodexSubagentProgressSheet', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.state.activeModal = 'codex-subagent-progress' + mocks.state.modalData = target() + mocks.state.agentStatusByPaneKey = {} + mocks.state.agentStatusEpoch = 0 + mocks.liveSession.mockReturnValue(liveSession()) + }) + + it('renders a read-only live child transcript on the local transport', async () => { + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + + expect(mocks.liveSession).toHaveBeenCalledWith({ + paneKey: 'parent\u0000subagent:child-1', + agent: 'codex', + sessionId: 'child-1', + runtimeEnvironmentId: null + }) + expect(markup).toContain('Read only') + expect(markup).toContain('data-testid="message-list"') + expect(markup).toContain('data-modal="false"') + expect(markup).toContain('data-show-overlay="false"') + expect(markup).not.toContain('Send a message') + }) + + it('removes parent turns copied into a full-history child rollout', async () => { + mocks.liveSession.mockReturnValue({ + ...liveSession(), + messages: [ + { + id: 'inherited-parent-message', + role: 'assistant', + timestamp: 900, + source: 'transcript', + blocks: [{ type: 'text', text: 'Parent history' }] + }, + { + id: 'child-message', + role: 'assistant', + timestamp: 1_100, + source: 'transcript', + blocks: [{ type: 'text', text: 'Child progress' }] + } + ], + hasMore: true + }) + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + + expect(markup).toContain('data-message-ids="child-message"') + expect(markup).toContain('data-has-more="false"') + expect(markup).not.toContain('inherited-parent-message') + }) + + it('routes runtime-owned child transcripts to the resolved remote runtime', async () => { + mocks.state.modalData = target({ kind: 'runtime', environmentId: 'runtime-env-1' }) + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + renderToStaticMarkup( + + + + ) + + expect(mocks.liveSession).toHaveBeenCalledWith( + expect.objectContaining({ runtimeEnvironmentId: 'runtime-env-1' }) + ) + }) + + it('stops showing a stale working state after the child leaves the live roster', async () => { + mocks.state.agentStatusByPaneKey = { 'parent-pane': { subagents: [] } } + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + + expect(markup).toContain('data-working="false"') + }) + + it('stops showing a captured working state after the parent entry disappears', async () => { + mocks.state.agentStatusByPaneKey = {} + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + + expect(markup).toContain('data-working="false"') + }) + + it('uses a fresh working roster while the transcript is between updates', async () => { + const now = Date.parse('2026-08-03T12:00:00.000Z') + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + mocks.state.agentStatusByPaneKey = { + 'parent-pane': { + updatedAt: now, + subagents: [{ id: 'child-1', state: 'working' }] + } + } + mocks.liveSession.mockReturnValue({ ...liveSession(), status: 'done' }) + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + dateNow.mockRestore() + + expect(markup).toContain('data-working="true"') + }) + + it('uses a fresh idle roster over an unfinished long-lived transcript', async () => { + const now = Date.parse('2026-08-03T12:00:00.000Z') + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + mocks.state.agentStatusByPaneKey = { + 'parent-pane': { + updatedAt: now, + subagents: [{ id: 'child-1', state: 'idle' }] + } + } + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + dateNow.mockRestore() + + expect(markup).toContain('Idle') + expect(markup).toContain('data-working="false"') + }) + + it('stops trusting an expired working roster after the freshness epoch advances', async () => { + const now = Date.parse('2026-08-03T12:00:00.000Z') + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + mocks.state.agentStatusEpoch = 1 + mocks.state.agentStatusByPaneKey = { + 'parent-pane': { + updatedAt: now - AGENT_STATUS_STALE_AFTER_MS - 1, + subagents: [{ id: 'child-1', state: 'working' }] + } + } + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + dateNow.mockRestore() + + expect(markup).toContain('data-working="false"') + }) + + it('blocks legacy SSH without attempting a local transcript read', async () => { + mocks.state.modalData = target({ kind: 'legacy-ssh' }) + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + const markup = renderToStaticMarkup( + + + + ) + + expect(mocks.liveSession).not.toHaveBeenCalled() + expect(markup).toContain( + 'Live subagent transcripts are not available for legacy SSH workspaces.' + ) + }) + + it('keeps captured runtime routing after the parent and workspace owner disappear', async () => { + mocks.state.modalData = target({ kind: 'runtime', environmentId: 'runtime-env-1' }) + mocks.state.agentStatusByPaneKey = {} + const { default: CodexSubagentProgressSheet } = await import('./CodexSubagentProgressSheet') + + renderToStaticMarkup( + + + + ) + + expect(mocks.liveSession).toHaveBeenCalledWith( + expect.objectContaining({ runtimeEnvironmentId: 'runtime-env-1' }) + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/CodexSubagentProgressSheet.tsx b/src/renderer/src/components/sidebar/CodexSubagentProgressSheet.tsx new file mode 100644 index 000000000000..58a703a142ae --- /dev/null +++ b/src/renderer/src/components/sidebar/CodexSubagentProgressSheet.tsx @@ -0,0 +1,266 @@ +import { Loader2, MessageSquare, TriangleAlert } from 'lucide-react' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { NativeChatMessageList } from '@/components/native-chat/NativeChatMessageList' +import { selectNativeChatViewState } from '@/components/native-chat/native-chat-view-state' +import { useNativeChatLiveSession } from '@/components/native-chat/use-native-chat-live-session' +import { Badge } from '@/components/ui/badge' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet' +import { translate } from '@/i18n/i18n' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' +import { useNow } from '@/hooks/use-now' +import { useAppStore } from '@/store' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentSubagentState +} from '../../../../shared/agent-status-types' +import { resolveCodexSubagentProgressRoute } from './codex-subagent-progress-route' +import { + parseCodexSubagentProgressTarget, + type CodexSubagentProgressTarget +} from './codex-subagent-progress-target' +import { scopeCodexSubagentTranscript } from './codex-subagent-transcript-scope' + +type ProgressPlaceholderKind = 'loading' | 'empty' | 'error' + +function ProgressPlaceholder({ + kind, + message +}: { + kind: ProgressPlaceholderKind + message?: string +}): React.JSX.Element { + const copy = + kind === 'loading' + ? { + title: translate( + 'components.codex-subagent-progress.loading.title', + 'Waiting for subagent output…' + ), + subtitle: translate( + 'components.codex-subagent-progress.loading.subtitle', + 'New messages, reasoning, and tool activity will appear here as Codex writes them.' + ) + } + : kind === 'empty' + ? { + title: translate( + 'components.codex-subagent-progress.empty.title', + 'No transcript entries' + ), + subtitle: translate( + 'components.codex-subagent-progress.empty.subtitle', + 'This subagent did not write any visible messages or tool activity.' + ) + } + : { + title: translate( + 'components.codex-subagent-progress.error.title', + 'Subagent progress unavailable' + ), + subtitle: + message ?? + translate( + 'components.codex-subagent-progress.error.subtitle', + 'The subagent transcript could not be read.' + ) + } + return ( +
+
+ {kind === 'loading' ? ( + + ) : kind === 'error' ? ( + + ) : ( + + )} +
+

{copy.title}

+

{copy.subtitle}

+
+ ) +} + +function unavailableMessage( + reason: 'unknown-owner' | 'legacy-ssh' | 'runtime-owner-missing' +): string { + switch (reason) { + case 'legacy-ssh': + return translate( + 'components.codex-subagent-progress.unavailable.legacy-ssh', + 'Live subagent transcripts are not available for legacy SSH workspaces.' + ) + case 'runtime-owner-missing': + return translate( + 'components.codex-subagent-progress.unavailable.runtime-owner-missing', + 'The remote runtime owner is not available yet. Reopen this view after the workspace reconnects.' + ) + case 'unknown-owner': + return translate( + 'components.codex-subagent-progress.unavailable.unknown-owner', + 'Orca could not determine which host owns this subagent transcript.' + ) + } +} + +function CodexSubagentTranscript({ + target, + state, + runtimeEnvironmentId +}: { + target: CodexSubagentProgressTarget + state: AgentSubagentState + runtimeEnvironmentId: string | null +}): React.JSX.Element { + const session = useNativeChatLiveSession({ + paneKey: target.paneKey, + agent: 'codex', + sessionId: target.sessionId, + runtimeEnvironmentId + }) + const transcriptScope = scopeCodexSubagentTranscript( + session.messages, + session.hasMore, + target.startedAt + ) + const scopedSession = { ...session, ...transcriptScope } + const viewState = selectNativeChatViewState(scopedSession) + const working = state === 'working' + + if (viewState.kind === 'error') { + return + } + if (viewState.kind === 'loading' || (working && scopedSession.messages.length === 0)) { + return + } + if (viewState.kind === 'empty') { + return + } + return ( + + ) +} + +function CodexSubagentProgressBody({ + target +}: { + target: CodexSubagentProgressTarget +}): React.JSX.Element { + const parentEntry = useAppStore((state) => state.agentStatusByPaneKey[target.parentPaneKey]) + // Why: a shared 30s tick re-evaluates parent freshness while the sheet stays open. + const now = useNow(30_000) + const freshParentEntry = + parentEntry && isExplicitAgentStatusFresh(parentEntry, now, AGENT_STATUS_STALE_AFTER_MS) + ? parentEntry + : undefined + const liveSubagent = freshParentEntry?.subagents?.find( + (subagent) => subagent.id === target.sessionId + ) + const route = resolveCodexSubagentProgressRoute(target.hostAuthority) + const state = liveSubagent?.state ?? 'idle' + const dotState = asDotState(state) + const label = liveSubagent?.description?.trim() || target.label + const model = liveSubagent?.model?.trim() || target.model + + return ( + <> + +
+ + + {label} + + + {translate('components.codex-subagent-progress.read-only', 'Read only')} + +
+ + + {translate( + 'components.codex-subagent-progress.description', + 'Codex subagent transcript' + )} + + · + {agentStateLabel(dotState)} + {model ? ( + <> + · + {model} + + ) : null} + +
+
+ {route.kind === 'readable' ? ( + + ) : ( + + )} +
+ + ) +} + +export default function CodexSubagentProgressSheet(): React.JSX.Element { + const activeModal = useAppStore((state) => state.activeModal) + const modalData = useAppStore((state) => state.modalData) + const closeModal = useAppStore((state) => state.closeModal) + const target = parseCodexSubagentProgressTarget(modalData) + const open = activeModal === 'codex-subagent-progress' + + return ( + { + if (!nextOpen) { + closeModal() + } + }} + > + event.preventDefault()} + > + {target ? ( + + ) : ( + <> + + {translate('components.codex-subagent-progress.title', 'Codex subagent progress')} + + + + )} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 3631f3152206..1fb14bd5d51c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -19,6 +19,11 @@ type MockAgentOptions = { prompt: string worktreeId: string startedAt?: number + rowSource?: DashboardAgentRowData['rowSource'] + model?: string + connectionId?: string | null + ptyId?: string | null + subagentSession?: DashboardAgentRowData['subagentSession'] } function mockAgent({ @@ -27,13 +32,18 @@ function mockAgent({ agentType, prompt, worktreeId, - startedAt = 1000 + startedAt = 1000, + rowSource = 'live', + model, + connectionId, + ptyId, + subagentSession }: MockAgentOptions): DashboardAgentRowData { return { paneKey, - tab: { id: tabId }, + tab: { id: tabId, ptyId }, agentType, - rowSource: 'live', + rowSource, state: 'working', startedAt, entry: { @@ -43,8 +53,11 @@ function mockAgent({ updatedAt: startedAt, stateStartedAt: startedAt, stateHistory: [], - worktreeId - } + worktreeId, + model, + connectionId + }, + subagentSession } as unknown as DashboardAgentRowData } @@ -86,6 +99,7 @@ function buildMockStoreState(): Record { ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, sendPromptToSidebarAgentTarget: vi.fn(), + openModal: activationMocks.openModal, settings: { promptCacheTimerEnabled: true, promptCacheTtlMs: 60_000 @@ -95,7 +109,8 @@ function buildMockStoreState(): Record { const activationMocks = vi.hoisted(() => ({ activateAndRevealWorktree: vi.fn(), - activateTabAndFocusPane: vi.fn() + activateTabAndFocusPane: vi.fn(), + openModal: vi.fn() })) const staleAgentRowMocks = vi.hoisted(() => ({ @@ -413,6 +428,88 @@ describe('WorktreeCardAgents activation', () => { expect(staleAgentRowMocks.dismissStaleAgentRowByKey).toHaveBeenCalledWith(paneKey) }) + it('opens Codex subagent progress instead of activating the parent pane', async () => { + mockAgentActivityDisplayMode = 'full' + const parentPaneKey = makePaneKey('tab-1', LEAF_A) + mockAgents = [ + mockAgent({ + paneKey: `${parentPaneKey}\u0000subagent:child-1`, + tabId: 'tab-1', + agentType: 'reviewer', + prompt: 'Review files', + worktreeId: 'wt-1', + rowSource: 'subagent', + model: 'gpt-5.4-mini', + connectionId: null, + ptyId: 'local-pty-1', + subagentSession: { id: 'child-1', provider: 'codex', parentPaneKey } + }) + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + renderToStaticMarkup() + capturedRowActivations[0].onActivate('tab-1', parentPaneKey) + + expect(activationMocks.openModal).toHaveBeenCalledWith( + 'codex-subagent-progress', + expect.objectContaining({ + sessionId: 'child-1', + startedAt: 1000, + parentPaneKey, + terminalTabId: 'tab-1', + worktreeId: 'wt-1', + label: 'Review files', + hostAuthority: { kind: 'local' } + }) + ) + expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) + + it('opens Codex subagent progress from compact rows', async () => { + mockAgentActivityDisplayMode = 'compact' + const parentPaneKey = makePaneKey('tab-1', LEAF_A) + mockAgents = [ + mockAgent({ + paneKey: `${parentPaneKey}\u0000subagent:child-compact`, + tabId: 'tab-1', + agentType: 'reviewer', + prompt: 'Inspect compact progress', + worktreeId: 'wt-1', + rowSource: 'subagent', + connectionId: null, + ptyId: 'local-pty-1', + subagentSession: { id: 'child-compact', provider: 'codex', parentPaneKey } + }) + ] + const host = document.createElement('div') + document.body.append(host) + const root: Root = createRoot(host) + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + await act(async () => { + root.render( + + + + ) + }) + const row = host.querySelector('.compact-agent-row') + expect(row).toBeInstanceOf(HTMLElement) + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(activationMocks.openModal).toHaveBeenCalledWith( + 'codex-subagent-progress', + expect.objectContaining({ sessionId: 'child-compact', parentPaneKey }) + ) + expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalled() + act(() => root.unmount()) + host.remove() + }) + it('reveals the worktree and focuses a compact automation worker row hydrated during reveal', async () => { mockAgentActivityDisplayMode = 'compact' const tabId = 'compact-worker-tab' diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 87f615fe7d92..c38b297783b2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -26,6 +26,8 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' +import { useCodexSubagentProgressPaneKey } from './codex-subagent-progress-selection' +import { useCodexSubagentRowActivation } from './use-codex-subagent-row-activation' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs' @@ -89,6 +91,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const sendTargetInputs = useAppStore(useShallow((s) => selectSendTargetInputs(s, worktreeId))) const sendPromptToSidebarAgentTarget = useAppStore((s) => s.sendPromptToSidebarAgentTarget) const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId) + const selectedSubagentPaneKey = useCodexSubagentProgressPaneKey(worktreeId) const compactAgentListRootRef = useRef(null) // Why: acknowledgement writes are app-global; project only this card's rows @@ -189,9 +192,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }, [worktreeId] ) - const handleActivateRetainedAgent = useCallback(() => { - // Why: hibernation-retained rows are passive completion evidence; activating would resume sleeping sessions, so the row is inert. - }, []) + // Why: hibernation-retained rows are passive completion evidence; activating would resume sleeping sessions, so the row is inert. + const handleActivateRetainedAgent = useCallback(() => undefined, []) + const handleActivateRow = useCodexSubagentRowActivation({ + worktreeId, + activateTab: handleActivateAgentTab, + activateRetained: handleActivateRetainedAgent + }) // Why: one 30s tick per non-empty inline list; zero-agent cards never mount this (see WorktreeCardAgents), so idle worktrees pay no timer cost. const now = useNow(30_000) @@ -231,9 +238,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ [toggleLineageParentState] ) - const stopBubble = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - }, []) + const stopBubble = useCallback((e: React.MouseEvent) => e.stopPropagation(), []) // Why: root leaf siblings reserve a leading spacer when any root has a chevron, keeping the state-dot column aligned (descendants already indent). const anyRootHasChildren = rootAgents.some( @@ -266,9 +271,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ toggleLineageParent(agent.paneKey) : undefined } reserveDisclosureGutter={isRootAgent && anyRootHasChildren && !hasChildAgents} - isFocusedPane={agent.paneKey === focusedAgentPaneKey} + isFocusedPane={selectedSubagentPaneKey === null && agent.paneKey === focusedAgentPaneKey} + isCurrentAgent={agent.paneKey === selectedSubagentPaneKey} cacheTimerActive={cacheTimerActive} /> {hasChildAgents ? ( diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.test.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.test.ts new file mode 100644 index 000000000000..4af96a21fb68 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { + resolveCodexSubagentProgressHostAuthority, + selectCodexSubagentProgressHostAuthority, + type CodexSubagentProgressHostAuthorityState +} from './codex-subagent-progress-host-authority' + +const PARENT_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function state( + overrides: Partial = {} +): CodexSubagentProgressHostAuthorityState { + return { + repos: [], + settings: { activeRuntimeEnvironmentId: null }, + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + terminalLayoutsByTabId: {}, + ...overrides + } +} + +function select( + overrides: Partial, + worktreeId: string, + tabPtyId: string +) { + return selectCodexSubagentProgressHostAuthority(state(overrides), { + worktreeId, + parentPaneKey: PARENT_PANE_KEY, + tabPtyId, + connectionId: null + }) +} + +describe('Codex subagent progress host authority', () => { + it('keeps a legacy-local worktree on its concrete local PTY despite a focused runtime', () => { + expect( + select( + { + repos: [{ id: 'repo', connectionId: null, executionHostId: null }], + settings: { activeRuntimeEnvironmentId: 'focused-env' }, + worktreesByRepo: { repo: [{ id: 'repo::wt-local', repoId: 'repo' }] } + }, + 'repo::wt-local', + 'local-pty-1' + ) + ).toEqual({ kind: 'local' }) + }) + + it('captures a null-stamped Model-B owner from explicit worktree authority', () => { + expect( + select( + { + repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:env-1' }], + worktreesByRepo: { repo: [{ id: 'repo::wt-runtime', repoId: 'repo' }] } + }, + 'repo::wt-runtime', + 'remote:env-1@@terminal-1' + ) + ).toEqual({ kind: 'runtime', environmentId: 'env-1' }) + }) + + it('uses the pane PTY instead of a stale tab-level fallback', () => { + expect( + selectCodexSubagentProgressHostAuthority( + state({ + repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:env-1' }], + worktreesByRepo: { repo: [{ id: 'repo::wt-runtime', repoId: 'repo' }] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + activeLeafId: '11111111-1111-4111-8111-111111111111', + expandedLeafId: null, + ptyIdsByLeafId: { + '11111111-1111-4111-8111-111111111111': 'remote:env-1@@terminal-1' + } + } + } + }), + { + worktreeId: 'repo::wt-runtime', + parentPaneKey: PARENT_PANE_KEY, + tabPtyId: 'local-stale-pty', + connectionId: null + } + ) + ).toEqual({ kind: 'runtime', environmentId: 'env-1' }) + }) + + it('preserves local and runtime folder workspace ownership', () => { + const folderState = { + folderWorkspaces: [ + { + id: 'local-folder', + projectGroupId: 'local-group', + connectionId: null, + executionHostId: 'local' as const + }, + { + id: 'runtime-folder', + projectGroupId: 'runtime-group', + connectionId: null, + executionHostId: 'runtime:env-2' as const + } + ], + projectGroups: [ + { id: 'local-group', connectionId: null, executionHostId: 'local' as const }, + { + id: 'runtime-group', + connectionId: null, + executionHostId: 'runtime:env-2' as const + } + ] + } + expect(select(folderState, 'folder:local-folder', 'local-folder-pty')).toEqual({ + kind: 'local' + }) + expect(select(folderState, 'folder:runtime-folder', 'remote:env-2@@folder-terminal')).toEqual({ + kind: 'runtime', + environmentId: 'env-2' + }) + }) + + it('fails closed for legacy SSH, unknown status ownership, and conflicting runtime evidence', () => { + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: 'ssh-target-1', + explicitRuntimeEnvironmentId: 'unrelated-runtime', + ptyId: 'remote:unrelated-runtime@@terminal-1' + }) + ).toEqual({ kind: 'legacy-ssh' }) + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: undefined, + explicitRuntimeEnvironmentId: 'unrelated-runtime', + ptyId: 'local-pty-1' + }) + ).toEqual({ kind: 'unknown', reason: 'unknown-owner' }) + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: null, + explicitRuntimeEnvironmentId: 'env-1', + ptyId: 'remote:env-2@@terminal-1' + }) + ).toEqual({ kind: 'unknown', reason: 'unknown-owner' }) + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: null, + explicitRuntimeEnvironmentId: 'env-1', + ptyId: 'stale-local-pty' + }) + ).toEqual({ kind: 'unknown', reason: 'unknown-owner' }) + }) + + it('accepts a runtime-owned SSH PTY only with its matching explicit runtime owner', () => { + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: 'runtime-ssh-target-1', + explicitRuntimeEnvironmentId: 'env-1', + ptyId: 'ssh:runtime-ssh-target-1@@relay-pty-1' + }) + ).toEqual({ kind: 'runtime', environmentId: 'env-1' }) + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: 'runtime-ssh-target-1', + explicitRuntimeEnvironmentId: null, + ptyId: 'ssh:runtime-ssh-target-1@@relay-pty-1' + }) + ).toEqual({ kind: 'unknown', reason: 'runtime-owner-missing' }) + }) + + it('does not infer local ownership after runtime evidence disappears', () => { + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: 'runtime-ssh-target-1', + explicitRuntimeEnvironmentId: null, + ptyId: null + }) + ).toEqual({ kind: 'unknown', reason: 'runtime-owner-missing' }) + expect( + resolveCodexSubagentProgressHostAuthority({ + connectionId: null, + explicitRuntimeEnvironmentId: null, + ptyId: null + }) + ).toEqual({ kind: 'unknown', reason: 'unknown-owner' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.ts new file mode 100644 index 000000000000..de64d56aa3cf --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-host-authority.ts @@ -0,0 +1,102 @@ +import type { AppState } from '@/store/types' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' + +export type CodexSubagentProgressHostAuthority = + | { kind: 'local' } + | { kind: 'runtime'; environmentId: string } + | { kind: 'legacy-ssh' } + | { kind: 'unknown'; reason: 'unknown-owner' | 'runtime-owner-missing' } + +export type CodexSubagentProgressHostAuthorityState = WorktreeRuntimeOwnerState & + Pick + +export function resolveCodexSubagentProgressHostAuthority(args: { + connectionId: string | null | undefined + explicitRuntimeEnvironmentId: string | null + ptyId: string | null | undefined +}): CodexSubagentProgressHostAuthority { + if (args.connectionId === undefined) { + return { kind: 'unknown', reason: 'unknown-owner' } + } + const runtimeOwnedConnection = isRuntimeOwnedSshTargetId(args.connectionId) + if (typeof args.connectionId === 'string' && !runtimeOwnedConnection) { + return { kind: 'legacy-ssh' } + } + + const explicitRuntimeEnvironmentId = args.explicitRuntimeEnvironmentId?.trim() || null + const ptyId = args.ptyId?.trim() || null + if (ptyId?.startsWith('remote:')) { + const remotePty = parseRemoteRuntimePtyId(ptyId) + if (!remotePty) { + return { kind: 'unknown', reason: 'unknown-owner' } + } + if ( + remotePty.environmentId && + explicitRuntimeEnvironmentId && + remotePty.environmentId !== explicitRuntimeEnvironmentId + ) { + return { kind: 'unknown', reason: 'unknown-owner' } + } + const environmentId = remotePty.environmentId ?? explicitRuntimeEnvironmentId + return environmentId + ? { kind: 'runtime', environmentId } + : { kind: 'unknown', reason: 'runtime-owner-missing' } + } + const sshPty = ptyId ? parseAppSshPtyId(ptyId) : null + if (sshPty) { + return runtimeOwnedConnection && + sshPty.connectionId === args.connectionId && + explicitRuntimeEnvironmentId + ? { kind: 'runtime', environmentId: explicitRuntimeEnvironmentId } + : { + kind: 'unknown', + reason: runtimeOwnedConnection ? 'runtime-owner-missing' : 'unknown-owner' + } + } + if (ptyId?.startsWith('ssh:')) { + return { kind: 'unknown', reason: 'unknown-owner' } + } + if (ptyId) { + if (explicitRuntimeEnvironmentId || runtimeOwnedConnection) { + return { kind: 'unknown', reason: 'unknown-owner' } + } + return { kind: 'local' } + } + if (explicitRuntimeEnvironmentId) { + return { kind: 'runtime', environmentId: explicitRuntimeEnvironmentId } + } + return { + kind: 'unknown', + reason: runtimeOwnedConnection ? 'runtime-owner-missing' : 'unknown-owner' + } +} + +export function selectCodexSubagentProgressHostAuthority( + state: CodexSubagentProgressHostAuthorityState, + args: { + worktreeId: string + parentPaneKey: string + tabPtyId: string | null | undefined + connectionId: string | null | undefined + } +): CodexSubagentProgressHostAuthority { + const pane = parsePaneKey(args.parentPaneKey) + const panePtyId = pane + ? state.terminalLayoutsByTabId[pane.tabId]?.ptyIdsByLeafId?.[pane.leafId] + : undefined + return resolveCodexSubagentProgressHostAuthority({ + connectionId: args.connectionId, + explicitRuntimeEnvironmentId: getExplicitRuntimeEnvironmentIdForWorktree( + state, + args.worktreeId + ), + ptyId: panePtyId ?? args.tabPtyId + }) +} diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-route.test.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-route.test.ts new file mode 100644 index 000000000000..21e37f6bfd6a --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-route.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexSubagentProgressRoute } from './codex-subagent-progress-route' + +describe('resolveCodexSubagentProgressRoute', () => { + it('keeps local transcripts on the local transport', () => { + expect(resolveCodexSubagentProgressRoute({ kind: 'local' })).toEqual({ + kind: 'readable', + runtimeEnvironmentId: null + }) + }) + + it('routes runtime-owned transcripts to their remote runtime', () => { + expect( + resolveCodexSubagentProgressRoute({ kind: 'runtime', environmentId: 'runtime-env-1' }) + ).toEqual({ + kind: 'readable', + runtimeEnvironmentId: 'runtime-env-1' + }) + }) + + it('blocks runtime-owned transcripts until the runtime owner resolves', () => { + expect( + resolveCodexSubagentProgressRoute({ + kind: 'unknown', + reason: 'runtime-owner-missing' + }) + ).toEqual({ + kind: 'unavailable', + reason: 'runtime-owner-missing' + }) + }) + + it('does not fall back to local reads for legacy SSH or unknown owners', () => { + expect(resolveCodexSubagentProgressRoute({ kind: 'legacy-ssh' })).toEqual({ + kind: 'unavailable', + reason: 'legacy-ssh' + }) + expect(resolveCodexSubagentProgressRoute({ kind: 'unknown', reason: 'unknown-owner' })).toEqual( + { + kind: 'unavailable', + reason: 'unknown-owner' + } + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-route.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-route.ts new file mode 100644 index 000000000000..bd190b220032 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-route.ts @@ -0,0 +1,20 @@ +import type { CodexSubagentProgressHostAuthority } from './codex-subagent-progress-host-authority' + +export type CodexSubagentProgressRoute = + | { kind: 'readable'; runtimeEnvironmentId: string | null } + | { kind: 'unavailable'; reason: 'unknown-owner' | 'legacy-ssh' | 'runtime-owner-missing' } + +export function resolveCodexSubagentProgressRoute( + authority: CodexSubagentProgressHostAuthority +): CodexSubagentProgressRoute { + switch (authority.kind) { + case 'local': + return { kind: 'readable', runtimeEnvironmentId: null } + case 'runtime': + return { kind: 'readable', runtimeEnvironmentId: authority.environmentId } + case 'legacy-ssh': + return { kind: 'unavailable', reason: 'legacy-ssh' } + case 'unknown': + return { kind: 'unavailable', reason: authority.reason } + } +} diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-selection.test.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-selection.test.ts new file mode 100644 index 000000000000..93f4fdb99501 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-selection.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexSubagentProgressPaneKey } from './codex-subagent-progress-selection' + +const target = { + sessionId: 'child-1', + startedAt: 10, + paneKey: 'parent\u0000subagent:child-1', + parentPaneKey: 'parent', + terminalTabId: 'tab-1', + worktreeId: 'wt-1', + label: '/root/test_agent', + hostAuthority: { kind: 'local' } +} + +describe('resolveCodexSubagentProgressPaneKey', () => { + it('selects only the inspected child in its worktree', () => { + expect(resolveCodexSubagentProgressPaneKey('codex-subagent-progress', target, 'wt-1')).toBe( + 'parent\u0000subagent:child-1' + ) + expect( + resolveCodexSubagentProgressPaneKey('codex-subagent-progress', target, 'wt-2') + ).toBeNull() + expect(resolveCodexSubagentProgressPaneKey('none', target, 'wt-1')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-selection.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-selection.ts new file mode 100644 index 000000000000..b148973d95c2 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-selection.ts @@ -0,0 +1,20 @@ +import { useAppStore } from '@/store' +import { parseCodexSubagentProgressTarget } from './codex-subagent-progress-target' + +export function resolveCodexSubagentProgressPaneKey( + activeModal: string, + modalData: Record, + worktreeId: string +): string | null { + if (activeModal !== 'codex-subagent-progress') { + return null + } + const target = parseCodexSubagentProgressTarget(modalData) + return target?.worktreeId === worktreeId ? target.paneKey : null +} + +export function useCodexSubagentProgressPaneKey(worktreeId: string): string | null { + const activeModal = useAppStore((state) => state.activeModal) + const modalData = useAppStore((state) => state.modalData) + return resolveCodexSubagentProgressPaneKey(activeModal, modalData, worktreeId) +} diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-target.test.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-target.test.ts new file mode 100644 index 000000000000..04d6df8672aa --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-target.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import { + createCodexSubagentProgressTarget, + parseCodexSubagentProgressTarget +} from './codex-subagent-progress-target' + +function childRow(provider = 'codex'): DashboardAgentRow { + return { + paneKey: 'parent\u0000subagent:child-1', + tab: { id: 'tab-1' }, + agentType: 'reviewer', + rowSource: 'subagent', + state: 'working', + startedAt: 10, + entry: { + prompt: 'Review files', + model: 'gpt-5.4-mini', + connectionId: null, + orchestration: { taskId: 'child', dispatchId: 'child' } + }, + subagentSession: { id: 'child-1', provider, parentPaneKey: 'parent-pane' } + } as DashboardAgentRow +} + +describe('Codex subagent progress target', () => { + it('preserves explicit child identity and local host authority', () => { + expect( + createCodexSubagentProgressTarget(childRow(), 'folder:workspace-1', { kind: 'local' }) + ).toEqual({ + sessionId: 'child-1', + startedAt: 10, + paneKey: 'parent\u0000subagent:child-1', + parentPaneKey: 'parent-pane', + terminalTabId: 'tab-1', + worktreeId: 'folder:workspace-1', + label: 'Review files', + model: 'gpt-5.4-mini', + hostAuthority: { kind: 'local' } + }) + }) + + it('does not create a Codex target for another provider', () => { + expect( + createCodexSubagentProgressTarget(childRow('claude'), 'wt-1', { kind: 'local' }) + ).toBeNull() + }) + + it('rejects malformed modal data', () => { + expect(parseCodexSubagentProgressTarget({ sessionId: 'child-1' })).toBeNull() + expect( + parseCodexSubagentProgressTarget({ + ...createCodexSubagentProgressTarget(childRow(), 'wt-1', { kind: 'local' }), + startedAt: 0 + } as Record) + ).toBeNull() + }) + + it('retains captured runtime authority without consulting live workspace state', () => { + const target = createCodexSubagentProgressTarget(childRow(), 'wt-1', { + kind: 'runtime', + environmentId: 'env-1' + }) + + expect(parseCodexSubagentProgressTarget(target as Record)).toMatchObject({ + hostAuthority: { kind: 'runtime', environmentId: 'env-1' } + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/codex-subagent-progress-target.ts b/src/renderer/src/components/sidebar/codex-subagent-progress-target.ts new file mode 100644 index 000000000000..0251e21424d5 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-progress-target.ts @@ -0,0 +1,112 @@ +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import type { CodexSubagentProgressHostAuthority } from './codex-subagent-progress-host-authority' + +export type CodexSubagentProgressTarget = { + sessionId: string + startedAt: number + paneKey: string + parentPaneKey: string + terminalTabId: string + worktreeId: string + label: string + model?: string + hostAuthority: CodexSubagentProgressHostAuthority +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed || null +} + +export function createCodexSubagentProgressTarget( + agent: DashboardAgentRow, + worktreeId: string, + hostAuthority: CodexSubagentProgressHostAuthority +): CodexSubagentProgressTarget | null { + const session = agent.subagentSession + if (agent.rowSource !== 'subagent' || session?.provider !== 'codex') { + return null + } + const label = + agent.entry.orchestration?.displayName?.trim() || + agent.entry.prompt.trim() || + agent.agentType.trim() || + 'Codex subagent' + const model = agent.entry.model?.trim() || undefined + return { + sessionId: session.id, + startedAt: agent.startedAt, + paneKey: agent.paneKey, + parentPaneKey: session.parentPaneKey, + terminalTabId: agent.tab.id, + worktreeId, + label, + ...(model ? { model } : {}), + hostAuthority + } +} + +function parseHostAuthority(value: unknown): CodexSubagentProgressHostAuthority | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const authority = value as Record + switch (authority.kind) { + case 'local': + case 'legacy-ssh': + return { kind: authority.kind } + case 'runtime': { + const environmentId = nonEmptyString(authority.environmentId) + return environmentId ? { kind: 'runtime', environmentId } : null + } + case 'unknown': + return authority.reason === 'unknown-owner' || authority.reason === 'runtime-owner-missing' + ? { kind: 'unknown', reason: authority.reason } + : null + default: + return null + } +} + +export function parseCodexSubagentProgressTarget( + value: Record +): CodexSubagentProgressTarget | null { + const sessionId = nonEmptyString(value.sessionId) + const startedAt = + typeof value.startedAt === 'number' && Number.isFinite(value.startedAt) && value.startedAt > 0 + ? value.startedAt + : null + const paneKey = nonEmptyString(value.paneKey) + const parentPaneKey = nonEmptyString(value.parentPaneKey) + const terminalTabId = nonEmptyString(value.terminalTabId) + const worktreeId = nonEmptyString(value.worktreeId) + const label = nonEmptyString(value.label) + const hostAuthority = parseHostAuthority(value.hostAuthority) + if ( + !sessionId || + !startedAt || + !paneKey || + !parentPaneKey || + !terminalTabId || + !worktreeId || + !label || + !hostAuthority + ) { + return null + } + const model = nonEmptyString(value.model) + return { + sessionId, + startedAt, + paneKey, + parentPaneKey, + terminalTabId, + worktreeId, + label, + ...(model ? { model } : {}), + hostAuthority + } +} diff --git a/src/renderer/src/components/sidebar/codex-subagent-transcript-scope.test.ts b/src/renderer/src/components/sidebar/codex-subagent-transcript-scope.test.ts new file mode 100644 index 000000000000..ba0bdef4a447 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-transcript-scope.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage, NativeChatRole } from '../../../../shared/native-chat-types' +import { scopeCodexSubagentTranscript } from './codex-subagent-transcript-scope' + +function message(id: string, role: NativeChatRole, timestamp: number | null): NativeChatMessage { + return { + id, + role, + timestamp, + source: 'transcript', + blocks: [{ type: 'text', text: id }] + } +} + +describe('scopeCodexSubagentTranscript', () => { + it('removes the parent prefix copied into a full-history child rollout', () => { + const scoped = scopeCodexSubagentTranscript( + [ + message('parent-user', 'user', 1_000), + message('parent-assistant', 'assistant', 1_100), + message('child-progress', 'assistant', 2_100), + message('child-tool-result', 'tool', null) + ], + true, + 2_000 + ) + + expect(scoped.messages.map((entry) => entry.id)).toEqual([ + 'child-progress', + 'child-tool-result' + ]) + expect(scoped.hasMore).toBe(false) + }) + + it('keeps older-history pagination until the child start boundary is reached', () => { + expect( + scopeCodexSubagentTranscript([message('child-later', 'assistant', 2_200)], true, 2_000) + .hasMore + ).toBe(true) + expect( + scopeCodexSubagentTranscript( + [message('parent', 'user', 1_900), message('child-first', 'assistant', 2_100)], + true, + 2_000 + ).hasMore + ).toBe(false) + }) + + it('does not expose transcript messages without a trusted child boundary', () => { + expect(scopeCodexSubagentTranscript([message('parent', 'user', 1_000)], true, 0)).toEqual({ + messages: [], + hasMore: false + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/codex-subagent-transcript-scope.ts b/src/renderer/src/components/sidebar/codex-subagent-transcript-scope.ts new file mode 100644 index 000000000000..63914cbb1958 --- /dev/null +++ b/src/renderer/src/components/sidebar/codex-subagent-transcript-scope.ts @@ -0,0 +1,27 @@ +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +export type CodexSubagentTranscriptScope = { + messages: NativeChatMessage[] + hasMore: boolean +} + +export function scopeCodexSubagentTranscript( + messages: readonly NativeChatMessage[], + hasMore: boolean, + startedAt: number +): CodexSubagentTranscriptScope { + if (!Number.isFinite(startedAt) || startedAt <= 0) { + return { messages: [], hasMore: false } + } + const firstOwnedMessage = messages.findIndex( + (message) => message.timestamp !== null && message.timestamp >= startedAt + ) + if (firstOwnedMessage === -1) { + return { messages: [], hasMore: false } + } + // Why: Codex full-history forks copy parent turns before the provider-authored child start boundary. + return { + messages: messages.slice(firstOwnedMessage), + hasMore: hasMore && firstOwnedMessage === 0 + } +} diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index c7a8c9781267..d0aa7d9b736f 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -29,6 +29,7 @@ const WorktreeVisibilityDialog = lazyWithRetry(() => import('./WorktreeVisibilit const OrcaYamlTrustDialog = lazyWithRetry(() => import('./OrcaYamlTrustDialog')) const ForgetSshWorkspaceDialog = lazyWithRetry(() => import('./ForgetSshWorkspaceDialog')) const AgentDashboardSidebarHost = lazyWithRetry(() => import('./AgentDashboardSidebarHost')) +const CodexSubagentProgressSheet = lazyWithRetry(() => import('./CodexSubagentProgressSheet')) const MIN_WIDTH = 220 const MAX_WIDTH = 500 @@ -249,6 +250,7 @@ function Sidebar({ {activeModal === 'worktree-visibility' ? : null} {activeModal === 'confirm-orca-yaml-hooks' ? : null} {activeModal === 'forget-ssh-workspace' ? : null} + {activeModal === 'codex-subagent-progress' ? : null} {sidebarOpen ? ( void + activateRetained: () => void +}): (agent: DashboardAgentRow) => (tabId: string, paneKey: string) => void { + const openModal = useAppStore((s) => s.openModal) + const openProgress = useCallback( + (agent: DashboardAgentRow) => { + const hostAuthority = selectCodexSubagentProgressHostAuthority(useAppStore.getState(), { + worktreeId, + parentPaneKey: agent.subagentSession?.parentPaneKey ?? agent.paneKey, + tabPtyId: agent.tab.ptyId, + connectionId: agent.entry.connectionId + }) + const target = createCodexSubagentProgressTarget(agent, worktreeId, hostAuthority) + // Why: an unresolvable target (legacy SSH / unknown owner) falls back to focusing the parent pane. + if (target) { + openModal('codex-subagent-progress', target) + } else { + activateTab(agent.tab.id, agent.activationPaneKey ?? agent.paneKey) + } + }, + [worktreeId, activateTab, openModal] + ) + return useCallback( + (agent: DashboardAgentRow) => + agent.rowSource === 'retained' + ? activateRetained + : agent.subagentSession?.provider === 'codex' + ? () => openProgress(agent) + : activateTab, + [activateRetained, activateTab, openProgress] + ) +} diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index b02afa1fbfc5..622b8246539c 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -729,6 +729,7 @@ describe('applyAgentRowLineage', () => { const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'review the PR', + connectionId: 'runtime-ssh-env-1', subagents: [ { id: 'a1', @@ -753,8 +754,9 @@ describe('applyAgentRowLineage', () => { expect(children[0]).toMatchObject({ state: 'working', agentType: 'general-purpose', - entry: { model: 'gpt-5.4-mini' }, + entry: { model: 'gpt-5.4-mini', connectionId: 'runtime-ssh-env-1' }, activationPaneKey: PANE_KEY_1, + subagentSession: { id: 'a1', provider: 'claude', parentPaneKey: PANE_KEY_1 }, startedAt: 1500 }) expect(children[0].entry.prompt).toBe('Review loop') @@ -768,6 +770,27 @@ describe('applyAgentRowLineage', () => { expect(ordered[2].lineage).toMatchObject({ depth: 1, isLastSibling: true }) }) + it('binds a Codex child row to the resolved parent provider session', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'codex', title: 'Codex' })], + entries: [ + makeEntry(PANE_KEY_1, 1000, { + agentType: undefined, + terminalTitle: 'Codex', + subagents: [{ id: 'child-codex', state: 'working', startedAt: 1500 }] + }) + ], + retained: [], + now: 2000 + }) + + expect(rows.find((row) => row.rowSource === 'subagent')?.subagentSession).toEqual({ + id: 'child-codex', + provider: 'codex', + parentPaneKey: PANE_KEY_1 + }) + }) + it('marks working subagent child rows unverifiable when the parent status is stale', () => { const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.subagent.test.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.subagent.test.tsx new file mode 100644 index 000000000000..d873a06fec28 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.subagent.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import { TooltipProvider } from '@/components/ui/tooltip' +import { CompactAgentRow } from './worktree-card-compact-agent-row' + +vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({ + useAgentRowConversationName: () => null +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +afterEach(cleanup) + +type AgentRowOverrides = Omit, 'entry'> & { + entry?: Partial +} + +function agentRow(overrides: AgentRowOverrides = {}): DashboardAgentRow { + const row = { + paneKey: 'parent\u0000subagent:child-1', + tab: { id: 'tab-1' }, + agentType: 'default', + rowSource: 'subagent', + state: 'working', + startedAt: 1_000, + entry: { + prompt: '/root/test_agent', + state: 'working', + stateStartedAt: 1_000, + stateHistory: [], + model: 'gpt-5.6-sol' + }, + subagentSession: { id: 'child-1', provider: 'codex', parentPaneKey: 'parent' }, + lineage: { depth: 1, isFirstSibling: true, isLastSibling: true, childCount: 0 } + } as unknown as DashboardAgentRow + return { + ...row, + ...overrides, + entry: { ...row.entry, ...overrides.entry } + } as DashboardAgentRow +} + +describe('CompactAgentRow subagent display', () => { + it('uses a concise child label and keeps canonical metadata in the tooltip', () => { + render( + + + + ) + + const row = screen.getByRole('treeitem') + expect(row.textContent).toContain('test_agent') + expect(row.textContent).not.toContain('/root/') + expect(row.textContent).not.toContain('Default') + expect(row.textContent).toContain('gpt-5.6-sol') + const labelSpan = row.querySelector('.truncate') + expect(labelSpan?.getAttribute('title')).toBe('/root/test_agent') + expect(row.getAttribute('data-subagent-id')).toBe('child-1') + expect(row.getAttribute('data-current')).toBe('true') + expect(row.getAttribute('aria-selected')).toBe('true') + }) + + it('keeps an aggregate parent row free of stale child-launch tool text', () => { + render( + + + + ) + + const row = screen.getByRole('treeitem') + expect(row.textContent).toContain('Agnes_Core | main') + expect(row.textContent).not.toContain('collaborationlist_agents') + expect(screen.getByRole('button').getAttribute('aria-label')).toBe('Hide 1 child agent') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index b698982a7ddf..aabb10a1d175 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -6,7 +6,6 @@ import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { cn } from '@/lib/utils' import { getAgentDotState } from './worktree-card-agent-summary' -import { translate } from '@/i18n/i18n' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview' import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state' @@ -14,19 +13,21 @@ import { useAgentRowConversationName } from '@/components/dashboard/use-agent-ro import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' +import { agentChildDisclosureLabel } from '@/components/agent-child-disclosure-label' +import { formatCodexSubagentDisplayLabel } from '@/components/codex-subagent-display-label' -function getCompactAgentPrimary( - agent: DashboardAgentRowData, - conversationName: string | null -): string { - const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry) - return prompt || agentStateLabel(getAgentDotState(agent)) +function getCompactAgentPrimary(agent: DashboardAgentRowData, primarySource: string): string { + const label = primarySource || agentStateLabel(getAgentDotState(agent)) + return agent.subagentSession?.provider === 'codex' + ? formatCodexSubagentDisplayLabel(label) + : label } export function getCompactAgentSecondary( agent: DashboardAgentRowData, now: number, - lastAssistantMessageOverride?: string + lastAssistantMessageOverride?: string, + hasChildAgents = false ): string { if (agent.entry.interrupted === true) { return 'Interrupted by user' @@ -40,6 +41,9 @@ export function getCompactAgentSecondary( if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { return agentStateLabel('monitoring') } + if (hasChildAgents) { + return '' + } const toolPreview = formatAgentToolPreview(agent.entry, agent.state) if (toolPreview) { return toolPreview @@ -50,8 +54,11 @@ export function getCompactAgentSecondary( return lastAssistantMessage } // Why: child rows without descriptions use their role as primary text; repeating its formatted label adds no information. - if (agent.rowSource === 'subagent' && agent.entry.prompt?.trim() === agent.agentType.trim()) { - return '' + if (agent.rowSource === 'subagent') { + const agentType = agent.agentType.trim() + if (agentType === 'default' || agent.entry.prompt?.trim() === agentType) { + return '' + } } return formatAgentTypeLabel(agent.agentType) } @@ -87,6 +94,8 @@ type CompactAgentRowProps = { onToggleChildAgents?: () => void reserveDisclosureGutter?: boolean isFocusedPane?: boolean + /** Mark a synthetic child row whose inspector is currently open. */ + isCurrentAgent?: boolean hideIdentityIcon?: boolean cacheTimerActive?: boolean } @@ -103,6 +112,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ onToggleChildAgents, reserveDisclosureGutter = false, isFocusedPane = false, + isCurrentAgent = false, hideIdentityIcon = false, cacheTimerActive = true }: CompactAgentRowProps) { @@ -116,7 +126,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent' const dotState = getAgentDotState(agent) const conversationName = useAgentRowConversationName(agent) - const primary = getCompactAgentPrimary(agent, conversationName) + const primarySource = conversationName ?? getAgentRowPrimaryText(agent.entry) + const primary = getCompactAgentPrimary(agent, primarySource) const isLineageChild = agent.lineage?.depth === 1 // Keep a live row's last assistant line stable while status/tool payloads // briefly omit the hook-only field between updates. Committed in an effect so a @@ -137,12 +148,18 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const held = heldMessageRef.current const stableMessage = turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined - const secondary = getCompactAgentSecondary(agent, now, stableMessage) + const secondary = getCompactAgentSecondary(agent, now, stableMessage, hasChildDisclosure) // Why: sidebar truncation must preserve the passive-vs-active distinction. const leadingText = dotState === 'monitoring' ? secondary : primary const trailingText = dotState === 'monitoring' ? (primary === secondary ? '' : primary) : secondary - const rowTitle = `${leadingText}${trailingText ? ` - ${trailingText}` : ''}` + // Why: the tooltip keeps the canonical label — shortened subagent display names hide the full /root path. + const canonicalPrimary = primarySource || primary + const canonicalTitle = + dotState === 'monitoring' + ? `${secondary}${canonicalPrimary !== secondary ? ` - ${canonicalPrimary}` : ''}` + : `${canonicalPrimary}${secondary ? ` - ${secondary}` : ''}` + const isHighlighted = isFocusedPane || isCurrentAgent const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) const cacheTimer = usePromptCacheCountdownForPane(agent.paneKey, cacheTimerActive) @@ -191,15 +208,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({