Skip to content
Closed
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
9 changes: 6 additions & 3 deletions src/lib/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions src/ui/tool-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -122,13 +122,14 @@ export const ToolGroup = forwardRef<HTMLDivElement, ToolGroupProps>(
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<HTMLDivElement | null>(null);
const focusInside = useRef(false);
Expand Down Expand Up @@ -235,10 +236,10 @@ export const ToolGroup = forwardRef<HTMLDivElement, ToolGroupProps>(
<span className="min-w-0 flex-1 truncate">
{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 ? (
<span className="text-destructive">
{" "}
Expand Down
17 changes: 17 additions & 0 deletions src/ui/tool.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -357,6 +358,22 @@ describe("ToolGroup", () => {
held.unmount();
});

test("a call held on other work is not counted as done", () => {
const mounted = mount(
<ToolGroup
part={batch([
tool({ toolCallId: "a" }),
tool({ toolCallId: "b", status: "waiting" }),
])}
/>,
);
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",
Expand Down
9 changes: 9 additions & 0 deletions src/ui/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ChevronRight,
CircleAlert,
CircleDashed,
CirclePause,
LoaderCircle,
ShieldAlert,
} from "lucide-react";
Expand Down Expand Up @@ -127,6 +128,7 @@ function ignoreDecision() {}
const STATUS_LABEL: Record<ToolTraceStatus, string> = {
pending: "Queued",
running: "Running",
waiting: "Waiting",
"output-available": "Done",
error: "Failed",
"approval-requested": "Needs approval",
Expand All @@ -153,6 +155,13 @@ function StatusIcon({ status }: { readonly status: ToolTraceStatus }) {
)}
/>
);
case "waiting":
return (
<CirclePause
aria-hidden
className={cn(className, "text-muted-foreground")}
/>
);
case "output-available":
return <Check aria-hidden className={cn(className, "text-ok")} />;
case "error":
Expand Down
1 change: 1 addition & 0 deletions stories/chat/tool.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function Column({ children }: { readonly children: React.ReactNode }) {
const STATUSES: readonly ToolTraceStatus[] = [
"pending",
"running",
"waiting",
"output-available",
"error",
"approval-requested",
Expand Down
Loading