From d0bc3d454ebe8825151e25b0825982dfeccbc3fe Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:14:23 -0400 Subject: [PATCH 1/4] feat(chat): adopt Claude Agent SDK 0.3.258 GUI contracts Hide ambient housekeeping, keep elevated permission asks silent, drop terminal-only slash commands from agent surfaces, render spawn-depth trees with resource-link file rows, default active-turn auto dispatch, and add ade chat status. Co-authored-by: Cursor --- apps/ade-cli/src/adeRpcServer.test.ts | 13 + apps/ade-cli/src/cli.test.ts | 26 +- apps/ade-cli/src/cli.ts | 48 ++- .../src/tuiClient/__tests__/adeApi.test.ts | 19 ++ .../main/services/adeActions/registry.test.ts | 12 + .../src/main/services/adeActions/registry.ts | 10 + .../services/chat/agentChatService.test.ts | 282 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 137 ++++++++- .../chat/projectSlashCommandDiscovery.ts | 2 + .../chat/ChatSubagentsPanel.test.tsx | 41 +++ .../components/chat/ChatSubagentsPanel.tsx | 149 ++++++++- .../chat/chatExecutionSummary.test.ts | 31 ++ .../components/chat/chatExecutionSummary.ts | 12 + .../src/shared/chatSubagentTree.test.ts | 52 ++++ apps/desktop/src/shared/chatSubagentTree.ts | 181 +++++++++++ .../desktop/src/shared/chatTurnStatus.test.ts | 101 +++++++ apps/desktop/src/shared/chatTurnStatus.ts | 180 +++++++++++ .../src/shared/claudeAgentSdkFields.test.ts | 46 +++ .../src/shared/claudeAgentSdkFields.ts | 97 ++++++ .../src/shared/claudeGuiSlashCommands.test.ts | 51 ++++ .../src/shared/claudeGuiSlashCommands.ts | 53 ++++ .../src/shared/claudePermissionDialog.test.ts | 27 ++ .../src/shared/claudePermissionDialog.ts | 27 ++ apps/desktop/src/shared/types/chat.ts | 16 + apps/ios/ADE/Models/RemoteModels.swift | 54 +++- apps/ios/ADE/Services/SyncService.swift | 3 + .../Views/Work/WorkChatRichCardViews.swift | 142 +++++---- .../ADE/Views/Work/WorkChatSessionView.swift | 3 + .../Work/WorkErrorAndMessageHelpers.swift | 5 +- apps/ios/ADE/Views/Work/WorkModels.swift | 14 +- .../Work/WorkStatusAndFormattingHelpers.swift | 125 ++++++++ .../ADE/Views/Work/WorkTimelineHelpers.swift | 28 +- .../ADE/Views/Work/WorkTranscriptParser.swift | 27 +- apps/ios/ADETests/ADETests.swift | 52 +++- docs/features/chat/README.md | 20 +- 35 files changed, 1990 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/src/shared/chatSubagentTree.test.ts create mode 100644 apps/desktop/src/shared/chatSubagentTree.ts create mode 100644 apps/desktop/src/shared/chatTurnStatus.test.ts create mode 100644 apps/desktop/src/shared/chatTurnStatus.ts create mode 100644 apps/desktop/src/shared/claudeAgentSdkFields.test.ts create mode 100644 apps/desktop/src/shared/claudeAgentSdkFields.ts create mode 100644 apps/desktop/src/shared/claudeGuiSlashCommands.test.ts create mode 100644 apps/desktop/src/shared/claudeGuiSlashCommands.ts create mode 100644 apps/desktop/src/shared/claudePermissionDialog.test.ts create mode 100644 apps/desktop/src/shared/claudePermissionDialog.ts diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 79752e7652..b5762d11cb 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -495,6 +495,14 @@ function createRuntime() { lastActivityAt: "2026-03-17T19:00:00.000Z", createdAt: "2026-03-17T19:00:00.000Z", })), + getTurnStatus: vi.fn(async (sessionId: string) => ({ + sessionId, + phase: "idle", + activity: "idle", + live: false, + hasAsk: false, + queued: 0, + })), createScheduledWork: vi.fn(async ({ sessionId, cron, runAt, prompt, recurring = true }: { sessionId: string; cron?: string; @@ -3480,6 +3488,11 @@ describe("adeRpcServer", () => { expect(getSessionSummary).toMatchObject({ input: expect.stringContaining("scalar sessionId"), }); + const getTurnStatus = chatActions.structuredContent.actions.find((entry: { action: string }) => entry.action === "getTurnStatus"); + expect(getTurnStatus).toMatchObject({ + input: expect.stringContaining("scalar sessionId"), + example: expect.stringContaining("chat.getTurnStatus"), + }); const smartLinkPreview = chatActions.structuredContent.actions.find( (entry: { action: string }) => entry.action === "resolveSmartLinkPreview", ); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 3939770d2f..9db72c2408 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -4909,7 +4909,7 @@ describe("ADE CLI", () => { expect(text).toContain("assistant 2026-06-29T12:00:01.000Z"); }); - it("builds chat show/status as positional session summary calls", () => { + it("builds chat show as a session summary and chat status as turn status", () => { const show = buildCliPlan(["chat", "show", "chat-1"]); expect(show.kind).toBe("execute"); if (show.kind !== "execute") return; @@ -4925,14 +4925,36 @@ describe("ADE CLI", () => { const status = buildCliPlan(["chat", "status", "--session-id", "chat-2"]); expect(status.kind).toBe("execute"); if (status.kind !== "execute") return; + expect(status.formatter).toBe("chat-status"); expect(status.steps[0]?.params).toEqual({ name: "run_ade_action", arguments: { domain: "chat", - action: "getSessionSummary", + action: "getTurnStatus", argsList: ["chat-2"], }, }); + expect(status.exitCodeFromResult?.({ phase: "running", sessionId: "chat-2" })).toBe(0); + expect(status.exitCodeFromResult?.({ phase: "idle", sessionId: "chat-2" })).toBe(1); + expect(status.exitCodeFromResult?.({ phase: "blocked", sessionId: "chat-2" })).toBe(2); + }); + + it("formats chat turn status as a RUNNING/BLOCKED/IDLE tree", () => { + const text = formatOutput( + { + sessionId: "chat-1", + phase: "running", + turnElapsedMs: 12_000, + lastActivityMsAgo: 1_000, + queuedMessageCount: 0, + currentTool: { name: "Bash" }, + subagents: [], + }, + { ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true }, + "chat-status", + ); + expect(text).toContain("RUNNING"); + expect(text).toContain("Bash"); }); it("maps chat list filters to the listSessions action", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 3deef5b496..85b70abb76 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -78,6 +78,12 @@ import { type AgentChatDroidPermissionMode, } from "../../desktop/src/shared/types/chat"; import type { AgentChatDispatchSteerMode } from "../../desktop/src/shared/types/chat"; +import { + chatTurnStatusExitCode, + formatChatTurnStatus, + type ChatTurnStatusPhase, + type ChatTurnStatusSnapshot, +} from "../../desktop/src/shared/chatTurnStatus"; import type { TerminalSessionSummary } from "../../desktop/src/shared/types/sessions"; import { formatWorkingDuration, @@ -328,6 +334,7 @@ type FormatterId = | "pr-comments" | "chat-list" | "chat-read" + | "chat-status" | "session-lifecycle" | "lane-drift" | "scheduled-work-create" @@ -2097,7 +2104,7 @@ const HELP_BY_COMMAND: Record = { already carries. Add --arg strictMcpConfig=false to also load the user's own MCP config (personal chats withhold it by default), or --arg strictMcpConfig=true to withhold it on a project chat. - Read mcpCapability on the created session (ade chat status + Read mcpCapability on the created session (ade chat show --personal --json) for what the provider could honor: only level "enforced" means the caller's servers are the whole surface. A server the provider cannot carry fails the create. @@ -2105,6 +2112,9 @@ const HELP_BY_COMMAND: Record = { $ ade chat create --from-linear-issue ENG-431 --parent --type subagent Start a child chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active + $ ade chat show Session summary (title, provider, model) + $ ade chat status Live turn status: RUNNING / BLOCKED / IDLE + Exit 0 running, 1 idle, 2 blocked. Use --text. $ ade chat note "testing desktop auth fallback" # Update the Work status line (aim for ${STATUS_NOTE_GUIDELINE_WORDS} words or fewer; truncated past ${MAX_STATUS_NOTE_CHARACTERS} characters) $ ade chat ask "Which account should I use?" Escalate a blocking question to the user 'note' and 'ask' default to the caller and accept --session . @@ -7633,16 +7643,36 @@ function buildChatPlan(args: string[]): CliPlan { ], }; } - if (sub === "show" || sub === "status") + if (sub === "show") return { kind: "execute", - label: "chat status", + label: "chat show", steps: [ actionArgsListStep("result", "chat", "getSessionSummary", [ requireValue(sessionId, "sessionId"), ]), ], }; + if (sub === "status") + return { + kind: "execute", + label: "chat status", + formatter: "chat-status", + steps: [ + actionArgsListStep("result", "chat", "getTurnStatus", [ + requireValue(sessionId, "sessionId"), + ]), + ], + exitCodeFromResult: (result) => { + const record = firstRecord(result, ["result", "status"]) + ?? (isRecord(result) ? result : {}); + const phase = asString(record.phase) as ChatTurnStatusPhase | undefined; + if (phase === "running" || phase === "idle" || phase === "blocked") { + return chatTurnStatusExitCode(phase); + } + return 1; + }, + }; if (sub === "read" || sub === "messages" || sub === "transcript") { const targetSession = requireValue(sessionId, "sessionId"); const limit = readIntOption(args, ["--limit"], 50); @@ -20570,6 +20600,15 @@ function formatExternalSessions(value: unknown): string { ); } +function formatChatStatus(value: unknown): string { + const record = firstRecord(value, ["result", "status"]) + ?? (isRecord(value) ? value : null); + if (!record || typeof record.sessionId !== "string" || typeof record.phase !== "string") { + return "ADE chat status\n(no session)"; + } + return formatChatTurnStatus(record as ChatTurnStatusSnapshot); +} + function formatChatList(value: unknown): string { const sessions = firstArray(value, ["sessions", "chats", "items"]); return renderTable( @@ -22000,6 +22039,8 @@ function formatTextOutput( return formatPrComments(value); case "chat-list": return formatChatList(value); + case "chat-status": + return formatChatStatus(value); case "chat-read": return formatChatRead(value); case "session-lifecycle": @@ -22149,6 +22190,7 @@ function inferFormatter( if (label === "pr checks") return "pr-checks"; if (label === "pr comments") return "pr-comments"; if (label === "chat list") return "chat-list"; + if (label === "chat status") return "chat-status"; if (label === "test runs") return "tests-runs"; if (label === "proof list") return "proof-list"; if (label === "ios simulator status") return "ios-sim-status"; diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 11fb3e1d10..964ef746df 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1061,6 +1061,25 @@ describe("discoverProjectSlashCommands", () => { ])); }); + it("hides Claude terminal-only slash commands from the TUI catalog", () => { + const projectRoot = makeTmpRoot("ade-code-terminal-slash-"); + const commandsDir = path.join(projectRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "exit.md"), "Exit the CLI.\n"); + fs.writeFileSync(path.join(commandsDir, "quit.md"), "Quit.\n"); + fs.writeFileSync(path.join(commandsDir, "statusline.md"), "Status line.\n"); + fs.writeFileSync(path.join(commandsDir, "compact.md"), "Compact.\n"); + + const commands = discoverProjectSlashCommands(projectRoot); + const names = commands.map((command) => command.name.toLowerCase()); + expect(names).not.toContain("/exit"); + expect(names).not.toContain("/quit"); + expect(names).not.toContain("/statusline"); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/compact" }), + ])); + }); + it("includes Cursor command files and subagents", () => { const projectRoot = makeTmpRoot("ade-code-cursor-command-"); const commandsDir = path.join(projectRoot, ".cursor", "commands"); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index b11f97d7c2..1805c16d6c 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -605,6 +605,10 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(getAdeActionInputContract("chat", "getSessionSummary")).toMatchObject({ input: expect.stringContaining("scalar sessionId"), }); + expect(getAdeActionInputContract("chat", "getTurnStatus")).toMatchObject({ + description: expect.stringContaining("RUNNING"), + input: expect.stringContaining("scalar sessionId"), + }); expect(getAdeActionInputContract("chat", "readTranscript")).toMatchObject({ input: expect.stringContaining("limit"), }); @@ -765,6 +769,7 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { const createSession = vi.fn(async (args?: unknown) => ({ sessionId: "chat-new", args })); const getAvailableModels = vi.fn(async (args: { provider?: string }) => [{ id: args.provider ?? "any" }]); const getSessionSummary = vi.fn(async (sessionId: string) => ({ sessionId })); + const getTurnStatus = vi.fn(async (sessionId: string) => ({ sessionId, phase: "idle" })); const readTranscript = vi.fn(async (sessionId: string, limit?: number, since?: string) => ([ { role: "user", text: sessionId, timestamp: since ?? "now", limit }, ])); @@ -783,6 +788,7 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { createSession, getAvailableModels, getSessionSummary, + getTurnStatus, readTranscript, getChatEventHistory, getChatEventHistoryPage, @@ -795,6 +801,7 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { createSession?: (args?: unknown) => Promise; getAvailableModels?: (args?: unknown) => Promise; getSessionSummary?: (args?: unknown) => Promise; + getTurnStatus?: (args?: unknown) => Promise; readTranscript?: (args?: unknown) => Promise; getChatEventHistory?: (args?: unknown, options?: unknown) => Promise; getChatEventHistoryPage?: (args?: unknown, options?: unknown) => Promise; @@ -810,6 +817,11 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(getSessionSummary).toHaveBeenNthCalledWith(1, "chat-1"); expect(getSessionSummary).toHaveBeenNthCalledWith(2, "chat-2"); + await expect(chat.getTurnStatus?.({ sessionId: " chat-1 " })).resolves.toEqual({ sessionId: "chat-1", phase: "idle" }); + await expect(chat.getTurnStatus?.("chat-2")).resolves.toEqual({ sessionId: "chat-2", phase: "idle" }); + expect(getTurnStatus).toHaveBeenNthCalledWith(1, "chat-1"); + expect(getTurnStatus).toHaveBeenNthCalledWith(2, "chat-2"); + await expect(chat.createSession?.({ laneId: "lane-1", provider: "codex", diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 1bc2de69c3..bdab4e9583 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -603,6 +603,7 @@ export const ADE_ACTION_ALLOWLIST: Partial agentChatService.getSessionSummary(readStringActionArg(args, "sessionId")); } + if (typeof base.getTurnStatus === "function") { + service.getTurnStatus = (args?: unknown) => + agentChatService.getTurnStatus(readStringActionArg(args, "sessionId")); + } if (typeof base.listScheduledWork === "function") { service.listScheduledWork = (args?: unknown) => { const record = args === undefined diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 4f66f501d5..8a32afbb80 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2748,7 +2748,7 @@ describe("createAgentChatService", () => { expect(service.resumeSession).toBeTypeOf("function"); expect(service.listSessions).toBeTypeOf("function"); expect(service.getSessionSummary).toBeTypeOf("function"); - expect(service.hasActiveWorkloads).toBeTypeOf("function"); + expect(service.getTurnStatus).toBeTypeOf("function"); expect(service.getChatTranscript).toBeTypeOf("function"); expect(service.getChatTranscriptPage).toBeTypeOf("function"); expect(service.ensureIdentitySession).toBeTypeOf("function"); @@ -3741,6 +3741,7 @@ describe("createAgentChatService", () => { enabledPlugins?: Record; outputStyle?: string; fastMode?: boolean; + dialogExpiry?: string; }; skills?: string; plugins?: Array<{ type?: string; path?: string }>; @@ -3763,6 +3764,7 @@ describe("createAgentChatService", () => { // ADE's own default, which applies only while no settings file states one. workflowSizeGuideline: "medium", fastMode: false, + dialogExpiry: "never", enabledPlugins: expect.objectContaining({ "learning-output-style@claude-code-plugins": false, "learning-output-style@claude-plugins-official": false, @@ -12103,6 +12105,13 @@ describe("createAgentChatService", () => { expect(summary).not.toBeNull(); expect(summary!.sessionId).toBe(created.id); expect(summary!.provider).toBe("opencode"); + + const status = await service.getTurnStatus(created.id); + expect(status).toMatchObject({ + sessionId: created.id, + phase: "idle", + provider: "opencode", + }); }); it("surfaces and updates the first mirrored Claude SDK tag", async () => { @@ -14048,6 +14057,93 @@ describe("createAgentChatService", () => { turnDone!(); await expect(sendPromise).resolves.toBeUndefined(); }); + + it("treats ambient tasks like skip_transcript and never counts them as activity", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let turnDone: (() => void) | null = null; + const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-ambient-1", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "system", + subtype: "task_started", + task_id: "task-ambient-true", + description: "Generate session title", + task_type: "other", + ambient: true, + }; + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ + task_id: "task-ambient-true", + description: "Generate session title", + ambient: true, + }], + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "task-ambient-true", + status: "completed", + summary: "Done", + }; + await turnDonePromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-ambient-1", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Drive an ambient task.", + }); + + await vi.waitFor(() => { + expect(events.some((e) => e.event.type === "status")).toBe(true); + }); + + expect(events.filter((e) => + e.event.type === "subagent_started" + || e.event.type === "subagent_progress" + || e.event.type === "subagent_result" + )).toEqual([]); + + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(service.hasActiveWorkloads()).toBe(false); + }); + }); }); // -------------------------------------------------------------------------- @@ -15521,6 +15617,9 @@ describe("createAgentChatService", () => { expect(names).toContain("/agents"); expect(names).toContain("/output-style"); + expect(names).not.toContain("/exit"); + expect(names).not.toContain("/quit"); + expect(names).not.toContain("/statusline"); expect(commands).toEqual(expect.arrayContaining([ expect.objectContaining({ name: "/shipLane", @@ -15720,6 +15819,45 @@ describe("createAgentChatService", () => { expect(loginCmd).toBeUndefined(); }); + it("filters SDK terminal_slash_commands extras from a live Claude session palette", async () => { + let warmupComplete = false; + const stream = vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-terminal-slash", + slash_commands: ["/compact", "/exit", "/foo-cli"], + terminal_slash_commands: ["/foo-cli"], + }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-terminal-slash", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + + const names = service.getSlashCommands({ sessionId: session.id }).map((command) => command.name); + expect(names).toContain("/compact"); + expect(names).not.toContain("/exit"); + expect(names).not.toContain("/quit"); + expect(names).not.toContain("/statusline"); + expect(names).not.toContain("/foo-cli"); + }); + it("advertises the ADE-hosted Claude output-style command", async () => { const { service } = createService(); const session = await service.createSession({ @@ -18558,6 +18696,45 @@ describe("createAgentChatService", () => { expect(readPersistedChatState(session.id).cursorSdkAgentId).toBe("cursor-sdk-agent-1"); }); + it("routes messageSession kind auto on Cursor through interrupt-and-continue", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + const result = await service.messageSession({ + sessionId: session.id, + text: "Do this instead.", + kind: "auto", + }); + + expect(result.routedAction).toBe("steer"); + await vi.waitFor(() => { + expect(mockState.cursorSdkSendCalls.length).toBeGreaterThanOrEqual(2); + }); + expect(String(mockState.cursorSdkSendCalls[1]?.promptText ?? "")) + .toContain("Do this instead."); + expect(events.some((event) => + event.event.type === "status" && event.event.turnStatus === "interrupted")).toBe(true); + }); + + it("keeps messageSession kind queue on Cursor queued instead of interrupting", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, session } = await startBusyCursorSession(events); + + const result = await service.messageSession({ + sessionId: session.id, + text: "Then update the docs.", + kind: "queue", + }); + + expect(result.routedAction).toBe("steer"); + expect(result.queued).toBe(true); + expect(mockState.cursorSdkSendCalls).toHaveLength(1); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "user_message" && event.event.deliveryState === "queued")).toBe(true); + }); + }); + it("still clears queued Droid steers on interrupt-replace, unlike Cursor", async () => { // The Cursor redirect softens the stop to `stop_only` so the user's other // queued messages ride through. That is Cursor-only: every other provider @@ -21630,6 +21807,109 @@ describe("createAgentChatService", () => { expect(service.hasActiveWorkloads()).toBe(false); }); + it("keeps idle ambient tasks out of visible chat info", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startAmbient!: () => void; + let ambientDrained = false; + const startAmbientPromise = new Promise((resolve) => { startAmbient = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-idle-ambient", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-ambient", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startAmbientPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-idle-ambient", + task_id: "task-ambient-idle-true", + description: "Generate session title", + task_type: "other", + ambient: true, + }; + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ + task_id: "task-ambient-idle-true", + description: "Generate session title", + ambient: true, + }], + }; + yield { + type: "system", + subtype: "task_notification", + session_id: "sdk-idle-ambient", + task_id: "task-ambient-idle-true", + status: "completed", + summary: "Done", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-ambient", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + ambientDrained = true; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-idle-ambient", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Complete a visible turn, then let idle ambient housekeeping run.", + }); + + startAmbient(); + await vi.waitFor(() => { + expect(ambientDrained).toBe(true); + }); + + expect(events.filter((event) => + event.sessionId === session.id + && (event.event.type === "subagent_started" + || event.event.type === "subagent_progress" + || event.event.type === "subagent_result") + && (event.event as { taskId?: string }).taskId === "task-ambient-idle-true", + )).toEqual([]); + expect(service.hasActiveWorkloads()).toBe(false); + }); + it("delivers queued steers after an idle Claude turn completes", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 169b2880c3..209de109d9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -421,14 +421,30 @@ import { isAcpChatProvider, legacyPermissionModeFromDroidPermissionMode, spawnCompletedNoticeMessage, + defaultActiveTurnDispatchMode, supportsActiveTurnDispatchMode, unsupportedActiveTurnDispatchModeMessage, waitingOnYouDescription, type AcpChatProvider, type AgentChatAcpConfigSnapshot, type AgentChatAcpPermissionMode, + type AgentChatResourceLink, } from "../../../shared/types/chat"; import { providerDisplayLabel } from "../../../shared/pendingInputLabels"; +import { buildClaudeToolApprovalOptions, claudeToolNeedsDefaultToNo } from "../../../shared/claudePermissionDialog"; +import { + collectClaudeTerminalSlashCommandNames, + filterClaudeGuiSlashCommands, +} from "../../../shared/claudeGuiSlashCommands"; +import { + isClaudeHousekeepingTask, + parseClaudeResourceLinks, + readClaudeSpawnDepth, +} from "../../../shared/claudeAgentSdkFields"; +import { + deriveChatTurnStatus, + type ChatTurnStatusSnapshot, +} from "../../../shared/chatTurnStatus"; import { flattenAnswerForSingleStringProvider, ownQuestionValue, @@ -1781,6 +1797,8 @@ type ClaudeActiveSubagent = { * symmetrically with the spawn. */ skipTranscript?: boolean; + spawnDepth?: number; + resourceLinks?: AgentChatResourceLink[]; /** * A Claude Code task run that is neither a real subagent (no agentType / * agentId, task_type not "subagent"/"local_workflow") nor a background shell @@ -1933,6 +1951,8 @@ type ClaudeRuntime = { activeProviderCronIds: Set; activeProviderCronIdsByPrompt: Map>; slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; + /** Runtime `terminal_slash_commands` from init, unioned with the known CLI-only set. */ + terminalSlashCommandNames: Set; busy: boolean; activeTurnId: string | null; /** Orders active-turn steers after the parent turn's first SDK input push. */ @@ -1990,6 +2010,25 @@ function resetClaudeProcessBackgroundLevel(runtime: ClaudeRuntime): void { syncClaudeBackgroundWorkAnchor(runtime); } +function claudeTaskTreeFields( + msg: Record, + existing?: Pick, +): { spawnDepth?: number; resourceLinks?: AgentChatResourceLink[] } { + const spawnDepth = readClaudeSpawnDepth(msg) ?? existing?.spawnDepth; + const parsedLinks = parseClaudeResourceLinks(msg); + const resourceLinks = parsedLinks.length ? parsedLinks : existing?.resourceLinks; + return { + ...(spawnDepth != null ? { spawnDepth } : {}), + ...(resourceLinks?.length ? { resourceLinks } : {}), + }; +} + +function rememberClaudeTerminalSlashCommands(runtime: ClaudeRuntime, raw: unknown): void { + for (const name of collectClaudeTerminalSlashCommandNames(raw)) { + runtime.terminalSlashCommandNames.add(name); + } +} + function settleClaudeInitialInputDispatch( runtime: ClaudeRuntime, error?: Error, @@ -2892,7 +2931,9 @@ function claudeHasBoundedWorkload(runtime: ClaudeRuntime): boolean { */ function claudeHasBackgroundWorkload(runtime: ClaudeRuntime): boolean { const hasUnlevelledSubagent = [...runtime.activeSubagents.values()].some( - (subagent) => !subagent.background || !runtime.backgroundTasksLevelObserved, + (subagent) => + !subagent.skipTranscript + && (!subagent.background || !runtime.backgroundTasksLevelObserved), ); return Boolean(hasUnlevelledSubagent || runtime.liveBackgroundTaskIds.size > 0); } @@ -9549,11 +9590,9 @@ export function createAgentChatService(args: { id: "tool_decision", header: toolName, question: description, - options: [ - { label: "Allow", value: "allow", recommended: true }, - { label: "Allow for Session", value: "allow_session" }, - { label: "Deny", value: "deny" }, - ], + options: buildClaudeToolApprovalOptions({ + defaultToNo: claudeToolNeedsDefaultToNo(sdkOptions), + }), allowsFreeform: true, }], allowsFreeform: true, @@ -9668,6 +9707,8 @@ export function createAgentChatService(args: { turnId: event.turnId ?? undefined, startTimestamp: previous?.startTimestamp ?? timestamp, background: event.background ?? false, + spawnDepth: event.spawnDepth ?? previous?.spawnDepth, + resourceLinks: event.resourceLinks?.length ? event.resourceLinks : previous?.resourceLinks, }); return; } @@ -9693,6 +9734,8 @@ export function createAgentChatService(args: { lastToolName: event.lastToolName ?? previous?.lastToolName, background: previous?.background, usage: event.usage ?? previous?.usage, + spawnDepth: event.spawnDepth ?? previous?.spawnDepth, + resourceLinks: event.resourceLinks?.length ? event.resourceLinks : previous?.resourceLinks, }); return; } @@ -9724,6 +9767,8 @@ export function createAgentChatService(args: { lastToolName: previous?.lastToolName, background: previous?.background, usage: event.usage ?? previous?.usage, + spawnDepth: event.spawnDepth ?? previous?.spawnDepth, + resourceLinks: event.resourceLinks?.length ? event.resourceLinks : previous?.resourceLinks, }); }; @@ -16562,6 +16607,9 @@ export function createAgentChatService(args: { const task = asRecord(rawTask); const taskId = compactString(task?.task_id); if (!task || !taskId) continue; + if (isClaudeHousekeepingTask(task) || runtime.activeSubagents.get(taskId)?.skipTranscript) { + continue; + } nextIds.add(taskId); const rawLevelTaskType = compactString(task.task_type); if (rawLevelTaskType) nextTaskTypes.set(taskId, rawLevelTaskType); @@ -20732,7 +20780,7 @@ export function createAgentChatService(args: { parentToolUseId: existing?.parentToolUseId, })) return true; } - if (subtype === "task_started" && msg.skip_transcript === true) { + if (subtype === "task_started" && isClaudeHousekeepingTask(msg)) { runtime.activeSubagents.set(taskId, { taskId, description: compactString(msg.description) ?? "", @@ -20886,6 +20934,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...(model ? { model } : {}), + ...claudeTaskTreeFields(msg, existing), }); if (taskType === "cron") { const scheduledWorkId = nativeCronScheduleId; @@ -20915,6 +20964,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...optionalSubagentModelFields(model), + ...claudeTaskTreeFields(msg, existing), turnId, }); return true; @@ -20964,6 +21014,7 @@ export function createAgentChatService(args: { ...optionalSubagentModelFields(existing?.model ?? model), ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), + ...claudeTaskTreeFields(msg, existing), turnId, }); return true; @@ -21143,6 +21194,7 @@ export function createAgentChatService(args: { if (isClaudeForwardedSubagentMessage(msg)) return; if (msg.type === "system" && record.subtype === "init") { + rememberClaudeTerminalSlashCommands(runtime, record.terminal_slash_commands); const initCommands = Array.isArray(record.slash_commands) ? record.slash_commands : []; if (initCommands.length) applyClaudeSlashCommands(runtime, initCommands as any[]); applyClaudeProtocolCapabilities(managed, record.capabilities); @@ -22402,6 +22454,7 @@ export function createAgentChatService(args: { adoptClaudeProviderSessionId(managed, runtime, initSessionId); } reportedInitModel = normalizeReportedModelName(initMsg.model) ?? reportedInitModel; + rememberClaudeTerminalSlashCommands(runtime, initMsg.terminal_slash_commands); if (Array.isArray(initMsg.slash_commands)) { applyClaudeSlashCommands(runtime, initMsg.slash_commands); } @@ -22881,6 +22934,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...(model ? { model } : {}), + ...claudeTaskTreeFields(taskMsg as Record, existing), }); emitChatEvent(managed, { type: "subagent_progress", @@ -22903,6 +22957,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...optionalSubagentModelFields(model), + ...claudeTaskTreeFields(taskMsg as Record, existing), turnId, }); if (workflowProgress && workflowProgress.agents.length > 0) { @@ -23056,6 +23111,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...(model ? { model } : {}), + ...claudeTaskTreeFields(taskMsg as Record, existing), }); emitChatEvent(managed, { type: "subagent_progress", @@ -23068,6 +23124,7 @@ export function createAgentChatService(args: { summary, ...optionalSubagentModelFields(model), ...(taskType ? { taskType } : {}), + ...claudeTaskTreeFields(taskMsg as Record, existing), ...(workflowName ? { workflowName } : {}), turnId, }); @@ -23082,7 +23139,7 @@ export function createAgentChatService(args: { // summary writer) with skip_transcript=true; those must not surface. if (msg.type === "system" && (msg as any).subtype === "task_started") { const taskMsg = msg as any; - if (taskMsg.skip_transcript === true) { + if (isClaudeHousekeepingTask(taskMsg)) { const skippedId = typeof taskMsg.task_id === "string" && taskMsg.task_id.trim().length ? taskMsg.task_id.trim() : null; @@ -23200,6 +23257,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...(model ? { model } : {}), + ...claudeTaskTreeFields(taskMsg as Record, existingStarted), }); const remappedTodoItems = remapClaudeTaskTodoFromRuntimeEvent( claudeTaskTodoMap(managed, runtime), @@ -23228,6 +23286,7 @@ export function createAgentChatService(args: { ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), ...optionalSubagentModelFields(model), + ...claudeTaskTreeFields(taskMsg as Record, existingStarted), turnId, }); continue; @@ -23344,6 +23403,7 @@ export function createAgentChatService(args: { } : undefined, ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), + ...claudeTaskTreeFields(taskMsg as Record, existing), turnId, }); // A workflow task that ends with agents still mid-flight (stop, @@ -32052,6 +32112,7 @@ export function createAgentChatService(args: { enabledPlugins: CLAUDE_SESSION_DISABLED_PLUGINS, fastMode: sessionEffectiveFastMode(managed.session), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), + dialogExpiry: "never", }, ...(pluginPaths.length ? { plugins: pluginPaths.map((pluginPath) => ({ type: "local" as const, path: pluginPath })) } : {}), permissionMode: claudePermissionMode as any, @@ -32815,7 +32876,10 @@ export function createAgentChatService(args: { ...command, }); } - runtime.slashCommands = [...existing.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + runtime.slashCommands = filterClaudeGuiSlashCommands( + [...existing.values()], + runtime.terminalSlashCommandNames, + ).sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }; const deliverNextQueuedSteer = async ( @@ -33401,6 +33465,7 @@ export function createAgentChatService(args: { activeProviderCronIds: new Set(), activeProviderCronIdsByPrompt: new Map(), slashCommands: [], + terminalSlashCommandNames: new Set(), busy: false, activeTurnId: null, initialInputDispatchGate: null, @@ -43412,15 +43477,18 @@ export function createAgentChatService(args: { ((normalizedKind === "auto" || (normalizedKind === "wake" && !wakeNeedsQueue)) && activeTarget); if (steerTarget) { + const dispatchMode = isSpawnCompletion && managed.session.provider === "claude" + ? "inline" as const + : normalizedKind === "auto" + ? defaultActiveTurnDispatchMode(managed.session.provider) + : "queue"; const result = await steerWithOptions({ sessionId, text, attachments, contextAttachments, metadata, - ...(isSpawnCompletion && managed.session.provider === "claude" - ? { dispatchMode: "inline" as const } - : {}), + ...(dispatchMode === "queue" ? {} : { dispatchMode }), }, { allowPendingInput: isSpawnCompletion }); if (result.reason === "queue_full") { throw new Error("The Claude steer queue is full; the message was not queued."); @@ -45696,6 +45764,45 @@ export function createAgentChatService(args: { return await summarizeSessionRow(row); }; + const getTurnStatus = async (sessionId: string): Promise => { + const summary = await getSessionSummary(sessionId); + if (!summary) return null; + const trimmed = sessionId.trim(); + const managed = managedSessions.get(trimmed) ?? null; + const pending = managed ? [...managed.localPendingInputs.values()][0]?.request : undefined; + const queuedMessageCount = managed?.runtime && "pendingSteers" in managed.runtime + ? managed.runtime.pendingSteers.length + : 0; + const snapshots = await getTrackedSubagents(trimmed); + const runningWithTool = snapshots.find((snapshot) => snapshot.status === "running" && snapshot.lastToolName?.trim()); + return deriveChatTurnStatus({ + sessionId: summary.sessionId, + provider: summary.provider, + sessionStatus: summary.status, + currentTurnStartedAt: summary.currentTurnStartedAt ?? null, + lastActivityAt: summary.lastActivityAt ?? null, + awaitingInput: summary.awaitingInput === true, + pendingTitle: pending?.title ?? null, + pendingDescription: pending?.description ?? null, + queuedMessageCount, + currentTool: runningWithTool?.lastToolName + ? { name: runningWithTool.lastToolName } + : null, + subagents: snapshots.map((snapshot) => ({ + taskId: snapshot.taskId, + ...(snapshot.agentId ? { agentId: snapshot.agentId } : {}), + parentAgentId: snapshot.parentAgentId ?? null, + description: snapshot.description, + status: snapshot.status, + ...(snapshot.background != null ? { background: snapshot.background } : {}), + ...(snapshot.spawnDepth != null ? { spawnDepth: snapshot.spawnDepth } : {}), + startTimestamp: snapshot.startTimestamp, + ...(snapshot.usage?.durationMs != null ? { durationMs: snapshot.usage.durationMs } : {}), + ...(snapshot.resourceLinks?.length ? { resourceLinks: snapshot.resourceLinks } : {}), + })), + }); + }; + /** * Repairs the owning surface for a session loaded from legacy metadata. * This is intentionally not part of the public chat action contract; the @@ -49033,7 +49140,10 @@ export function createAgentChatService(args: { argumentHint: cmd.argumentHint, source: "sdk" as const, })); - return mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]); + return filterClaudeGuiSlashCommands( + mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]), + managed?.runtime?.kind === "claude" ? managed.runtime.terminalSlashCommandNames : [], + ); } // Codex SDK commands @@ -51526,6 +51636,7 @@ export function createAgentChatService(args: { resumeSession, listSessions, getSessionSummary, + getTurnStatus, ensureSessionSurface, hasActiveWorkloads, hasRetainableSessions, diff --git a/apps/desktop/src/main/services/chat/projectSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/projectSlashCommandDiscovery.ts index fedca4482f..d27ab7b06c 100644 --- a/apps/desktop/src/main/services/chat/projectSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/projectSlashCommandDiscovery.ts @@ -3,12 +3,14 @@ import { discoverClaudeSlashCommands } from "./claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands } from "./codexSlashCommandDiscovery"; import { discoverCursorSlashCommands } from "./cursorSlashCommandDiscovery"; import { slashCommandKey } from "./markdownSlashCommandDiscovery"; +import { isClaudeTerminalOnlySlashCommand } from "../../../shared/claudeGuiSlashCommands"; export function discoverAllProjectSlashCommands(workspaceRoot: string): AgentChatSlashCommand[] { const byName = new Map(); function add(command: { name: string; description: string; argumentHint?: string }): void { const key = slashCommandKey(command.name); if (key === "/login") return; + if (isClaudeTerminalOnlySlashCommand(command.name)) return; if (byName.has(key)) return; byName.set(key, { name: command.name, diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index 511b03c70a..25ed930510 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -916,4 +916,45 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(header?.className).toContain("sticky"); expect(header?.className).toContain("--work-sidebar-bg"); }); + + it("indents nested agents with connector glyphs and a collapsible files-returned row", () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const parent: ChatSubagentSnapshot = { + ...baseSnapshot, + taskId: "root", + agentId: "root", + description: "typecheck desktop", + background: false, + }; + const child: ChatSubagentSnapshot = { + ...baseSnapshot, + taskId: "child", + agentId: "child", + parentAgentId: "root", + description: "explore chat tests", + background: false, + spawnDepth: 1, + resourceLinks: [{ path: "apps/desktop/src/foo.ts" }, { path: "apps/desktop/src/bar.ts" }], + }; + + render( + , + ); + + expect(screen.getByText(/└/)).toBeTruthy(); + expect(screen.getByText("2 files returned")).toBeTruthy(); + expect(screen.queryByText("apps/desktop/src/foo.ts")).toBeNull(); + + fireEvent.click(screen.getByText("2 files returned")); + expect(screen.getByText("apps/desktop/src/foo.ts")).toBeTruthy(); + expect(screen.getByText("apps/desktop/src/bar.ts")).toBeTruthy(); + + fireEvent.click(screen.getByTitle("Copy all paths")); + expect(writeText).toHaveBeenCalledWith("apps/desktop/src/foo.ts\napps/desktop/src/bar.ts"); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 5cf329dfb5..248a28c147 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -6,6 +6,7 @@ import { Check, Circle, CircleHalf, + CopySimple, Pause, Play, Square, @@ -16,6 +17,8 @@ import { cn } from "../ui/cn"; import { formatDurationMs, formatSubagentDurationMs } from "../../lib/format"; import type { ChatScheduledWorkSnapshot, ChatSubagentSnapshot } from "./chatExecutionSummary"; import { derivePlan, subagentTreeDepth } from "./chatExecutionSummary"; +import { annotateSubagentTree, shouldAutoCollapseFinishedSubtree } from "../../../shared/chatSubagentTree"; +import { resourceLinkCopyPaths, resourceLinkDisplayPath } from "../../../shared/claudeAgentSdkFields"; import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { ChatTaskList } from "./ChatTasksPanel"; import type { ChatInfoPlanStep, PaneSectionKey } from "../../../shared/chatSubagents"; @@ -65,6 +68,10 @@ const EMPTY_PANE_CLEARED_STATE: PaneClearedStorageState = { subagents: [], backg const PANE_SECTION_KEYS: PaneSectionKey[] = ["progress", "tasks", "subagents", "background", "schedule"]; const PANE_EARLIER_SECTION_KEYS = ["subagents", "background", "schedule"] as const; +function subagentIdentity(snapshot: ChatSubagentSnapshot): string { + return snapshot.agentId?.trim() || snapshot.taskId; +} + export function chatPaneUiStorageKey(sessionId: string): string { return `${PANE_UI_STORAGE_PREFIX}:${sessionId}`; } @@ -802,6 +809,9 @@ function SubagentRow({ canViewFullTranscript, onClick, depth = 0, + treePrefix = "", + collapsedDescendantCount = 0, + onToggleCollapsedSubtree, spawnedChatTitle = null, sessionModelLabel = null, }: { @@ -811,6 +821,10 @@ function SubagentRow({ expanded: boolean; /** Nesting depth (parentAgentId chain); indents the row one level per ancestor. */ depth?: number; + /** Connector-glyph prefix such as `│ └ `. Empty for roots. */ + treePrefix?: string; + collapsedDescendantCount?: number; + onToggleCollapsedSubtree?: () => void; /** True while we're checking whether this agent has a pullable transcript. */ probing: boolean; /** Whether this runtime can surface a full child transcript (drives the @@ -843,6 +857,7 @@ function SubagentRow({ const isStopped = snapshot.status === "stopped"; const isFailed = snapshot.status === "failed"; const [nowMs, setNowMs] = useState(() => Date.now()); + const [filesOpen, setFilesOpen] = useState(false); useEffect(() => { if (!isRunning) return; const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000); @@ -864,9 +879,14 @@ function SubagentRow({ // summary) — shown only when it adds something beyond the description. const summaryRaw = (snapshot.finalSummary ?? snapshot.summary)?.trim(); const summaryText = summaryRaw && summaryRaw !== snapshot.description?.trim() ? summaryRaw : null; + const filePaths = resourceLinkCopyPaths(snapshot.resourceLinks ?? []); + const copyFilePaths = useCallback(() => { + if (!filePaths.length || typeof navigator === "undefined" || !navigator.clipboard?.writeText) return; + void navigator.clipboard.writeText(filePaths.join("\n")); + }, [filePaths]); return ( -
0 ? { paddingLeft: depth * 14 } : undefined}> +
+ {filePaths.length > 0 ? ( +
+
+ + +
+ {filesOpen ? ( +
    + {(snapshot.resourceLinks ?? []).map((link, index) => { + const path = resourceLinkDisplayPath(link) ?? link.name ?? link.uri ?? `file-${index}`; + return
  • {path}
  • ; + })} +
+ ) : null} +
+ ) : null} + {/* No pullable transcript → a tiny details drawer slides out beneath the row instead of taking over the chat with an empty page. */} @@ -1195,6 +1271,42 @@ export function ChatSubagentsPanel({ }; }, [snapshots]); + const [expandedFinishedIds, setExpandedFinishedIds] = useState>(() => new Set()); + const annotatedSubagents = useMemo(() => annotateSubagentTree(subagents), [subagents]); + const treeById = useMemo(() => { + const map = new Map(); + for (const entry of annotatedSubagents) { + map.set(subagentIdentity(entry.node), entry.tree); + } + return map; + }, [annotatedSubagents]); + const hiddenDescendantIds = useMemo(() => { + const hidden = new Set(); + const byId = new Map(subagents.map((snapshot) => [subagentIdentity(snapshot), snapshot])); + for (const { node, tree } of annotatedSubagents) { + const id = subagentIdentity(node); + if (expandedFinishedIds.has(id)) continue; + const descendantStatuses = tree.descendantIds.map((descendantId) => byId.get(descendantId)?.status); + if (shouldAutoCollapseFinishedSubtree(node, descendantStatuses)) { + for (const descendantId of tree.descendantIds) hidden.add(descendantId); + } + } + return hidden; + }, [annotatedSubagents, expandedFinishedIds, subagents]); + const collapsedCountById = useMemo(() => { + const counts = new Map(); + const byId = new Map(subagents.map((snapshot) => [subagentIdentity(snapshot), snapshot])); + for (const { node, tree } of annotatedSubagents) { + const id = subagentIdentity(node); + if (expandedFinishedIds.has(id)) continue; + const descendantStatuses = tree.descendantIds.map((descendantId) => byId.get(descendantId)?.status); + if (shouldAutoCollapseFinishedSubtree(node, descendantStatuses)) { + counts.set(id, tree.descendantIds.filter((descendantId) => hiddenDescendantIds.has(descendantId)).length); + } + } + return counts; + }, [annotatedSubagents, expandedFinishedIds, hiddenDescendantIds, subagents]); + const pinnedSubagentIds = useMemo(() => new Set( [selectedTaskId, expandedTaskId].filter((id): id is string => Boolean(id)), ), [expandedTaskId, selectedTaskId]); @@ -1202,11 +1314,19 @@ export function ChatSubagentsPanel({ const clearedBackgroundIds = useMemo(() => new Set(paneCleared.background), [paneCleared.background]); const clearedScheduleIds = useMemo(() => new Set(paneCleared.schedule), [paneCleared.schedule]); - const subagentGroups = useMemo(() => groupPaneSectionItems(subagents, { - isEarlier: isEarlierSubagentSnapshot, - isCleared: (snapshot) => clearedSubagentIds.has(snapshot.taskId), - isPinned: (snapshot) => pinnedSubagentIds.has(snapshot.taskId), - }), [clearedSubagentIds, pinnedSubagentIds, subagents]); + const subagentGroups = useMemo(() => { + const grouped = groupPaneSectionItems(subagents, { + isEarlier: isEarlierSubagentSnapshot, + isCleared: (snapshot) => clearedSubagentIds.has(snapshot.taskId), + isPinned: (snapshot) => pinnedSubagentIds.has(snapshot.taskId), + }); + const visible = (items: ChatSubagentSnapshot[]) => items.filter((item) => !hiddenDescendantIds.has(subagentIdentity(item))); + return { + ...grouped, + active: visible(grouped.active), + earlier: visible(grouped.earlier), + }; + }, [clearedSubagentIds, hiddenDescendantIds, pinnedSubagentIds, subagents]); const backgroundGroups = useMemo(() => groupPaneSectionItems(backgroundItems, { isEarlier: isEarlierBackgroundItem, isCleared: (snapshot) => clearedBackgroundIds.has(snapshot.id), @@ -1391,7 +1511,18 @@ export function ChatSubagentsPanel({ probing={probingTaskId === snap.taskId} canViewFullTranscript={canTakeover} category={snap.background ? "background" : "subagent"} - depth={subagentTreeDepth(snap, snapshots)} + depth={treeById.get(subagentIdentity(snap))?.depth ?? subagentTreeDepth(snap, snapshots)} + treePrefix={treeById.get(subagentIdentity(snap))?.prefix ?? ""} + collapsedDescendantCount={collapsedCountById.get(subagentIdentity(snap)) ?? 0} + onToggleCollapsedSubtree={() => { + const id = subagentIdentity(snap); + setExpandedFinishedIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }} spawnedChatTitle={ snap.childSessionId != null ? resolveSpawnedChatTitle?.(snap.childSessionId) ?? null diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index dcd557cb40..5018a6fd3a 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -40,6 +40,12 @@ describe("subagentTreeDepth", () => { expect(subagentTreeDepth(orphan, list)).toBe(0); }); + it("prefers SDK spawnDepth over a parentAgentId walk", () => { + const root = snap("root", null); + const child = { ...snap("child", "root"), spawnDepth: 3 }; + expect(subagentTreeDepth(child, [root, child])).toBe(3); + }); + it("caps depth at 3 for deep chains", () => { const a = snap("a", null); const b = snap("b", "a"); @@ -248,6 +254,31 @@ describe("deriveChatSubagentSnapshots", () => { ]); }); + it("copies spawnDepth and resourceLinks from subagent envelopes", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "task-files", + agentId: "agent-files", + description: "Collect returned files", + spawnDepth: 2, + resourceLinks: [{ path: "apps/desktop/src/foo.ts" }, { uri: "file:///tmp/a.ts" }], + }, + }, + ]; + + expect(deriveChatSubagentSnapshots(events)).toEqual([ + expect.objectContaining({ + taskId: "task-files", + spawnDepth: 2, + resourceLinks: [{ path: "apps/desktop/src/foo.ts" }, { uri: "file:///tmp/a.ts" }], + }), + ]); + }); + it("keeps Codex sibling subagents separate when they share a parent tool use id", () => { const events: AgentChatEventEnvelope[] = [ { diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index de90230758..f037616f0a 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -1,5 +1,6 @@ import type { AgentChatEventEnvelope, + AgentChatResourceLink, AgentChatSpawnKind, TurnDiffSummary, } from "../../../shared/types"; @@ -57,6 +58,8 @@ export type ChatSubagentSnapshot = { */ spawnKind?: AgentChatSpawnKind; workflowName?: string; + spawnDepth?: number; + resourceLinks?: AgentChatResourceLink[]; usage?: { totalTokens?: number; toolUses?: number; @@ -80,6 +83,9 @@ export function subagentTreeDepth( snapshots: ChatSubagentSnapshot[], cap = 3, ): number { + if (typeof snapshot.spawnDepth === "number" && Number.isFinite(snapshot.spawnDepth)) { + return Math.max(0, Math.min(cap, Math.floor(snapshot.spawnDepth))); + } const byAgentId = new Map(); for (const candidate of snapshots) { if (candidate.agentId) byAgentId.set(candidate.agentId, candidate); @@ -242,6 +248,8 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C // fall back to the existing snapshot to survive the twin's overwrite. spawnKind, workflowName: event.workflowName ?? existing?.workflowName, + spawnDepth: event.spawnDepth ?? existing?.spawnDepth, + resourceLinks: event.resourceLinks?.length ? event.resourceLinks : existing?.resourceLinks, usage: existing?.usage, }); continue; @@ -274,6 +282,8 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C taskType: event.taskType ?? existing?.taskType, spawnKind, workflowName: event.workflowName ?? existing?.workflowName, + spawnDepth: event.spawnDepth ?? existing?.spawnDepth, + resourceLinks: event.resourceLinks?.length ? event.resourceLinks : existing?.resourceLinks, usage: event.usage ? { ...(existing?.usage ?? {}), ...event.usage } : existing?.usage, }); continue; @@ -306,6 +316,8 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C taskType: event.taskType ?? existing?.taskType, spawnKind, workflowName: event.workflowName ?? existing?.workflowName, + spawnDepth: event.spawnDepth ?? existing?.spawnDepth, + resourceLinks: event.resourceLinks?.length ? event.resourceLinks : existing?.resourceLinks, usage: event.usage ? { ...(existing?.usage ?? {}), ...event.usage } : existing?.usage, }); } diff --git a/apps/desktop/src/shared/chatSubagentTree.test.ts b/apps/desktop/src/shared/chatSubagentTree.test.ts new file mode 100644 index 0000000000..7d25519883 --- /dev/null +++ b/apps/desktop/src/shared/chatSubagentTree.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { annotateSubagentTree, shouldAutoCollapseFinishedSubtree } from "./chatSubagentTree"; + +describe("subagent tree connectors", () => { + it("indents children with ├ / └ and prefers SDK spawn_depth when present", () => { + const annotated = annotateSubagentTree([ + { taskId: "root", agentId: "root", description: "typecheck desktop", status: "running", startedAt: "2026-05-01T00:00:02.000Z" }, + { taskId: "child", agentId: "child", parentAgentId: "root", status: "running", startedAt: "2026-05-01T00:00:01.000Z" }, + { taskId: "leaf", agentId: "leaf", parentAgentId: "child", spawnDepth: 2, status: "completed", startedAt: "2026-05-01T00:00:00.000Z" }, + ] as const); + + expect(annotated.map((entry) => identity(entry))).toEqual(["root", "child", "leaf"]); + expect(annotated[0]?.tree).toMatchObject({ depth: 0, glyph: "", prefix: "" }); + expect(annotated[1]?.tree).toMatchObject({ depth: 1, glyph: "└", prefix: "└ " }); + expect(annotated[2]?.tree).toMatchObject({ depth: 2, glyph: "└", prefix: " └ " }); + }); + + it("uses ├ for a non-last sibling", () => { + const annotated = annotateSubagentTree([ + { taskId: "root", agentId: "root", startedAt: "2026-05-01T00:00:02.000Z" }, + { taskId: "older", agentId: "older", parentAgentId: "root", startedAt: "2026-05-01T00:00:00.000Z" }, + { taskId: "newer", agentId: "newer", parentAgentId: "root", startedAt: "2026-05-01T00:00:01.000Z" }, + ]); + const older = annotated.find((entry) => entry.node.taskId === "older")?.tree; + const newer = annotated.find((entry) => entry.node.taskId === "newer")?.tree; + expect(newer).toMatchObject({ glyph: "├", prefix: "├ " }); + expect(older).toMatchObject({ glyph: "└", prefix: "└ " }); + }); + + it("auto-collapses a finished parent whose descendants are also finished", () => { + expect(shouldAutoCollapseFinishedSubtree( + { taskId: "root", status: "completed" }, + ["completed", "failed"], + )).toBe(true); + expect(shouldAutoCollapseFinishedSubtree( + { taskId: "root", status: "completed" }, + ["running"], + )).toBe(false); + expect(shouldAutoCollapseFinishedSubtree( + { taskId: "root", status: "running" }, + ["completed"], + )).toBe(false); + expect(shouldAutoCollapseFinishedSubtree( + { taskId: "root", status: "completed" }, + [], + )).toBe(false); + }); +}); + +function identity(entry: { node: { agentId?: string; taskId: string } }): string { + return entry.node.agentId ?? entry.node.taskId; +} diff --git a/apps/desktop/src/shared/chatSubagentTree.ts b/apps/desktop/src/shared/chatSubagentTree.ts new file mode 100644 index 0000000000..4f97fba653 --- /dev/null +++ b/apps/desktop/src/shared/chatSubagentTree.ts @@ -0,0 +1,181 @@ +export type SubagentTreeIdentity = { + taskId: string; + agentId?: string; + parentAgentId?: string | null; + spawnDepth?: number; + status?: "running" | "completed" | "failed" | "stopped"; + startTimestamp?: string; + startedAt?: string; +}; + +export type SubagentTreeAnnotation = { + depth: number; + isLastSibling: boolean; + /** Visible connector prefix such as `│ └ `. Empty for roots. */ + prefix: string; + glyph: "├" | "└" | ""; + childIds: string[]; + descendantIds: string[]; +}; + +const DEFAULT_TREE_CAP = 3; + +function identityKey(node: SubagentTreeIdentity): string { + return node.agentId?.trim() || node.taskId; +} + +function parentKey(node: SubagentTreeIdentity): string | null { + const parent = node.parentAgentId?.trim(); + return parent || null; +} + +function spawnOrder(node: SubagentTreeIdentity): number { + const stamp = node.startedAt ?? node.startTimestamp; + if (!stamp) return 0; + const parsed = Date.parse(stamp); + return Number.isFinite(parsed) ? parsed : 0; +} + +function computedDepth( + node: SubagentTreeIdentity, + byId: Map, + cap: number, +): number { + if (typeof node.spawnDepth === "number" && Number.isFinite(node.spawnDepth)) { + return Math.max(0, Math.min(cap, Math.floor(node.spawnDepth))); + } + const seen = new Set(); + let depth = 0; + let current: SubagentTreeIdentity | undefined = node; + while (current) { + const parentId = parentKey(current); + if (!parentId) break; + const id = identityKey(current); + if (seen.has(id)) break; + seen.add(id); + const parent = byId.get(parentId); + if (!parent || parent === current) break; + depth += 1; + if (depth >= cap) break; + current = parent; + } + return depth; +} + +function collectDescendants(rootId: string, childrenByParent: Map): string[] { + const out: string[] = []; + const stack = [...(childrenByParent.get(rootId) ?? [])]; + const seen = new Set(); + while (stack.length) { + const id = stack.pop(); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(id); + const children = childrenByParent.get(id); + if (children) stack.push(...children); + } + return out; +} + +function isFinished(status: SubagentTreeIdentity["status"] | undefined): boolean { + return status === "completed" || status === "failed" || status === "stopped"; +} + +/** + * Preorder tree walk: newest roots first, children immediately under their + * parent (newest sibling first). Connector glyphs are computed against that + * visible order. SDK `spawn_depth` wins over a parentAgentId walk when present. + */ +export function annotateSubagentTree( + snapshots: readonly T[], + cap = DEFAULT_TREE_CAP, +): Array<{ node: T; tree: SubagentTreeAnnotation }> { + const byId = new Map(); + for (const snapshot of snapshots) { + byId.set(identityKey(snapshot), snapshot); + } + const childrenByParent = new Map(); + const roots: T[] = []; + for (const snapshot of snapshots) { + const parentId = parentKey(snapshot); + if (parentId && byId.has(parentId) && parentId !== identityKey(snapshot)) { + const children = childrenByParent.get(parentId) ?? []; + children.push(identityKey(snapshot)); + childrenByParent.set(parentId, children); + } else { + roots.push(snapshot); + } + } + const newestFirst = (leftId: string, rightId: string): number => { + const left = byId.get(leftId); + const right = byId.get(rightId); + if (!left || !right) return 0; + return spawnOrder(right) - spawnOrder(left); + }; + for (const children of childrenByParent.values()) { + children.sort(newestFirst); + } + roots.sort((left, right) => spawnOrder(right) - spawnOrder(left)); + + const ordered: T[] = []; + const visit = (node: T): void => { + ordered.push(node); + const children = childrenByParent.get(identityKey(node)) ?? []; + for (const childId of children) { + const child = byId.get(childId); + if (child) visit(child); + } + }; + for (const root of roots) visit(root); + + const lastChildByParent = new Map(); + for (const [parentId, children] of childrenByParent) { + const last = children[children.length - 1]; + if (last) lastChildByParent.set(parentId, last); + } + + return ordered.map((node) => { + const id = identityKey(node); + const parentId = parentKey(node); + const depth = computedDepth(node, byId, cap); + const isLastSibling = Boolean(parentId && lastChildByParent.get(parentId) === id); + const glyph: SubagentTreeAnnotation["glyph"] = depth <= 0 ? "" : isLastSibling ? "└" : "├"; + const ancestorBars: string[] = []; + if (depth > 0) { + let current: T | undefined = node; + const chain: boolean[] = []; + while (current) { + const currentParentId = parentKey(current); + if (!currentParentId) break; + const parent = byId.get(currentParentId); + if (!parent) break; + chain.push(lastChildByParent.get(currentParentId) === identityKey(current)); + current = parent; + } + chain.reverse(); + for (let index = 0; index < chain.length - 1; index += 1) { + ancestorBars.push(chain[index] ? " " : "│ "); + } + } + const prefix = depth <= 0 ? "" : `${ancestorBars.join("")}${glyph} `; + const childIds = childrenByParent.get(id) ?? []; + return { + node, + tree: { + depth, + isLastSibling: depth > 0 && isLastSibling, + prefix, + glyph, + childIds, + descendantIds: collectDescendants(id, childrenByParent), + }, + }; + }); +} + +/** Finished parent whose descendants are all finished: collapse the subtree. */ +export function shouldAutoCollapseFinishedSubtree(node: SubagentTreeIdentity, descendantStatuses: SubagentTreeIdentity["status"][]): boolean { + if (!isFinished(node.status)) return false; + if (descendantStatuses.length === 0) return false; + return descendantStatuses.every((status) => isFinished(status)); +} diff --git a/apps/desktop/src/shared/chatTurnStatus.test.ts b/apps/desktop/src/shared/chatTurnStatus.test.ts new file mode 100644 index 0000000000..b9aa91a073 --- /dev/null +++ b/apps/desktop/src/shared/chatTurnStatus.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + chatTurnStatusExitCode, + deriveChatTurnStatus, + formatChatTurnStatus, + formatCompactDuration, +} from "./chatTurnStatus"; + +describe("chat turn status", () => { + it("maps RUNNING / IDLE / BLOCKED to exit codes 0 / 1 / 2", () => { + expect(chatTurnStatusExitCode("running")).toBe(0); + expect(chatTurnStatusExitCode("idle")).toBe(1); + expect(chatTurnStatusExitCode("blocked")).toBe(2); + }); + + it("formats a running tree with background and files-returned suffixes", () => { + const status = deriveChatTurnStatus({ + sessionId: "48e654c9", + provider: "claude", + sessionStatus: "active", + currentTurnStartedAt: "2026-05-01T00:00:00.000Z", + lastActivityAt: "2026-05-01T00:04:04.000Z", + queuedMessageCount: 1, + currentTool: { name: "Bash", detail: "npm install --prefix apps/desktop" }, + nowMs: Date.parse("2026-05-01T00:04:12.000Z"), + subagents: [ + { + taskId: "root", + agentId: "root", + description: "typecheck desktop", + status: "running", + background: true, + durationMs: 124_000, + startedAt: "2026-05-01T00:02:08.000Z", + }, + { + taskId: "child", + agentId: "child", + parentAgentId: "root", + description: "explore chat tests", + status: "running", + durationMs: 72_000, + startedAt: "2026-05-01T00:03:00.000Z", + }, + { + taskId: "leaf", + agentId: "leaf", + parentAgentId: "child", + description: "read fixtures", + status: "completed", + durationMs: 18_000, + startedAt: "2026-05-01T00:03:54.000Z", + resourceLinks: [{ path: "a.ts" }, { path: "b.ts" }, { path: "c.ts" }], + }, + ], + }); + expect(status.phase).toBe("running"); + const text = formatChatTurnStatus(status); + expect(text).toContain("● RUNNING"); + expect(text).toContain("turn 4m12s"); + expect(text).toContain("last activity 8s ago"); + expect(text).toContain("tool Bash · npm install --prefix apps/desktop"); + expect(text).toContain("queued 1 message waiting"); + expect(text).toContain("typecheck desktop"); + expect(text).toContain("└ explore chat tests"); + expect(text).toContain("▸ 3 files returned"); + expect(text).toContain("● bg"); + }); + + it("marks a blocked Claude permission ask as stranded with no dialog expiry", () => { + const status = deriveChatTurnStatus({ + sessionId: "blocked-1", + provider: "claude", + awaitingInput: true, + pendingTitle: "Allow Bash?", + pendingDescription: "Bash(rm -rf build/)", + currentTurnStartedAt: "2026-05-01T00:00:00.000Z", + nowMs: Date.parse("2026-05-01T00:15:03.000Z"), + }); + expect(status.phase).toBe("blocked"); + expect(status.ask?.stranded).toBe(true); + const text = formatChatTurnStatus(status); + expect(text).toContain("● BLOCKED"); + expect(text).toContain("stranded — no deadline set (dialogExpiry: never)"); + expect(text).toContain("ask Bash(rm -rf build/)"); + expect(chatTurnStatusExitCode(status.phase)).toBe(2); + }); + + it("formats idle when no turn is live", () => { + const status = deriveChatTurnStatus({ + sessionId: "idle-1", + sessionStatus: "idle", + lastActivityAt: "2026-05-01T00:00:00.000Z", + nowMs: Date.parse("2026-05-01T00:12:00.000Z"), + }); + expect(status.phase).toBe("idle"); + expect(formatChatTurnStatus(status)).toContain("○ IDLE"); + expect(formatChatTurnStatus(status)).toContain("last turn ended 12m00s ago"); + expect(formatCompactDuration(12_000)).toBe("12s"); + }); +}); diff --git a/apps/desktop/src/shared/chatTurnStatus.ts b/apps/desktop/src/shared/chatTurnStatus.ts new file mode 100644 index 0000000000..79d3204b1b --- /dev/null +++ b/apps/desktop/src/shared/chatTurnStatus.ts @@ -0,0 +1,180 @@ +import { annotateSubagentTree } from "./chatSubagentTree"; +import { resourceLinkCopyPaths } from "./claudeAgentSdkFields"; +import type { AgentChatResourceLink } from "./types/chat"; + +export type ChatTurnStatusPhase = "running" | "blocked" | "idle"; + +export type ChatTurnStatusTool = { + name: string; + detail?: string; +}; + +export type ChatTurnStatusAsk = { + title: string; + description?: string; + stranded: boolean; +}; + +export type ChatTurnStatusSubagent = { + taskId: string; + agentId?: string; + parentAgentId?: string | null; + description: string; + status: "running" | "completed" | "failed" | "stopped"; + background?: boolean; + spawnDepth?: number; + startTimestamp?: string; + startedAt?: string; + durationMs?: number; + resourceLinks?: AgentChatResourceLink[]; +}; + +export type ChatTurnStatusSnapshot = { + sessionId: string; + phase: ChatTurnStatusPhase; + provider?: string; + turnElapsedMs?: number | null; + lastActivityMsAgo?: number | null; + currentTool?: ChatTurnStatusTool | null; + queuedMessageCount: number; + ask?: ChatTurnStatusAsk | null; + subagents: ChatTurnStatusSubagent[]; +}; + +export type DeriveChatTurnStatusInput = { + sessionId: string; + provider?: string; + sessionStatus?: string; + currentTurnStartedAt?: string | null; + lastActivityAt?: string | null; + awaitingInput?: boolean; + pendingTitle?: string | null; + pendingDescription?: string | null; + queuedMessageCount?: number; + currentTool?: ChatTurnStatusTool | null; + subagents?: ChatTurnStatusSubagent[]; + nowMs?: number; +}; + +export function chatTurnStatusExitCode(phase: ChatTurnStatusPhase): number { + switch (phase) { + case "running": + return 0; + case "idle": + return 1; + case "blocked": + return 2; + default: { + const exhaustive: never = phase; + return exhaustive; + } + } +} + +export function deriveChatTurnStatus(input: DeriveChatTurnStatusInput): ChatTurnStatusSnapshot { + const nowMs = input.nowMs ?? Date.now(); + const awaitingInput = input.awaitingInput === true; + const turnStartedMs = parseTime(input.currentTurnStartedAt); + const lastActivityMs = parseTime(input.lastActivityAt); + const hasLiveTurn = input.sessionStatus === "active" || turnStartedMs != null; + const phase: ChatTurnStatusPhase = awaitingInput + ? "blocked" + : hasLiveTurn + ? "running" + : "idle"; + const stranded = awaitingInput && (input.provider === "claude" || input.provider == null); + return { + sessionId: input.sessionId, + phase, + ...(input.provider ? { provider: input.provider } : {}), + turnElapsedMs: turnStartedMs == null ? null : Math.max(0, nowMs - turnStartedMs), + lastActivityMsAgo: lastActivityMs == null ? null : Math.max(0, nowMs - lastActivityMs), + currentTool: input.currentTool ?? null, + queuedMessageCount: Math.max(0, input.queuedMessageCount ?? 0), + ask: awaitingInput + ? { + title: input.pendingTitle?.trim() || "awaiting input", + ...(input.pendingDescription?.trim() ? { description: input.pendingDescription.trim() } : {}), + stranded, + } + : null, + subagents: input.subagents ?? [], + }; +} + +export function formatChatTurnStatus(status: ChatTurnStatusSnapshot): string { + const marker = status.phase === "running" ? "●" : status.phase === "blocked" ? "●" : "○"; + const phaseLabel = status.phase.toUpperCase(); + const headlineBits: string[] = []; + if (status.phase === "running") { + if (status.turnElapsedMs != null) headlineBits.push(`turn ${formatCompactDuration(status.turnElapsedMs)}`); + if (status.lastActivityMsAgo != null) headlineBits.push(`last activity ${formatCompactDuration(status.lastActivityMsAgo)} ago`); + } else if (status.phase === "blocked") { + headlineBits.push(status.ask?.title ? `awaiting permission` : "awaiting input"); + if (status.turnElapsedMs != null) headlineBits.push(formatCompactDuration(status.turnElapsedMs)); + else if (status.lastActivityMsAgo != null) headlineBits.push(formatCompactDuration(status.lastActivityMsAgo)); + } else if (status.lastActivityMsAgo != null) { + headlineBits.push(`last turn ended ${formatCompactDuration(status.lastActivityMsAgo)} ago`); + } + const lines = [ + `${marker} ${phaseLabel.padEnd(9)} ${headlineBits.join(" · ")}`.trimEnd(), + ]; + + if (status.currentTool) { + const detail = status.currentTool.detail?.trim(); + lines.push(` tool ${status.currentTool.name}${detail ? ` · ${detail}` : ""}`); + } + if (status.queuedMessageCount > 0) { + lines.push(` queued ${status.queuedMessageCount} message${status.queuedMessageCount === 1 ? "" : "s"} waiting`); + } + if (status.ask?.stranded) { + lines.push(" ⚠ stranded — no deadline set (dialogExpiry: never)"); + } + if (status.ask) { + const askDetail = status.ask.description?.trim() || status.ask.title; + lines.push(` ask ${askDetail}`); + } + + if (status.subagents.length) { + lines.push(""); + const annotated = annotateSubagentTree(status.subagents); + for (const { node, tree } of annotated) { + const indent = tree.prefix; + const name = node.description.trim() || node.agentId || node.taskId; + const statusLabel = node.status === "running" ? "running" : node.status === "failed" ? "failed" : node.status === "stopped" ? "stopped" : "done"; + const duration = node.durationMs != null ? formatCompactDuration(node.durationMs) : ""; + const bg = node.background ? "● bg" : ""; + const files = node.resourceLinks?.length + ? `▸ ${node.resourceLinks.length} file${node.resourceLinks.length === 1 ? "" : "s"} returned` + : ""; + const columns = [indent + name, statusLabel, duration, bg, files].filter((part) => part.length > 0); + lines.push(` ${columns[0]!.padEnd(Math.max(28, columns[0]!.length))} ${statusLabel.padEnd(8)} ${duration.padStart(5)} ${bg} ${files}`.trimEnd()); + } + } + + return lines.join("\n").trimEnd(); +} + +export function chatTurnStatusCopyPaths(status: ChatTurnStatusSnapshot): string[] { + return resourceLinkCopyPaths(status.subagents.flatMap((subagent) => subagent.resourceLinks ?? [])); +} + +function parseTime(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function formatCompactDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) { + return `${hours}h${String(minutes).padStart(2, "0")}m`; + } + if (minutes > 0) { + return `${minutes}m${String(seconds).padStart(2, "0")}s`; + } + return `${seconds}s`; +} diff --git a/apps/desktop/src/shared/claudeAgentSdkFields.test.ts b/apps/desktop/src/shared/claudeAgentSdkFields.test.ts new file mode 100644 index 0000000000..5a6804ef0e --- /dev/null +++ b/apps/desktop/src/shared/claudeAgentSdkFields.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + isClaudeHousekeepingTask, + parseClaudeResourceLinks, + readClaudeSpawnDepth, + resourceLinkCopyPaths, +} from "./claudeAgentSdkFields"; + +describe("claude Agent SDK field readers", () => { + it("treats ambient the same as skip_transcript", () => { + expect(isClaudeHousekeepingTask({ skip_transcript: true })).toBe(true); + expect(isClaudeHousekeepingTask({ ambient: true })).toBe(true); + expect(isClaudeHousekeepingTask({ skip_transcript: false, ambient: false })).toBe(false); + expect(isClaudeHousekeepingTask({ task_id: "task-1" })).toBe(false); + }); + + it("reads spawn_depth when it is a non-negative integer", () => { + expect(readClaudeSpawnDepth({ spawn_depth: 2 })).toBe(2); + expect(readClaudeSpawnDepth({ spawnDepth: 0 })).toBe(0); + expect(readClaudeSpawnDepth({ spawn_depth: -1 })).toBeUndefined(); + expect(readClaudeSpawnDepth({})).toBeUndefined(); + }); + + it("parses resource_links from the notification or the nested tool result", () => { + expect(parseClaudeResourceLinks({ + resource_links: [ + { uri: "file:///tmp/a.ts", name: "a.ts" }, + { path: "apps/desktop/src/foo.ts" }, + ], + })).toEqual([ + { uri: "file:///tmp/a.ts", name: "a.ts" }, + { path: "apps/desktop/src/foo.ts" }, + ]); + expect(parseClaudeResourceLinks({ + tool_use_result: { resourceLinks: ["src/cli.ts"] }, + })).toEqual([{ path: "src/cli.ts", uri: "src/cli.ts" }]); + expect(resourceLinkCopyPaths([ + { uri: "file:///tmp/a.ts" }, + { path: "apps/desktop/src/foo.ts" }, + { uri: "file:///tmp/a.ts" }, + ])).toEqual(["/tmp/a.ts", "apps/desktop/src/foo.ts"]); + expect(resourceLinkCopyPaths([ + { uri: "file:///C:/Users/ade/src/foo.ts" }, + ])).toEqual(["C:/Users/ade/src/foo.ts"]); + }); +}); diff --git a/apps/desktop/src/shared/claudeAgentSdkFields.ts b/apps/desktop/src/shared/claudeAgentSdkFields.ts new file mode 100644 index 0000000000..890a86aa22 --- /dev/null +++ b/apps/desktop/src/shared/claudeAgentSdkFields.ts @@ -0,0 +1,97 @@ +import type { AgentChatResourceLink } from "./types/chat"; + +export type { AgentChatResourceLink }; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +/** Housekeeping Claude tasks must never surface or count as activity. */ +export function isClaudeHousekeepingTask(value: unknown): boolean { + const record = asRecord(value); + if (!record) return false; + return record.skip_transcript === true || record.ambient === true; +} + +export function readClaudeSpawnDepth(value: unknown): number | undefined { + const record = asRecord(value); + const raw = record?.spawn_depth ?? record?.spawnDepth; + if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined; + const depth = Math.floor(raw); + return depth >= 0 ? depth : undefined; +} + +function readResourceLink(value: unknown): AgentChatResourceLink | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length ? { path: trimmed, uri: trimmed } : null; + } + const record = asRecord(value); + if (!record) return null; + const uri = typeof record.uri === "string" && record.uri.trim() ? record.uri.trim() : undefined; + const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : undefined; + const path = typeof record.path === "string" && record.path.trim() + ? record.path.trim() + : typeof record.filePath === "string" && record.filePath.trim() + ? record.filePath.trim() + : undefined; + if (!uri && !name && !path) return null; + return { ...(uri ? { uri } : {}), ...(name ? { name } : {}), ...(path ? { path } : {}) }; +} + +function readResourceLinkList(raw: unknown): AgentChatResourceLink[] { + if (!Array.isArray(raw)) return []; + const links: AgentChatResourceLink[] = []; + for (const entry of raw) { + const link = readResourceLink(entry); + if (link) links.push(link); + } + return links; +} + +/** Files a backgrounded MCP task returned (`resource_links` / `resourceLinks`). */ +export function parseClaudeResourceLinks(value: unknown): AgentChatResourceLink[] { + const record = asRecord(value); + if (!record) return []; + const direct = readResourceLinkList(record.resource_links ?? record.resourceLinks); + if (direct.length) return direct; + const toolResult = asRecord(record.tool_use_result) ?? asRecord(record.toolUseResult); + if (!toolResult) return []; + return readResourceLinkList(toolResult.resource_links ?? toolResult.resourceLinks); +} + +export function resourceLinkDisplayPath(link: AgentChatResourceLink): string | null { + if (link.path?.trim()) return link.path.trim(); + if (link.uri?.trim()) { + return displayPathFromUri(link.uri.trim()); + } + if (link.name?.trim()) return link.name.trim(); + return null; +} + +function displayPathFromUri(uri: string): string { + if (!uri.startsWith("file://")) return uri; + let rest = uri.slice("file://".length); + try { + rest = decodeURIComponent(rest); + } catch { + // Keep the raw remainder when it is not valid percent-encoding. + } + // file:///C:/Users/... (Windows drive-letter URLs). + if (/^\/[A-Za-z]:[\\/]/.test(rest)) return rest.slice(1); + return rest; +} + +export function resourceLinkCopyPaths(links: readonly AgentChatResourceLink[]): string[] { + const paths: string[] = []; + const seen = new Set(); + for (const link of links) { + const path = resourceLinkDisplayPath(link); + if (!path || seen.has(path)) continue; + seen.add(path); + paths.push(path); + } + return paths; +} diff --git a/apps/desktop/src/shared/claudeGuiSlashCommands.test.ts b/apps/desktop/src/shared/claudeGuiSlashCommands.test.ts new file mode 100644 index 0000000000..1f8e36e5a0 --- /dev/null +++ b/apps/desktop/src/shared/claudeGuiSlashCommands.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + CLAUDE_TERMINAL_ONLY_SLASH_COMMANDS, + claudeSlashCommandKey, + collectClaudeTerminalSlashCommandNames, + filterClaudeGuiSlashCommands, + isClaudeTerminalOnlySlashCommand, +} from "./claudeGuiSlashCommands"; + +describe("claude GUI slash-command filter", () => { + it("normalizes names to a leading-slash lowercase key", () => { + expect(claudeSlashCommandKey("Exit")).toBe("/exit"); + expect(claudeSlashCommandKey("/STATUSLINE")).toBe("/statusline"); + expect(claudeSlashCommandKey(" /quit ")).toBe("/quit"); + }); + + it("treats /exit, /quit, and /statusline as terminal-only even without init extras", () => { + for (const name of CLAUDE_TERMINAL_ONLY_SLASH_COMMANDS) { + expect(isClaudeTerminalOnlySlashCommand(name)).toBe(true); + } + expect(isClaudeTerminalOnlySlashCommand("/status")).toBe(false); + expect(isClaudeTerminalOnlySlashCommand("/agents")).toBe(false); + }); + + it("unions runtime terminal_slash_commands with the known terminal-only set", () => { + expect(isClaudeTerminalOnlySlashCommand("/theme", ["/theme", "Statusline"])).toBe(true); + expect(collectClaudeTerminalSlashCommandNames(["theme", { name: "/Exit" }])).toEqual([ + "/theme", + "/exit", + ]); + }); + + it("drops terminal-only commands from every AgentChatSurface catalog", () => { + const commands = [ + { name: "/agents", description: "Manage agents" }, + { name: "/exit", description: "Exit the CLI." }, + { name: "/quit", description: "Exit the CLI." }, + { name: "/statusline", description: "Configure status line." }, + { name: "/status", description: "Show version" }, + { name: "/theme", description: "Change theme" }, + ]; + expect(filterClaudeGuiSlashCommands(commands).map((command) => command.name)).toEqual([ + "/agents", + "/status", + "/theme", + ]); + expect( + filterClaudeGuiSlashCommands(commands, ["/theme"]).map((command) => command.name), + ).toEqual(["/agents", "/status"]); + }); +}); diff --git a/apps/desktop/src/shared/claudeGuiSlashCommands.ts b/apps/desktop/src/shared/claudeGuiSlashCommands.ts new file mode 100644 index 0000000000..8a8d9f3358 --- /dev/null +++ b/apps/desktop/src/shared/claudeGuiSlashCommands.ts @@ -0,0 +1,53 @@ +/** Normalize a slash-command name to `/lowercase`. */ +export function claudeSlashCommandKey(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!trimmed.length) return ""; + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} + +/** + * Claude Code commands that only make sense in a real terminal (exit the + * process, paint a TUI statusline). Agent chat surfaces — Work, automation, + * personal, desktop, iOS, TUI — must never list them. + */ +export const CLAUDE_TERMINAL_ONLY_SLASH_COMMANDS = ["/exit", "/quit", "/statusline"] as const; + +export function collectClaudeTerminalSlashCommandNames(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const names: string[] = []; + for (const entry of raw) { + if (typeof entry === "string") { + const key = claudeSlashCommandKey(entry); + if (key) names.push(key); + continue; + } + if (entry && typeof entry === "object" && "name" in entry) { + const name = (entry as { name?: unknown }).name; + if (typeof name === "string") { + const key = claudeSlashCommandKey(name); + if (key) names.push(key); + } + } + } + return names; +} + +export function isClaudeTerminalOnlySlashCommand( + name: string, + extra: Iterable = [], +): boolean { + const key = claudeSlashCommandKey(name); + if (!key) return false; + if ((CLAUDE_TERMINAL_ONLY_SLASH_COMMANDS as readonly string[]).includes(key)) return true; + for (const extraName of extra) { + if (claudeSlashCommandKey(extraName) === key) return true; + } + return false; +} + +export function filterClaudeGuiSlashCommands( + commands: readonly T[], + extra: Iterable = [], +): T[] { + return commands.filter((command) => !isClaudeTerminalOnlySlashCommand(command.name, extra)); +} diff --git a/apps/desktop/src/shared/claudePermissionDialog.test.ts b/apps/desktop/src/shared/claudePermissionDialog.test.ts new file mode 100644 index 0000000000..a8056ab6d0 --- /dev/null +++ b/apps/desktop/src/shared/claudePermissionDialog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { buildClaudeToolApprovalOptions, claudeToolNeedsDefaultToNo } from "./claudePermissionDialog"; + +describe("claude permission dialog options", () => { + it("detects default_to_no from either camelCase or snake_case SDK options", () => { + expect(claudeToolNeedsDefaultToNo({ defaultToNo: true })).toBe(true); + expect(claudeToolNeedsDefaultToNo({ default_to_no: true })).toBe(true); + expect(claudeToolNeedsDefaultToNo({ defaultToNo: false })).toBe(false); + expect(claudeToolNeedsDefaultToNo({})).toBe(false); + expect(claudeToolNeedsDefaultToNo(null)).toBe(false); + }); + + it("omits a recommended Allow and the session override on elevated-risk asks", () => { + expect(buildClaudeToolApprovalOptions({ defaultToNo: true })).toEqual([ + { label: "Allow", value: "allow" }, + { label: "Deny", value: "deny" }, + ]); + }); + + it("keeps Allow recommended plus Allow for Session on ordinary asks", () => { + expect(buildClaudeToolApprovalOptions({ defaultToNo: false })).toEqual([ + { label: "Allow", value: "allow", recommended: true }, + { label: "Allow for Session", value: "allow_session" }, + { label: "Deny", value: "deny" }, + ]); + }); +}); diff --git a/apps/desktop/src/shared/claudePermissionDialog.ts b/apps/desktop/src/shared/claudePermissionDialog.ts new file mode 100644 index 0000000000..1475292216 --- /dev/null +++ b/apps/desktop/src/shared/claudePermissionDialog.ts @@ -0,0 +1,27 @@ +import type { PendingInputOption } from "./types/chat"; + +/** + * Elevated-risk Claude permission asks (`default_to_no` on the SDK canUseTool + * options) must not pre-select Allow and must not offer a session-wide + * always-allow. Ordinary asks keep the existing recommended Allow + session + * override. + */ +export function claudeToolNeedsDefaultToNo(sdkOptions: unknown): boolean { + if (!sdkOptions || typeof sdkOptions !== "object" || Array.isArray(sdkOptions)) return false; + const record = sdkOptions as Record; + return record.defaultToNo === true || record.default_to_no === true; +} + +export function buildClaudeToolApprovalOptions(args: { defaultToNo: boolean }): PendingInputOption[] { + if (args.defaultToNo) { + return [ + { label: "Allow", value: "allow" }, + { label: "Deny", value: "deny" }, + ]; + } + return [ + { label: "Allow", value: "allow", recommended: true }, + { label: "Allow for Session", value: "allow_session" }, + { label: "Deny", value: "deny" }, + ]; +} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 8309112729..aaebeebc6e 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -754,6 +754,13 @@ export type AgentChatScheduledWorkOrigin = | "background_task" | "sdk"; +/** Files a backgrounded MCP task returned on `task_notification`. */ +export type AgentChatResourceLink = { + uri?: string; + name?: string; + path?: string; +}; + export type AgentChatEvent = | { type: "user_message"; @@ -1087,6 +1094,9 @@ export type AgentChatEvent = taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; spawnKind?: AgentChatSpawnKind; workflowName?: string; + /** SDK spawn_depth when the host publishes it; 0 is the top-level agent. */ + spawnDepth?: number; + resourceLinks?: AgentChatResourceLink[]; turnId?: string; } | { @@ -1111,6 +1121,8 @@ export type AgentChatEvent = lastToolName?: string; taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; workflowName?: string; + spawnDepth?: number; + resourceLinks?: AgentChatResourceLink[]; turnId?: string; } | { @@ -1139,6 +1151,8 @@ export type AgentChatEvent = worktreeBranch?: string; totalTokens?: number; toolUseCount?: number; + spawnDepth?: number; + resourceLinks?: AgentChatResourceLink[]; turnId?: string; } | { @@ -2125,6 +2139,8 @@ export type AgentChatSubagentSnapshot = { /** USD cost, when the runtime reports a per-subagent figure (OpenCode). */ costUsd?: number; }; + spawnDepth?: number; + resourceLinks?: AgentChatResourceLink[]; }; export type AgentChatSubagentListArgs = { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index d1cecc5ad8..5ae804e03c 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2221,6 +2221,33 @@ struct AgentChatEventProvenance: Decodable, Equatable { var runId: String? } +struct AgentChatResourceLink: Codable, Equatable { + var uri: String? + var name: String? + var path: String? + + var displayPath: String { + if let path, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return path + } + if let uri, !uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if uri.hasPrefix("file://") { + var rest = String(uri.dropFirst("file://".count)) + rest = rest.removingPercentEncoding ?? rest + if rest.count >= 3 { + let chars = Array(rest) + if chars[0] == "/", chars[1].isLetter, chars[2] == ":" { + rest.removeFirst() + } + } + return rest + } + return uri + } + return name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +} + struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { /// Identity must include the timestamp, not just the sequence. /// @@ -2249,6 +2276,9 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { var subagentTaskType: String? var subagentCommand: String? var subagentSpawnKind: AgentChatSpawnKind? + var subagentParentAgentId: String? + var subagentSpawnDepth: Int? + var subagentResourceLinks: [AgentChatResourceLink]? init( sessionId: String, @@ -2258,7 +2288,10 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { provenance: AgentChatEventProvenance? = nil, subagentTaskType: String? = nil, subagentCommand: String? = nil, - subagentSpawnKind: AgentChatSpawnKind? = nil + subagentSpawnKind: AgentChatSpawnKind? = nil, + subagentParentAgentId: String? = nil, + subagentSpawnDepth: Int? = nil, + subagentResourceLinks: [AgentChatResourceLink]? = nil ) { self.sessionId = sessionId self.timestamp = timestamp @@ -2268,6 +2301,9 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { self.subagentTaskType = subagentTaskType self.subagentCommand = subagentCommand self.subagentSpawnKind = subagentSpawnKind + self.subagentParentAgentId = subagentParentAgentId + self.subagentSpawnDepth = subagentSpawnDepth + self.subagentResourceLinks = subagentResourceLinks } private enum CodingKeys: String, CodingKey { @@ -2282,12 +2318,20 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { var taskType: String? var command: String? var spawnKind: AgentChatSpawnKind? + var parentAgentId: String? + var spawnDepth: Int? + var resourceLinks: [AgentChatResourceLink]? private enum CodingKeys: String, CodingKey { case taskType case taskTypeSnake = "task_type" case command case spawnKind + case parentAgentId + case spawnDepth + case spawnDepthSnake = "spawn_depth" + case resourceLinks + case resourceLinksSnake = "resource_links" } init(from decoder: Decoder) throws { @@ -2296,6 +2340,11 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { ?? container.decodeIfPresent(String.self, forKey: .taskTypeSnake) command = try container.decodeIfPresent(String.self, forKey: .command) spawnKind = try container.decodeIfPresent(AgentChatSpawnKind.self, forKey: .spawnKind) + parentAgentId = try container.decodeIfPresent(String.self, forKey: .parentAgentId) + spawnDepth = try container.decodeIfPresent(Int.self, forKey: .spawnDepth) + ?? container.decodeIfPresent(Int.self, forKey: .spawnDepthSnake) + resourceLinks = try container.decodeIfPresent([AgentChatResourceLink].self, forKey: .resourceLinks) + ?? container.decodeIfPresent([AgentChatResourceLink].self, forKey: .resourceLinksSnake) } } @@ -2310,6 +2359,9 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { subagentTaskType = metadata?.taskType subagentCommand = metadata?.command subagentSpawnKind = metadata?.spawnKind + subagentParentAgentId = metadata?.parentAgentId + subagentSpawnDepth = metadata?.spawnDepth + subagentResourceLinks = metadata?.resourceLinks } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ce1374fe5a..c9d652438f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13202,6 +13202,9 @@ final class SyncService: ObservableObject { var lastToolName: String? var background: Bool? var usage: AgentChatSubagentUsage? + var parentAgentId: String? + var spawnDepth: Int? + var resourceLinks: [AgentChatResourceLink]? } /// Fetch a transcript page. Without `cursor` this returns the newest diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 631b6e2911..780cbf76e3 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -3244,6 +3244,7 @@ struct WorkChatInfoDetailsSheet: View { probing: probingTaskId == snapshot.taskId, expanded: expandedTaskIds.contains(snapshot.taskId), sessionModel: sessionModel, + treePrefix: workSubagentTreePrefix(snapshot, in: subagents), onSelect: { Task { await onSelect(snapshot) } } ) } @@ -3382,85 +3383,126 @@ private struct WorkChatInfoSubagentRow: View { let probing: Bool let expanded: Bool let sessionModel: String? + var treePrefix: String = "" let onSelect: () -> Void + @State private var filesOpen = false + private var elapsed: String? { workSubagentElapsedLabel(snapshot) } private var detailText: String? { if let summary = filteredDetail(snapshot.latestSummary) { return summary } return filteredDetail(snapshot.description) } private var lastToolName: String? { trimmedNonEmpty(snapshot.lastToolName) } + private var filePaths: [String] { workSubagentResourcePaths(snapshot) } private var showsDisclosure: Bool { snapshot.status == .running || detailText != nil || lastToolName != nil } var body: some View { - Button(action: onSelect) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - WorkSubagentGlyph(id: snapshot.agentId ?? snapshot.taskId, status: snapshot.status) - VStack(alignment: .leading, spacing: 2) { - Text(workSubagentMeaningfulName(snapshot)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(titleColor) - .lineLimit(1) - .truncationMode(.tail) - if let subtitleText { - subtitleText - .font(.caption2) + VStack(alignment: .leading, spacing: 6) { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + if !treePrefix.isEmpty { + Text(treePrefix) + .font(.caption.monospaced()) .foregroundStyle(ADEColor.textMuted) + } + WorkSubagentGlyph(id: snapshot.agentId ?? snapshot.taskId, status: snapshot.status) + VStack(alignment: .leading, spacing: 2) { + Text(workSubagentMeaningfulName(snapshot)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(titleColor) .lineLimit(1) + .truncationMode(.tail) + if let subtitleText { + subtitleText + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } } - } - if snapshot.background { - WorkSubagentTinyChip(text: "background", tint: ADEColor.textMuted) - } - if snapshot.spawnKind == .peer { - WorkSubagentTinyChip(text: "peer", tint: ADEColor.textMuted) - } - Spacer(minLength: 0) - HStack(spacing: 8) { - if probing { - ProgressView().controlSize(.small) + if snapshot.background { + WorkSubagentTinyChip(text: "background", tint: ADEColor.textMuted) } - WorkSubagentStatusChip(status: snapshot.status) - if showsDisclosure { - Image(systemName: selected ? "arrow.uturn.left" : "chevron.right") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) + if snapshot.spawnKind == .peer { + WorkSubagentTinyChip(text: "peer", tint: ADEColor.textMuted) + } + Spacer(minLength: 0) + HStack(spacing: 8) { + if probing { + ProgressView().controlSize(.small) + } + WorkSubagentStatusChip(status: snapshot.status) + if showsDisclosure { + Image(systemName: selected ? "arrow.uturn.left" : "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + } } } + + if expanded, detailText != nil || lastToolName != nil { + VStack(alignment: .leading, spacing: 5) { + if let detailText { + Text(detailText) + } + if let tool = lastToolName { + Text("last: \(tool)") + .font(.caption2.monospaced()) + .foregroundStyle(ADEColor.textMuted) + } + } + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 34) + } } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(selected ? ADEColor.accent.opacity(0.12) : ADEColor.cardBackground.opacity(0.52)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected ? ADEColor.accent.opacity(0.45) : ADEColor.glassBorder, lineWidth: 1) + ) + } + .buttonStyle(.plain) - if expanded, detailText != nil || lastToolName != nil { - VStack(alignment: .leading, spacing: 5) { - if let detailText { - Text(detailText) + if !filePaths.isEmpty { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Button { + filesOpen.toggle() + } label: { + Text("\(filesOpen ? "▾" : "▸") \(filePaths.count) file\(filePaths.count == 1 ? "" : "s") returned") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) } - if let tool = lastToolName { - Text("last: \(tool)") + .buttonStyle(.plain) + Button("Copy paths") { + UIPasteboard.general.string = filePaths.joined(separator: "\n") + } + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .buttonStyle(.plain) + } + if filesOpen { + ForEach(filePaths, id: \.self) { path in + Text(path) .font(.caption2.monospaced()) .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) } } - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 34) } + .padding(.leading, 34) } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(selected ? ADEColor.accent.opacity(0.12) : ADEColor.cardBackground.opacity(0.52)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(selected ? ADEColor.accent.opacity(0.45) : ADEColor.glassBorder, lineWidth: 1) - ) } - .buttonStyle(.plain) } private var subtitleText: Text? { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index f500d5d1c9..d5604c88ac 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2352,6 +2352,9 @@ func workSubagentSnapshotsRenderSignature(_ snapshots: [WorkSubagentSnapshot]) - hasher.combine(snapshot.startedAt) hasher.combine(snapshot.updatedAt) hasher.combine(snapshot.spawnKind.map { String(describing: $0) }) + hasher.combine(snapshot.parentAgentId) + hasher.combine(snapshot.spawnDepth ?? Int.min) + hasher.combine(snapshot.resourceLinks.count) } return hasher.finalize() } diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 456861d8d2..5960b83659 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -866,7 +866,10 @@ func makeWorkChatTranscript(from entries: [AgentChatEventEnvelope]) -> [WorkChat event: makeWorkChatEvent(from: entry.event), subagentTaskType: entry.subagentTaskType, subagentCommand: entry.subagentCommand, - subagentSpawnKind: entry.subagentSpawnKind + subagentSpawnKind: entry.subagentSpawnKind, + subagentParentAgentId: entry.subagentParentAgentId, + subagentSpawnDepth: entry.subagentSpawnDepth, + subagentResourceLinks: entry.subagentResourceLinks ?? [] ) } .sorted(by: workChatEnvelopeOrderedBefore) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index a4128c447d..808b3a585e 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -977,6 +977,9 @@ struct WorkSubagentSnapshot: Identifiable, Equatable { var taskType: String? = nil var command: String? = nil var spawnKind: AgentChatSpawnKind? = nil + var parentAgentId: String? = nil + var spawnDepth: Int? = nil + var resourceLinks: [AgentChatResourceLink] = [] var id: String { taskId } } @@ -1334,6 +1337,9 @@ struct WorkChatEnvelope: Identifiable, Equatable { let subagentTaskType: String? let subagentCommand: String? let subagentSpawnKind: AgentChatSpawnKind? + let subagentParentAgentId: String? + let subagentSpawnDepth: Int? + let subagentResourceLinks: [AgentChatResourceLink] init( sessionId: String, @@ -1342,7 +1348,10 @@ struct WorkChatEnvelope: Identifiable, Equatable { event: WorkChatEvent, subagentTaskType: String? = nil, subagentCommand: String? = nil, - subagentSpawnKind: AgentChatSpawnKind? = nil + subagentSpawnKind: AgentChatSpawnKind? = nil, + subagentParentAgentId: String? = nil, + subagentSpawnDepth: Int? = nil, + subagentResourceLinks: [AgentChatResourceLink] = [] ) { self.sessionId = sessionId self.timestamp = timestamp @@ -1351,6 +1360,9 @@ struct WorkChatEnvelope: Identifiable, Equatable { self.subagentTaskType = subagentTaskType self.subagentCommand = subagentCommand self.subagentSpawnKind = subagentSpawnKind + self.subagentParentAgentId = subagentParentAgentId + self.subagentSpawnDepth = subagentSpawnDepth + self.subagentResourceLinks = subagentResourceLinks } } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index e5a32803d0..828e6a9e4a 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -161,6 +161,131 @@ func workSubagentMeaningfulName(_ snapshot: WorkSubagentSnapshot) -> String { return snapshot.taskId } +func workSubagentIdentity(_ snapshot: WorkSubagentSnapshot) -> String { + let agentId = snapshot.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return agentId.isEmpty ? snapshot.taskId : agentId +} + +func workSubagentTreeDepth(_ snapshot: WorkSubagentSnapshot, in snapshots: [WorkSubagentSnapshot], cap: Int = 3) -> Int { + if let spawnDepth = snapshot.spawnDepth { + return min(cap, max(0, spawnDepth)) + } + var byId: [String: WorkSubagentSnapshot] = [:] + for candidate in snapshots { + byId[workSubagentIdentity(candidate)] = candidate + } + var depth = 0 + var seen = Set() + var current: WorkSubagentSnapshot? = snapshot + while let node = current, depth < cap { + let parentId = node.parentAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if parentId.isEmpty { break } + let id = workSubagentIdentity(node) + if seen.contains(id) { break } + seen.insert(id) + guard let parent = byId[parentId], parent.taskId != node.taskId else { break } + depth += 1 + current = parent + } + return depth +} + +func workSubagentTreePrefix(depth: Int) -> String { + if depth <= 0 { return "" } + return String(repeating: " ", count: max(0, depth - 1)) + "└ " +} + +struct WorkSubagentTreeAnnotation: Equatable { + var depth: Int + var prefix: String + var glyph: String + var isLastSibling: Bool +} + +/// Connector-glyph prefix matching desktop `annotateSubagentTree`: newest +/// siblings first, `├` / `└` / `│`, SDK `spawnDepth` winning over a parent walk. +func workSubagentTreeAnnotation( + _ snapshot: WorkSubagentSnapshot, + in snapshots: [WorkSubagentSnapshot], + cap: Int = 3 +) -> WorkSubagentTreeAnnotation { + func identity(_ node: WorkSubagentSnapshot) -> String { workSubagentIdentity(node) } + func parentId(_ node: WorkSubagentSnapshot) -> String? { + let parent = node.parentAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return parent.isEmpty ? nil : parent + } + func spawnOrder(_ node: WorkSubagentSnapshot) -> TimeInterval { + guard let stamp = node.startedAt, let date = workParsedDate(stamp) else { return 0 } + return date.timeIntervalSince1970 + } + + var byId: [String: WorkSubagentSnapshot] = [:] + for node in snapshots { + byId[identity(node)] = node + } + var childrenByParent: [String: [String]] = [:] + for node in snapshots { + if let parent = parentId(node), byId[parent] != nil, parent != identity(node) { + childrenByParent[parent, default: []].append(identity(node)) + } + } + var lastChildByParent: [String: String] = [:] + for parent in Array(childrenByParent.keys) { + let children = (childrenByParent[parent] ?? []).sorted { left, right in + guard let leftNode = byId[left], let rightNode = byId[right] else { return false } + return spawnOrder(leftNode) > spawnOrder(rightNode) + } + childrenByParent[parent] = children + if let last = children.last { + lastChildByParent[parent] = last + } + } + + let depth = workSubagentTreeDepth(snapshot, in: snapshots, cap: cap) + let id = identity(snapshot) + let parent = parentId(snapshot) + let isLastSibling = parent.flatMap { lastChildByParent[$0] } == id + let glyph = depth <= 0 ? "" : (isLastSibling ? "└" : "├") + var ancestorBars: [String] = [] + if depth > 0 { + var chain: [Bool] = [] + var current: WorkSubagentSnapshot? = snapshot + while let node = current, let currentParentId = parentId(node), let parentNode = byId[currentParentId] { + chain.append(lastChildByParent[currentParentId] == identity(node)) + current = parentNode + } + chain.reverse() + if chain.count > 1 { + for index in 0..<(chain.count - 1) { + ancestorBars.append(chain[index] ? " " : "│ ") + } + } + } + let prefix = depth <= 0 ? "" : ancestorBars.joined() + glyph + " " + return WorkSubagentTreeAnnotation( + depth: depth, + prefix: prefix, + glyph: glyph, + isLastSibling: depth > 0 && isLastSibling + ) +} + +func workSubagentTreePrefix(_ snapshot: WorkSubagentSnapshot, in snapshots: [WorkSubagentSnapshot]) -> String { + workSubagentTreeAnnotation(snapshot, in: snapshots).prefix +} + +func workSubagentResourcePaths(_ snapshot: WorkSubagentSnapshot) -> [String] { + var seen = Set() + var paths: [String] = [] + for link in snapshot.resourceLinks { + let path = link.displayPath.trimmingCharacters(in: .whitespacesAndNewlines) + if path.isEmpty || seen.contains(path) { continue } + seen.insert(path) + paths.append(path) + } + return paths +} + func workSubagentSelection(from snapshot: WorkSubagentSnapshot) -> WorkSubagentSelection { WorkSubagentSelection( taskId: snapshot.taskId, diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 7639d5452e..3c2f06dfe8 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -91,6 +91,9 @@ private func workChatTimelineSnapshotSignature( combineOptional(envelope.subagentTaskType, into: &hasher) combineOptional(envelope.subagentCommand, into: &hasher) combineOptional(envelope.subagentSpawnKind.map { String(describing: $0) }, into: &hasher) + combineOptional(envelope.subagentParentAgentId, into: &hasher) + hasher.combine(envelope.subagentSpawnDepth ?? Int.min) + hasher.combine(envelope.subagentResourceLinks.count) combineWorkChatEventSignature(envelope.event, into: &hasher) } @@ -830,7 +833,10 @@ func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSub updatedAt: envelope.timestamp, taskType: trimmedWorkSubagentText(envelope.subagentTaskType) ?? existing?.taskType, command: longerWorkSubagentText(existing?.command, envelope.subagentCommand), - spawnKind: envelope.subagentSpawnKind ?? existing?.spawnKind + spawnKind: envelope.subagentSpawnKind ?? existing?.spawnKind, + parentAgentId: trimmedWorkSubagentText(envelope.subagentParentAgentId) ?? existing?.parentAgentId, + spawnDepth: envelope.subagentSpawnDepth ?? existing?.spawnDepth, + resourceLinks: envelope.subagentResourceLinks.isEmpty ? (existing?.resourceLinks ?? []) : envelope.subagentResourceLinks ), order: resolved.order) case .subagentProgress(let taskId, let agentId, let agentType, let parentToolUseId, let description, let summary, let toolName, let label, let model, let reasoningEffort, let turnId): let resolved = resolve(taskId: taskId, agentId: agentId, parentToolUseId: parentToolUseId) @@ -853,7 +859,10 @@ func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSub updatedAt: envelope.timestamp, taskType: trimmedWorkSubagentText(envelope.subagentTaskType) ?? existing?.taskType, command: longerWorkSubagentText(existing?.command, envelope.subagentCommand), - spawnKind: envelope.subagentSpawnKind ?? existing?.spawnKind + spawnKind: envelope.subagentSpawnKind ?? existing?.spawnKind, + parentAgentId: trimmedWorkSubagentText(envelope.subagentParentAgentId) ?? existing?.parentAgentId, + spawnDepth: envelope.subagentSpawnDepth ?? existing?.spawnDepth, + resourceLinks: envelope.subagentResourceLinks.isEmpty ? (existing?.resourceLinks ?? []) : envelope.subagentResourceLinks ), order: resolved.order) case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, let label, let model, let reasoningEffort, let turnId): let normalized = workSubagentStatus(from: status) @@ -877,7 +886,10 @@ func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSub updatedAt: envelope.timestamp, taskType: trimmedWorkSubagentText(envelope.subagentTaskType) ?? existing?.taskType, command: longerWorkSubagentText(existing?.command, envelope.subagentCommand), - spawnKind: envelope.subagentSpawnKind ?? existing?.spawnKind + spawnKind: envelope.subagentSpawnKind ?? existing?.spawnKind, + parentAgentId: trimmedWorkSubagentText(envelope.subagentParentAgentId) ?? existing?.parentAgentId, + spawnDepth: envelope.subagentSpawnDepth ?? existing?.spawnDepth, + resourceLinks: envelope.subagentResourceLinks.isEmpty ? (existing?.resourceLinks ?? []) : envelope.subagentResourceLinks ), order: resolved.order) default: break @@ -1385,7 +1397,10 @@ func workSubagentSnapshot(from remote: SyncService.AgentChatSubagentSnapshot) -> latestSummary: remote.finalSummary ?? remote.summary, turnId: remote.turnId, startedAt: startedAt, - updatedAt: updatedAt + updatedAt: updatedAt, + parentAgentId: trimmedWorkSubagentText(remote.parentAgentId), + spawnDepth: remote.spawnDepth, + resourceLinks: remote.resourceLinks ?? [] ) } @@ -1459,7 +1474,10 @@ private func mergedWorkSubagentSnapshot( updatedAt: latestWorkSubagentTimestamp(remote.updatedAt, local.updatedAt), taskType: local.taskType ?? remote.taskType, command: longerWorkSubagentText(remote.command, local.command), - spawnKind: local.spawnKind ?? remote.spawnKind + spawnKind: local.spawnKind ?? remote.spawnKind, + parentAgentId: local.parentAgentId ?? remote.parentAgentId, + spawnDepth: local.spawnDepth ?? remote.spawnDepth, + resourceLinks: local.resourceLinks.isEmpty ? remote.resourceLinks : local.resourceLinks ) } diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index d2662017fc..f5e37addb6 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -409,6 +409,12 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { let subagentCommand = optionalString(eventDict["command"]) let subagentSpawnKind = optionalString(eventDict["spawnKind"]) .map(AgentChatSpawnKind.init(wireValue:)) + let subagentParentAgentId = optionalString(eventDict["parentAgentId"]) + let subagentSpawnDepth = eventDict["spawnDepth"] as? Int + ?? eventDict["spawn_depth"] as? Int + let subagentResourceLinks = parseAgentChatResourceLinks( + from: eventDict["resourceLinks"] ?? eventDict["resource_links"] + ) let event: WorkChatEvent switch type { @@ -1051,7 +1057,10 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { event: event, subagentTaskType: subagentTaskType, subagentCommand: subagentCommand, - subagentSpawnKind: subagentSpawnKind + subagentSpawnKind: subagentSpawnKind, + subagentParentAgentId: subagentParentAgentId, + subagentSpawnDepth: subagentSpawnDepth, + subagentResourceLinks: subagentResourceLinks ) } .sorted(by: workChatEnvelopeOrderedBefore) @@ -1085,6 +1094,22 @@ private func workSpawnCompletionEvent( ) } +private func parseAgentChatResourceLinks(from value: Any?) -> [AgentChatResourceLink] { + guard let array = value as? [Any], !array.isEmpty else { return [] } + return array.compactMap { entry -> AgentChatResourceLink? in + if let path = entry as? String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : AgentChatResourceLink(uri: trimmed, name: nil, path: trimmed) + } + guard let dict = entry as? [String: Any] else { return nil } + let uri = optionalString(dict["uri"]) + let name = optionalString(dict["name"]) + let path = optionalString(dict["path"]) ?? optionalString(dict["filePath"]) + if uri == nil && name == nil && path == nil { return nil } + return AgentChatResourceLink(uri: uri, name: name, path: path) + } +} + private func parseAgentChatFileRefs(from value: Any?) -> [AgentChatFileRef]? { guard let array = value as? [[String: Any]], !array.isEmpty else { return nil } let refs = array.compactMap { dict -> AgentChatFileRef? in diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 639b1bdc0f..4ef9d00097 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -14891,7 +14891,7 @@ final class ADETests: XCTestCase { func testWorkSubagentSnapshotsPreserveAgentIdAndRunningCount() { let raw = """ - {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","parentAgentId":"parent-agent-1","description":"Docs helper","background":true,"label":"Researcher","model":"gpt-5.6-luna","reasoningEffort":"xhigh","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","parentAgentId":"parent-agent-1","spawnDepth":1,"resourceLinks":[{"path":"apps/ios/README.md"}],"description":"Docs helper","background":true,"label":"Researcher","model":"gpt-5.6-luna","reasoningEffort":"xhigh","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"subagent_progress","taskId":"task-1","agentId":"agent-1","summary":"Reading README.md","lastToolName":"functions.Read","label":"Researcher","model":"gpt-5.6-luna","reasoningEffort":"xhigh","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":3,"event":{"type":"subagent_started","taskId":"task-2","agentId":"agent-2","description":"Done helper","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":4,"event":{"type":"subagent_result","taskId":"task-2","agentId":"agent-2","status":"completed","summary":"Done","turnId":"turn-1"}} @@ -14910,6 +14910,56 @@ final class ADETests: XCTestCase { XCTAssertEqual(snapshots.first?.lastToolName, "functions.Read") XCTAssertEqual(snapshots.first?.startedAt, "2026-03-25T00:00:01.000Z") XCTAssertEqual(snapshots.first?.updatedAt, "2026-03-25T00:00:02.000Z") + XCTAssertEqual(snapshots.first?.parentAgentId, "parent-agent-1") + XCTAssertEqual(snapshots.first?.spawnDepth, 1) + XCTAssertEqual(snapshots.first?.resourceLinks.first?.path, "apps/ios/README.md") + XCTAssertEqual(workSubagentResourcePaths(snapshots[0]), ["apps/ios/README.md"]) + } + + func testWorkSubagentTreePrefixUsesConnectorGlyphsAndPrefersSpawnDepth() { + func snap( + _ id: String, + parent: String?, + startedAt: String, + spawnDepth: Int? = nil + ) -> WorkSubagentSnapshot { + WorkSubagentSnapshot( + taskId: id, + agentId: id, + agentType: nil, + parentToolUseId: nil, + description: id, + background: false, + label: nil, + model: nil, + reasoningEffort: nil, + status: .running, + lastToolName: nil, + latestSummary: nil, + turnId: nil, + startedAt: startedAt, + updatedAt: startedAt, + parentAgentId: parent, + spawnDepth: spawnDepth + ) + } + let root = snap("root", parent: nil, startedAt: "2026-05-01T00:00:02.000Z") + let newer = snap("newer", parent: "root", startedAt: "2026-05-01T00:00:01.000Z") + let older = snap("older", parent: "root", startedAt: "2026-05-01T00:00:00.000Z") + let leaf = snap("leaf", parent: "newer", startedAt: "2026-05-01T00:00:00.500Z", spawnDepth: 2) + let list = [root, newer, older, leaf] + XCTAssertEqual(workSubagentTreePrefix(root, in: list), "") + XCTAssertEqual(workSubagentTreePrefix(newer, in: list), "├ ") + XCTAssertEqual(workSubagentTreePrefix(older, in: list), "└ ") + XCTAssertEqual(workSubagentTreeDepth(leaf, in: list), 2) + XCTAssertEqual(workSubagentTreePrefix(leaf, in: list), "│ └ ") + } + + func testAgentChatResourceLinkDisplayPathStripsWindowsFileHostSlash() { + let windows = AgentChatResourceLink(uri: "file:///C:/Users/ade/src/foo.ts", name: nil, path: nil) + XCTAssertEqual(windows.displayPath, "C:/Users/ade/src/foo.ts") + let posix = AgentChatResourceLink(uri: "file:///tmp/a.ts", name: nil, path: nil) + XCTAssertEqual(posix.displayPath, "/tmp/a.ts") } func testWorkSubagentSnapshotsAdoptCodexPlaceholderAndPreserveStoppedAgentName() { diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 3e011949c6..0c533854a2 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -51,7 +51,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/buildClaudeV2Message.ts` | Builds Claude SDK user messages for the `query()` input stream. Handles base64 image content blocks and MIME inference. | | `apps/desktop/src/main/services/chat/claudeInputPump.ts` | Async iterable input pump that feeds live user turns into the Claude Agent SDK `query()` stream. | | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | Provider-runtime system-prompt assembly, including runtime-specific native-subagent versus ADE-child routing guidance and the shared scheduled-work contract. | -| `apps/desktop/src/main/services/chat/claudeSdkCompat.ts` | Narrow runtime normalizers for Claude SDK response fields whose published declarations have drifted across SDK releases. It defensively reads interrupt receipt UUIDs (`still_queued`, `cancelled`), rewind `skippedLinks`, and historical/current session-message model fields without casting the whole chat service to an inaccurate SDK shape. | +| `apps/desktop/src/shared/claudeGuiSlashCommands.ts`, `claudePermissionDialog.ts`, `claudeAgentSdkFields.ts`, `chatSubagentTree.ts`, `chatTurnStatus.ts` | Claude Agent SDK 0.3.258 GUI adapters: drop terminal-only slash commands (`/exit`, `/quit`, `/statusline` plus init `terminal_slash_commands`) from every AgentChatSurface; `default_to_no` permission options; ambient/`skip_transcript` housekeeping; spawn-depth tree connectors; dedicated `chat.getTurnStatus` snapshot used by `ade chat status`. | | `apps/desktop/src/main/services/chat/claudeThinkingTranscriptRepair.ts` | Best-effort repair for Claude SDK JSONL transcripts where multiple distinct assistant responses reused one `message.id`. The repair preserves top-level threading, tool ids, thinking content, and signatures, but rekeys later responses before resume so Anthropic thinking blocks remain in the message shape originally generated by the model. | | `apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts` | Resume-time repair for historical ADE envelope streams written before Claude text fragments used the stable SDK message id. It detects only runs of at least three consecutive text envelopes in one turn with distinct message ids, rebuilds from SDK message text when possible (otherwise locally merges), preserves every other JSONL line verbatim, skips files over 64 MB, and rewrites atomically with a one-time `.splice.bak`. | | `apps/desktop/src/shared/claudeSessionQuota.ts` | Classifies Claude hard session-quota rejects (`session limit` / non-`allowed` `rate_limit_event`), parses reset clocks, and builds the sticky `claude_session_quota` `ade_card`. Approaching (`allowed_warning`) stays a quiet notice. | @@ -1122,13 +1122,17 @@ happen to begin with `User request:`. `ade.agentChat.send` dispatches a turn. The ADE action bridge exposes the same low-level path as `chat.sendMessage`, plus `chat.messageSession({ sessionId, text, kind })` as the normalized - agent-to-agent primitive. `kind: "auto"` steers active sessions and wakes - idle sessions, `queue` always routes through the provider-normalized steer - path, `wake` starts a normal turn, and `interrupt-replace` uses Claude SDK - priority `now` on active Claude sessions (other providers keep their native - interrupt-then-send path). The result reports the routed action and whether a - steer was delivered or queued. `ade chat send` uses the auto route, while - `ade chat message --kind ...` exposes the explicit primitive. Provider + agent-to-agent primitive. `kind: "auto"` steers active sessions using that + provider's `defaultActiveTurnDispatchMode` (Cursor interrupt, Claude inline, + others queue) and wakes idle sessions. Explicit `queue` always stages for + the next turn and never picks the provider default. `wake` starts a normal + turn, and `interrupt-replace` uses Claude SDK priority `now` on active + Claude sessions (other providers keep their native interrupt-then-send + path). The result reports the routed action and whether a + steer was delivered or queued. `ade chat send` uses the auto route, while + `ade chat message --kind ...` exposes the explicit primitive. `ade chat show` + stays on `chat.getSessionSummary`; `ade chat status` is the dedicated live + turn snapshot (`chat.getTurnStatus`, RUNNING / BLOCKED / IDLE). Provider dispatch and event streaming continue asynchronously after acceptance. `ade chat create --prompt` uses this same follow-up send after the session is created, and `ade chat read ` calls bounded From 5256af5593dd2726cac17363eed97d5533360dad Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:52:50 -0400 Subject: [PATCH 2/4] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20address?= =?UTF-8?q?=20CodeRabbit=20#3915778371=E2=80=938523?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../services/chat/agentChatService.test.ts | 20 +++- .../chat/ChatSubagentsPanel.test.tsx | 68 +++++++++++++ .../components/chat/ChatSubagentsPanel.tsx | 75 ++++++++------- .../src/shared/chatSubagentTree.test.ts | 13 +++ apps/desktop/src/shared/chatSubagentTree.ts | 8 +- .../desktop/src/shared/chatTurnStatus.test.ts | 7 +- apps/desktop/src/shared/chatTurnStatus.ts | 7 +- .../src/shared/claudeAgentSdkFields.test.ts | 5 + .../src/shared/claudeAgentSdkFields.ts | 10 +- apps/ios/ADE/Models/RemoteModels.swift | 14 ++- .../ADE/Views/Work/WorkChatSessionView.swift | 2 +- .../Work/WorkStatusAndFormattingHelpers.swift | 11 ++- .../ADE/Views/Work/WorkTimelineHelpers.swift | 2 +- .../ADE/Views/Work/WorkTranscriptParser.swift | 18 +++- apps/ios/ADETests/ADETests.swift | 95 +++++++++++++++++++ 15 files changed, 289 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8a32afbb80..774fa13975 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -14062,6 +14062,9 @@ describe("createAgentChatService", () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; let warmupComplete = false; + let ambientLive = false; + let holdAmbientComplete!: () => void; + const holdAmbientCompletePromise = new Promise((resolve) => { holdAmbientComplete = resolve; }); let turnDone: (() => void) | null = null; const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); const send = vi.fn().mockResolvedValue(undefined); @@ -14091,6 +14094,8 @@ describe("createAgentChatService", () => { ambient: true, }], }; + ambientLive = true; + await holdAmbientCompletePromise; yield { type: "system", subtype: "task_notification", @@ -14130,6 +14135,7 @@ describe("createAgentChatService", () => { await vi.waitFor(() => { expect(events.some((e) => e.event.type === "status")).toBe(true); + expect(ambientLive).toBe(true); }); expect(events.filter((e) => @@ -14138,6 +14144,7 @@ describe("createAgentChatService", () => { || e.event.type === "subagent_result" )).toEqual([]); + holdAmbientComplete(); turnDone!(); await expect(sendPromise).resolves.toBeUndefined(); await vi.waitFor(() => { @@ -21813,8 +21820,11 @@ describe("createAgentChatService", () => { const send = vi.fn().mockResolvedValue(undefined); let streamCall = 0; let startAmbient!: () => void; + let holdAmbientComplete!: () => void; + let ambientLive = false; let ambientDrained = false; const startAmbientPromise = new Promise((resolve) => { startAmbient = resolve; }); + const holdAmbientCompletePromise = new Promise((resolve) => { holdAmbientComplete = resolve; }); const stream = vi.fn(() => (async function* () { streamCall += 1; @@ -21855,6 +21865,8 @@ describe("createAgentChatService", () => { ambient: true, }], }; + ambientLive = true; + await holdAmbientCompletePromise; yield { type: "system", subtype: "task_notification", @@ -21897,9 +21909,8 @@ describe("createAgentChatService", () => { startAmbient(); await vi.waitFor(() => { - expect(ambientDrained).toBe(true); + expect(ambientLive).toBe(true); }); - expect(events.filter((event) => event.sessionId === session.id && (event.event.type === "subagent_started" @@ -21908,6 +21919,11 @@ describe("createAgentChatService", () => { && (event.event as { taskId?: string }).taskId === "task-ambient-idle-true", )).toEqual([]); expect(service.hasActiveWorkloads()).toBe(false); + + holdAmbientComplete(); + await vi.waitFor(() => { + expect(ambientDrained).toBe(true); + }); }); it("delivers queued steers after an idle Claude turn completes", async () => { diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index 25ed930510..2845fc510b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -957,4 +957,72 @@ describe("ChatSubagentsPanel (pane variant)", () => { fireEvent.click(screen.getByTitle("Copy all paths")); expect(writeText).toHaveBeenCalledWith("apps/desktop/src/foo.ts\napps/desktop/src/bar.ts"); }); + + it("keeps the expand caret as a sibling button, not nested inside the row", () => { + const parent: ChatSubagentSnapshot = { + ...baseSnapshot, + taskId: "parent", + agentId: "parent", + description: "Finished parent", + status: "completed", + background: false, + }; + const child: ChatSubagentSnapshot = { + ...baseSnapshot, + taskId: "child", + agentId: "child", + parentAgentId: "parent", + description: "Finished child", + status: "completed", + background: false, + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Completed (1)" })); + const caret = screen.getByRole("button", { name: "Show nested agents" }); + const row = screen.getByTitle("Finished parent"); + expect(caret.tagName).toBe("BUTTON"); + expect(row.tagName).toBe("BUTTON"); + expect(row.contains(caret)).toBe(false); + expect(screen.queryByTitle("Finished child")).toBeNull(); + }); + + it("keeps a selected finished descendant visible while auto-collapsing the rest", () => { + const parent: ChatSubagentSnapshot = { + ...baseSnapshot, + taskId: "parent", + agentId: "parent", + description: "Finished parent", + status: "completed", + background: false, + }; + const child: ChatSubagentSnapshot = { + ...baseSnapshot, + taskId: "child", + agentId: "child", + parentAgentId: "parent", + description: "Finished child", + status: "completed", + background: false, + }; + + render( + , + ); + + expect(screen.getByTitle("Finished child")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Show nested agents" })).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 248a28c147..f8afefb531 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -18,7 +18,7 @@ import { formatDurationMs, formatSubagentDurationMs } from "../../lib/format"; import type { ChatScheduledWorkSnapshot, ChatSubagentSnapshot } from "./chatExecutionSummary"; import { derivePlan, subagentTreeDepth } from "./chatExecutionSummary"; import { annotateSubagentTree, shouldAutoCollapseFinishedSubtree } from "../../../shared/chatSubagentTree"; -import { resourceLinkCopyPaths, resourceLinkDisplayPath } from "../../../shared/claudeAgentSdkFields"; +import { resourceLinkCopyPaths } from "../../../shared/claudeAgentSdkFields"; import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { ChatTaskList } from "./ChatTasksPanel"; import type { ChatInfoPlanStep, PaneSectionKey } from "../../../shared/chatSubagents"; @@ -887,46 +887,40 @@ function SubagentRow({ return (
- ) : null} + +
{filePaths.length > 0 ? (
@@ -1018,10 +1013,9 @@ function SubagentRow({
{filesOpen ? (
    - {(snapshot.resourceLinks ?? []).map((link, index) => { - const path = resourceLinkDisplayPath(link) ?? link.name ?? link.uri ?? `file-${index}`; - return
  • {path}
  • ; - })} + {filePaths.map((path) => ( +
  • {path}
  • + ))}
) : null}
@@ -1280,19 +1274,30 @@ export function ChatSubagentsPanel({ } return map; }, [annotatedSubagents]); + const pinnedSubagentIds = useMemo(() => new Set( + [selectedTaskId, expandedTaskId].filter((id): id is string => Boolean(id)), + ), [expandedTaskId, selectedTaskId]); const hiddenDescendantIds = useMemo(() => { const hidden = new Set(); const byId = new Map(subagents.map((snapshot) => [subagentIdentity(snapshot), snapshot])); + const isPinned = (snapshot: ChatSubagentSnapshot | undefined, id: string): boolean => { + if (pinnedSubagentIds.has(id)) return true; + if (!snapshot) return false; + return pinnedSubagentIds.has(snapshot.taskId) || pinnedSubagentIds.has(subagentIdentity(snapshot)); + }; for (const { node, tree } of annotatedSubagents) { const id = subagentIdentity(node); if (expandedFinishedIds.has(id)) continue; const descendantStatuses = tree.descendantIds.map((descendantId) => byId.get(descendantId)?.status); if (shouldAutoCollapseFinishedSubtree(node, descendantStatuses)) { - for (const descendantId of tree.descendantIds) hidden.add(descendantId); + for (const descendantId of tree.descendantIds) { + if (isPinned(byId.get(descendantId), descendantId)) continue; + hidden.add(descendantId); + } } } return hidden; - }, [annotatedSubagents, expandedFinishedIds, subagents]); + }, [annotatedSubagents, expandedFinishedIds, pinnedSubagentIds, subagents]); const collapsedCountById = useMemo(() => { const counts = new Map(); const byId = new Map(subagents.map((snapshot) => [subagentIdentity(snapshot), snapshot])); @@ -1307,9 +1312,6 @@ export function ChatSubagentsPanel({ return counts; }, [annotatedSubagents, expandedFinishedIds, hiddenDescendantIds, subagents]); - const pinnedSubagentIds = useMemo(() => new Set( - [selectedTaskId, expandedTaskId].filter((id): id is string => Boolean(id)), - ), [expandedTaskId, selectedTaskId]); const clearedSubagentIds = useMemo(() => new Set(paneCleared.subagents), [paneCleared.subagents]); const clearedBackgroundIds = useMemo(() => new Set(paneCleared.background), [paneCleared.background]); const clearedScheduleIds = useMemo(() => new Set(paneCleared.schedule), [paneCleared.schedule]); @@ -1318,7 +1320,8 @@ export function ChatSubagentsPanel({ const grouped = groupPaneSectionItems(subagents, { isEarlier: isEarlierSubagentSnapshot, isCleared: (snapshot) => clearedSubagentIds.has(snapshot.taskId), - isPinned: (snapshot) => pinnedSubagentIds.has(snapshot.taskId), + isPinned: (snapshot) => + pinnedSubagentIds.has(snapshot.taskId) || pinnedSubagentIds.has(subagentIdentity(snapshot)), }); const visible = (items: ChatSubagentSnapshot[]) => items.filter((item) => !hiddenDescendantIds.has(subagentIdentity(item))); return { diff --git a/apps/desktop/src/shared/chatSubagentTree.test.ts b/apps/desktop/src/shared/chatSubagentTree.test.ts index 7d25519883..db7bbcfc36 100644 --- a/apps/desktop/src/shared/chatSubagentTree.test.ts +++ b/apps/desktop/src/shared/chatSubagentTree.test.ts @@ -15,6 +15,19 @@ describe("subagent tree connectors", () => { expect(annotated[2]?.tree).toMatchObject({ depth: 2, glyph: "└", prefix: " └ " }); }); + it("caps connector ancestors to the displayed spawn_depth", () => { + const annotated = annotateSubagentTree([ + { taskId: "root", agentId: "root", startedAt: "2026-05-01T00:00:03.000Z" }, + { taskId: "child", agentId: "child", parentAgentId: "root", startedAt: "2026-05-01T00:00:02.000Z" }, + { taskId: "leaf", agentId: "leaf", parentAgentId: "child", spawnDepth: 1, startedAt: "2026-05-01T00:00:01.000Z" }, + ]); + expect(annotated.find((entry) => entry.node.taskId === "leaf")?.tree).toMatchObject({ + depth: 1, + glyph: "└", + prefix: "└ ", + }); + }); + it("uses ├ for a non-last sibling", () => { const annotated = annotateSubagentTree([ { taskId: "root", agentId: "root", startedAt: "2026-05-01T00:00:02.000Z" }, diff --git a/apps/desktop/src/shared/chatSubagentTree.ts b/apps/desktop/src/shared/chatSubagentTree.ts index 4f97fba653..81f3e5bbc8 100644 --- a/apps/desktop/src/shared/chatSubagentTree.ts +++ b/apps/desktop/src/shared/chatSubagentTree.ts @@ -144,12 +144,16 @@ export function annotateSubagentTree( if (depth > 0) { let current: T | undefined = node; const chain: boolean[] = []; - while (current) { + const seen = new Set(); + while (current && chain.length < depth) { + const currentId = identityKey(current); + if (seen.has(currentId)) break; + seen.add(currentId); const currentParentId = parentKey(current); if (!currentParentId) break; const parent = byId.get(currentParentId); if (!parent) break; - chain.push(lastChildByParent.get(currentParentId) === identityKey(current)); + chain.push(lastChildByParent.get(currentParentId) === currentId); current = parent; } chain.reverse(); diff --git a/apps/desktop/src/shared/chatTurnStatus.test.ts b/apps/desktop/src/shared/chatTurnStatus.test.ts index b9aa91a073..6f0112b988 100644 --- a/apps/desktop/src/shared/chatTurnStatus.test.ts +++ b/apps/desktop/src/shared/chatTurnStatus.test.ts @@ -50,7 +50,7 @@ describe("chat turn status", () => { status: "completed", durationMs: 18_000, startedAt: "2026-05-01T00:03:54.000Z", - resourceLinks: [{ path: "a.ts" }, { path: "b.ts" }, { path: "c.ts" }], + resourceLinks: [{ path: "a.ts" }, { path: "a.ts" }, { name: "label-only" }], }, ], }); @@ -63,7 +63,8 @@ describe("chat turn status", () => { expect(text).toContain("queued 1 message waiting"); expect(text).toContain("typecheck desktop"); expect(text).toContain("└ explore chat tests"); - expect(text).toContain("▸ 3 files returned"); + expect(text).toContain("▸ 1 file returned"); + expect(text).not.toContain("▸ 3 files returned"); expect(text).toContain("● bg"); }); @@ -81,6 +82,8 @@ describe("chat turn status", () => { expect(status.ask?.stranded).toBe(true); const text = formatChatTurnStatus(status); expect(text).toContain("● BLOCKED"); + expect(text).toContain("Allow Bash?"); + expect(text).not.toContain("awaiting permission"); expect(text).toContain("stranded — no deadline set (dialogExpiry: never)"); expect(text).toContain("ask Bash(rm -rf build/)"); expect(chatTurnStatusExitCode(status.phase)).toBe(2); diff --git a/apps/desktop/src/shared/chatTurnStatus.ts b/apps/desktop/src/shared/chatTurnStatus.ts index 79d3204b1b..56e5bbd475 100644 --- a/apps/desktop/src/shared/chatTurnStatus.ts +++ b/apps/desktop/src/shared/chatTurnStatus.ts @@ -110,7 +110,7 @@ export function formatChatTurnStatus(status: ChatTurnStatusSnapshot): string { if (status.turnElapsedMs != null) headlineBits.push(`turn ${formatCompactDuration(status.turnElapsedMs)}`); if (status.lastActivityMsAgo != null) headlineBits.push(`last activity ${formatCompactDuration(status.lastActivityMsAgo)} ago`); } else if (status.phase === "blocked") { - headlineBits.push(status.ask?.title ? `awaiting permission` : "awaiting input"); + headlineBits.push(status.ask?.title?.trim() || "awaiting input"); if (status.turnElapsedMs != null) headlineBits.push(formatCompactDuration(status.turnElapsedMs)); else if (status.lastActivityMsAgo != null) headlineBits.push(formatCompactDuration(status.lastActivityMsAgo)); } else if (status.lastActivityMsAgo != null) { @@ -144,8 +144,9 @@ export function formatChatTurnStatus(status: ChatTurnStatusSnapshot): string { const statusLabel = node.status === "running" ? "running" : node.status === "failed" ? "failed" : node.status === "stopped" ? "stopped" : "done"; const duration = node.durationMs != null ? formatCompactDuration(node.durationMs) : ""; const bg = node.background ? "● bg" : ""; - const files = node.resourceLinks?.length - ? `▸ ${node.resourceLinks.length} file${node.resourceLinks.length === 1 ? "" : "s"} returned` + const fileCount = resourceLinkCopyPaths(node.resourceLinks ?? []).length; + const files = fileCount > 0 + ? `▸ ${fileCount} file${fileCount === 1 ? "" : "s"} returned` : ""; const columns = [indent + name, statusLabel, duration, bg, files].filter((part) => part.length > 0); lines.push(` ${columns[0]!.padEnd(Math.max(28, columns[0]!.length))} ${statusLabel.padEnd(8)} ${duration.padStart(5)} ${bg} ${files}`.trimEnd()); diff --git a/apps/desktop/src/shared/claudeAgentSdkFields.test.ts b/apps/desktop/src/shared/claudeAgentSdkFields.test.ts index 5a6804ef0e..9b88ba2d2f 100644 --- a/apps/desktop/src/shared/claudeAgentSdkFields.test.ts +++ b/apps/desktop/src/shared/claudeAgentSdkFields.test.ts @@ -17,6 +17,7 @@ describe("claude Agent SDK field readers", () => { it("reads spawn_depth when it is a non-negative integer", () => { expect(readClaudeSpawnDepth({ spawn_depth: 2 })).toBe(2); expect(readClaudeSpawnDepth({ spawnDepth: 0 })).toBe(0); + expect(readClaudeSpawnDepth({ spawn_depth: 1, spawnDepth: 9 })).toBe(1); expect(readClaudeSpawnDepth({ spawn_depth: -1 })).toBeUndefined(); expect(readClaudeSpawnDepth({})).toBeUndefined(); }); @@ -42,5 +43,9 @@ describe("claude Agent SDK field readers", () => { expect(resourceLinkCopyPaths([ { uri: "file:///C:/Users/ade/src/foo.ts" }, ])).toEqual(["C:/Users/ade/src/foo.ts"]); + expect(resourceLinkCopyPaths([ + { name: "README" }, + { path: "apps/desktop/src/foo.ts" }, + ])).toEqual(["apps/desktop/src/foo.ts"]); }); }); diff --git a/apps/desktop/src/shared/claudeAgentSdkFields.ts b/apps/desktop/src/shared/claudeAgentSdkFields.ts index 890a86aa22..f6d1a68331 100644 --- a/apps/desktop/src/shared/claudeAgentSdkFields.ts +++ b/apps/desktop/src/shared/claudeAgentSdkFields.ts @@ -62,12 +62,10 @@ export function parseClaudeResourceLinks(value: unknown): AgentChatResourceLink[ return readResourceLinkList(toolResult.resource_links ?? toolResult.resourceLinks); } -export function resourceLinkDisplayPath(link: AgentChatResourceLink): string | null { +/** Path or URI remainder. Name-only links are labels, not copyable paths. */ +export function resourceLinkCopyPath(link: AgentChatResourceLink): string | null { if (link.path?.trim()) return link.path.trim(); - if (link.uri?.trim()) { - return displayPathFromUri(link.uri.trim()); - } - if (link.name?.trim()) return link.name.trim(); + if (link.uri?.trim()) return displayPathFromUri(link.uri.trim()); return null; } @@ -88,7 +86,7 @@ export function resourceLinkCopyPaths(links: readonly AgentChatResourceLink[]): const paths: string[] = []; const seen = new Set(); for (const link of links) { - const path = resourceLinkDisplayPath(link); + const path = resourceLinkCopyPath(link); if (!path || seen.has(path)) continue; seen.add(path); paths.push(path); diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 5ae804e03c..870ad74f31 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2221,12 +2221,12 @@ struct AgentChatEventProvenance: Decodable, Equatable { var runId: String? } -struct AgentChatResourceLink: Codable, Equatable { +struct AgentChatResourceLink: Codable, Equatable, Hashable { var uri: String? var name: String? var path: String? - var displayPath: String { + var copyPath: String? { if let path, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return path } @@ -2244,7 +2244,11 @@ struct AgentChatResourceLink: Codable, Equatable { } return uri } - return name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return nil + } + + var displayPath: String { + copyPath ?? name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } @@ -2341,8 +2345,8 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { command = try container.decodeIfPresent(String.self, forKey: .command) spawnKind = try container.decodeIfPresent(AgentChatSpawnKind.self, forKey: .spawnKind) parentAgentId = try container.decodeIfPresent(String.self, forKey: .parentAgentId) - spawnDepth = try container.decodeIfPresent(Int.self, forKey: .spawnDepth) - ?? container.decodeIfPresent(Int.self, forKey: .spawnDepthSnake) + spawnDepth = try container.decodeIfPresent(Int.self, forKey: .spawnDepthSnake) + ?? container.decodeIfPresent(Int.self, forKey: .spawnDepth) resourceLinks = try container.decodeIfPresent([AgentChatResourceLink].self, forKey: .resourceLinks) ?? container.decodeIfPresent([AgentChatResourceLink].self, forKey: .resourceLinksSnake) } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index d5604c88ac..0e7cc8a2c0 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2354,7 +2354,7 @@ func workSubagentSnapshotsRenderSignature(_ snapshots: [WorkSubagentSnapshot]) - hasher.combine(snapshot.spawnKind.map { String(describing: $0) }) hasher.combine(snapshot.parentAgentId) hasher.combine(snapshot.spawnDepth ?? Int.min) - hasher.combine(snapshot.resourceLinks.count) + hasher.combine(snapshot.resourceLinks) } return hasher.finalize() } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 828e6a9e4a..c41d884289 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -249,9 +249,14 @@ func workSubagentTreeAnnotation( var ancestorBars: [String] = [] if depth > 0 { var chain: [Bool] = [] + var seen = Set() var current: WorkSubagentSnapshot? = snapshot - while let node = current, let currentParentId = parentId(node), let parentNode = byId[currentParentId] { - chain.append(lastChildByParent[currentParentId] == identity(node)) + while let node = current, chain.count < depth { + let nodeId = identity(node) + if seen.contains(nodeId) { break } + seen.insert(nodeId) + guard let currentParentId = parentId(node), let parentNode = byId[currentParentId] else { break } + chain.append(lastChildByParent[currentParentId] == nodeId) current = parentNode } chain.reverse() @@ -278,7 +283,7 @@ func workSubagentResourcePaths(_ snapshot: WorkSubagentSnapshot) -> [String] { var seen = Set() var paths: [String] = [] for link in snapshot.resourceLinks { - let path = link.displayPath.trimmingCharacters(in: .whitespacesAndNewlines) + let path = link.copyPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if path.isEmpty || seen.contains(path) { continue } seen.insert(path) paths.append(path) diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 3c2f06dfe8..c20ca40f4f 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -93,7 +93,7 @@ private func workChatTimelineSnapshotSignature( combineOptional(envelope.subagentSpawnKind.map { String(describing: $0) }, into: &hasher) combineOptional(envelope.subagentParentAgentId, into: &hasher) hasher.combine(envelope.subagentSpawnDepth ?? Int.min) - hasher.combine(envelope.subagentResourceLinks.count) + hasher.combine(envelope.subagentResourceLinks) combineWorkChatEventSignature(envelope.event, into: &hasher) } diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index f5e37addb6..d724d858e4 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -410,11 +410,9 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { let subagentSpawnKind = optionalString(eventDict["spawnKind"]) .map(AgentChatSpawnKind.init(wireValue:)) let subagentParentAgentId = optionalString(eventDict["parentAgentId"]) - let subagentSpawnDepth = eventDict["spawnDepth"] as? Int - ?? eventDict["spawn_depth"] as? Int - let subagentResourceLinks = parseAgentChatResourceLinks( - from: eventDict["resourceLinks"] ?? eventDict["resource_links"] - ) + let subagentSpawnDepth = eventDict["spawn_depth"] as? Int + ?? eventDict["spawnDepth"] as? Int + let subagentResourceLinks = parseAgentChatResourceLinksFromEvent(eventDict) let event: WorkChatEvent switch type { @@ -1094,6 +1092,16 @@ private func workSpawnCompletionEvent( ) } +private func parseAgentChatResourceLinksFromEvent(_ eventDict: [String: Any]) -> [AgentChatResourceLink] { + let direct = parseAgentChatResourceLinks( + from: eventDict["resourceLinks"] ?? eventDict["resource_links"] + ) + if !direct.isEmpty { return direct } + let toolResult = eventDict["tool_use_result"] ?? eventDict["toolUseResult"] + guard let dict = toolResult as? [String: Any] else { return [] } + return parseAgentChatResourceLinks(from: dict["resourceLinks"] ?? dict["resource_links"]) +} + private func parseAgentChatResourceLinks(from value: Any?) -> [AgentChatResourceLink] { guard let array = value as? [Any], !array.isEmpty else { return [] } return array.compactMap { entry -> AgentChatResourceLink? in diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 4ef9d00097..a457951bab 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -14960,6 +14960,101 @@ final class ADETests: XCTestCase { XCTAssertEqual(windows.displayPath, "C:/Users/ade/src/foo.ts") let posix = AgentChatResourceLink(uri: "file:///tmp/a.ts", name: nil, path: nil) XCTAssertEqual(posix.displayPath, "/tmp/a.ts") + let named = AgentChatResourceLink(uri: nil, name: "README", path: nil) + XCTAssertNil(named.copyPath) + XCTAssertEqual(named.displayPath, "README") + } + + func testAgentChatEventEnvelopePrefersSpawnDepthSnakeCase() throws { + let json = """ + { + "sessionId": "chat-1", + "timestamp": "2026-05-01T00:00:01.000Z", + "sequence": 1, + "event": { + "type": "subagent_started", + "taskId": "task-1", + "description": "Docs helper", + "spawn_depth": 1, + "spawnDepth": 9 + } + } + """ + let envelope = try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(json.utf8)) + XCTAssertEqual(envelope.subagentSpawnDepth, 1) + } + + func testWorkTranscriptParsesNestedToolUseResultResourceLinksAndPrefersSpawnDepthSnakeCase() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","spawn_depth":1,"spawnDepth":9,"tool_use_result":{"resource_links":[{"path":"apps/ios/Foo.swift"},{"name":"README"}]},"description":"Docs helper","turnId":"turn-1"}} + """ + let snapshots = buildWorkSubagentSnapshots(from: parseWorkChatTranscript(raw)) + XCTAssertEqual(snapshots.first?.spawnDepth, 1) + XCTAssertEqual(snapshots.first?.resourceLinks.count, 2) + XCTAssertEqual(snapshots.first?.resourceLinks.first?.path, "apps/ios/Foo.swift") + XCTAssertEqual(workSubagentResourcePaths(snapshots[0]), ["apps/ios/Foo.swift"]) + } + + func testWorkSubagentTreePrefixCapsConnectorsToDisplayedSpawnDepth() { + func snap( + _ id: String, + parent: String?, + startedAt: String, + spawnDepth: Int? = nil + ) -> WorkSubagentSnapshot { + WorkSubagentSnapshot( + taskId: id, + agentId: id, + agentType: nil, + parentToolUseId: nil, + description: id, + background: false, + label: nil, + model: nil, + reasoningEffort: nil, + status: .running, + lastToolName: nil, + latestSummary: nil, + turnId: nil, + startedAt: startedAt, + updatedAt: startedAt, + parentAgentId: parent, + spawnDepth: spawnDepth + ) + } + let root = snap("root", parent: nil, startedAt: "2026-05-01T00:00:03.000Z") + let child = snap("child", parent: "root", startedAt: "2026-05-01T00:00:02.000Z") + let leaf = snap("leaf", parent: "child", startedAt: "2026-05-01T00:00:01.000Z", spawnDepth: 1) + let list = [root, child, leaf] + XCTAssertEqual(workSubagentTreeDepth(leaf, in: list), 1) + XCTAssertEqual(workSubagentTreePrefix(leaf, in: list), "└ ") + } + + func testWorkSubagentSnapshotsRenderSignatureHashesEachResourceLink() { + func snap(_ path: String) -> WorkSubagentSnapshot { + WorkSubagentSnapshot( + taskId: "task-1", + agentId: "agent-1", + agentType: nil, + parentToolUseId: nil, + description: "Docs helper", + background: false, + label: nil, + model: nil, + reasoningEffort: nil, + status: .running, + lastToolName: nil, + latestSummary: nil, + turnId: nil, + startedAt: "2026-05-01T00:00:01.000Z", + updatedAt: "2026-05-01T00:00:01.000Z", + resourceLinks: [AgentChatResourceLink(uri: nil, name: nil, path: path)] + ) + } + XCTAssertNotEqual( + workSubagentSnapshotsRenderSignature([snap("a.ts")]), + workSubagentSnapshotsRenderSignature([snap("b.ts")]) + ) } func testWorkSubagentSnapshotsAdoptCodexPlaceholderAndPreserveStoppedAgentName() { From ecbed795fe67da8a034a1d17a2ed806be9c8b719 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:24:16 -0400 Subject: [PATCH 3/4] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20rebase?= =?UTF-8?q?=20onto=20main,=20fix=20test-desktop=20(1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/main/services/cto/linearAuth.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 09cec8d7cf..15c71e632b 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -339,6 +339,26 @@ function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForCallbackPortFree(): Promise { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + const free = await new Promise((resolve) => { + const probe = http.createServer(); + const finish = (ok: boolean) => { + probe.removeAllListeners(); + resolve(ok); + }; + probe.once("error", () => finish(false)); + probe.listen(19836, "127.0.0.1", () => { + probe.close(() => finish(true)); + }); + }); + if (free) return; + await waitMs(10); + } + throw new Error("Linear OAuth callback port 19836 stayed in use after disposing test servers."); +} + async function waitForSessionStatus( service: ReturnType, sessionId: string, @@ -358,6 +378,7 @@ async function waitForSessionStatus( afterEach(async () => { await Promise.all(activeServices.map((service) => service.dispose())); activeServices.length = 0; + await waitForCallbackPortFree(); }); describe("linearOAuthService", () => { From b715b03ff1606a1cf92e7f09a47dcd0a2ee2aa5b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:47:06 -0400 Subject: [PATCH 4/4] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20address?= =?UTF-8?q?=20CodeRabbit=20#3916416852=E2=80=936882?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/main/services/chat/agentChatService.test.ts | 8 ++++++++ apps/desktop/src/main/services/chat/agentChatService.ts | 2 +- apps/desktop/src/main/services/cto/linearAuth.test.ts | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 774fa13975..0ab5a3aaff 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -31982,6 +31982,14 @@ describe("createAgentChatService", () => { return { service, session, events, approvalEvent }; }; + it("includes runtime Codex approvals in getTurnStatus ask fields", async () => { + const { service, session } = await stageCompletedCodexPlanApproval(); + const status = await service.getTurnStatus(session.id); + expect(status?.phase).toBe("blocked"); + expect(status?.ask?.title).toBeTruthy(); + expect(status?.ask?.title).not.toBe("awaiting input"); + }); + it("defaults interaction mode to null or undefined", async () => { const { service } = createService(); const session = await service.createSession({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 209de109d9..162add9e88 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -45769,7 +45769,7 @@ export function createAgentChatService(args: { if (!summary) return null; const trimmed = sessionId.trim(); const managed = managedSessions.get(trimmed) ?? null; - const pending = managed ? [...managed.localPendingInputs.values()][0]?.request : undefined; + const pending = managed ? collectPendingInputRequests(managed)[0] : undefined; const queuedMessageCount = managed?.runtime && "pendingSteers" in managed.runtime ? managed.runtime.pendingSteers.length : 0; diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 15c71e632b..5cad7ad659 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -350,6 +350,7 @@ async function waitForCallbackPortFree(): Promise { }; probe.once("error", () => finish(false)); probe.listen(19836, "127.0.0.1", () => { + probe.closeAllConnections(); probe.close(() => finish(true)); }); });