Open subagent threads from agent summaries

This commit is contained in:
murashit 2026-07-08 12:54:26 +09:00
parent 0ce5ceedd5
commit 2353f725d1
12 changed files with 236 additions and 13 deletions

View file

@ -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: {

View file

@ -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;
}

View file

@ -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);
},

View file

@ -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";

View file

@ -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 (
<IconButton
icon="external-link"
label="Open agent thread"
className="clickable-icon codex-panel__message-action codex-panel__agent-open-thread"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openThreadInNewView(threadId);
}}
/>
);
}

View file

@ -53,6 +53,7 @@ interface MessageStreamRenderContext {
activeThreadId: string | null;
workspaceRoot?: string | null;
loadOlderTurns: () => void;
openThreadInNewView?: (threadId: string) => void;
pendingRequests?: PendingRequestBlockContext;
}

View file

@ -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 (
<div className={className}>
<DetailHeader view={view} />
<DetailHeader view={view} openThreadInNewView={context.openThreadInNewView} />
{hasSummary ? <DetailSummary text={view.summary} /> : null}
</div>
);
@ -45,7 +47,7 @@ function Detail({ view, context }: { view: DetailView; context: DetailRenderCont
context.onDisclosureToggle?.("details", view.detailsKey, event.currentTarget.open);
}}
>
<DetailHeader view={view} />
<DetailHeader view={view} openThreadInNewView={context.openThreadInNewView} />
{view.sections.map((section, index) => (
<DetailSectionView key={`${section.kind}:${section.title ?? ""}:${String(index)}`} section={section} />
))}
@ -62,8 +64,25 @@ function executionClassName(state: DetailView["state"]): string {
return "";
}
function DetailHeader({ view }: { view: DetailView }): UiNode {
const content = <span className="codex-panel__message-role codex-panel__detail-label">{view.label}</span>;
function DetailHeader({
view,
openThreadInNewView,
}: {
view: DetailView;
openThreadInNewView?: ((threadId: string) => void) | undefined;
}): UiNode {
const content = (
<span className="codex-panel__message-role codex-panel__detail-label">
<span>{view.label}</span>
{openThreadInNewView && view.summaryThreadIds.length > 0 ? (
<span className="codex-panel__detail-header-actions">
{view.summaryThreadIds.map((threadId) => (
<OpenAgentThreadAction key={threadId} threadId={threadId} openThreadInNewView={openThreadInNewView} />
))}
</span>
) : null}
</span>
);
return view.sections.length > 0 ? (
<summary className="codex-panel__detail-header" tabIndex={-1}>
{content}

View file

@ -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 <AgentRunSummary view={view} />;
export function agentRunSummaryNode(view: AgentRunSummaryView, context: Pick<MessageStreamContext, "openThreadInNewView">): UiNode {
return <AgentRunSummary view={view} openThreadInNewView={context.openThreadInNewView} />;
}
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 (
<StatusMessage label={view.label} className={view.className} state={view.state}>
<div className="codex-panel__stream-summary">{view.summary}</div>
<AgentSummaryRows view={view} />
<AgentSummaryRows view={view} openThreadInNewView={openThreadInNewView} />
</StatusMessage>
);
}
@ -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 (
<ul className="codex-panel__agent-list codex-panel__agent-list--summary">
{view.rows.map((agent) => (
<li key={agent.threadId} className="codex-panel__agent-row">
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
<li key={agent.threadId} className={openThreadInNewView ? "codex-panel__agent-row-shell" : "codex-panel__agent-row"}>
{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.
<div
className="codex-panel-ui__nav-item codex-panel__agent-row codex-panel__agent-row--interactive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openThreadInNewView(agent.threadId);
}}
>
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
</div>
) : (
<>
<span className="codex-panel__agent-thread">{agent.threadLabel}</span>
<span className="codex-panel__agent-status">{agent.status}</span>
</>
)}
</li>
))}
{view.additionalAgents > 0 ? (

View file

@ -56,7 +56,7 @@ function presentationBlockNode(block: MessageStreamViewBlock, context: MessageSt
return <ActivityGroup group={block} context={context} />;
}
if (block.kind === "liveAgentSummary") {
return agentRunSummaryNode(block.view);
return agentRunSummaryNode(block.view, context);
}
if (block.kind === "pendingRequests") {
const pendingRequests = pendingRequestContext(context);

View file

@ -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);
}

View file

@ -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: {

View file

@ -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<HTMLElement>("details summary"));
const open = expectPresent(header.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>("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<HTMLButtonElement>('[aria-label="Open agent thread"]')).toBeNull();
const open = expectPresent(summary.querySelector<HTMLElement>(".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", () => {