@parallelworks/ui 0.75.1 → 0.77.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,14 @@ 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;
31
+ tool?: string;
24
32
  }
25
33
  export interface StreamHandlers {
26
34
  onContent: (content: string) => void;
@@ -43,6 +51,9 @@ export type PartDelta = {
43
51
  type: 'tool_update';
44
52
  id: string;
45
53
  args: string;
54
+ } | {
55
+ type: 'tool_wait';
56
+ id: string;
46
57
  } | {
47
58
  type: 'tool_end';
48
59
  id: string;
@@ -133,6 +144,7 @@ export interface ChatToolOption {
133
144
  id: string;
134
145
  label: string;
135
146
  description?: string;
147
+ asksFirst?: boolean;
136
148
  }
137
149
  export interface ChatToolsAdapter {
138
150
  list(): Promise<ChatToolOption[]>;
@@ -0,0 +1,2 @@
1
+ /** An agent session's working line for the provider's reply, naming its current step. */
2
+ export default function ChatActivity(): import("react").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { toolHeaderFor } from "../agent/toolfmt.js";
2
+ import { useChatConfig } from "../core/config.js";
3
+ import { useChat, useChatStream } from "../core/ChatProvider.js";
4
+ import ActivityLine from "./agent/ActivityLine.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ import { useEffect, useState } from "react";
7
+ import { DateTime } from "luxon";
8
+ //#region src/ai/components/ChatActivity.tsx
9
+ /** An agent session's working line for the provider's reply, naming its current step. */
10
+ function ChatActivity() {
11
+ const { isStreaming, tools } = useChat();
12
+ const { streamingParts } = useChatStream();
13
+ const { strings } = useChatConfig();
14
+ const [startedAt, setStartedAt] = useState(null);
15
+ useEffect(() => {
16
+ setStartedAt(isStreaming ? DateTime.now().toUTC().toISO() : null);
17
+ }, [isStreaming]);
18
+ if (!isStreaming) return null;
19
+ const waiting = streamingParts.some((p) => p.kind === "approval" && !p.resolved);
20
+ const running = streamingParts.findLast((p) => p.kind === "tool_call" && p.status === "running");
21
+ let word;
22
+ if (waiting) word = strings.activity.waitingForAnswer;
23
+ else if (running?.kind === "tool_call") word = tools.find((t) => t.id === running.name)?.label ?? toolHeaderFor(running.name).label;
24
+ return /* @__PURE__ */ jsx(ActivityLine, {
25
+ testId: "chat-activity",
26
+ startedAt,
27
+ word
28
+ });
29
+ }
30
+ //#endregion
31
+ export { ChatActivity as default };
@@ -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",
@@ -4,6 +4,7 @@ import DragOverlay from "./DragOverlay.js";
4
4
  import ChatInput from "./ChatInput.js";
5
5
  import { escapeClosesLayer } from "../core/useKeyboardShortcuts.js";
6
6
  import ChatMessageList from "./ChatMessageList.js";
7
+ import ChatActivity from "./ChatActivity.js";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  import { useCallback, useEffect, useRef, useState } from "react";
9
10
  //#region src/ai/components/SessionView.tsx
@@ -36,9 +37,11 @@ function SessionView({ sessionKey, messages, allMessages, streamingMessage, queu
36
37
  inputRef.current?.addFiles(files);
37
38
  }, []);
38
39
  const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped });
40
+ const activity = composer?.activity ?? (!turn && running ? /* @__PURE__ */ jsx(ChatActivity, {}) : void 0);
39
41
  const input = /* @__PURE__ */ jsx(ChatInput, {
40
42
  ref: inputRef,
41
43
  ...composer,
44
+ activity,
42
45
  onSend,
43
46
  conversationId: sessionKey,
44
47
  disabled: loading || !!composer?.disabled,
@@ -87,7 +90,7 @@ function SessionView({ sessionKey, messages, allMessages, streamingMessage, queu
87
90
  ...footer ? { footer } : {},
88
91
  ...label ? { label } : {},
89
92
  ...flush ? { flush } : {},
90
- ...workingIndicator !== void 0 ? { workingIndicator } : {},
93
+ workingIndicator: workingIndicator ?? !activity,
91
94
  inputElement: input
92
95
  }),
93
96
  error && input
@@ -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 };
@@ -20,7 +20,7 @@ type RenderItem = {
20
20
  kind: 'rollup';
21
21
  members: RunMember[];
22
22
  };
23
- export declare function buildRenderItems(parts: MessagePart[]): RenderItem[];
23
+ export declare function buildRenderItems(parts: MessagePart[], hostDrawn?: (toolName: string) => boolean): RenderItem[];
24
24
  export default function AgentMessageParts({ message, isStreaming, onApprovalAnswer, }: {
25
25
  message: PartRendererProps['message'];
26
26
  isStreaming?: boolean;
@@ -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", {
@@ -224,7 +232,10 @@ function ToolStatusDot({ part }) {
224
232
  }
225
233
  /** A part's toggle row: its status mark and name, across the transcript's width. */
226
234
  var rowSummaryClasses = "min-h-6 w-full";
227
- function ToolCallBlock({ part }) {
235
+ /** A call's row: its status and signature, opening to its full arguments and
236
+ * result. `children` is a host's drawing of the call, in place of the
237
+ * one-line summary. */
238
+ function ToolCallBlock({ part, children }) {
228
239
  const { strings } = useChatConfig();
229
240
  const t = strings.agentTranscript;
230
241
  const [expanded, setExpanded] = useState(false);
@@ -271,7 +282,11 @@ function ToolCallBlock({ part }) {
271
282
  children: fullToolArgs(header.arg, part.args)
272
283
  }), /* @__PURE__ */ jsx(ResultBody, { part })]
273
284
  }),
274
- !isDiff && !expanded && /* @__PURE__ */ jsx("div", {
285
+ children && /* @__PURE__ */ jsx("div", {
286
+ className: "ml-5 mt-1.5 min-w-0",
287
+ children
288
+ }),
289
+ !children && !isDiff && !expanded && part.status !== "waiting" && /* @__PURE__ */ jsx("div", {
275
290
  className: cx("ml-5 mt-0.5 truncate text-[13px]", part.status === "error" ? "text-(--theme-error-text)" : "theme-muted-text"),
276
291
  children: summary
277
292
  }),
@@ -390,11 +405,31 @@ function SubagentCard({ part }) {
390
405
  })]
391
406
  });
392
407
  }
408
+ /** Whether the ISO time has passed, turning true when it does. */
409
+ function useExpired(expiresAt) {
410
+ const [expired, setExpired] = useState(() => !!expiresAt && DateTime.fromISO(expiresAt) <= DateTime.now());
411
+ useEffect(() => {
412
+ if (!expiresAt || expired) return;
413
+ let timer;
414
+ const arm = () => {
415
+ const left = DateTime.fromISO(expiresAt).diffNow().toMillis();
416
+ if (left <= 0) {
417
+ setExpired(true);
418
+ return;
419
+ }
420
+ timer = setTimeout(arm, Math.min(left, 864e5));
421
+ };
422
+ arm();
423
+ return () => clearTimeout(timer);
424
+ }, [expiresAt, expired]);
425
+ return expired;
426
+ }
393
427
  function ConfirmApproval({ part, onAnswer }) {
394
428
  const { strings } = useChatConfig();
395
429
  const { common } = useStrings();
396
430
  const t = strings.agentTranscript;
397
431
  const [feedback, setFeedback] = useState(null);
432
+ const expired = useExpired(part.expiresAt);
398
433
  const answer = (a) => onAnswer?.(part.id, a);
399
434
  const [allowLabel, denyLabel] = {
400
435
  command: [t.allowOnce, t.deny],
@@ -411,9 +446,12 @@ function ConfirmApproval({ part, onAnswer }) {
411
446
  className: "text-xs theme-muted-text",
412
447
  children: part.reason
413
448
  }),
414
- !onAnswer ? /* @__PURE__ */ jsx("div", {
449
+ expired ? /* @__PURE__ */ jsx("div", {
415
450
  className: "text-xs theme-muted-text",
416
- children: t.approvalPending
451
+ children: t.approvalRequestExpired
452
+ }) : !onAnswer ? /* @__PURE__ */ jsx("div", {
453
+ className: "text-xs theme-muted-text",
454
+ children: part.waitingOn ? t.approvalWaitingOn(part.waitingOn) : t.approvalPending
417
455
  }) : feedback === null ? /* @__PURE__ */ jsxs("div", {
418
456
  className: "flex flex-wrap gap-2",
419
457
  children: [
@@ -704,6 +742,21 @@ function TextBlock({ part, isStreaming }) {
704
742
  }
705
743
  function PartDispatch(props) {
706
744
  const { slots } = useChatConfig();
745
+ const part = props.part;
746
+ const Tool = part.kind === "tool_call" ? slots.toolRenderers?.[part.name] : void 0;
747
+ if (Tool && part.kind === "tool_call") {
748
+ const approval = props.message.parts?.find((p) => p.kind === "approval" && p.id === part.id);
749
+ return /* @__PURE__ */ jsx(ToolCallBlock, {
750
+ part,
751
+ children: /* @__PURE__ */ jsx(Tool, {
752
+ part,
753
+ message: props.message,
754
+ ...approval ? { approval } : {},
755
+ ...props.isStreaming !== void 0 ? { isStreaming: props.isStreaming } : {},
756
+ ...props.onApprovalAnswer ? { onApprovalAnswer: props.onApprovalAnswer } : {}
757
+ })
758
+ });
759
+ }
707
760
  const Override = slots.partRenderers?.[props.part.kind];
708
761
  if (Override) return /* @__PURE__ */ jsx(Override, { ...props });
709
762
  return /* @__PURE__ */ jsx(BuiltinPart, {
@@ -752,7 +805,7 @@ function mergeReasoning(parts) {
752
805
  ...durationMs > 0 ? { durationMs } : {}
753
806
  };
754
807
  }
755
- function buildRenderItems(parts) {
808
+ function buildRenderItems(parts, hostDrawn = () => false) {
756
809
  const items = [];
757
810
  let run = [];
758
811
  const emitThoughts = (members) => {
@@ -793,8 +846,12 @@ function buildRenderItems(parts) {
793
846
  emitThoughts(run.slice(end));
794
847
  run = [];
795
848
  };
849
+ const decided = new Set(parts.flatMap((p) => p.kind === "tool_call" && p.decision ? [p.id] : []));
850
+ const drawnByHost = new Set(parts.flatMap((p) => p.kind === "tool_call" && hostDrawn(p.name) ? [p.id] : []));
796
851
  parts.forEach((part, index) => {
797
- if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && isGroupableTool(part.name)) {
852
+ if (part.kind === "approval" && part.resolved && decided.has(part.id)) return;
853
+ if (part.kind === "approval" && drawnByHost.has(part.id)) return;
854
+ if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && !drawnByHost.has(part.id) && isGroupableTool(part.name)) {
798
855
  run.push({
799
856
  part,
800
857
  index
@@ -838,7 +895,9 @@ function RollupGroup({ members, message }) {
838
895
  }
839
896
  function AgentMessageParts({ message, isStreaming, onApprovalAnswer }) {
840
897
  const parts = message.parts ?? [];
841
- const items = useMemo(() => buildRenderItems(parts), [parts]);
898
+ const { slots } = useChatConfig();
899
+ const toolRenderers = slots.toolRenderers;
900
+ const items = useMemo(() => buildRenderItems(parts, (name) => !!toolRenderers?.[name]), [parts, toolRenderers]);
842
901
  const lastIndex = parts.length - 1;
843
902
  return /* @__PURE__ */ jsx("div", {
844
903
  className: "w-full",
@@ -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;
@@ -44,6 +44,20 @@ function heldSendArgs(held) {
44
44
  preceding.length > 0 ? preceding : void 0
45
45
  ];
46
46
  }
47
+ /** The wire history for the messages shown, oldest first. */
48
+ function historyMessages(messages, selectedProvider) {
49
+ return messages.filter((m) => m.content || m.toolCalls?.length || m.parts?.length).flatMap((m) => {
50
+ const msg = {
51
+ role: m.role,
52
+ content: m.content || null
53
+ };
54
+ if (m.role === "assistant" && m.reasoning) msg.reasoning_content = m.reasoning;
55
+ if (m.role === "assistant" && m.toolCalls?.length) msg.tool_calls = m.toolCalls;
56
+ if (m.role === "assistant" && m.responsesOutput?.length && m.model === selectedProvider) msg.responses_output = m.responsesOutput;
57
+ if (m.role === "tool" && m.toolCallId) msg.tool_call_id = m.toolCallId;
58
+ return m.role === "assistant" ? toolTurnMessages(m.parts, msg) : [msg];
59
+ });
60
+ }
47
61
  var NO_TOOLS = [];
48
62
  var ChatContext = createContext(void 0);
49
63
  var ChatStreamContext = createContext(void 0);
@@ -291,6 +305,124 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
291
305
  state.currentConversation,
292
306
  navigation
293
307
  ]);
308
+ const streamReply = useCallback(async (targetConversationId, request, parentId, onFailure, routePart) => {
309
+ backgroundStreamsRef.current.set(targetConversationId, {
310
+ content: "",
311
+ reasoning: "",
312
+ parts: [],
313
+ messageId: null,
314
+ isActive: true
315
+ });
316
+ dispatch({
317
+ type: "SET_STREAMING",
318
+ isStreaming: true,
319
+ conversationId: targetConversationId
320
+ });
321
+ dispatch({
322
+ type: "CLEAR_STREAMING_CONTENT",
323
+ conversationId: targetConversationId
324
+ });
325
+ const controller = new AbortController();
326
+ abortControllerRef.current = controller;
327
+ try {
328
+ let thinkingStart = null;
329
+ let thinkingEnd = null;
330
+ const markStreamStart = () => {
331
+ if (thinkingStart === null) thinkingStart = Date.now();
332
+ };
333
+ let liveParts = [];
334
+ const { content: assistantContent, messageId: assistantMessageId, toolCalls, model: resolvedModel, reasoning: assistantReasoning, responsesOutput } = await adapter.streamCompletion(request, {
335
+ onContent: (chunk) => {
336
+ markStreamStart();
337
+ onStreamingChunk(chunk);
338
+ },
339
+ onReasoning: (reasoning) => {
340
+ markStreamStart();
341
+ thinkingEnd = Date.now();
342
+ dispatch({
343
+ type: "APPEND_STREAMING_REASONING",
344
+ reasoning,
345
+ conversationId: targetConversationId
346
+ });
347
+ const bgStream = backgroundStreamsRef.current.get(targetConversationId);
348
+ if (bgStream) bgStream.reasoning += reasoning;
349
+ },
350
+ onMessageId: (messageId) => {
351
+ markStreamStart();
352
+ const bgStream = backgroundStreamsRef.current.get(targetConversationId);
353
+ if (bgStream) bgStream.messageId = messageId;
354
+ },
355
+ onPart: (delta) => {
356
+ markStreamStart();
357
+ if (routePart?.(delta)) return;
358
+ liveParts = applyPartDelta(liveParts, delta);
359
+ dispatch({
360
+ type: "APPLY_STREAMING_PART",
361
+ delta,
362
+ conversationId: targetConversationId
363
+ });
364
+ const bgStream = backgroundStreamsRef.current.get(targetConversationId);
365
+ if (bgStream) bgStream.parts = liveParts;
366
+ }
367
+ }, controller.signal);
368
+ const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
369
+ const assistantMessage = {
370
+ id: assistantMessageId || crypto.randomUUID(),
371
+ parentId,
372
+ role: "assistant",
373
+ content: assistantContent || (toolCalls.length > 0 ? null : ""),
374
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
375
+ model: request.useResponses ? request.model : resolvedModel || request.model,
376
+ providerId: state.selectedProvider,
377
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
378
+ reasoning: assistantReasoning || void 0,
379
+ reasoningDuration: thinkingDuration ?? void 0,
380
+ responsesOutput: responsesOutput.length ? responsesOutput : void 0,
381
+ ...liveParts.length ? { parts: finalizeParts(liveParts) } : {}
382
+ };
383
+ dispatch({
384
+ type: "ADD_MESSAGE",
385
+ message: assistantMessage,
386
+ conversationId: targetConversationId
387
+ });
388
+ return "sent";
389
+ } catch (error) {
390
+ if (error instanceof DOMException && error.name === "AbortError") return "aborted";
391
+ console.error("Failed to send message:", error);
392
+ onFailure(errorText(error));
393
+ return "failed";
394
+ } finally {
395
+ abortControllerRef.current = null;
396
+ const { unrevealed } = stopAnimationLoop();
397
+ if (unrevealed) dispatch({
398
+ type: "APPEND_STREAMING_CONTENT",
399
+ content: unrevealed,
400
+ conversationId: targetConversationId
401
+ });
402
+ streamingConversationIdRef.current = null;
403
+ backgroundStreamsRef.current.delete(targetConversationId);
404
+ setTimeout(() => {
405
+ streamingToConversationRef.current = null;
406
+ }, 500);
407
+ dispatch({
408
+ type: "SET_STREAMING",
409
+ isStreaming: false,
410
+ conversationId: targetConversationId
411
+ });
412
+ dispatch({
413
+ type: "CLEAR_STREAMING_CONTENT",
414
+ conversationId: targetConversationId
415
+ });
416
+ }
417
+ }, [
418
+ adapter,
419
+ state.selectedProvider,
420
+ onStreamingChunk,
421
+ stopAnimationLoop,
422
+ streamingConversationIdRef,
423
+ backgroundStreamsRef,
424
+ abortControllerRef
425
+ ]);
294
426
  const sendMessage = useCallback(async (content, attachmentIds, parentId, attachmentMeta, precedingMessages) => {
295
427
  if (isSendingRef.current) {
296
428
  const convId = currentConversationIdRef.current;
@@ -405,13 +537,6 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
405
537
  message: userMessage,
406
538
  conversationId: targetConversationId
407
539
  });
408
- backgroundStreamsRef.current.set(targetConversationId, {
409
- content: "",
410
- reasoning: "",
411
- parts: [],
412
- messageId: null,
413
- isActive: true
414
- });
415
540
  const setMessageError = (errorMsg) => {
416
541
  dispatch({
417
542
  type: "UPDATE_MESSAGE",
@@ -421,17 +546,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
421
546
  });
422
547
  };
423
548
  const apiMessages = [
424
- ...(isNewConversation ? [] : state.currentConversation?.messages || []).filter((m) => m.content || m.toolCalls?.length || m.parts?.length).flatMap((m) => {
425
- const msg = {
426
- role: m.role,
427
- content: m.content || null
428
- };
429
- if (m.role === "assistant" && m.reasoning) msg.reasoning_content = m.reasoning;
430
- if (m.role === "assistant" && m.toolCalls?.length) msg.tool_calls = m.toolCalls;
431
- if (m.role === "assistant" && m.responsesOutput?.length && m.model === state.selectedProvider) msg.responses_output = m.responsesOutput;
432
- if (m.role === "tool" && m.toolCallId) msg.tool_call_id = m.toolCallId;
433
- return m.role === "assistant" ? toolTurnMessages(m.parts, msg) : [msg];
434
- }),
549
+ ...historyMessages(isNewConversation ? [] : state.currentConversation?.messages || [], state.selectedProvider),
435
550
  ...precedingApiMessages,
436
551
  {
437
552
  role: "user",
@@ -439,28 +554,10 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
439
554
  attachment_ids: attachmentIds?.length ? attachmentIds : void 0
440
555
  }
441
556
  ];
442
- dispatch({
443
- type: "SET_STREAMING",
444
- isStreaming: true,
445
- conversationId: targetConversationId
446
- });
447
- dispatch({
448
- type: "CLEAR_STREAMING_CONTENT",
449
- conversationId: targetConversationId
450
- });
451
- const controller = new AbortController();
452
- abortControllerRef.current = controller;
453
- let sendSucceeded = false;
454
- let wasAborted = false;
557
+ const useResponses = state.models.find((m) => m.id === modelString)?.supports_responses === true;
558
+ let outcome = "failed";
455
559
  try {
456
- const useResponses = state.models.find((m) => m.id === modelString)?.supports_responses === true;
457
- let thinkingStart = null;
458
- let thinkingEnd = null;
459
- const markStreamStart = () => {
460
- if (thinkingStart === null) thinkingStart = Date.now();
461
- };
462
- let liveParts = [];
463
- const { content: assistantContent, messageId: assistantMessageId, toolCalls, model: resolvedModel, reasoning: assistantReasoning, responsesOutput } = await adapter.streamCompletion({
560
+ outcome = await streamReply(targetConversationId, {
464
561
  model: modelString,
465
562
  messages: apiMessages,
466
563
  conversationId,
@@ -469,94 +566,11 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
469
566
  allocation: state.selectedAllocation,
470
567
  useResponses,
471
568
  ...tools ? { tools: enabledToolIds } : {}
472
- }, {
473
- onContent: (chunk) => {
474
- markStreamStart();
475
- onStreamingChunk(chunk);
476
- },
477
- onReasoning: (reasoning) => {
478
- markStreamStart();
479
- thinkingEnd = Date.now();
480
- dispatch({
481
- type: "APPEND_STREAMING_REASONING",
482
- reasoning,
483
- conversationId: targetConversationId
484
- });
485
- const bgStream = backgroundStreamsRef.current.get(targetConversationId);
486
- if (bgStream) bgStream.reasoning += reasoning;
487
- },
488
- onMessageId: (messageId) => {
489
- markStreamStart();
490
- const bgStream = backgroundStreamsRef.current.get(targetConversationId);
491
- if (bgStream) bgStream.messageId = messageId;
492
- },
493
- onPart: (delta) => {
494
- markStreamStart();
495
- liveParts = applyPartDelta(liveParts, delta);
496
- dispatch({
497
- type: "APPLY_STREAMING_PART",
498
- delta,
499
- conversationId: targetConversationId
500
- });
501
- const bgStream = backgroundStreamsRef.current.get(targetConversationId);
502
- if (bgStream) bgStream.parts = liveParts;
503
- }
504
- }, controller.signal);
505
- const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
506
- const assistantMessage = {
507
- id: assistantMessageId || crypto.randomUUID(),
508
- parentId: userMessageId,
509
- role: "assistant",
510
- content: assistantContent || (toolCalls.length > 0 ? null : ""),
511
- toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
512
- model: useResponses ? modelString : resolvedModel || modelString,
513
- providerId: state.selectedProvider,
514
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
515
- reasoning: assistantReasoning || void 0,
516
- reasoningDuration: thinkingDuration ?? void 0,
517
- responsesOutput: responsesOutput.length ? responsesOutput : void 0,
518
- ...liveParts.length ? { parts: finalizeParts(liveParts) } : {}
519
- };
520
- dispatch({
521
- type: "ADD_MESSAGE",
522
- message: assistantMessage,
523
- conversationId: targetConversationId
524
- });
525
- if (isNewConversation) loadConversations();
526
- sendSucceeded = true;
527
- return true;
528
- } catch (error) {
529
- if (error instanceof DOMException && error.name === "AbortError") {
530
- wasAborted = true;
531
- return false;
532
- }
533
- console.error("Failed to send message:", error);
534
- setMessageError(errorText(error));
535
- return false;
569
+ }, userMessageId, setMessageError);
570
+ if (outcome === "sent" && isNewConversation) loadConversations();
571
+ return outcome === "sent";
536
572
  } finally {
537
- abortControllerRef.current = null;
538
- const { unrevealed } = stopAnimationLoop();
539
- if (unrevealed) dispatch({
540
- type: "APPEND_STREAMING_CONTENT",
541
- content: unrevealed,
542
- conversationId: targetConversationId
543
- });
544
- streamingConversationIdRef.current = null;
545
- backgroundStreamsRef.current.delete(targetConversationId);
546
- setTimeout(() => {
547
- streamingToConversationRef.current = null;
548
- }, 500);
549
- dispatch({
550
- type: "SET_STREAMING",
551
- isStreaming: false,
552
- conversationId: targetConversationId
553
- });
554
- dispatch({
555
- type: "CLEAR_STREAMING_CONTENT",
556
- conversationId: targetConversationId
557
- });
558
- if (wasAborted) isSendingRef.current = false;
559
- else if (sendSucceeded) {
573
+ if (outcome === "sent") {
560
574
  const next = heldSendArgs(pendingQueueRef.current);
561
575
  pendingQueueRef.current = [];
562
576
  dispatch({ type: "CLEAR_QUEUED_MESSAGES" });
@@ -581,16 +595,93 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
581
595
  createConversation,
582
596
  loadConversations,
583
597
  navigation,
584
- onStreamingChunk,
585
598
  startStreaming,
586
- stopAnimationLoop,
587
- streamingConversationIdRef,
588
- backgroundStreamsRef,
589
- abortControllerRef
599
+ streamReply
590
600
  ]);
591
601
  useEffect(() => {
592
602
  sendMessageRef.current = sendMessage;
593
603
  }, [sendMessage]);
604
+ const answerToolApproval = useCallback(async (approvalId, answer) => {
605
+ const conversation = state.currentConversation;
606
+ const modelString = state.selectedProvider;
607
+ if (answer.kind !== "confirm" || !conversation || !modelString || isSendingRef.current) return;
608
+ const pausedAt = conversation.messages.findIndex((m) => m.role === "assistant" && m.parts?.some((p) => p.kind === "approval" && p.id === approvalId && !p.resolved));
609
+ const paused = conversation.messages[pausedAt];
610
+ if (!paused) return;
611
+ isSendingRef.current = true;
612
+ const targetConversationId = conversation.id;
613
+ const t = resolvedConfig.strings.agentTranscript;
614
+ let pausedParts = paused.parts ?? [];
615
+ const updatePaused = (updates) => dispatch({
616
+ type: "UPDATE_MESSAGE",
617
+ messageId: paused.id,
618
+ updates,
619
+ conversationId: targetConversationId
620
+ });
621
+ const applyToPaused = (delta) => {
622
+ pausedParts = applyPartDelta(pausedParts, delta);
623
+ updatePaused({ parts: pausedParts });
624
+ };
625
+ applyToPaused({
626
+ type: "approval_resolved",
627
+ id: approvalId,
628
+ answer: answer.allowed ? t.approvalAllowed : t.approvalDenied
629
+ });
630
+ applyToPaused({
631
+ type: "tool_decision",
632
+ id: approvalId,
633
+ decision: {
634
+ status: answer.allowed ? "approved" : "denied",
635
+ by: "user",
636
+ name: currentUser.name || currentUser.username,
637
+ at: DateTime.now().toUTC().toISO()
638
+ }
639
+ });
640
+ const instead = answer.allowed ? answer.instead : void 0;
641
+ if (instead) {
642
+ pausedParts = pausedParts.map((p) => p.kind === "tool_call" && p.id === approvalId ? {
643
+ ...p,
644
+ name: instead
645
+ } : p);
646
+ updatePaused({ parts: pausedParts });
647
+ }
648
+ streamingToConversationRef.current = targetConversationId;
649
+ startStreaming(targetConversationId);
650
+ try {
651
+ await streamReply(targetConversationId, {
652
+ model: modelString,
653
+ messages: historyMessages(conversation.messages.slice(0, pausedAt + 1), state.selectedProvider),
654
+ conversationId: targetConversationId,
655
+ parentMessageId: paused.id,
656
+ userMessageId: crypto.randomUUID(),
657
+ allocation: state.selectedAllocation,
658
+ ...tools ? { tools: enabledToolIds } : {},
659
+ resume: {
660
+ messageId: paused.id,
661
+ toolCallId: approvalId,
662
+ allowed: answer.allowed,
663
+ ...answer.denyMessage ? { feedback: answer.denyMessage } : {},
664
+ ...instead ? { tool: instead } : {}
665
+ }
666
+ }, paused.id, (message) => updatePaused({ error: message }), (delta) => {
667
+ if (delta.type !== "tool_end" || delta.id !== approvalId) return false;
668
+ applyToPaused(delta);
669
+ return true;
670
+ });
671
+ } finally {
672
+ isSendingRef.current = false;
673
+ }
674
+ }, [
675
+ resolvedConfig,
676
+ state.currentConversation,
677
+ state.selectedProvider,
678
+ state.selectedAllocation,
679
+ tools,
680
+ enabledToolIds,
681
+ startStreaming,
682
+ streamReply,
683
+ currentUser
684
+ ]);
594
685
  const updateConversationTitle = useCallback(async (id, title) => {
595
686
  try {
596
687
  await adapter.conversations.rename(id, title);
@@ -807,6 +898,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
807
898
  deleteConversation,
808
899
  sendMessage,
809
900
  retryMessage,
901
+ answerToolApproval,
810
902
  setSelectedProvider,
811
903
  setSelectedAllocation,
812
904
  setToolEnabled,
@@ -854,6 +946,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
854
946
  deleteConversation,
855
947
  sendMessage,
856
948
  retryMessage,
949
+ answerToolApproval,
857
950
  setSelectedProvider,
858
951
  setSelectedAllocation,
859
952
  setToolEnabled,
@@ -1,13 +1,21 @@
1
1
  import { type ComponentType, type MouseEventHandler, type ReactNode } from 'react';
2
2
  import type { Components } from 'streamdown';
3
3
  import { type ChatStrings } from '../strings.js';
4
- import type { ApprovalAnswerValue, ChatLinkTarget, ChatMessage, CspKind, MessagePart, MessagePartKind } from '../types.js';
4
+ import type { ApprovalAnswerValue, ApprovalPart, ChatLinkTarget, ChatMessage, CspKind, MessagePart, MessagePartKind, ToolCallPart } from '../types.js';
5
5
  export interface PartRendererProps {
6
6
  part: MessagePart;
7
7
  message: ChatMessage;
8
8
  isStreaming?: boolean;
9
9
  onApprovalAnswer?: (id: string, answer: ApprovalAnswerValue) => void;
10
10
  }
11
+ export interface ToolRendererProps {
12
+ part: ToolCallPart;
13
+ approval?: ApprovalPart;
14
+ message: ChatMessage;
15
+ isStreaming?: boolean;
16
+ /** Absent when the reader may not answer, such as a call someone else asked for. */
17
+ onApprovalAnswer?: (id: string, answer: ApprovalAnswerValue) => void;
18
+ }
11
19
  export interface ChatLinkProps {
12
20
  target: ChatLinkTarget;
13
21
  className?: string;
@@ -27,6 +35,7 @@ export interface ChatUIConfig {
27
35
  composerUsage?: ReactNode;
28
36
  providerIcon?: (cspKind: CspKind, className?: string) => ReactNode;
29
37
  partRenderers?: Partial<Record<MessagePartKind, ComponentType<PartRendererProps>>>;
38
+ toolRenderers?: Record<string, ComponentType<ToolRendererProps>>;
30
39
  };
31
40
  strings?: {
32
41
  [S in keyof ChatStrings]?: Partial<ChatStrings[S]>;
@@ -34,6 +34,10 @@ function applyPartDelta(parts, delta) {
34
34
  ...p,
35
35
  args: p.args + delta.args
36
36
  } : p);
37
+ case "tool_wait": return parts.map((p) => p.kind === "tool_call" && p.id === delta.id ? {
38
+ ...p,
39
+ status: "waiting"
40
+ } : p);
37
41
  case "tool_end": return parts.map((p) => p.kind === "tool_call" && p.id === delta.id ? {
38
42
  ...p,
39
43
  status: delta.isError ? "error" : "ok",
@@ -99,7 +103,7 @@ function isStreamedText(part) {
99
103
  * calls comes back as the answer alone. */
100
104
  function toolTurnMessages(parts, answer) {
101
105
  const calls = (parts ?? []).filter((p) => p.kind === "tool_call" && p.status !== "running" && p.result !== void 0);
102
- if (calls.length === 0) return [answer];
106
+ if (calls.length === 0) return answer.content || answer.tool_calls?.length ? [answer] : [];
103
107
  return [
104
108
  {
105
109
  role: "assistant",
@@ -27,7 +27,7 @@ export { type SlashCommandOption, type SlashMenuConfig, slashMatches, } from './
27
27
  export type { ComposerPastes, PasteUploadResult, } from './components/usePasteCards.js';
28
28
  export { ChatProvider, type ChatProviderProps, type ChatStream, useChat, useChatStream, } from './core/ChatProvider.js';
29
29
  export { type ChatAction, type ChatState, chatReducer, initialState, } from './core/chatReducer.js';
30
- export type { PartRendererProps } from './core/config.js';
30
+ export type { PartRendererProps, ToolRendererProps } from './core/config.js';
31
31
  export { type ChatLinkProps, type ChatUIConfig, resolveChatConfig, useChatConfig, } from './core/config.js';
32
32
  export { FOCUS_SIDEBAR_SEARCH_EVENT } from './core/events.js';
33
33
  export { getGreeting } from './core/greeting.js';
@@ -15,6 +15,8 @@ export interface ChatStrings {
15
15
  addGroups: string;
16
16
  info: ReactNode;
17
17
  shareButton: string;
18
+ /** Beside the share button in a shared conversation that can use tools. */
19
+ toolResultsShared: string;
18
20
  };
19
21
  blockedGroup: {
20
22
  title: (group: string) => string;
@@ -67,6 +69,8 @@ export interface ChatStrings {
67
69
  turnAllOff: string;
68
70
  /** Closes the tools modal; each switch takes effect as it is flipped. */
69
71
  done: string;
72
+ /** Marks a tool whose calls wait for the reader's approval. */
73
+ asksFirst: string;
70
74
  };
71
75
  allocationPicker: {
72
76
  choose: string;
@@ -87,6 +91,8 @@ export interface ChatStrings {
87
91
  stop: string;
88
92
  /** The status words an agent's working line picks from, one per turn. */
89
93
  words: readonly string[];
94
+ /** The working line while a call waits for the reader to allow it. */
95
+ waitingForAnswer: string;
90
96
  };
91
97
  emptyState: {
92
98
  greeting: string | null;
@@ -121,6 +127,9 @@ export interface ChatStrings {
121
127
  activity: string;
122
128
  /** A finished turn's label when it ran tools without reasoning. */
123
129
  ranToolCalls: (count: number) => string;
130
+ /** The same, in a shared conversation, for a turn someone else started:
131
+ * its tools ran with that person's access. */
132
+ ranToolCallsAs: (count: number, name: string) => string;
124
133
  };
125
134
  thread: {
126
135
  viewOnly: string;
@@ -253,6 +262,13 @@ export interface ChatStrings {
253
262
  approvalOptions: string;
254
263
  /** Names a question the agent waits on that has no heading of its own. */
255
264
  approvalLabel: string;
265
+ /** A call the reader approved or declined. */
266
+ approvalAllowed: string;
267
+ approvalDenied: string;
268
+ /** A call nobody answered in time. */
269
+ approvalRequestExpired: string;
270
+ /** A call only the person who asked for it may answer. */
271
+ approvalWaitingOn: (name: string) => string;
256
272
  /** Names the field for the reason a request is denied. */
257
273
  feedbackLabel: string;
258
274
  /** Names the field for an answer of the reader's own. */
@@ -20,7 +20,8 @@ var defaultChatStrings = {
20
20
  /* @__PURE__ */ jsx("strong", { children: "Collaborate" }),
21
21
  " permission allows adding messages."
22
22
  ] }),
23
- shareButton: "Share conversation"
23
+ shareButton: "Share conversation",
24
+ toolResultsShared: "Tool results here are visible to the people it's shared with"
24
25
  },
25
26
  blockedGroup: {
26
27
  title: (group) => `Billing group \u201c${group}\u201d is over its allocation`,
@@ -59,12 +60,13 @@ var defaultChatStrings = {
59
60
  tools: "Tools",
60
61
  someToolsOff: "Some tools are off",
61
62
  toolsOn: (enabled, total) => `${enabled.toLocaleString()}/${total.toLocaleString()}`,
62
- toolsDescription: "What the assistant may look up for you.",
63
+ toolsDescription: "What the assistant may look up or do for you.",
63
64
  searchTools: "Search tools",
64
65
  noToolsMatch: "No tools match",
65
66
  turnAllOn: "Turn all on",
66
67
  turnAllOff: "Turn all off",
67
- done: "Done"
68
+ done: "Done",
69
+ asksFirst: "Asks first"
68
70
  },
69
71
  allocationPicker: {
70
72
  choose: "Allocation",
@@ -114,7 +116,8 @@ var defaultChatStrings = {
114
116
  "Crunching",
115
117
  "Forging",
116
118
  "Cooking"
117
- ]
119
+ ],
120
+ waitingForAnswer: "Waiting for your answer"
118
121
  },
119
122
  emptyState: {
120
123
  greeting: null,
@@ -150,7 +153,8 @@ var defaultChatStrings = {
150
153
  thoughtFor: (duration) => `Thought for ${duration}`,
151
154
  thoughtProcess: "Thought process",
152
155
  activity: "Activity",
153
- ranToolCalls: (count) => `Ran ${countOf(count, "tool call", "tool calls")}`
156
+ ranToolCalls: (count) => `Ran ${countOf(count, "tool call", "tool calls")}`,
157
+ ranToolCallsAs: (count, name) => `Ran ${countOf(count, "tool call", "tool calls")} as ${name}`
154
158
  },
155
159
  thread: {
156
160
  viewOnly: "You have view only access",
@@ -270,6 +274,10 @@ var defaultChatStrings = {
270
274
  approvalPending: "Waiting for an answer in the terminal",
271
275
  approvalOptions: "Offered choices",
272
276
  approvalLabel: "Waiting for your answer",
277
+ approvalAllowed: "Allowed",
278
+ approvalDenied: "Declined",
279
+ approvalRequestExpired: "This request expired. Ask again to run it.",
280
+ approvalWaitingOn: (name) => `Waiting for ${name} to answer`,
273
281
  feedbackLabel: "Feedback for the agent",
274
282
  ownAnswerLabel: "Your answer",
275
283
  todoPending: "To do",
@@ -45,7 +45,7 @@ export interface TokenUsage {
45
45
  completion_tokens?: number;
46
46
  total_tokens?: number;
47
47
  }
48
- export type ToolCallStatus = 'running' | 'ok' | 'error';
48
+ export type ToolCallStatus = 'running' | 'waiting' | 'ok' | 'error';
49
49
  export interface TextPart {
50
50
  kind: 'text';
51
51
  text: string;
@@ -100,6 +100,9 @@ export type ApprovalAnswerValue = {
100
100
  allowed: boolean;
101
101
  allowDir?: boolean;
102
102
  denyMessage?: string;
103
+ /** A sibling tool to run in place of the one asked for, with the same
104
+ * arguments, such as destroying a cluster the model asked to stop. */
105
+ instead?: string;
103
106
  } | {
104
107
  kind: 'ask';
105
108
  text: string;
@@ -122,6 +125,8 @@ export interface ApprovalPart {
122
125
  options?: ApprovalOption[];
123
126
  plan?: string;
124
127
  canAllowDirectory?: boolean;
128
+ expiresAt?: string;
129
+ waitingOn?: string;
125
130
  resolved?: boolean;
126
131
  answer?: string;
127
132
  }
@@ -65,7 +65,7 @@ function useRelativeTime(value, format = "long") {
65
65
  const unit = unitOf(age);
66
66
  const key = `${format}:${at}:${unit}:${Math.trunc(age / unit)}`;
67
67
  if (last.current?.key !== key) {
68
- const label = format === "compact" ? compactLabel(age, t) : DateTime.fromMillis(at).toRelative({ base: DateTime.fromMillis(current) }) ?? "";
68
+ const label = format === "compact" ? compactLabel(age, t) : Math.abs(age) < SECOND ? t.now : DateTime.fromMillis(at).toRelative({ base: DateTime.fromMillis(current) }) ?? "";
69
69
  last.current = {
70
70
  key,
71
71
  label
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallelworks/ui",
3
- "version": "0.75.1",
3
+ "version": "0.77.0",
4
4
  "description": "React UI components: a theme contract, icons, brand logos, primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {