From 2353f725d1bc2c3d2aefdda3df75b80578fa3bf0 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 8 Jul 2026 12:54:26 +0900 Subject: [PATCH] Open subagent threads from agent summaries --- .../chat/host/bundles/shell-bundle.ts | 1 + .../panel/surface/message-stream-presenter.ts | 1 + .../surface/message-stream-projection.ts | 4 + .../message-stream/detail-view.ts | 8 ++ .../ui/message-stream/agent-thread-action.tsx | 24 ++++ .../chat/ui/message-stream/context.ts | 1 + .../chat/ui/message-stream/detail.tsx | 27 ++++- .../chat/ui/message-stream/status.tsx | 46 ++++++-- .../chat/ui/message-stream/stream-blocks.tsx | 2 +- src/styles/24-chat-stream-items.css | 28 +++++ .../surface/message-stream-presenter.test.ts | 2 + .../ui/message-stream/stream-items.test.tsx | 105 ++++++++++++++++++ 12 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 src/features/chat/ui/message-stream/agent-thread-action.tsx diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index 37d42950..95c6329a 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -109,6 +109,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan rollbackThread: (threadId) => void threadActions.rollbackThread(threadId), forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource), implementPlan: (itemId) => void turn.turnActions.planImplementation.implement(itemId), + openThreadInNewView: (threadId) => void environment.plugin.workspace.openThreadInNewView(threadId), openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state), }, requests: { diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index d2ad3d43..10e55ea7 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -33,6 +33,7 @@ interface ChatMessageStreamActions { rollbackThread: (threadId: string) => void; forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void; implementPlan: (itemId: string) => void; + openThreadInNewView: (threadId: string) => void; openTurnDiff: (state: TurnDiffViewState) => void; } diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/message-stream-projection.ts index 64eae63e..eae46461 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/message-stream-projection.ts @@ -14,6 +14,7 @@ interface ChatMessageStreamActions { rollbackThread: (threadId: string) => void; forkThreadFromTurn: (threadId: string, turnId: string, archiveSource: boolean) => void; implementPlan: (itemId: string) => void; + openThreadInNewView: (threadId: string) => void; openTurnDiff: (state: TurnDiffViewState) => void; } @@ -91,6 +92,9 @@ function messageStreamContextFromProjection( context.actions.forkThreadFromTurn(projection.activeThreadId, target.turnId, archiveSource); } }, + openThreadInNewView: (threadId) => { + context.actions.openThreadInNewView(threadId); + }, openTurnDiff: (turnDiffState) => { context.actions.openTurnDiff(turnDiffState); }, diff --git a/src/features/chat/presentation/message-stream/detail-view.ts b/src/features/chat/presentation/message-stream/detail-view.ts index 9ac0575c..e3f8c3ed 100644 --- a/src/features/chat/presentation/message-stream/detail-view.ts +++ b/src/features/chat/presentation/message-stream/detail-view.ts @@ -30,6 +30,7 @@ export interface DetailView { className: string; label: string; summary: string; + summaryThreadIds: readonly string[]; detailsKey: string; sections: DetailSection[]; state: ExecutionState; @@ -68,11 +69,13 @@ function detailViewBase( detailsKey: string, sections: DetailSection[], summary = fallbackSummary(item), + summaryThreadIds: readonly string[] = [], ): DetailView { return { className: `codex-panel__message codex-panel__message--tool ${className}`, label, summary, + summaryThreadIds, detailsKey, sections, state: item.executionState ?? null, @@ -152,6 +155,7 @@ function agentDetailView(item: AgentMessageStreamItem): DetailView { messageDetailKey(item.id, "agent-details"), agentDetailSections(item), agentSummaryText(item), + agentThreadIds(item), ); } @@ -430,6 +434,10 @@ function agentSummaryText(item: AgentMessageStreamItem): string { return `${agentActivityMetaLabel(item.tool)}${target}${promptPreview ? `: ${promptPreview}` : ""} (${item.status})`; } +function agentThreadIds(item: AgentMessageStreamItem): readonly string[] { + return [...new Set([...item.receiverThreadIds, ...item.agents.map((agent) => agent.threadId)])].sort((a, b) => a.localeCompare(b)); +} + function agentActivityMetaLabel(tool: string): string { if (tool === "spawnAgent") return "spawn"; if (tool === "sendInput") return "send input"; diff --git a/src/features/chat/ui/message-stream/agent-thread-action.tsx b/src/features/chat/ui/message-stream/agent-thread-action.tsx new file mode 100644 index 00000000..f8954a2c --- /dev/null +++ b/src/features/chat/ui/message-stream/agent-thread-action.tsx @@ -0,0 +1,24 @@ +import type { ComponentChild as UiNode } from "preact"; + +import { IconButton } from "../../../../shared/obsidian/components.obsidian"; + +export function OpenAgentThreadAction({ + threadId, + openThreadInNewView, +}: { + threadId: string; + openThreadInNewView: (threadId: string) => void; +}): UiNode { + return ( + { + event.preventDefault(); + event.stopPropagation(); + openThreadInNewView(threadId); + }} + /> + ); +} diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 42d668e4..acd3bd4a 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -53,6 +53,7 @@ interface MessageStreamRenderContext { activeThreadId: string | null; workspaceRoot?: string | null; loadOlderTurns: () => void; + openThreadInNewView?: (threadId: string) => void; pendingRequests?: PendingRequestBlockContext; } diff --git a/src/features/chat/ui/message-stream/detail.tsx b/src/features/chat/ui/message-stream/detail.tsx index f3e1c886..d6417bb5 100644 --- a/src/features/chat/ui/message-stream/detail.tsx +++ b/src/features/chat/ui/message-stream/detail.tsx @@ -2,11 +2,13 @@ import type { ComponentChild as UiNode } from "preact"; import { RawDiffView } from "../../../../shared/ui/diff-view"; import type { DetailSection, DetailView } from "../../presentation/message-stream/detail-view"; +import { OpenAgentThreadAction } from "./agent-thread-action"; import type { MessageStreamDisclosureState } from "./context"; export interface DetailRenderContext { disclosures: MessageStreamDisclosureState; onDisclosureToggle?: (bucket: "details", id: string, open: boolean) => void; + openThreadInNewView?: (threadId: string) => void; } export function detailNode(view: DetailView, context: DetailRenderContext): UiNode { @@ -30,7 +32,7 @@ function Detail({ view, context }: { view: DetailView; context: DetailRenderCont if (view.sections.length === 0) { return (
- + {hasSummary ? : null}
); @@ -45,7 +47,7 @@ function Detail({ view, context }: { view: DetailView; context: DetailRenderCont context.onDisclosureToggle?.("details", view.detailsKey, event.currentTarget.open); }} > - + {view.sections.map((section, index) => ( ))} @@ -62,8 +64,25 @@ function executionClassName(state: DetailView["state"]): string { return ""; } -function DetailHeader({ view }: { view: DetailView }): UiNode { - const content = {view.label}; +function DetailHeader({ + view, + openThreadInNewView, +}: { + view: DetailView; + openThreadInNewView?: ((threadId: string) => void) | undefined; +}): UiNode { + const content = ( + + {view.label} + {openThreadInNewView && view.summaryThreadIds.length > 0 ? ( + + {view.summaryThreadIds.map((threadId) => ( + + ))} + + ) : null} + + ); return view.sections.length > 0 ? ( {content} diff --git a/src/features/chat/ui/message-stream/status.tsx b/src/features/chat/ui/message-stream/status.tsx index 56d1dd33..32b65c66 100644 --- a/src/features/chat/ui/message-stream/status.tsx +++ b/src/features/chat/ui/message-stream/status.tsx @@ -2,9 +2,10 @@ import type { ComponentChild as UiNode } from "preact"; import type { ExecutionState } from "../../domain/message-stream/items"; import type { AgentRunSummaryView, MessageStreamStatusView } from "../../presentation/message-stream/status-view"; +import type { MessageStreamContext } from "./context"; -export function agentRunSummaryNode(view: AgentRunSummaryView): UiNode { - return ; +export function agentRunSummaryNode(view: AgentRunSummaryView, context: Pick): UiNode { + return ; } export function statusNode(view: MessageStreamStatusView): UiNode { @@ -26,11 +27,17 @@ export function createStatusMessageClassName(className: string, tone?: "warning" .join(" "); } -function AgentRunSummary({ view }: { view: AgentRunSummaryView }): UiNode { +function AgentRunSummary({ + view, + openThreadInNewView, +}: { + view: AgentRunSummaryView; + openThreadInNewView?: ((threadId: string) => void) | undefined; +}): UiNode { return (
{view.summary}
- +
); } @@ -127,14 +134,37 @@ function executionClassName(state: ExecutionState): string { return ""; } -function AgentSummaryRows({ view }: { view: AgentRunSummaryView }): UiNode { +function AgentSummaryRows({ + view, + openThreadInNewView, +}: { + view: AgentRunSummaryView; + openThreadInNewView?: ((threadId: string) => void) | undefined; +}): UiNode { if (view.rows.length === 0 && view.additionalAgents === 0) return null; return (
    {view.rows.map((agent) => ( -
  • - {agent.threadLabel} - {agent.status} +
  • + {openThreadInNewView ? ( + // biome-ignore lint/a11y: Agent summary rows follow the toolbar subpanel nav-item pattern: pointer-first rows with visible text as the interaction target. +
    { + event.preventDefault(); + event.stopPropagation(); + openThreadInNewView(agent.threadId); + }} + > + {agent.threadLabel} + {agent.status} +
    + ) : ( + <> + {agent.threadLabel} + {agent.status} + + )}
  • ))} {view.additionalAgents > 0 ? ( diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index 62dd1968..515e7996 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -56,7 +56,7 @@ function presentationBlockNode(block: MessageStreamViewBlock, context: MessageSt return ; } if (block.kind === "liveAgentSummary") { - return agentRunSummaryNode(block.view); + return agentRunSummaryNode(block.view, context); } if (block.kind === "pendingRequests") { const pendingRequests = pendingRequestContext(context); diff --git a/src/styles/24-chat-stream-items.css b/src/styles/24-chat-stream-items.css index 323664f6..6d04bdb7 100644 --- a/src/styles/24-chat-stream-items.css +++ b/src/styles/24-chat-stream-items.css @@ -158,6 +158,34 @@ margin-top: var(--codex-panel-panel-gap); } +.codex-panel__detail-header-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0; +} + +.codex-panel__agent-row-shell { + min-width: 0; +} + +.codex-panel__agent-row--interactive { + min-height: 0; + padding: 0 var(--codex-panel-control-gap); + font: inherit; + font-size: var(--font-ui-small); + line-height: var(--line-height-tight); +} + +.codex-panel__agent-row--interactive .codex-panel__agent-thread { + font-family: inherit; + font-size: inherit; +} + +.codex-panel__agent-row--interactive:hover .codex-panel__agent-thread { + color: var(--nav-item-color-hover, var(--text-normal)); +} + .codex-panel__agent-row--more .codex-panel__agent-status { color: var(--text-faint); } diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index 7794b0ef..3c3f15e3 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -568,6 +568,7 @@ function testMessageStreamSurfaceContext(options: { rollbackThread: vi.fn(), forkThreadFromTurn: vi.fn(), implementPlan: vi.fn(), + openThreadInNewView: vi.fn(), openTurnDiff: vi.fn(), }, requests: { @@ -711,6 +712,7 @@ function messageStreamPresenter( rollbackThread: vi.fn(), forkThreadFromTurn: vi.fn(), implementPlan: vi.fn(), + openThreadInNewView: vi.fn(), openTurnDiff: vi.fn(), }, requests: { diff --git a/tests/features/chat/ui/message-stream/stream-items.test.tsx b/tests/features/chat/ui/message-stream/stream-items.test.tsx index 71f22c2c..94ec90b9 100644 --- a/tests/features/chat/ui/message-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/message-stream/stream-items.test.tsx @@ -500,6 +500,102 @@ describe("message stream item renderer decisions", () => { expect(element.textContent).toContain("childcompleted: Done"); }); + it("opens agent threads from agent activity headers", () => { + const openThreadInNewView = vi.fn(); + const block = messageStreamBlocks({ + turnLifecycle: runningTurnLifecycle("turn"), + openThreadInNewView, + items: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: "Inspect the renderer.", + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "completed", executionState: "completed", message: "Done" }], + }, + ], + })[0]; + + const element = renderMessageBlockElement(block); + const header = expectPresent(element.querySelector("details summary")); + const open = expectPresent(header.querySelector('[aria-label="Open agent thread"]')); + + expect(open.classList.contains("codex-panel__message-action")).toBe(true); + expect(open.classList.contains("codex-panel-ui__nav-row-action")).toBe(false); + expect(textContents(element, "details summary")).toEqual(["agent"]); + open.click(); + + expect(openThreadInNewView).toHaveBeenCalledWith("child"); + }); + + it("keeps agent activity headers passive without an open thread handler", () => { + const block = messageStreamBlocks({ + turnLifecycle: runningTurnLifecycle("turn"), + items: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: "Inspect the renderer.", + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "completed", executionState: "completed", message: "Done" }], + }, + ], + })[0]; + + const element = renderMessageBlockElement(block); + + expect(element.querySelector('[aria-label="Open agent thread"]')).toBeNull(); + }); + + it("keeps agent thread actions available after agent details expand", () => { + const openThreadInNewView = vi.fn(); + const block = messageStreamBlocks({ + turnLifecycle: runningTurnLifecycle("turn"), + openThreadInNewView, + disclosures: testDisclosures({ details: ["agent-1:agent-details"] }), + items: [ + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "completed", executionState: "completed", message: "Done" }], + }, + ], + })[0]; + + const element = renderMessageBlockElement(block); + expect(element.querySelector("details")?.hasAttribute("open")).toBe(true); + + expectPresent(element.querySelector("details summary [aria-label='Open agent thread']")).click(); + + expect(openThreadInNewView).toHaveBeenCalledWith("child"); + }); + it("keeps agent activity prompt previews visually constrained to one line", () => { const block = messageStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), @@ -571,8 +667,10 @@ describe("message stream item renderer decisions", () => { }); it("renders a compact live agent summary while subagents are running", () => { + const openThreadInNewView = vi.fn(); const blocks = messageStreamBlocks({ turnLifecycle: runningTurnLifecycle("turn"), + openThreadInNewView, items: [ { id: "agent-1", @@ -603,6 +701,13 @@ describe("message stream item renderer decisions", () => { expect(summary.textContent).toContain("Agents 1 running, 1 done"); expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); expect(summary.textContent).not.toContain("donecompleted"); + expect(summary.querySelector('[aria-label="Open agent thread"]')).toBeNull(); + const open = expectPresent(summary.querySelector(".codex-panel__agent-row--interactive")); + expect(open.classList.contains("codex-panel-ui__nav-item")).toBe(true); + expect(open.getAttribute("aria-label")).toBeNull(); + expect(open.textContent).toBe("runningrunning: Inspecting renderer"); + open.click(); + expect(openThreadInNewView).toHaveBeenCalledWith("running"); }); it("renders context compaction as a one-line status item while running and after completion", () => {