diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 79752e765..b5762d11c 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 3939770d2..9db72c240 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 3deef5b49..85b70abb7 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 11fb3e1d1..964ef746d 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 b11f97d7c..1805c16d6 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 1bc2de69c..bdab4e958 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 4f66f501d..0ab5a3aaf 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,100 @@ 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 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); + 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, + }], + }; + ambientLive = true; + await holdAmbientCompletePromise; + 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(ambientLive).toBe(true); + }); + + expect(events.filter((e) => + e.event.type === "subagent_started" + || e.event.type === "subagent_progress" + || e.event.type === "subagent_result" + )).toEqual([]); + + holdAmbientComplete(); + turnDone!(); + await expect(sendPromise).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(service.hasActiveWorkloads()).toBe(false); + }); + }); }); // -------------------------------------------------------------------------- @@ -15521,6 +15624,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 +15826,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 +18703,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 +21814,118 @@ 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 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; + 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, + }], + }; + ambientLive = true; + await holdAmbientCompletePromise; + 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(ambientLive).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); + + holdAmbientComplete(); + await vi.waitFor(() => { + expect(ambientDrained).toBe(true); + }); + }); + it("delivers queued steers after an idle Claude turn completes", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -31686,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 169b2880c..162add9e8 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 ? collectPendingInputRequests(managed)[0] : 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 fedca4482..d27ab7b06 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/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 09cec8d7c..5cad7ad65 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -339,6 +339,27 @@ 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.closeAllConnections(); + 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 +379,7 @@ async function waitForSessionStatus( afterEach(async () => { await Promise.all(activeServices.map((service) => service.dispose())); activeServices.length = 0; + await waitForCallbackPortFree(); }); describe("linearOAuthService", () => { diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index 511b03c70..2845fc510 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -916,4 +916,113 @@ 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"); + }); + + 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 5cf329dfb..f8afefb53 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 } 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,25 +879,48 @@ 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}> +
+
+ {treePrefix || depth > 0 ? ( + + {treePrefix || `${" ".repeat(Math.max(0, depth - 1))}└ `} + + ) : null} + {collapsedDescendantCount > 0 && onToggleCollapsedSubtree ? ( + + ) : null} +
+ + {filePaths.length > 0 ? ( +
+
+ + +
+ {filesOpen ? ( +
    + {filePaths.map((path) => ( +
  • {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,18 +1265,71 @@ 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 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) { + if (isPinned(byId.get(descendantId), descendantId)) continue; + hidden.add(descendantId); + } + } + } + return hidden; + }, [annotatedSubagents, expandedFinishedIds, pinnedSubagentIds, 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 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]); - 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) || pinnedSubagentIds.has(subagentIdentity(snapshot)), + }); + 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 +1514,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 dcd557cb4..5018a6fd3 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 de9023075..f037616f0 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 000000000..db7bbcfc3 --- /dev/null +++ b/apps/desktop/src/shared/chatSubagentTree.test.ts @@ -0,0 +1,65 @@ +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("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" }, + { 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 000000000..81f3e5bbc --- /dev/null +++ b/apps/desktop/src/shared/chatSubagentTree.ts @@ -0,0 +1,185 @@ +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[] = []; + 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) === currentId); + 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 000000000..6f0112b98 --- /dev/null +++ b/apps/desktop/src/shared/chatTurnStatus.test.ts @@ -0,0 +1,104 @@ +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: "a.ts" }, { name: "label-only" }], + }, + ], + }); + 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("▸ 1 file returned"); + expect(text).not.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("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); + }); + + 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 000000000..56e5bbd47 --- /dev/null +++ b/apps/desktop/src/shared/chatTurnStatus.ts @@ -0,0 +1,181 @@ +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?.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) { + 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 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()); + } + } + + 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 000000000..9b88ba2d2 --- /dev/null +++ b/apps/desktop/src/shared/claudeAgentSdkFields.test.ts @@ -0,0 +1,51 @@ +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, spawnDepth: 9 })).toBe(1); + 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"]); + 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 new file mode 100644 index 000000000..f6d1a6833 --- /dev/null +++ b/apps/desktop/src/shared/claudeAgentSdkFields.ts @@ -0,0 +1,95 @@ +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); +} + +/** 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()); + 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 = resourceLinkCopyPath(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 000000000..1f8e36e5a --- /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 000000000..8a8d9f335 --- /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 000000000..a8056ab6d --- /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 000000000..147529221 --- /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 830911272..aaebeebc6 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 d1cecc5ad..870ad74f3 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2221,6 +2221,37 @@ struct AgentChatEventProvenance: Decodable, Equatable { var runId: String? } +struct AgentChatResourceLink: Codable, Equatable, Hashable { + var uri: String? + var name: String? + var path: String? + + var copyPath: 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 nil + } + + var displayPath: String { + copyPath ?? name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +} + struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { /// Identity must include the timestamp, not just the sequence. /// @@ -2249,6 +2280,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 +2292,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 +2305,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 +2322,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 +2344,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: .spawnDepthSnake) + ?? container.decodeIfPresent(Int.self, forKey: .spawnDepth) + resourceLinks = try container.decodeIfPresent([AgentChatResourceLink].self, forKey: .resourceLinks) + ?? container.decodeIfPresent([AgentChatResourceLink].self, forKey: .resourceLinksSnake) } } @@ -2310,6 +2363,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 ce1374fe5..c9d652438 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 631b6e291..780cbf76e 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 f500d5d1c..0e7cc8a2c 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) } return hasher.finalize() } diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 456861d8d..5960b8365 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 a4128c447..808b3a585 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 e5a32803d..c41d88428 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -161,6 +161,136 @@ 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 seen = Set() + var current: WorkSubagentSnapshot? = snapshot + 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() + 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.copyPath?.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 7639d5452..c20ca40f4 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) 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 d2662017f..d724d858e 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -409,6 +409,10 @@ 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["spawn_depth"] as? Int + ?? eventDict["spawnDepth"] as? Int + let subagentResourceLinks = parseAgentChatResourceLinksFromEvent(eventDict) let event: WorkChatEvent switch type { @@ -1051,7 +1055,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 +1092,32 @@ 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 + 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 639b1bdc0..a457951ba 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,151 @@ 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") + 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() { diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 3e011949c..0c533854a 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