@parallelworks/ui 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -924,6 +924,19 @@ OpenAI-compatible transport sends them as the `X-Platform-Tools` header
924
924
  (`TOOLS_HEADER`), comma-separated and empty when every tool is off. A request
925
925
  without a tool choice sends no header.
926
926
 
927
+ A tool that changes something sets `asksFirst`, which the menu shows, and its
928
+ calls wait for the reader. The gateway streams the call, then a `toolApproval`
929
+ chunk (`toolCallId`, optional `expiresAt`) in place of a result, and ends the
930
+ reply there. Chat shows a confirm under the call with Allow, Deny and Deny
931
+ with feedback, which expires at `expiresAt`. The answer goes back through
932
+ `useChat().answerToolApproval` as a request with no new user turn:
933
+ `StreamRequest.resume` (`messageId`, `toolCallId`, `allowed`, optional
934
+ `feedback`), sent as `resume` in the body. The gateway's `toolResult` for the
935
+ call lands on the reply that paused, and the model's answer streams as a new
936
+ reply under it. In a shared conversation only the person whose message
937
+ started the turn may answer; others see whom it waits on, and a turn someone
938
+ else started says whose access its tools ran with.
939
+
927
940
  Tailwind v4 consumers add the chat rules (markdown rendering, streaming
928
941
  animations) and streamdown's classes next to the theme contract:
929
942
 
@@ -24,6 +24,7 @@ function createOpenAICompatibleStream(config) {
24
24
  messages: req.messages,
25
25
  stream: true
26
26
  };
27
+ if (req.resume && !useResponses) body["resume"] = req.resume;
27
28
  const res = await (config.fetch ?? globalThis.fetch.bind(globalThis))(url, {
28
29
  method: "POST",
29
30
  headers: requestHeaders(config.buildHeaders?.(req), req.tools),
@@ -1,6 +1,6 @@
1
1
  import type { ToolCall, ToolCallDelta } from '../../types.js';
2
2
  import type { PartDelta } from '../types.js';
3
- import type { ToolResultChunk } from './wire.js';
3
+ import type { ToolApprovalChunk, ToolResultChunk } from './wire.js';
4
4
  export type ParsedSSELine = {
5
5
  id?: string | undefined;
6
6
  messageId?: string | undefined;
@@ -8,6 +8,7 @@ export type ParsedSSELine = {
8
8
  reasoningContent?: string | undefined;
9
9
  toolCalls?: ToolCallDelta[] | undefined;
10
10
  toolResult?: ToolResultChunk | undefined;
11
+ toolApproval?: ToolApprovalChunk | undefined;
11
12
  finishReason?: string | null | undefined;
12
13
  model?: string | undefined;
13
14
  error?: string | undefined;
@@ -15,6 +15,7 @@ function parseSSELine(line) {
15
15
  reasoningContent: choice?.delta?.reasoning_content,
16
16
  toolCalls: choice?.delta?.tool_calls ?? void 0,
17
17
  toolResult: chunk.toolResult,
18
+ toolApproval: chunk.toolApproval,
18
19
  finishReason: choice?.finish_reason,
19
20
  model: chunk.model
20
21
  };
@@ -108,6 +109,25 @@ async function processSSEStream(reader, onContent, onToolCall, onError, onReason
108
109
  ...parsed.toolResult.isError ? { isError: true } : {}
109
110
  });
110
111
  }
112
+ if (parsed.toolApproval) {
113
+ const { toolCallId, expiresAt } = parsed.toolApproval;
114
+ answeredToolCalls.add(toolCallId);
115
+ onPart?.({
116
+ type: "tool_wait",
117
+ id: toolCallId
118
+ });
119
+ onPart?.({
120
+ type: "approval_request",
121
+ approval: {
122
+ kind: "approval",
123
+ id: toolCallId,
124
+ approvalKind: "confirm",
125
+ subject: "call",
126
+ canAllowDirectory: false,
127
+ ...expiresAt ? { expiresAt } : {}
128
+ }
129
+ });
130
+ }
111
131
  }
112
132
  }
113
133
  }
@@ -31,9 +31,14 @@ export interface CompletionChunk {
31
31
  created?: number;
32
32
  messageId?: string;
33
33
  toolResult?: ToolResultChunk;
34
+ toolApproval?: ToolApprovalChunk;
34
35
  choices?: CompletionChunkChoice[] | null;
35
36
  usage?: TokenUsage;
36
37
  }
38
+ export interface ToolApprovalChunk {
39
+ toolCallId: string;
40
+ expiresAt?: string;
41
+ }
37
42
  export interface ToolResultChunk {
38
43
  toolCallId: string;
39
44
  content: string;
@@ -21,6 +21,13 @@ export interface StreamRequest {
21
21
  allocation?: string | null;
22
22
  useResponses?: boolean;
23
23
  tools?: string[];
24
+ resume?: ToolApprovalAnswer;
25
+ }
26
+ export interface ToolApprovalAnswer {
27
+ messageId: string;
28
+ toolCallId: string;
29
+ allowed: boolean;
30
+ feedback?: string;
24
31
  }
25
32
  export interface StreamHandlers {
26
33
  onContent: (content: string) => void;
@@ -43,6 +50,9 @@ export type PartDelta = {
43
50
  type: 'tool_update';
44
51
  id: string;
45
52
  args: string;
53
+ } | {
54
+ type: 'tool_wait';
55
+ id: string;
46
56
  } | {
47
57
  type: 'tool_end';
48
58
  id: string;
@@ -133,6 +143,7 @@ export interface ChatToolOption {
133
143
  id: string;
134
144
  label: string;
135
145
  description?: string;
146
+ asksFirst?: boolean;
136
147
  }
137
148
  export interface ChatToolsAdapter {
138
149
  list(): Promise<ChatToolOption[]>;
@@ -34,6 +34,11 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
34
34
  const isTool = message.role === "tool";
35
35
  const otherAuthor = isUser && showAuthor && message.author && currentUsername && message.author.username !== currentUsername ? message.author : void 0;
36
36
  const isOtherUser = !!otherAuthor;
37
+ const asker = !isUser && showAuthor && currentUsername ? turnAuthor(message, allMessages) : void 0;
38
+ const otherAsker = asker && asker.username !== currentUsername ? asker : void 0;
39
+ const askerName = otherAsker ? otherAsker.name || otherAsker.username : void 0;
40
+ const partsMessage = askerName ? waitingOn(message, askerName) : message;
41
+ const approvalAnswer = askerName ? void 0 : onApprovalAnswer;
37
42
  const isRightAligned = isUser && !isOtherUser;
38
43
  const hasError = !!message.error;
39
44
  const hasPastes = !!message.pastes?.length;
@@ -105,9 +110,9 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
105
110
  return /* @__PURE__ */ jsxs("div", {
106
111
  className: cx("group py-3 w-full", !flush && "px-4"),
107
112
  children: [/* @__PURE__ */ jsx(AgentMessageParts, {
108
- message,
113
+ message: partsMessage,
109
114
  isStreaming,
110
- onApprovalAnswer
115
+ onApprovalAnswer: approvalAnswer
111
116
  }), !isUser && !isStreaming && (said || onRegenerate || meta) && /* @__PURE__ */ jsxs("div", {
112
117
  className: "chat-actions -ml-1.5 mt-2 flex items-center gap-0.5",
113
118
  children: [
@@ -185,15 +190,15 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
185
190
  className: "mb-2",
186
191
  children: /* @__PURE__ */ jsx("div", {
187
192
  className: "flex items-center gap-x-1.5 text-sm theme-muted-text",
188
- children: /* @__PURE__ */ jsx("span", { children: message.reasoningDuration ? tThinking.thoughtFor(formatDuration(message.reasoningDuration)) : tThinking.ranToolCalls(toolCallCount) })
193
+ children: /* @__PURE__ */ jsx("span", { children: message.reasoningDuration ? tThinking.thoughtFor(formatDuration(message.reasoningDuration)) : askerName ? tThinking.ranToolCallsAs(toolCallCount, askerName) : tThinking.ranToolCalls(toolCallCount) })
189
194
  })
190
195
  }),
191
196
  !isUser && hasParts && /* @__PURE__ */ jsx("div", {
192
197
  className: "mb-1",
193
198
  children: /* @__PURE__ */ jsx(AgentMessageParts, {
194
- message,
199
+ message: partsMessage,
195
200
  isStreaming,
196
- onApprovalAnswer
201
+ onApprovalAnswer: approvalAnswer
197
202
  })
198
203
  }),
199
204
  otherAuthor && /* @__PURE__ */ jsx("span", {
@@ -294,5 +299,25 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
294
299
  });
295
300
  }
296
301
  var ChatMessage_default = memo(ChatMessage);
302
+ /** The author of the user message that started the turn a reply belongs to.
303
+ * A reply that resumes after an approval hangs under the reply it resumes. */
304
+ function turnAuthor(message, allMessages) {
305
+ let parentId = message.parentId;
306
+ for (let hops = 0; parentId && hops < allMessages.length; hops++) {
307
+ const parent = allMessages.find((m) => m.id === parentId);
308
+ if (parent?.role !== "assistant") return parent?.role === "user" ? parent.author : void 0;
309
+ parentId = parent.parentId;
310
+ }
311
+ }
312
+ function waitingOn(message, name) {
313
+ if (!message.parts?.some((p) => p.kind === "approval")) return message;
314
+ return {
315
+ ...message,
316
+ parts: message.parts.map((p) => p.kind === "approval" ? {
317
+ ...p,
318
+ waitingOn: name
319
+ } : p)
320
+ };
321
+ }
297
322
  //#endregion
298
323
  export { ChatMessage_default as default };
@@ -3,13 +3,13 @@ import { useChat } from "../core/ChatProvider.js";
3
3
  import BlockedGroupBanner from "./BlockedGroupBanner.js";
4
4
  import { ComposerControls, ComposerUsage, ConnectToolsLink } from "./ComposerChrome.js";
5
5
  import ProviderIssueBanner from "./ProviderIssueBanner.js";
6
- import { ShareButton } from "./ShareDialog.js";
6
+ import { ShareButton, ToolResultsSharedNote } from "./ShareDialog.js";
7
7
  import SessionView from "./SessionView.js";
8
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  import { useCallback, useEffect, useMemo, useRef } from "react";
10
10
  //#region src/ai/components/ChatThread.tsx
11
11
  function ChatThread({ conversationId }) {
12
- const { adapter, currentUser, currentConversation, loadConversation, loadProviders, sendMessage, retryMessage, isLoadingConversation, navigateToBranch, queuedMessages, removeQueuedMessage } = useChat();
12
+ const { adapter, currentUser, currentConversation, loadConversation, loadProviders, sendMessage, retryMessage, answerToolApproval, tools, isLoadingConversation, navigateToBranch, queuedMessages, removeQueuedMessage } = useChat();
13
13
  const { slots, strings } = useChatConfig();
14
14
  const requestedConversationIdRef = useRef(null);
15
15
  useEffect(() => {
@@ -52,6 +52,7 @@ function ChatThread({ conversationId }) {
52
52
  onRetry: (id) => void retryMessage(id)
53
53
  } : {},
54
54
  onNavigateBranch: navigateToBranch,
55
+ ...canWrite ? { onApprovalAnswer: answerToolApproval } : {},
55
56
  showAuthor,
56
57
  currentUsername: currentUser.username,
57
58
  loading: isLoadingConversation && !currentConversation,
@@ -60,7 +61,7 @@ function ChatThread({ conversationId }) {
60
61
  composer: {
61
62
  disabled: isLoadingConversation || !canWrite,
62
63
  ...canWrite ? {} : { placeholder: strings.thread.viewOnly },
63
- settingsLeft: /* @__PURE__ */ jsxs(Fragment, { children: [currentConversation && currentConversation.isOwner !== false && /* @__PURE__ */ jsx(ShareButton, { conversationId }), /* @__PURE__ */ jsx(ConnectToolsLink, {})] }),
64
+ settingsLeft: /* @__PURE__ */ jsxs(Fragment, { children: [currentConversation && currentConversation.isOwner !== false ? /* @__PURE__ */ jsx(ShareButton, { conversationId }) : tools.length > 0 && /* @__PURE__ */ jsx(ToolResultsSharedNote, {}), /* @__PURE__ */ jsx(ConnectToolsLink, {})] }),
64
65
  settingsRight: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ComposerUsage, { children: slots.composerUsage }), /* @__PURE__ */ jsx(ComposerControls, {})] })
65
66
  }
66
67
  });
@@ -46,12 +46,23 @@ function RowSwitch() {
46
46
  children: /* @__PURE__ */ jsx("span", { className: "size-3 rounded-full bg-white shadow-sm transition-transform duration-150 group-data-checked:translate-x-3 rtl:group-data-checked:-translate-x-3 motion-reduce:transition-none" })
47
47
  });
48
48
  }
49
+ function AsksFirst({ tool }) {
50
+ const t = useChatConfig().strings.composerMenu;
51
+ if (!tool.asksFirst) return null;
52
+ return /* @__PURE__ */ jsx("span", {
53
+ className: "ml-1.5 shrink-0 text-xs theme-muted-text",
54
+ children: t.asksFirst
55
+ });
56
+ }
49
57
  function ToolText({ tool, descriptionId }) {
50
58
  return /* @__PURE__ */ jsxs("span", {
51
59
  className: "min-w-0 flex-1",
52
- children: [/* @__PURE__ */ jsx("span", {
53
- className: "block truncate",
54
- children: tool.label
60
+ children: [/* @__PURE__ */ jsxs("span", {
61
+ className: "flex min-w-0 items-baseline",
62
+ children: [/* @__PURE__ */ jsx("span", {
63
+ className: "truncate",
64
+ children: tool.label
65
+ }), /* @__PURE__ */ jsx(AsksFirst, { tool })]
55
66
  }), tool.description && /* @__PURE__ */ jsx("span", {
56
67
  id: descriptionId,
57
68
  className: "block truncate text-xs theme-muted-text",
@@ -106,9 +117,9 @@ function ToolsModalRow({ tool, enabled, onChange }) {
106
117
  className: "flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-(--theme-hover)",
107
118
  children: [/* @__PURE__ */ jsxs("span", {
108
119
  className: "min-w-0 flex-1",
109
- children: [/* @__PURE__ */ jsx("span", {
110
- className: "block text-sm",
111
- children: tool.label
120
+ children: [/* @__PURE__ */ jsxs("span", {
121
+ className: "flex items-baseline text-sm",
122
+ children: [tool.label, /* @__PURE__ */ jsx(AsksFirst, { tool })]
112
123
  }), tool.description && /* @__PURE__ */ jsx("span", {
113
124
  id: descriptionId,
114
125
  className: "block text-xs theme-muted-text",
@@ -8,4 +8,7 @@ export declare function ShareButton({ conversationId, disabled, }: {
8
8
  conversationId: string;
9
9
  disabled?: boolean;
10
10
  }): import("react").JSX.Element | null;
11
+ /** Says that tool results in a shared conversation are visible to the
12
+ * people it is shared with. */
13
+ export declare function ToolResultsSharedNote(): import("react").JSX.Element;
11
14
  export {};
@@ -249,21 +249,57 @@ function ShareDialog({ conversationId, isOpen, onClose }) {
249
249
  }
250
250
  function ShareButton({ conversationId, disabled = false }) {
251
251
  const { strings } = useChatConfig();
252
- const { adapter } = useChat();
252
+ const { adapter, tools } = useChat();
253
253
  const [isOpen, setIsOpen] = useState(false);
254
+ const shared = useShared(conversationId, tools.length > 0 && !isOpen);
254
255
  if (!adapter.sharing) return null;
255
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
256
- type: "button",
257
- onClick: () => setIsOpen(true),
258
- disabled,
259
- className: "flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] theme-muted-text transition-colors hover:theme-muted-panel hover:theme-text disabled:opacity-50",
260
- title: strings.shareConversation.shareButton,
261
- children: [/* @__PURE__ */ jsx(ShareIcon, { className: "h-3 w-3" }), strings.shareConversation.shareButton]
262
- }), /* @__PURE__ */ jsx(ShareDialog, {
256
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
257
+ /* @__PURE__ */ jsxs("button", {
258
+ type: "button",
259
+ onClick: () => setIsOpen(true),
260
+ disabled,
261
+ className: "flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] theme-muted-text transition-colors hover:theme-muted-panel hover:theme-text disabled:opacity-50",
262
+ title: strings.shareConversation.shareButton,
263
+ children: [/* @__PURE__ */ jsx(ShareIcon, { className: "h-3 w-3" }), strings.shareConversation.shareButton]
264
+ }),
265
+ shared && /* @__PURE__ */ jsx(ToolResultsSharedNote, {}),
266
+ /* @__PURE__ */ jsx(ShareDialog, {
267
+ conversationId,
268
+ isOpen,
269
+ onClose: () => setIsOpen(false)
270
+ })
271
+ ] });
272
+ }
273
+ /** Says that tool results in a shared conversation are visible to the
274
+ * people it is shared with. */
275
+ function ToolResultsSharedNote() {
276
+ const { strings } = useChatConfig();
277
+ return /* @__PURE__ */ jsx("span", {
278
+ className: "text-[11px] theme-muted-text",
279
+ children: strings.shareConversation.toolResultsShared
280
+ });
281
+ }
282
+ /** Whether anyone else can read the conversation, checked while `check` is
283
+ * true: tool results in it are visible to them. */
284
+ function useShared(conversationId, check) {
285
+ const { adapter } = useChat();
286
+ const sharing = adapter.sharing;
287
+ const [shared, setShared] = useState(false);
288
+ useEffect(() => {
289
+ if (!check || !sharing) return;
290
+ let current = true;
291
+ sharing.listPermissions(conversationId).then((permissions) => {
292
+ if (current) setShared(permissions.length > 0);
293
+ }, () => {});
294
+ return () => {
295
+ current = false;
296
+ };
297
+ }, [
263
298
  conversationId,
264
- isOpen,
265
- onClose: () => setIsOpen(false)
266
- })] });
299
+ check,
300
+ sharing
301
+ ]);
302
+ return shared;
267
303
  }
268
304
  //#endregion
269
- export { ShareButton, ShareDialog as default };
305
+ export { ShareButton, ToolResultsSharedNote, ShareDialog as default };
@@ -19,6 +19,7 @@ import { chatProseClasses } from "../../ui/prose.js";
19
19
  import { Reasoning, ReasoningBody } from "../Reasoning.js";
20
20
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
21
21
  import { memo, useEffect, useId, useMemo, useState } from "react";
22
+ import { DateTime } from "luxon";
22
23
  //#region src/ai/components/agent/AgentMessageParts.tsx
23
24
  var COLLAPSED_RESULT_LINES = 12;
24
25
  function LineSpans({ text, tokens, emphasis }) {
@@ -207,6 +208,13 @@ function ToolStatusDot({ part }) {
207
208
  color: "var(--theme-muted-text-color)"
208
209
  })
209
210
  });
211
+ if (part.status === "waiting") return /* @__PURE__ */ jsx(StatusMark, {
212
+ status: t.approvalLabel,
213
+ children: /* @__PURE__ */ jsx("span", {
214
+ className: "leading-none text-(--theme-accent)",
215
+ children: "●"
216
+ })
217
+ });
210
218
  if (part.status === "error") return /* @__PURE__ */ jsx(StatusMark, {
211
219
  status: t.failed,
212
220
  children: /* @__PURE__ */ jsx("span", {
@@ -271,7 +279,7 @@ function ToolCallBlock({ part }) {
271
279
  children: fullToolArgs(header.arg, part.args)
272
280
  }), /* @__PURE__ */ jsx(ResultBody, { part })]
273
281
  }),
274
- !isDiff && !expanded && /* @__PURE__ */ jsx("div", {
282
+ !isDiff && !expanded && part.status !== "waiting" && /* @__PURE__ */ jsx("div", {
275
283
  className: cx("ml-5 mt-0.5 truncate text-[13px]", part.status === "error" ? "text-(--theme-error-text)" : "theme-muted-text"),
276
284
  children: summary
277
285
  }),
@@ -292,32 +300,38 @@ function decisionLabel(d, t) {
292
300
  if (d.by === "auto-reviewer") return t.deniedByReviewer;
293
301
  return d.name ? t.deniedBy(d.name) : t.denied;
294
302
  case "expired": return t.approvalExpired;
295
- case "timed_out": return t.approvalTimedOut;
296
- case "failed": return t.reviewFailed;
303
+ case "timed_out": return d.by === "auto-reviewer" ? t.reviewTimedOut : t.approvalTimedOut;
304
+ case "failed": return d.by === "auto-reviewer" ? t.reviewFailed : t.denied;
297
305
  }
298
306
  }
299
307
  function DecisionLine({ decision }) {
300
308
  const { strings } = useChatConfig();
301
309
  return /* @__PURE__ */ jsxs("div", {
302
- className: "ml-5 mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-1 text-xs theme-muted-text",
310
+ className: "ml-5 mt-0.5 text-xs theme-muted-text",
303
311
  "data-testid": "tool-decision",
304
- children: [
305
- /* @__PURE__ */ jsx("span", { children: decisionLabel(decision, strings.agentTranscript) }),
306
- decision.at && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
307
- "aria-hidden": "true",
308
- children: "·"
309
- }), /* @__PURE__ */ jsx(RelativeTime, {
310
- value: decision.at,
311
- className: "shrink-0"
312
- })] }),
313
- decision.note && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
314
- "aria-hidden": "true",
315
- children: "·"
316
- }), /* @__PURE__ */ jsx("span", {
317
- className: "min-w-0 max-w-full truncate",
318
- children: decision.note
319
- })] })
320
- ]
312
+ children: [/* @__PURE__ */ jsxs("div", {
313
+ className: "flex min-w-0 flex-wrap items-baseline gap-x-1",
314
+ children: [
315
+ /* @__PURE__ */ jsx("span", { children: decisionLabel(decision, strings.agentTranscript) }),
316
+ decision.at && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
317
+ "aria-hidden": "true",
318
+ children: "·"
319
+ }), /* @__PURE__ */ jsx(RelativeTime, {
320
+ value: decision.at,
321
+ className: "shrink-0"
322
+ })] }),
323
+ decision.note && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
324
+ "aria-hidden": "true",
325
+ children: "·"
326
+ }), /* @__PURE__ */ jsx("span", {
327
+ className: "min-w-0 max-w-full truncate",
328
+ children: decision.note
329
+ })] })
330
+ ]
331
+ }), decision.reason && /* @__PURE__ */ jsx("p", {
332
+ className: "mt-0.5 line-clamp-2",
333
+ children: decision.reason
334
+ })]
321
335
  });
322
336
  }
323
337
  /** Drawn the way the TUI draws a delegated Task: a tool-shaped header
@@ -384,11 +398,31 @@ function SubagentCard({ part }) {
384
398
  })]
385
399
  });
386
400
  }
401
+ /** Whether the ISO time has passed, turning true when it does. */
402
+ function useExpired(expiresAt) {
403
+ const [expired, setExpired] = useState(() => !!expiresAt && DateTime.fromISO(expiresAt) <= DateTime.now());
404
+ useEffect(() => {
405
+ if (!expiresAt || expired) return;
406
+ let timer;
407
+ const arm = () => {
408
+ const left = DateTime.fromISO(expiresAt).diffNow().toMillis();
409
+ if (left <= 0) {
410
+ setExpired(true);
411
+ return;
412
+ }
413
+ timer = setTimeout(arm, Math.min(left, 864e5));
414
+ };
415
+ arm();
416
+ return () => clearTimeout(timer);
417
+ }, [expiresAt, expired]);
418
+ return expired;
419
+ }
387
420
  function ConfirmApproval({ part, onAnswer }) {
388
421
  const { strings } = useChatConfig();
389
422
  const { common } = useStrings();
390
423
  const t = strings.agentTranscript;
391
424
  const [feedback, setFeedback] = useState(null);
425
+ const expired = useExpired(part.expiresAt);
392
426
  const answer = (a) => onAnswer?.(part.id, a);
393
427
  const [allowLabel, denyLabel] = {
394
428
  command: [t.allowOnce, t.deny],
@@ -405,9 +439,12 @@ function ConfirmApproval({ part, onAnswer }) {
405
439
  className: "text-xs theme-muted-text",
406
440
  children: part.reason
407
441
  }),
408
- !onAnswer ? /* @__PURE__ */ jsx("div", {
442
+ expired ? /* @__PURE__ */ jsx("div", {
409
443
  className: "text-xs theme-muted-text",
410
- children: t.approvalPending
444
+ children: t.approvalRequestExpired
445
+ }) : !onAnswer ? /* @__PURE__ */ jsx("div", {
446
+ className: "text-xs theme-muted-text",
447
+ children: part.waitingOn ? t.approvalWaitingOn(part.waitingOn) : t.approvalPending
411
448
  }) : feedback === null ? /* @__PURE__ */ jsxs("div", {
412
449
  className: "flex flex-wrap gap-2",
413
450
  children: [
@@ -787,7 +824,9 @@ function buildRenderItems(parts) {
787
824
  emitThoughts(run.slice(end));
788
825
  run = [];
789
826
  };
827
+ const decided = new Set(parts.flatMap((p) => p.kind === "tool_call" && p.decision ? [p.id] : []));
790
828
  parts.forEach((part, index) => {
829
+ if (part.kind === "approval" && part.resolved && decided.has(part.id)) return;
791
830
  if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && isGroupableTool(part.name)) {
792
831
  run.push({
793
832
  part,
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { ChatAdapter, ChatToolOption } from '../adapter/types.js';
3
- import type { AttachmentMeta, ChatModel, ChatNavigation, ChatUser, Conversation, ConversationSummary, ChatMessage as Message, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
3
+ import type { ApprovalAnswerValue, AttachmentMeta, ChatModel, ChatNavigation, ChatUser, Conversation, ConversationSummary, ChatMessage as Message, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
4
4
  import { type ChatUIConfig } from './config.js';
5
5
  import { type SidebarPresentation, type SidebarState } from './sidebarState.js';
6
6
  interface ChatContextValue {
@@ -41,6 +41,8 @@ interface ChatContextValue {
41
41
  deleteConversation: (id: string) => Promise<boolean>;
42
42
  sendMessage: (content: string, attachmentIds?: string[], parentId?: string, attachmentMeta?: AttachmentMeta) => Promise<boolean>;
43
43
  retryMessage: (messageId: string) => Promise<boolean>;
44
+ /** Answers a tool call waiting for approval, resuming the reply it paused. */
45
+ answerToolApproval: (approvalId: string, answer: ApprovalAnswerValue) => Promise<void>;
44
46
  setSelectedProvider: (providerId: string | null) => void;
45
47
  setSelectedAllocation: (allocation: string | null) => void;
46
48
  setToolEnabled: (id: string, enabled: boolean) => void;