diff --git a/src/lib/conversation.ts b/src/lib/conversation.ts index 2165c98c..38ad5822 100644 --- a/src/lib/conversation.ts +++ b/src/lib/conversation.ts @@ -28,13 +28,16 @@ export type PartReasoning = { }; /** - * A tool call's lifecycle. Six states, not three: a tool call is not only + * A tool call's lifecycle. Seven states, not three: a tool call is not only * running-or-finished, it can wait on human approval and that approval can be - * refused — a state distinct from an execution error. + * refused — a state distinct from an execution error. `waiting` is a call + * held on something outside it, such as work it started that is itself + * waiting; unlike `approval-requested`, the call is not the question. */ export type ToolTraceStatus = | "pending" | "running" + | "waiting" | "output-available" | "error" | "approval-requested" @@ -132,7 +135,7 @@ export type ToolApprovalRecord = { * alone answers every approval question: a call whose record is `pending` is * waiting on a person whatever `status` says, and one whose record is settled * is not, even if `status` still reads `approval-requested`. Without a record, - * `status === "approval-requested"` is the only sign of a wait. + * `status === "approval-requested"` is the only sign of a wait on a person. */ export type PartTool = PartToolTrace & { readonly approval?: ToolApprovalRecord; diff --git a/src/ui/tool-group.tsx b/src/ui/tool-group.tsx index b2155416..a5c55825 100644 --- a/src/ui/tool-group.tsx +++ b/src/ui/tool-group.tsx @@ -27,7 +27,7 @@ export type ToolGroupLabels = { * leaves out failed calls, which `failed` counts. */ readonly running: (count: number, done: number) => string; - /** None is queued or running, but some wait on a person. */ + /** None is queued or running, but some wait on a person or on other work. */ readonly held: (count: number, done: number) => string; /** Follows `running` or `held` while calls wait on a person. */ readonly waiting: (count: number) => string; @@ -122,13 +122,14 @@ export const ToolGroup = forwardRef( const active = statuses.filter( (status) => status === "pending" || status === "running", ).length; - const waiting = statuses.filter( + const asking = statuses.filter( (status) => status === "approval-requested", ).length; + const paused = statuses.filter((status) => status === "waiting").length; const failed = countFailedTools(part.tools); const count = part.tools.length; // Finished without failing, so each call counts once in the header. - const done = count - active - waiting - failed; + const done = count - active - asking - paused - failed; const rootRef = useRef(null); const focusInside = useRef(false); @@ -235,10 +236,10 @@ export const ToolGroup = forwardRef( {active > 0 ? labels.running(count, done) - : waiting > 0 + : asking + paused > 0 ? labels.held(count, done) : labels.ran(count)} - {waiting > 0 ? <> · {labels.waiting(waiting)} : null} + {asking > 0 ? <> · {labels.waiting(asking)} : null} {failed > 0 ? ( {" "} diff --git a/src/ui/tool.test.tsx b/src/ui/tool.test.tsx index 43913e58..4532f911 100644 --- a/src/ui/tool.test.tsx +++ b/src/ui/tool.test.tsx @@ -63,6 +63,7 @@ function click(element: Element | null) { const STATUSES: readonly [ToolTraceStatus, string, string][] = [ ["pending", "Queued", "lucide-circle-dashed"], ["running", "Running", "lucide-loader-circle"], + ["waiting", "Waiting", "lucide-circle-pause"], ["output-available", "Done", "lucide-check"], ["error", "Failed", "lucide-circle-alert"], ["approval-requested", "Needs approval", "lucide-shield-alert"], @@ -357,6 +358,22 @@ describe("ToolGroup", () => { held.unmount(); }); + test("a call held on other work is not counted as done", () => { + const mounted = mount( + , + ); + expect( + mounted.container.querySelector("[data-slot='tool-group-header']") + ?.textContent, + ).toBe("2 at once · 1 done"); + mounted.unmount(); + }); + test("a call waiting on a person is not counted as done", () => { const pending = { id: "x", diff --git a/src/ui/tool.tsx b/src/ui/tool.tsx index cccda5e2..76cdd38c 100644 --- a/src/ui/tool.tsx +++ b/src/ui/tool.tsx @@ -6,6 +6,7 @@ import { ChevronRight, CircleAlert, CircleDashed, + CirclePause, LoaderCircle, ShieldAlert, } from "lucide-react"; @@ -127,6 +128,7 @@ function ignoreDecision() {} const STATUS_LABEL: Record = { pending: "Queued", running: "Running", + waiting: "Waiting", "output-available": "Done", error: "Failed", "approval-requested": "Needs approval", @@ -153,6 +155,13 @@ function StatusIcon({ status }: { readonly status: ToolTraceStatus }) { )} /> ); + case "waiting": + return ( + + ); case "output-available": return ; case "error": diff --git a/stories/chat/tool.stories.tsx b/stories/chat/tool.stories.tsx index 39d46eda..103e61bb 100644 --- a/stories/chat/tool.stories.tsx +++ b/stories/chat/tool.stories.tsx @@ -42,6 +42,7 @@ function Column({ children }: { readonly children: React.ReactNode }) { const STATUSES: readonly ToolTraceStatus[] = [ "pending", "running", + "waiting", "output-available", "error", "approval-requested",