Skip to content

Commit 2bd011a

Browse files
committed
feat(tool): add a waiting status for a call held on other work
A call can be held on something outside it, such as a hand-off whose run is paused on its own approval. The only waiting status, approval-requested, means the call itself is the question, so a host had to mark such a call as asking and got an approval card with no decision behind it. waiting draws as a plain row with a pause icon and the word "Waiting", overridable through statusLabels. It folds into activity runs like any step that is not asking. A tool group counts it as neither done nor running and reads as held, as it does for a call waiting on a person.
1 parent 6fd1ebb commit 2bd011a

5 files changed

Lines changed: 39 additions & 8 deletions

File tree

‎src/lib/conversation.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ export type PartReasoning = {
2828
};
2929

3030
/**
31-
* A tool call's lifecycle. Six states, not three: a tool call is not only
31+
* A tool call's lifecycle. Seven states, not three: a tool call is not only
3232
* running-or-finished, it can wait on human approval and that approval can be
33-
* refused — a state distinct from an execution error.
33+
* refused — a state distinct from an execution error. `waiting` is a call
34+
* held on something outside it, such as work it started that is itself
35+
* waiting; unlike `approval-requested`, the call is not the question.
3436
*/
3537
export type ToolTraceStatus =
3638
| "pending"
3739
| "running"
40+
| "waiting"
3841
| "output-available"
3942
| "error"
4043
| "approval-requested"
@@ -132,7 +135,7 @@ export type ToolApprovalRecord = {
132135
* alone answers every approval question: a call whose record is `pending` is
133136
* waiting on a person whatever `status` says, and one whose record is settled
134137
* is not, even if `status` still reads `approval-requested`. Without a record,
135-
* `status === "approval-requested"` is the only sign of a wait.
138+
* `status === "approval-requested"` is the only sign of a wait on a person.
136139
*/
137140
export type PartTool = PartToolTrace & {
138141
readonly approval?: ToolApprovalRecord;

‎src/ui/tool-group.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type ToolGroupLabels = {
2727
* leaves out failed calls, which `failed` counts.
2828
*/
2929
readonly running: (count: number, done: number) => string;
30-
/** None is queued or running, but some wait on a person. */
30+
/** None is queued or running, but some wait on a person or on other work. */
3131
readonly held: (count: number, done: number) => string;
3232
/** Follows `running` or `held` while calls wait on a person. */
3333
readonly waiting: (count: number) => string;
@@ -122,13 +122,14 @@ export const ToolGroup = forwardRef<HTMLDivElement, ToolGroupProps>(
122122
const active = statuses.filter(
123123
(status) => status === "pending" || status === "running",
124124
).length;
125-
const waiting = statuses.filter(
125+
const asking = statuses.filter(
126126
(status) => status === "approval-requested",
127127
).length;
128+
const paused = statuses.filter((status) => status === "waiting").length;
128129
const failed = countFailedTools(part.tools);
129130
const count = part.tools.length;
130131
// Finished without failing, so each call counts once in the header.
131-
const done = count - active - waiting - failed;
132+
const done = count - active - asking - paused - failed;
132133

133134
const rootRef = useRef<HTMLDivElement | null>(null);
134135
const focusInside = useRef(false);
@@ -235,10 +236,10 @@ export const ToolGroup = forwardRef<HTMLDivElement, ToolGroupProps>(
235236
<span className="min-w-0 flex-1 truncate">
236237
{active > 0
237238
? labels.running(count, done)
238-
: waiting > 0
239+
: asking + paused > 0
239240
? labels.held(count, done)
240241
: labels.ran(count)}
241-
{waiting > 0 ? <> · {labels.waiting(waiting)}</> : null}
242+
{asking > 0 ? <> · {labels.waiting(asking)}</> : null}
242243
{failed > 0 ? (
243244
<span className="text-destructive">
244245
{" "}

‎src/ui/tool.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function click(element: Element | null) {
6363
const STATUSES: readonly [ToolTraceStatus, string, string][] = [
6464
["pending", "Queued", "lucide-circle-dashed"],
6565
["running", "Running", "lucide-loader-circle"],
66+
["waiting", "Waiting", "lucide-circle-pause"],
6667
["output-available", "Done", "lucide-check"],
6768
["error", "Failed", "lucide-circle-alert"],
6869
["approval-requested", "Needs approval", "lucide-shield-alert"],
@@ -357,6 +358,22 @@ describe("ToolGroup", () => {
357358
held.unmount();
358359
});
359360

361+
test("a call held on other work is not counted as done", () => {
362+
const mounted = mount(
363+
<ToolGroup
364+
part={batch([
365+
tool({ toolCallId: "a" }),
366+
tool({ toolCallId: "b", status: "waiting" }),
367+
])}
368+
/>,
369+
);
370+
expect(
371+
mounted.container.querySelector("[data-slot='tool-group-header']")
372+
?.textContent,
373+
).toBe("2 at once · 1 done");
374+
mounted.unmount();
375+
});
376+
360377
test("a call waiting on a person is not counted as done", () => {
361378
const pending = {
362379
id: "x",

‎src/ui/tool.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ChevronRight,
77
CircleAlert,
88
CircleDashed,
9+
CirclePause,
910
LoaderCircle,
1011
ShieldAlert,
1112
} from "lucide-react";
@@ -127,6 +128,7 @@ function ignoreDecision() {}
127128
const STATUS_LABEL: Record<ToolTraceStatus, string> = {
128129
pending: "Queued",
129130
running: "Running",
131+
waiting: "Waiting",
130132
"output-available": "Done",
131133
error: "Failed",
132134
"approval-requested": "Needs approval",
@@ -153,6 +155,13 @@ function StatusIcon({ status }: { readonly status: ToolTraceStatus }) {
153155
)}
154156
/>
155157
);
158+
case "waiting":
159+
return (
160+
<CirclePause
161+
aria-hidden
162+
className={cn(className, "text-muted-foreground")}
163+
/>
164+
);
156165
case "output-available":
157166
return <Check aria-hidden className={cn(className, "text-ok")} />;
158167
case "error":

‎stories/chat/tool.stories.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function Column({ children }: { readonly children: React.ReactNode }) {
4242
const STATUSES: readonly ToolTraceStatus[] = [
4343
"pending",
4444
"running",
45+
"waiting",
4546
"output-available",
4647
"error",
4748
"approval-requested",

0 commit comments

Comments
 (0)