mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Open subagent threads from agent summaries
This commit is contained in:
parent
0ce5ceedd5
commit
2353f725d1
12 changed files with 236 additions and 13 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
24
src/features/chat/ui/message-stream/agent-thread-action.tsx
Normal file
24
src/features/chat/ui/message-stream/agent-thread-action.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ interface MessageStreamRenderContext {
|
|||
activeThreadId: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
loadOlderTurns: () => void;
|
||||
openThreadInNewView?: (threadId: string) => void;
|
||||
pendingRequests?: PendingRequestBlockContext;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue