Skip to content
This repository was archived by the owner on Sep 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ reset this file.
scrollable "view all" dialog. Active runs are deliberately never capped: run
ids sort by start time, so a strict newest-N page would hide a long-running
run — exactly the one still worth acting on.
- MCP and tool rows in chat carry their arguments, result and a real terminal
status behind the new default-off `mcpToolDetail` flag (#362, #365): the row
resolves in place from WORKING to done or failed instead of staying
permanently "running", its chevron opens the full payload, and the toggle now
survives scrolling away and back.
- Fixed a race in the default-off `changesReview` re-fold (#368): a turn that
started while the re-fold was fetching history had its just-sent message
wiped from the timeline, because the commit replaced the timeline wholesale
Expand Down
46 changes: 44 additions & 2 deletions src/components/chat/ChatTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ function expansionToggle(
// when the flag is on. Flag off: the reducer never sets
// `turnComplete`/groups events either, so this is already the legacy
// per-event card; the explicit flag check here is defense in depth.
/** `mcpToolDetail` gates the richer rows (#362, #365): a persisted row's shape
* changed and the parser only emits completions on newer builds, so main stays
* releasable while this is dark. Same precedent as `changesReview` gating the
* receipt card. Split out of `renderItem` to keep it under the complexity cap. */
function renderToolUseItem(
item: Extract<AgentTimelineItem, { type: "toolUse" }>,
rowKey: string,
expansion: RowExpansion | undefined,
): JSX.Element {
return (
<ToolUseCard
name={item.name}
detail={item.detail}
status={flagEnabled("mcpToolDetail") ? item.status : undefined}
open={expansionOpen(expansion, rowKey)}
onToggle={expansionToggle(expansion, rowKey)}
/>
);
}

function renderMcpItem(
item: Extract<AgentTimelineItem, { type: "mcpToolCall" }>,
rowKey: string,
expansion: RowExpansion | undefined,
): JSX.Element {
const gated = () => flagEnabled("mcpToolDetail");
return (
<McpCard
server={item.server}
tool={item.tool}
detail={gated() ? item.detail : undefined}
status={gated() ? item.status : undefined}
open={expansionOpen(expansion, rowKey)}
onToggle={expansionToggle(expansion, rowKey)}
/>
);
}

function renderFileChangeItem(
item: Extract<AgentTimelineItem, { type: "fileChange" }>,
rowKey: string,
Expand Down Expand Up @@ -176,10 +214,14 @@ function renderItem(
);
case "fileChange":
return renderFileChangeItem(item, rowKey, chatId, expansion, projectRoot, changesReceipts);
// `mcpToolDetail` gates the richer rows (#362, #365): a persisted row's
// shape changed, and the parser only emits completions on newer builds, so
// main stays releasable while this is dark. Same precedent as
// `changesReview` gating the receipt card above.
case "toolUse":
return <ToolUseCard name={item.name} detail={item.detail} />;
return renderToolUseItem(item, rowKey, expansion);
case "mcpToolCall":
return <McpCard server={item.server} tool={item.tool} />;
return renderMcpItem(item, rowKey, expansion);
case "webSearch":
return <WebSearchCard query={item.query} />;
case "plan":
Expand Down
29 changes: 26 additions & 3 deletions src/components/chat/McpCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { type JSX, Show, createSignal } from "solid-js";
import { compactInline, hasHiddenDetail } from "../../lib/chatDisplay";
import { IconChevronRight } from "../icons";
import { Disclosure } from "../ui";
import type { ToolCallStatus } from "../../stores/agentChat";
import { Disclosure, StatusPill, type StatusIntent } from "../ui";
import "./chat.css";

const STATUS_INTENT: Record<ToolCallStatus, StatusIntent> = {
inProgress: "warning",
completed: "connected",
failed: "error",
};

export function McpCard(props: {
server: string;
tool: string;
detail?: string | null;
status?: ToolCallStatus;
open?: boolean;
onToggle?: () => void;
}): JSX.Element {
const [open, setOpen] = createSignal(false);
const [localOpen, setLocalOpen] = createSignal(false);
const open = () => props.open ?? localOpen();
const toggle = () => (props.onToggle ? props.onToggle() : setLocalOpen((v) => !v));
const detail = () => props.detail ?? "";
const canExpand = () => hasHiddenDetail(detail(), 120);

Expand All @@ -21,7 +33,7 @@ export function McpCard(props: {
aria-expanded={open()}
aria-label={open() ? "Hide MCP call details" : "Show MCP call details"}
disabled={!canExpand()}
onClick={() => canExpand() && setOpen((v) => !v)}
onClick={() => canExpand() && toggle()}
>
<span class="pf-chat-line-chevron" aria-hidden="true">
<Show when={canExpand()} fallback={<span class="pf-chat-line-chevron-spacer" />}>
Expand All @@ -39,6 +51,17 @@ export function McpCard(props: {
{compactInline(detail(), 120)}
</span>
</Show>
<Show when={props.status}>
{(status) => (
<span class="pf-chat-line-trail">
<StatusPill
label={status() === "inProgress" ? "running" : status()}
intent={STATUS_INTENT[status()]}
pulsing={status() === "inProgress"}
/>
</span>
)}
</Show>
</button>
<Disclosure open={open()}>
<Show when={detail()}>
Expand Down
31 changes: 28 additions & 3 deletions src/components/chat/ToolUseCard.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { type JSX, Show, createSignal } from "solid-js";
import { compactInline, hasHiddenDetail } from "../../lib/chatDisplay";
import { IconChevronRight } from "../icons";
import { Disclosure } from "../ui";
import type { ToolCallStatus } from "../../stores/agentChat";
import { Disclosure, StatusPill, type StatusIntent } from "../ui";
import "./chat.css";

const STATUS_INTENT: Record<ToolCallStatus, StatusIntent> = {
inProgress: "warning",
completed: "connected",
failed: "error",
};

export function ToolUseCard(props: {
name: string;
detail?: string | null;
status?: ToolCallStatus;
/** Hoisted so the toggle survives virtualization remount, the same contract
* `CommandCard` and `ThinkingBubble` already use (#362). */
open?: boolean;
onToggle?: () => void;
}): JSX.Element {
const [open, setOpen] = createSignal(false);
const [localOpen, setLocalOpen] = createSignal(false);
const open = () => props.open ?? localOpen();
const toggle = () => (props.onToggle ? props.onToggle() : setLocalOpen((v) => !v));
const detail = () => props.detail ?? "";
const canExpand = () => hasHiddenDetail(detail(), 120);

Expand All @@ -20,7 +34,7 @@ export function ToolUseCard(props: {
aria-expanded={open()}
aria-label={open() ? "Hide tool details" : "Show tool details"}
disabled={!canExpand()}
onClick={() => canExpand() && setOpen((v) => !v)}
onClick={() => canExpand() && toggle()}
>
<span class="pf-chat-line-chevron" aria-hidden="true">
<Show when={canExpand()} fallback={<span class="pf-chat-line-chevron-spacer" />}>
Expand All @@ -36,6 +50,17 @@ export function ToolUseCard(props: {
{compactInline(detail(), 120)}
</span>
</Show>
<Show when={props.status}>
{(status) => (
<span class="pf-chat-line-trail">
<StatusPill
label={status() === "inProgress" ? "running" : status()}
intent={STATUS_INTENT[status()]}
pulsing={status() === "inProgress"}
/>
</span>
)}
</Show>
</button>
<Disclosure open={open()}>
<Show when={detail()}>
Expand Down
43 changes: 39 additions & 4 deletions src/stores/agentChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,25 @@ export type AgentTimelineItem =
* through the existing `FileChangeCard` until it closes. */
turnComplete: boolean;
}
| { type: "toolUse"; seq: number; itemId: string; name: string; detail: string | null }
| { type: "mcpToolCall"; seq: number; itemId: string; server: string; tool: string }
| {
type: "toolUse";
seq: number;
itemId: string;
name: string;
detail: string | null;
/** Absent on rows persisted before the parser learned to resolve
* non-Bash tools (#365), so a replayed history stays readable. */
status?: ToolCallStatus;
}
| {
type: "mcpToolCall";
seq: number;
itemId: string;
server: string;
tool: string;
detail?: string | null;
status?: ToolCallStatus;
}
| { type: "webSearch"; seq: number; itemId: string; query: string }
| { type: "plan"; seq: number; items: PlanItem[] }
| {
Expand All @@ -107,6 +124,10 @@ export type AgentApprovalQuestion = {
options: { label: string; description?: string }[];
};

/** Mirrors the wire event's status. Kept as its own alias so the two card
* types and the reducers cannot drift apart. */
export type ToolCallStatus = "inProgress" | "completed" | "failed";

export type AgentApproval = {
approvalId: string;
kind: "command" | "fileChange" | "toolUse" | "question";
Expand Down Expand Up @@ -931,7 +952,15 @@ function reduceMcpToolCall(
const timeline = chat.timeline.map((item) => {
if (item.type !== "mcpToolCall" || item.itemId !== event.itemId) return item;
matched = true;
return { ...item, server: event.server, tool: event.tool };
return {
...item,
server: event.server,
tool: event.tool,
// A completion carries the result; keep the arg summary when it does
// not, so resolving a row never blanks what it already showed.
detail: event.detail ?? item.detail,
status: event.status,
};
});
if (matched) return withTimeline(chat, timeline);
return withTimeline(chat, [
Expand All @@ -942,6 +971,8 @@ function reduceMcpToolCall(
itemId: event.itemId,
server: event.server,
tool: event.tool,
detail: event.detail,
status: event.status,
},
]);
}
Expand All @@ -958,7 +989,10 @@ function reduceToolUse(
return {
...item,
name: event.name,
detail: event.detail,
// A completion carries the result; keep the arg summary when it does not,
// so resolving a row never blanks what it already showed.
detail: event.detail ?? item.detail,
status: event.status,
};
});
if (matched) return withTimeline(chat, timeline);
Expand All @@ -970,6 +1004,7 @@ function reduceToolUse(
itemId: event.itemId,
name: event.name,
detail: event.detail,
status: event.status,
},
]);
}
Expand Down
1 change: 1 addition & 0 deletions src/stores/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const definitions = {
pikitLanes: { description: "Pi-kit lanes visibility in Settings (#274 slice 3, #288)" },
pikitContext: { description: "PickForge context surfaced in Pi sessions (#299 slice 4)" },
changesReview: { description: "In-chat changes receipt and Review-changes action (#231)" },
mcpToolDetail: { description: "MCP/tool row args, results and terminal status (#362, #365)" },
flatChatList: { description: "Flat chat-first sidebar sorted by state (#306 PR1)" },
orchestraBoard: { description: "Orchestra column-per-status board scaffold (#319, #196 PR1)" },
messageQueue: { description: "Queue messages while an agent turn is running (#357)" },
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/agentChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,74 @@ describe("agentChat store reducer", () => {
expect(rows[0].itemId).toBe("mcp-1");
});

it("carries an MCP call's status and detail, and resolves in place (#362)", async () => {
const { chatId, emit } = await startChat();

emit({
kind: "mcpToolCall",
itemId: "mcp-1",
server: "pickforge-lanes",
tool: "lanes_wait",
status: "inProgress",
detail: "run: run-7",
});
expect(timeline(chatId).find((item) => item.type === "mcpToolCall")).toMatchObject({
status: "inProgress",
detail: "run: run-7",
});

emit({
kind: "mcpToolCall",
itemId: "mcp-1",
server: "pickforge-lanes",
tool: "lanes_wait",
status: "completed",
detail: "lane finished",
});

const rows = timeline(chatId).filter((item) => item.type === "mcpToolCall");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ status: "completed", detail: "lane finished" });
});

it("keeps the arg summary when a completion carries no result (#362)", async () => {
// Resolving a row must never blank what it already showed.
const { chatId, emit } = await startChat();

emit({
kind: "mcpToolCall",
itemId: "mcp-1",
server: "srv",
tool: "do",
status: "inProgress",
detail: "a: 1",
});
emit({
kind: "mcpToolCall",
itemId: "mcp-1",
server: "srv",
tool: "do",
status: "completed",
detail: null,
});

expect(timeline(chatId).find((item) => item.type === "mcpToolCall")).toMatchObject({
status: "completed",
detail: "a: 1",
});
});

it("carries a generic tool's terminal status (#365)", async () => {
const { chatId, emit } = await startChat();

emit({ kind: "toolUse", itemId: "t1", name: "Task", status: "inProgress", detail: "scout" });
emit({ kind: "toolUse", itemId: "t1", name: "Task", status: "failed", detail: "boom" });

const rows = timeline(chatId).filter((item) => item.type === "toolUse");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ status: "failed", detail: "boom" });
});

it("still appends a distinct MCP call as its own row (#362)", async () => {
const { chatId, emit } = await startChat();

Expand Down
1 change: 1 addition & 0 deletions tests/unit/flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("flags", () => {
"pikitLanes",
"pikitContext",
"changesReview",
"mcpToolDetail",
"flatChatList",
"orchestraBoard",
"messageQueue",
Expand Down