@parallelworks/ui 0.75.1 → 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
  }),
@@ -390,11 +398,31 @@ function SubagentCard({ part }) {
390
398
  })]
391
399
  });
392
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
+ }
393
420
  function ConfirmApproval({ part, onAnswer }) {
394
421
  const { strings } = useChatConfig();
395
422
  const { common } = useStrings();
396
423
  const t = strings.agentTranscript;
397
424
  const [feedback, setFeedback] = useState(null);
425
+ const expired = useExpired(part.expiresAt);
398
426
  const answer = (a) => onAnswer?.(part.id, a);
399
427
  const [allowLabel, denyLabel] = {
400
428
  command: [t.allowOnce, t.deny],
@@ -411,9 +439,12 @@ function ConfirmApproval({ part, onAnswer }) {
411
439
  className: "text-xs theme-muted-text",
412
440
  children: part.reason
413
441
  }),
414
- !onAnswer ? /* @__PURE__ */ jsx("div", {
442
+ expired ? /* @__PURE__ */ jsx("div", {
415
443
  className: "text-xs theme-muted-text",
416
- 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
417
448
  }) : feedback === null ? /* @__PURE__ */ jsxs("div", {
418
449
  className: "flex flex-wrap gap-2",
419
450
  children: [
@@ -793,7 +824,9 @@ function buildRenderItems(parts) {
793
824
  emitThoughts(run.slice(end));
794
825
  run = [];
795
826
  };
827
+ const decided = new Set(parts.flatMap((p) => p.kind === "tool_call" && p.decision ? [p.id] : []));
796
828
  parts.forEach((part, index) => {
829
+ if (part.kind === "approval" && part.resolved && decided.has(part.id)) return;
797
830
  if (part.kind === "reasoning" || part.kind === "tool_call" && part.status === "ok" && !part.decision && isGroupableTool(part.name)) {
798
831
  run.push({
799
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;
@@ -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,84 @@ 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
+ streamingToConversationRef.current = targetConversationId;
641
+ startStreaming(targetConversationId);
642
+ try {
643
+ await streamReply(targetConversationId, {
644
+ model: modelString,
645
+ messages: historyMessages(conversation.messages.slice(0, pausedAt + 1), state.selectedProvider),
646
+ conversationId: targetConversationId,
647
+ parentMessageId: paused.id,
648
+ userMessageId: crypto.randomUUID(),
649
+ allocation: state.selectedAllocation,
650
+ ...tools ? { tools: enabledToolIds } : {},
651
+ resume: {
652
+ messageId: paused.id,
653
+ toolCallId: approvalId,
654
+ allowed: answer.allowed,
655
+ ...answer.denyMessage ? { feedback: answer.denyMessage } : {}
656
+ }
657
+ }, paused.id, (message) => updatePaused({ error: message }), (delta) => {
658
+ if (delta.type !== "tool_end" || delta.id !== approvalId) return false;
659
+ applyToPaused(delta);
660
+ return true;
661
+ });
662
+ } finally {
663
+ isSendingRef.current = false;
664
+ }
665
+ }, [
666
+ resolvedConfig,
667
+ state.currentConversation,
668
+ state.selectedProvider,
669
+ state.selectedAllocation,
670
+ tools,
671
+ enabledToolIds,
672
+ startStreaming,
673
+ streamReply,
674
+ currentUser
675
+ ]);
594
676
  const updateConversationTitle = useCallback(async (id, title) => {
595
677
  try {
596
678
  await adapter.conversations.rename(id, title);
@@ -807,6 +889,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
807
889
  deleteConversation,
808
890
  sendMessage,
809
891
  retryMessage,
892
+ answerToolApproval,
810
893
  setSelectedProvider,
811
894
  setSelectedAllocation,
812
895
  setToolEnabled,
@@ -854,6 +937,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
854
937
  deleteConversation,
855
938
  sendMessage,
856
939
  retryMessage,
940
+ answerToolApproval,
857
941
  setSelectedProvider,
858
942
  setSelectedAllocation,
859
943
  setToolEnabled,
@@ -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",
@@ -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;
@@ -121,6 +125,9 @@ export interface ChatStrings {
121
125
  activity: string;
122
126
  /** A finished turn's label when it ran tools without reasoning. */
123
127
  ranToolCalls: (count: number) => string;
128
+ /** The same, in a shared conversation, for a turn someone else started:
129
+ * its tools ran with that person's access. */
130
+ ranToolCallsAs: (count: number, name: string) => string;
124
131
  };
125
132
  thread: {
126
133
  viewOnly: string;
@@ -253,6 +260,13 @@ export interface ChatStrings {
253
260
  approvalOptions: string;
254
261
  /** Names a question the agent waits on that has no heading of its own. */
255
262
  approvalLabel: string;
263
+ /** A call the reader approved or declined. */
264
+ approvalAllowed: string;
265
+ approvalDenied: string;
266
+ /** A call nobody answered in time. */
267
+ approvalRequestExpired: string;
268
+ /** A call only the person who asked for it may answer. */
269
+ approvalWaitingOn: (name: string) => string;
256
270
  /** Names the field for the reason a request is denied. */
257
271
  feedbackLabel: string;
258
272
  /** 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",
@@ -150,7 +152,8 @@ var defaultChatStrings = {
150
152
  thoughtFor: (duration) => `Thought for ${duration}`,
151
153
  thoughtProcess: "Thought process",
152
154
  activity: "Activity",
153
- ranToolCalls: (count) => `Ran ${countOf(count, "tool call", "tool calls")}`
155
+ ranToolCalls: (count) => `Ran ${countOf(count, "tool call", "tool calls")}`,
156
+ ranToolCallsAs: (count, name) => `Ran ${countOf(count, "tool call", "tool calls")} as ${name}`
154
157
  },
155
158
  thread: {
156
159
  viewOnly: "You have view only access",
@@ -270,6 +273,10 @@ var defaultChatStrings = {
270
273
  approvalPending: "Waiting for an answer in the terminal",
271
274
  approvalOptions: "Offered choices",
272
275
  approvalLabel: "Waiting for your answer",
276
+ approvalAllowed: "Allowed",
277
+ approvalDenied: "Declined",
278
+ approvalRequestExpired: "This request expired. Ask again to run it.",
279
+ approvalWaitingOn: (name) => `Waiting for ${name} to answer`,
273
280
  feedbackLabel: "Feedback for the agent",
274
281
  ownAnswerLabel: "Your answer",
275
282
  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;
@@ -122,6 +122,8 @@ export interface ApprovalPart {
122
122
  options?: ApprovalOption[];
123
123
  plan?: string;
124
124
  canAllowDirectory?: boolean;
125
+ expiresAt?: string;
126
+ waitingOn?: string;
125
127
  resolved?: boolean;
126
128
  answer?: string;
127
129
  }
@@ -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.76.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": {