From 67f8d21d94b0eb85414bd3e9f1dd4beec30d990d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:54:38 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(chat):=20spawned-chat=20lineage=20?= =?UTF-8?q?=E2=80=94=20pane=20routing=20fix,=20sidebar=20glyph,=20View=20p?= =?UTF-8?q?arent=20thread,=20CLI=20spawn=20lineage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the Subagents-pane row for a spawned ADE chat opening the inline "Transcript not ready yet" drawer instead of routing to the child chat: the canonical dot-form subagent.started twin overwrote the chat:-prefixed taskId in snapshot derivation. Derivation now preserves the navigation alias via one mergeSubagentTaskId helper and exposes an explicit childSessionId + spawnKind on ChatSubagentSnapshot; the panel routes on childSessionId, shows the live child title (Work-level sessionTitleById), and adds an open › affordance. Lineage UX: session cards render a small clickable identicon (matching the parent pane's glyph) left of the status dot for spawned sessions, with parent-title tooltip; the child chat's breadcrumb is renamed to "View parent thread"; the Work select-session listener resolves the target lane for cross-lane jumps; the TUI drawer gains compact sub/peer markers. ade new chat --mode cli now records the same lineage for agent-provider CLI sessions via resumeMetadata (never chatSessionId, which is attached-terminal ownership and would have rescoped the parent's browser capability), projected onto TerminalSessionSummary by the session service. Shell terminals opt out explicitly. Browser-mock parity: lanes listDeleteProgress stub + spawnKind passthrough. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/README.md | 3 +- apps/ade-cli/src/adeRpcServer.test.ts | 51 ++++++++ apps/ade-cli/src/adeRpcServer.ts | 35 +++++- apps/ade-cli/src/cli.test.ts | 59 ++++++++++ apps/ade-cli/src/cli.ts | 36 ++++-- .../src/tuiClient/__tests__/Drawer.test.tsx | 74 ++++++++++++ apps/ade-cli/src/tuiClient/app.tsx | 1 + .../src/tuiClient/closedCliSessions.ts | 6 + .../src/tuiClient/components/Drawer.tsx | 14 ++- .../services/sessions/sessionService.test.ts | 49 ++++++++ .../main/services/sessions/sessionService.ts | 18 ++- apps/desktop/src/renderer/browserMock.ts | 2 + .../components/chat/AgentChatPane.tsx | 17 ++- .../chat/ChatSubagentsPanel.test.tsx | 111 ++++++++++++++++++ .../components/chat/ChatSubagentsPanel.tsx | 51 ++++++-- .../chat/chatExecutionSummary.test.ts | 80 +++++++++++++ .../components/chat/chatExecutionSummary.ts | 68 ++++++++++- .../components/chat/chatSubagentIdentity.tsx | 17 ++- .../chat/chatTranscriptRows.test.ts | 69 +++++++++++ .../components/terminals/SessionCard.test.tsx | 68 +++++++++++ .../components/terminals/SessionCard.tsx | 32 +++++ .../components/terminals/SessionListPane.tsx | 17 ++- .../terminals/TerminalsPage.test.tsx | 26 ++++ .../components/terminals/TerminalsPage.tsx | 6 +- .../components/terminals/WorkViewArea.tsx | 9 ++ apps/desktop/src/shared/types/sessions.ts | 15 ++- docs/features/agents/README.md | 27 +++-- docs/features/chat/README.md | 48 +++++--- docs/features/chat/composer-and-ui.md | 19 +-- .../features/terminals-and-sessions/README.md | 48 ++++++-- .../pty-and-processes.md | 14 ++- .../terminals-and-sessions/ui-surfaces.md | 28 ++++- 32 files changed, 1018 insertions(+), 100 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 36e487017..da8cd04e2 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -382,7 +382,8 @@ ade terminal resume --terminal session-id --text ade new chat --mode chat --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode cli --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode chat --lane auto --lane-name fix-checkout-flow --prompt "fix failing tests" -ade new chat --mode chat --lane lane-id --type subagent --prompt "repro the flake" # --type subagent|peer|none (chat mode only): cosmetic relationship + completion-report policy — subagent wakes the parent on completion, peer leaves a quiet note, none (default) is silent; a typed agent is still a full agent +ade new chat --mode chat --lane lane-id --type subagent --prompt "repro the flake" # --type subagent|peer|none: cosmetic relationship + completion-report policy — subagent wakes the parent on completion, peer leaves a quiet note, none (default) is silent; a typed agent is still a full agent +ade new chat --mode cli --lane lane-id --provider codex --type peer --parent chat-session-id --prompt "review the diff" # agent-provider CLI sessions record spawn lineage without becoming attached terminals; shell sessions do not record lineage ade chat list --lane lane-id --include-automation --no-archived --text ade chat create --lane lane-id --provider codex --model openai/gpt-5.6-sol --permissions full-auto --print-config --json ade chat create --lane lane-id --provider codex --no-parent # spawned chats default their parent to $ADE_CHAT_SESSION_ID; --parent overrides, --no-parent opts out diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 14ca2a6d4..8459ca362 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1763,6 +1763,57 @@ describe("adeRpcServer", () => { }); }); + it("persists CLI spawn lineage in resume metadata without assigning terminal ownership", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-1", + laneId: "lane-1", + ptyId: "pty-1", + tracked: true, + toolType: "codex", + title: "Codex", + status: "running", + resumeCommand: null, + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { permissionMode: "default" }, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + }, + chatSessionId: null, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + orchestrationParentSessionId: " parent-session-1 ", + spawnKind: "peer", + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall).toEqual(expect.objectContaining({ + resumeMetadata: expect.objectContaining({ + provider: "codex", + targetKind: "thread", + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + }), + })); + expect(createCall).not.toHaveProperty("chatSessionId"); + expect(response.structuredContent.session).toEqual(expect.objectContaining({ + orchestrationParentSessionId: "parent-session-1", + spawnKind: "peer", + chatSessionId: null, + })); + }); + it("preserves wide start_cli_session terminal dimensions", async () => { const fixture = createRuntime(); fixture.runtime.sessionService.get.mockReturnValue({ diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index dcd468ff2..bfacc8116 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -59,7 +59,7 @@ import { type LaunchProfile, type TrackedCliLaunchCommand, } from "../../desktop/src/shared/cliLaunch"; -import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types"; +import type { AgentChatPermissionMode, AgentChatSpawnKind, TerminalResumeMetadata, TerminalSessionSummary } from "../../desktop/src/shared/types"; import type { AdeRuntime } from "./bootstrap"; import { recordUsageInteraction, @@ -72,6 +72,7 @@ import { getSharedModelPickerStore } from "./services/modelPickerStore"; import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase"; import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods"; import { resolveCodexComputerUseMcpConfig } from "../../desktop/src/main/utils/codexComputerUse"; +import { parseTrackedCliLaunchConfig } from "../../desktop/src/main/utils/terminalSessionSignals"; // Cross-surface (desktop + TUI + iOS) model picker favorites & recents. // Backed by the per-project cr-sqlite CRR DB (runtime.db) so the three surfaces @@ -337,6 +338,8 @@ const TOOL_SPECS: ToolSpec[] = [ codexFastMode: { type: "boolean", deprecated: true }, cwd: { type: "string" }, chatSessionId: { type: "string" }, + orchestrationParentSessionId: { type: "string", minLength: 1 }, + spawnKind: { type: "string", enum: ["subagent", "peer", "none"] }, tracked: { type: "boolean", default: true } } } @@ -1708,6 +1711,18 @@ function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode ); } +function parseCliSessionSpawnKind(value: unknown): AgentChatSpawnKind | null { + if (value == null) return null; + const spawnKind = asTrimmedString(value).toLowerCase(); + if (spawnKind === "subagent" || spawnKind === "peer" || spawnKind === "none") { + return spawnKind; + } + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "spawnKind must be one of subagent, peer, or none", + ); +} + function clampInteger(value: unknown, fallback: number, min: number, max: number): number { const raw = typeof value === "number" && Number.isFinite(value) ? value : fallback; return Math.max(min, Math.min(max, Math.floor(raw))); @@ -3589,6 +3604,10 @@ async function runTool(args: { const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); const provider = parseCliSessionProvider(toolArgs.provider); const permissionMode = parseCliSessionPermissionMode(toolArgs.permissionMode); + const orchestrationParentSessionId = toolArgs.orchestrationParentSessionId == null + ? null + : assertNonEmptyString(toolArgs.orchestrationParentSessionId, "orchestrationParentSessionId"); + const spawnKind = parseCliSessionSpawnKind(toolArgs.spawnKind); try { validateLaunchProfilePermissionMode(provider, permissionMode); } catch (err) { @@ -3634,6 +3653,17 @@ async function runTool(args: { ...(provider === "codex" ? { codexComputerUse } : {}), }); })(); + const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider]; + const resumeMetadata: TerminalResumeMetadata | null = isCliProvider(provider) + ? { + provider, + targetKind: provider === "codex" ? "thread" : "session", + targetId: provider === "claude" ? preassignedSessionId ?? null : null, + launch: parseTrackedCliLaunchConfig(launchFields.startupCommand ?? "", toolType) ?? {}, + ...(orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(spawnKind ? { spawnKind } : {}), + } + : null; const created = await ptyService.create({ ...(preassignedSessionId ? { sessionId: preassignedSessionId } : {}), @@ -3643,7 +3673,8 @@ async function runTool(args: { rows, title, tracked: toolArgs.tracked !== false, - toolType: LAUNCH_PROFILE_TOOL_TYPE[provider], + toolType, + ...(resumeMetadata ? { resumeMetadata } : {}), ...(asOptionalTrimmedString(toolArgs.cwd) ? { cwd: asOptionalTrimmedString(toolArgs.cwd)! } : {}), ...(asOptionalTrimmedString(toolArgs.chatSessionId) ? { chatSessionId: asOptionalTrimmedString(toolArgs.chatSessionId) } : {}), ...launchFields, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 37cf43a83..96c6132f7 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2442,6 +2442,56 @@ describe("ADE CLI", () => { expect((staticPlan.value as { launch: Record }).launch.orchestrationParentSessionId) .toBe("parent-session-1"); }); + + it("ade new chat --mode cli records the env parent as spawn lineage without terminal ownership", () => { + process.env.ADE_CHAT_SESSION_ID = "parent-session-1"; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "cli", + "--lane", "lane-1", + "--provider", "codex", + "--type", "peer", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const launch = (staticPlan.value as { launch: Record }).launch; + expect(launch.orchestrationParentSessionId).toBe("parent-session-1"); + expect(launch.spawnKind).toBe("peer"); + expect(launch).not.toHaveProperty("chatSessionId"); + }); + + it("ade new chat --mode cli --no-parent leaves the terminal unlinked", () => { + process.env.ADE_CHAT_SESSION_ID = "parent-session-1"; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "cli", + "--lane", "lane-1", + "--provider", "codex", + "--no-parent", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const launch = (staticPlan.value as { launch: Record }).launch; + expect(launch).not.toHaveProperty("orchestrationParentSessionId"); + expect(launch).not.toHaveProperty("chatSessionId"); + }); + + it("does not record spawn lineage for plain shell terminals", () => { + process.env.ADE_CHAT_SESSION_ID = "parent-session-1"; + const plan = buildCliPlan([ + "new", "chat", + "--mode", "cli", + "--lane", "lane-1", + "--provider", "shell", + "--type", "peer", + "--print-config", + ]); + const staticPlan = expectStaticPlan(plan); + const launch = (staticPlan.value as { launch: Record }).launch; + expect(launch.provider).toBe("shell"); + expect(launch).not.toHaveProperty("orchestrationParentSessionId"); + expect(launch).not.toHaveProperty("spawnKind"); + }); }); it("prints chat create --prompt dry-run with the follow-up send", () => { @@ -5680,6 +5730,15 @@ describe("ADE CLI", () => { expect(newChatHelp.kind).toBe("help"); if (newChatHelp.kind !== "help") return; expect(newChatHelp.text).toContain("--type "); + expect(newChatHelp.text).toContain("Override with --parent "); + expect(newChatHelp.text).toContain("plain shell terminals don't record lineage"); + + const newHelp = buildCliPlan(["new", "--help"]); + expect(newHelp.kind).toBe("help"); + if (newHelp.kind !== "help") return; + expect(newHelp.text).toContain("--type "); + expect(newHelp.text).toContain("--parent "); + expect(newHelp.text).toContain("Plain shell terminals do not record lineage"); const laneCommandHelp = buildCliPlan(["help", "lanes"]); expect(laneCommandHelp.kind).toBe("help"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 123d3fadb..b4b13099b 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1378,6 +1378,12 @@ const HELP_BY_COMMAND: Record = { --no-fast, --standard Disable fast service tier explicitly. --prompt First chat message or CLI initial input. + Spawn lineage: + In tracked agent shells, chat mode and agent-provider CLI mode default + their parent to $ADE_CHAT_SESSION_ID. Use --parent to override + it or --no-parent to opt out. CLI terminals record lineage without taking + attached-terminal ownership. Plain shell terminals do not record lineage. + Compatibility: ade chat create still creates persistent Work chats. ade shell start-cli still starts tracked provider CLI sessions. @@ -1397,6 +1403,12 @@ const HELP_BY_COMMAND: Record = { The command defaults to the current ADE lane when ADE_LANE_ID is set. Use --auto-create-lane or --lane auto to create a lane before launching. --type sets only the cosmetic relationship and completion-report policy; a typed agent is a full agent. subagent wakes the parent, peer leaves a quiet note, and none (default) sends no report. + + Spawn lineage: when run from a tracked agent shell (ADE_CHAT_SESSION_ID set), + the new session links back to the spawning chat. In CLI mode, the terminal + shows that spawn lineage on its own sidebar card and links back to the chat + (agent providers only — plain shell terminals don't record lineage). + Override with --parent ; opt out with --no-parent. `, lanes: `${ADE_BANNER} Lanes @@ -2535,12 +2547,12 @@ function readLaneId(args: string[]): string | null { } /** - * Parent chat-session lineage for spawned chat sessions. Defaults to the - * spawning agent's own session — ADE injects ADE_CHAT_SESSION_ID into every - * tracked agent shell (chat runtimes and tracked CLI/PTY sessions) — so a - * child created via `ade chat create` links back to the chat that spawned it - * instead of becoming an orphan. `--parent ` overrides the - * default; `--no-parent` opts out entirely. + * Parent chat-session lineage for spawned chat and agent-provider CLI + * sessions. Defaults to the spawning agent's own session — ADE injects + * ADE_CHAT_SESSION_ID into every tracked agent shell (chat runtimes and + * tracked CLI/PTY sessions). `--parent ` overrides the default; + * `--no-parent` opts out entirely. CLI callers keep chatSessionId separate + * because that field represents attached-terminal ownership, not lineage. */ function readParentSessionId(args: string[]): string | undefined { const override = readValue(args, ["--parent", "--parent-session", "--parent-session-id"]); @@ -4184,7 +4196,7 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort", "--reasoning"]); const permissionMode = readValue(args, ["--permission-mode", "--permissions"]); const spawnTypeArg = readValue(args, ["--type", "--spawn-type"]); - const spawnKind = mode === "chat" ? spawnTypeArg?.trim().toLowerCase() : undefined; + const spawnKind = spawnTypeArg?.trim().toLowerCase(); const fastMode = readFastModeFlag(args); const title = readValue(args, ["--title"]); const printConfig = readFlag(args, ["--print-config", "--dry-run"]); @@ -4218,10 +4230,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan }; // Consume the flags in both modes so they never leak into the generic arg - // bag; lineage only applies to chat mode (a PTY session is not a chat - // record, so there is nothing to link). + // bag. Both chat and CLI modes record the same spawn lineage fields; CLI + // sessions keep chatSessionId free for true attached-terminal ownership. const parentSessionId = readParentSessionId(args); - const orchestrationParentSessionId = mode === "chat" ? parentSessionId : undefined; + const orchestrationParentSessionId = parentSessionId; const launchArgs = mode === "chat" ? collectGenericObjectArgs(args, { provider, @@ -4249,6 +4261,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan modelId: modelArg, reasoningEffort, ...(fastMode !== undefined ? { fastMode, codexFastMode: fastMode } : {}), + // Spawn lineage rides on resume metadata, which only agent providers + // have — plain shell terminals can't persist it, so don't pretend. + ...(provider !== "shell" && orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(provider !== "shell" && spawnKind ? { spawnKind } : {}), cols: readIntOption(args, ["--cols"], 120), rows: readIntOption(args, ["--rows"], 36), cwd: readValue(args, ["--cwd"]), diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx index 8cb0b914b..c6d0c2037 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -134,6 +134,25 @@ describe("Drawer closed CLI sessions", () => { }); }); + it("projects spawn lineage from tracked CLI resume metadata", () => { + const summary = terminalSessionToChatSummary(terminal({ + terminalId: "cli-subagent", + resumeMetadata: { + provider: "codex", + targetKind: "session", + targetId: "cli-subagent", + launch: {}, + orchestrationParentSessionId: "parent-chat", + spawnKind: "subagent", + }, + })); + + expect(summary).toMatchObject({ + orchestrationParentSessionId: "parent-chat", + spawnKind: "subagent", + }); + }); + it.each([ ["failed status", { status: "failed", exitCode: 1, runtimeState: "killed" }, "failed"], ["non-zero exit", { status: "completed", exitCode: 2, runtimeState: "exited" }, "failed"], @@ -172,6 +191,61 @@ describe("Drawer closed CLI sessions", () => { }); describe("Drawer lane and chat navigation layout", () => { + it("renders compact spawn-kind markers for chat and tracked CLI rows", () => { + const chat: AgentChatSessionSummary = { + sessionId: "chat-subagent", + laneId: "lane-1", + provider: "claude", + model: "claude-code", + title: "Chat child", + status: "idle", + startedAt: "2026-05-12T11:30:00.000Z", + endedAt: null, + lastActivityAt: "2026-05-12T11:31:00.000Z", + lastOutputPreview: null, + summary: null, + nextWakeAt: null, + orchestrationParentSessionId: "parent-chat", + spawnKind: "subagent", + }; + const cli = terminalSessionToChatSummary(terminal({ + terminalId: "cli-peer", + resumeMetadata: { + provider: "codex", + targetKind: "session", + targetId: "cli-peer", + launch: {}, + orchestrationParentSessionId: "parent-chat", + spawnKind: "peer", + }, + })); + + const baseProps = { + lanes: [lane("lane-1", "Feature", "feature/spawn-kind", "2026-05-12T11:55:00.000Z")], + activeLaneId: "lane-1", + activeSessionId: null, + browsingLaneId: "lane-1", + selectedLaneIndex: 0, + selectedChatIndex: -1, + panelHeight: 30, + }; + const chatFrame = stripAnsi(render( + , + ).lastFrame() ?? ""); + const cliFrame = stripAnsi(render( + , + ).lastFrame() ?? ""); + + expect(chatFrame).toContain("Chat child sub"); + expect(cliFrame).toContain("Codex CLI peer"); + }); + it("puts the primary lane first and removes old header/footer controls", () => { const frame = stripAnsi(render( {" "} : {dot.glyph} } {exec.glyph} {label} + {spawnLabel ? ( + {` ${spawnLabel}`} + ) : null} {tag ? {` ${tag}`} : null} {wake ? {` ⏰${wake}`} : null} {running ? : null} diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index d62188b48..714753e49 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -150,6 +150,55 @@ describe("sessionService resume metadata", () => { activeDisposers.push(async () => db.close()); }); + it("projects tracked CLI spawn lineage from resume metadata without assigning a chat owner", async () => { + const projectRoot = makeProjectRoot("ade-session-service-lineage-"); + const dbPath = path.join(projectRoot, ".ade", "ade.db"); + const db = await openKvDb(dbPath, createLogger() as any); + activeDisposers.push(async () => db.close()); + insertProjectGraph(db); + const service = createSessionService({ db }); + + service.create({ + sessionId: "session-lineage", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI child", + startedAt: "2026-07-18T04:10:54.789Z", + transcriptPath: "/tmp/session-lineage.log", + toolType: "codex", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: null, + launch: { permissionMode: "default" }, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }, + }); + + expect(service.list({ laneId: "lane-1" })).toEqual([ + expect.objectContaining({ + id: "session-lineage", + chatSessionId: null, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }), + ]); + + service.setResumeCommand("session-lineage", "codex resume thread-1"); + expect(service.get("session-lineage")).toEqual(expect.objectContaining({ + chatSessionId: null, + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + resumeMetadata: expect.objectContaining({ + targetId: "thread-1", + orchestrationParentSessionId: "parent-session-1", + spawnKind: "subagent", + }), + })); + }); + it("preserves Codex approval and sandbox settings when rebuilding the resume command", async () => { const projectRoot = makeProjectRoot("ade-session-service-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 2b71bc7c9..f025cf5ea 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -11,8 +11,9 @@ import type { TerminalSessionSummary, TerminalToolType, ListSessionsArgs, - UpdateSessionMetaArgs + UpdateSessionMetaArgs, } from "../../../shared/types"; +import { isTrackedAgentCliToolType } from "../../../shared/types"; import { stripAnsi } from "../../utils/ansiStrip"; import { readHistoryFileSync } from "../storage/historyCompression"; import { @@ -152,6 +153,12 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { const importedFromAt = typeof importedFromRecord?.importedAt === "string" && importedFromRecord.importedAt.trim().length ? importedFromRecord.importedAt.trim() : null; + const orchestrationParentSessionId = typeof record.orchestrationParentSessionId === "string" + ? record.orchestrationParentSessionId.trim() + : ""; + const spawnKind = record.spawnKind === "subagent" || record.spawnKind === "peer" || record.spawnKind === "none" + ? record.spawnKind + : null; if (!provider || !targetKind) return null; return { provider, @@ -178,6 +185,8 @@ function normalizeResumeMetadata(raw: unknown): TerminalResumeMetadata | null { : {}), ...(legacyTarget ? { target: legacyTarget } : {}), ...(permissionMode ? { permissionMode } : {}), + ...(orchestrationParentSessionId ? { orchestrationParentSessionId } : {}), + ...(spawnKind ? { spawnKind } : {}), }; } @@ -363,6 +372,12 @@ export function createSessionService({ db }: { db: AdeDb }) { chatSessionId: row.chatSessionId ?? null, ownerPid: normalizeOwnerPid(row.ownerPid), ownerProcessStartedAt: normalizeOwnerProcessStartedAt(row.ownerProcessStartedAt), + ...(isTrackedAgentCliToolType(toolType) && resumeMetadata?.orchestrationParentSessionId + ? { orchestrationParentSessionId: resumeMetadata.orchestrationParentSessionId } + : {}), + ...(isTrackedAgentCliToolType(toolType) && resumeMetadata?.spawnKind + ? { spawnKind: resumeMetadata.spawnKind } + : {}), }; }; @@ -1059,6 +1074,7 @@ export function createSessionService({ db }: { db: AdeDb }) { : null; const nextMetadata = parsed ? { + ...currentMetadata, provider: parsed.provider, targetKind: parsed.provider === "codex" ? "thread" : "session", targetId: parsed.targetId ?? currentMetadata?.targetId ?? null, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 98f8fad7d..1083123b1 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1244,6 +1244,7 @@ function mockAgentChatSummaryFromSession(session: any): any | null { orchestrationRunId: session.orchestrationRunId ?? undefined, orchestrationRole: session.orchestrationRole ?? undefined, orchestrationParentSessionId: session.orchestrationParentSessionId ?? undefined, + spawnKind: session.spawnKind ?? undefined, orchestrationTag: session.orchestrationTag ?? undefined, orchestrationStepId: session.orchestrationStepId ?? undefined, orchestrationBundlePath: session.orchestrationBundlePath ?? undefined, @@ -4517,6 +4518,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateAppearance: resolvedArg(undefined), archive: resolvedArg(undefined), delete: resolvedArg(undefined), + listDeleteProgress: resolved([]), cancelDelete: resolvedArg({ cancelled: false, reason: "no active delete", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index bfa4d645d..9a1071374 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2853,6 +2853,7 @@ export function AgentChatPane({ initialSessionId, initialSessionSummary, lockSessionId, + sessionTitleById, hideSessionTabs = false, hideNativeControls = false, hideModelControls = false, @@ -2899,6 +2900,8 @@ export function AgentChatPane({ initialSessionId?: string | null; initialSessionSummary?: AgentChatSessionSummary | null; lockSessionId?: string | null; + /** Full host-surface title index for locked single-session embeddings. */ + sessionTitleById?: ReadonlyMap; hideSessionTabs?: boolean; hideNativeControls?: boolean; /** Hide model/reasoning/fast controls when the embedding surface owns them. */ @@ -4858,6 +4861,15 @@ export function AgentChatPane({ return { parentId, parentTitle, spawnKind: selectedSession?.spawnKind ?? null }; }, [selectedSession?.orchestrationParentSessionId, selectedSession?.sessionId, selectedSession?.spawnKind, sessions]); + // Resolve a spawned child chat's live title so the Subagents pane's spawned-chat + // rows read as the chat they open, not the bare runtime name. + const resolveSpawnedChatTitle = useCallback( + (id: string): string | null => sessionTitleById?.get(id)?.trim() + || sessions.find((s) => s.sessionId === id)?.title?.trim() + || null, + [sessionTitleById, sessions], + ); + // Keep configured models selectable unless a caller explicitly constrains // this surface. Unconstrained sessions keep their active model visible even // when it has fallen out of the discovered catalog. @@ -9765,6 +9777,7 @@ export function AgentChatPane({ }} onClearSelectedSubagent={() => setSubagentView(null)} probeSubagentTranscript={probeSubagentTranscript} + resolveSpawnedChatTitle={resolveSpawnedChatTitle} capability={selectedSubagentCapability} selectedTaskId={subagentView?.taskId ?? null} goal={selectedSession?.provider === "codex" ? selectedCodexGoal : null} @@ -10266,10 +10279,10 @@ export function AgentChatPane({ ? "border-slate-400/18 bg-slate-400/[0.06] text-slate-300/75 hover:border-slate-300/30 hover:text-slate-100/90" : "border-violet-400/20 bg-violet-400/[0.06] text-violet-200/80 hover:border-violet-300/32 hover:text-violet-100", )} - title={spawnLineage.parentTitle ? `Open spawner: ${spawnLineage.parentTitle}` : "Open spawner"} + title={spawnLineage.parentTitle ? `Parent thread: "${spawnLineage.parentTitle}"` : "View parent thread"} > - from {spawnLineage.parentTitle ? `"${spawnLineage.parentTitle}"` : "spawner"} + View parent thread ) : null} {chatTerminalVisible && selectedSessionId ? ( diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index bcaa9fb8a..674ff5e9d 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -770,6 +770,117 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.getByRole("button", { name: "Completed (2)" })).toBeTruthy(); }); + // A spawned ADE child chat row navigates to that chat instead of taking over + // the transcript / opening the inline drawer. The click is gated on either a + // `chat:`-prefixed taskId OR a `spawnKind` (the latter survives the dot-twin + // that can strip the `chat:` prefix — see chatExecutionSummary preservation). + describe("spawned-chat row navigation", () => { + const spawnedChatSnapshot = (overrides: Partial = {}): ChatSubagentSnapshot => ({ + taskId: "chat:child-1", + childSessionId: "child-1", + agentId: "child-1", + agentType: "codex", + description: "Codex Chat", + status: "running", + startedAt: "2026-07-18T04:10:54.789Z", + updatedAt: "2026-07-18T04:10:54.789Z", + summary: null, + spawnKind: "subagent", + ...overrides, + }); + + function withSelectSessionSpy(run: (selectSession: ReturnType) => void): void { + const selectSession = vi.fn(); + const listener = (event: Event) => selectSession((event as CustomEvent).detail); + window.addEventListener("ade:work:select-session", listener); + try { + run(selectSession); + } finally { + window.removeEventListener("ade:work:select-session", listener); + } + } + + it("navigates to the child chat (chat:-prefixed taskId) instead of drawer/takeover", () => { + withSelectSessionSpy((selectSession) => { + const onSelectSubagent = vi.fn<[SubagentSelection], void>(); + const probeSubagentTranscript = vi.fn().mockResolvedValue(true); + render( + , + ); + + fireEvent.click(screen.getByTitle("Open the spawned chat")); + + expect(selectSession).toHaveBeenCalledWith({ sessionId: "child-1", laneId: null }); + expect(onSelectSubagent).not.toHaveBeenCalled(); + expect(probeSubagentTranscript).not.toHaveBeenCalled(); + expect(screen.queryByText(/Transcript not ready yet/)).toBeNull(); + }); + }); + + it("navigates when the taskId is bare but spawnKind is set (post-dot-twin corruption shape)", () => { + withSelectSessionSpy((selectSession) => { + const onSelectSubagent = vi.fn<[SubagentSelection], void>(); + render( + , + ); + + fireEvent.click(screen.getByTitle("Open the spawned chat")); + + expect(selectSession).toHaveBeenCalledWith({ sessionId: "child-1", laneId: null }); + expect(onSelectSubagent).not.toHaveBeenCalled(); + expect(screen.queryByText(/Transcript not ready yet/)).toBeNull(); + }); + }); + + it("shows the resolved live chat title instead of the runtime agentType", () => { + render( + (id === "child-1" ? "Investigate flaky CI" : null)} + />, + ); + + // Resolved title is the row label; the runtime name stays as the small chip. + expect(screen.getByText("Investigate flaky CI")).toBeTruthy(); + expect(screen.getByText("codex")).toBeTruthy(); + }); + + it("falls back to the snapshot description when no live title resolves", () => { + render( + null} + />, + ); + + expect(screen.getByText("Codex Chat")).toBeTruthy(); + expect(screen.getByText("codex")).toBeTruthy(); + }); + }); + it("owns the pane scroller and uses sticky opaque section headers", () => { render(); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 9717fa5aa..2e44526ac 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -781,6 +781,7 @@ function SubagentRow({ canViewFullTranscript, onClick, depth = 0, + spawnedChatTitle = null, }: { snapshot: ChatSubagentSnapshot; selected: boolean; @@ -793,10 +794,21 @@ function SubagentRow({ /** Whether this runtime can surface a full child transcript (drives the * drawer's "transcript not ready yet" vs "live details only" footer). */ canViewFullTranscript: boolean; + /** Live title of the spawned child chat this row navigates to, when resolved. + * Non-null marks this as a spawned-chat row (navigates on click). */ + spawnedChatTitle?: string | null; onClick: () => void; }) { - const name = chatSubagentDisplayName(snapshot); - const kindLabel = kindBadge(snapshot); + const isSpawnedChat = snapshot.childSessionId != null; + // Spawned-chat rows read as the chat they open: prefer the resolved live + // session title, then the snapshot description ("Codex Chat") — never the bare + // agentType ("codex"), which becomes the small chip instead. + const name = isSpawnedChat + ? (spawnedChatTitle?.trim() || snapshot.description?.trim() || chatSubagentDisplayName(snapshot)) + : chatSubagentDisplayName(snapshot); + // Spawned-chat rows surface the runtime as the uppercase chip (reusing the + // kindBadge chip idiom); other rows keep their taskType kind chip. + const kindLabel = isSpawnedChat ? (snapshot.agentType?.trim() || null) : kindBadge(snapshot); const color = chatSubagentColor(snapshot.agentId ?? snapshot.taskId); const isRunning = snapshot.status === "running"; const isCompleted = snapshot.status === "completed"; @@ -830,7 +842,7 @@ function SubagentRow({