@parallelworks/ui 0.83.1 → 0.83.3

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/dist/ai/ai.css CHANGED
@@ -347,6 +347,22 @@
347
347
  0 10px 24px -10px rgb(0 0 0 / 0.18);
348
348
  }
349
349
 
350
+ /* The thread scrolls under the dock and the jump pill, so they must hide it
351
+ * even when a theme makes the panel color translucent: a second layer of the
352
+ * same color leaves the thread at most a hundredth visible. */
353
+ .chat-composer-dock,
354
+ .chat-jump {
355
+ background:
356
+ linear-gradient(var(--theme-panel-bg, #fff), var(--theme-panel-bg, #fff)),
357
+ var(--theme-panel-bg, #fff);
358
+ }
359
+
360
+ .chat-jump:hover {
361
+ background:
362
+ linear-gradient(var(--theme-muted-panel-bg, #f3f4f6), var(--theme-muted-panel-bg, #f3f4f6)),
363
+ var(--theme-muted-panel-bg, #f3f4f6);
364
+ }
365
+
350
366
  /* The thread runs under the docked composer and fades out above it, so the
351
367
  * last lines dissolve rather than cut off at the dock's edge. */
352
368
  .chat-composer-dock::before {
@@ -204,7 +204,7 @@ function ChatInput({ onSend, disabled = false, placeholder, conversationId, onEd
204
204
  testId: sendTestId
205
205
  });
206
206
  return /* @__PURE__ */ jsxs("div", {
207
- className: "chat-composer-dock relative bg-(--theme-panel-bg) px-4 pt-2 pb-5",
207
+ className: "chat-composer-dock relative px-4 pt-2 pb-5",
208
208
  ...attachmentsAvailable ? dragHandlers : {},
209
209
  children: [isDragging && /* @__PURE__ */ jsx(DragOverlay, { className: "rounded-2xl" }), /* @__PURE__ */ jsxs("div", {
210
210
  className: "mx-auto w-full max-w-[var(--chat-column,50rem)] space-y-2 sm:px-4",
@@ -9,6 +9,7 @@ import { CopyButton } from "../../components/CopyButton.js";
9
9
  import { formatDuration } from "../../format.js";
10
10
  import { RelativeTime } from "../../components/relativeTime.js";
11
11
  import { useChatConfig } from "../core/config.js";
12
+ import { useChatModelName } from "../core/ChatProvider.js";
12
13
  import Markdown from "../ui/Markdown.js";
13
14
  import { chatProseClasses } from "../ui/prose.js";
14
15
  import { Reasoning } from "./Reasoning.js";
@@ -93,11 +94,15 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
93
94
  };
94
95
  const timestamp = parseTimestamp(message.timestamp);
95
96
  const totalTokens = message.tokensUsed?.total_tokens;
97
+ const modelName = useChatModelName(isUser ? void 0 : message.model);
96
98
  const meta = (timestamp || !isUser && (message.model || totalTokens)) && /* @__PURE__ */ jsxs("span", {
97
99
  className: cx("chat-meta flex items-center gap-2 ml-2 text-xs tabular-nums theme-muted-text transition-opacity duration-150 select-none", "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"),
98
100
  children: [
99
101
  /* @__PURE__ */ jsx(RelativeTime, { value: timestamp }),
100
- !isUser && message.model && /* @__PURE__ */ jsx("span", { children: message.model }),
102
+ !isUser && message.model && /* @__PURE__ */ jsx("span", {
103
+ title: modelName ? message.model : void 0,
104
+ children: modelName ?? message.model
105
+ }),
101
106
  !isUser && totalTokens ? /* @__PURE__ */ jsx("span", {
102
107
  title: message.tokensUsed?.prompt_tokens !== void 0 && message.tokensUsed?.completion_tokens !== void 0 ? t.tokensBreakdown(message.tokensUsed.prompt_tokens, message.tokensUsed.completion_tokens) : void 0,
103
108
  children: t.tokens(totalTokens)
@@ -12,6 +12,8 @@ import ChatMessage_default from "./ChatMessage.js";
12
12
  import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
13
13
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
14
14
  //#region src/ai/components/ChatMessageList.tsx
15
+ var READER_GROWTH_MS = 600;
16
+ var jumpClasses = "chat-composer chat-jump rounded-full px-3 py-1.5 text-xs font-medium theme-text transition-colors";
15
17
  function ChatMessageList({ messages, allMessages, streamingMessage: streamingMessageProp, queuedMessages, onRemoveQueued, canRemoveQueued, queueDelivery = "afterReply", editingMessageId, showAuthor, currentUsername, onNavigateBranch, onEditMessage, onSaveEdit, onCancelEdit, onRegenerateMessage, onRetryMessage, onOpenReasoning, onApprovalAnswer, inputElement, isStreaming: isStreamingProp, flush, label, footer, workingIndicator = true }) {
16
18
  const { adapter, isStreaming: providerStreaming } = useChat();
17
19
  const { isThinking, streamingContent, streamingReasoning, streamingParts, thinkingStartTime } = useChatStream();
@@ -154,15 +156,25 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
154
156
  useEffect(() => {
155
157
  const content = contentRef.current;
156
158
  if (!content || typeof ResizeObserver === "undefined") return;
159
+ let touchedAt = Number.NEGATIVE_INFINITY;
160
+ const touched = () => {
161
+ touchedAt = performance.now();
162
+ };
163
+ content.addEventListener("click", touched, true);
164
+ content.addEventListener("keydown", touched, true);
157
165
  const observer = new ResizeObserver(() => {
158
166
  const el = containerRef.current;
159
- if (el && isNearBottomRef.current && !hasQueuedMessages) {
167
+ if (el && isNearBottomRef.current && !hasQueuedMessages && performance.now() - touchedAt > READER_GROWTH_MS) {
160
168
  el.scrollTop = el.scrollHeight;
161
169
  lastScrollTopRef.current = el.scrollTop;
162
170
  }
163
171
  });
164
172
  observer.observe(content);
165
- return () => observer.disconnect();
173
+ return () => {
174
+ observer.disconnect();
175
+ content.removeEventListener("click", touched, true);
176
+ content.removeEventListener("keydown", touched, true);
177
+ };
166
178
  }, [hasContent, hasQueuedMessages]);
167
179
  if (messages.length === 0 && !streamingMessage && !hasQueuedMessages) return /* @__PURE__ */ jsxs("div", {
168
180
  className: "chat-thread flex-1 overflow-y-auto flex flex-col bg-(--theme-panel-bg)",
@@ -304,18 +316,26 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
304
316
  /* @__PURE__ */ jsx("div", { className: "h-8" })
305
317
  ]
306
318
  }),
307
- !pinned && /* @__PURE__ */ jsx("div", {
319
+ !pinned && !inputElement && /* @__PURE__ */ jsx("div", {
308
320
  className: "sticky bottom-4 z-10 flex h-0 items-start justify-center overflow-visible",
309
321
  children: /* @__PURE__ */ jsx("button", {
310
322
  type: "button",
311
323
  onClick: jumpToLatest,
312
- className: "chat-composer -translate-y-full rounded-full bg-(--theme-panel-bg) px-3 py-1.5 text-xs font-medium theme-text transition-colors hover:bg-(--theme-muted-panel-bg)",
324
+ className: cx(jumpClasses, "-translate-y-full"),
313
325
  children: strings.chrome.jumpToLatest
314
326
  })
315
327
  }),
316
- inputElement && /* @__PURE__ */ jsx("div", {
328
+ inputElement && /* @__PURE__ */ jsxs("div", {
317
329
  className: "sticky bottom-0 z-10",
318
- children: inputElement
330
+ children: [!pinned && /* @__PURE__ */ jsx("div", {
331
+ className: "pointer-events-none absolute inset-x-0 bottom-full flex justify-center pb-2",
332
+ children: /* @__PURE__ */ jsx("button", {
333
+ type: "button",
334
+ onClick: jumpToLatest,
335
+ className: cx(jumpClasses, "pointer-events-auto"),
336
+ children: strings.chrome.jumpToLatest
337
+ })
338
+ }), inputElement]
319
339
  })
320
340
  ]
321
341
  });
@@ -19,6 +19,12 @@ type RenderItem = {
19
19
  } | {
20
20
  kind: 'rollup';
21
21
  members: RunMember[];
22
+ } | {
23
+ kind: 'calls';
24
+ members: {
25
+ part: ToolCallPart;
26
+ index: number;
27
+ }[];
22
28
  };
23
29
  export declare function buildRenderItems(parts: MessagePart[], hostDrawn?: (toolName: string) => boolean): RenderItem[];
24
30
  export default function AgentMessageParts({ message, isStreaming, onApprovalAnswer, }: {
@@ -282,12 +282,16 @@ function ToolCallBlock({ part, children }) {
282
282
  children: fullToolArgs(header.arg, part.args)
283
283
  }), /* @__PURE__ */ jsx(ResultBody, { part })]
284
284
  }),
285
+ part.status === "error" && !expanded && /* @__PURE__ */ jsx("div", {
286
+ className: "ml-5 mt-0.5 truncate text-[13px] text-(--theme-error-text)",
287
+ children: t.failedWith(summary)
288
+ }),
285
289
  children && /* @__PURE__ */ jsx("div", {
286
290
  className: "mt-1.5 min-w-0 sm:ml-5",
287
291
  children
288
292
  }),
289
- !children && !isDiff && !expanded && part.status !== "waiting" && /* @__PURE__ */ jsx("div", {
290
- className: cx("ml-5 mt-0.5 truncate text-[13px]", part.status === "error" ? "text-(--theme-error-text)" : "theme-muted-text"),
293
+ !children && !isDiff && !expanded && part.status !== "waiting" && part.status !== "error" && /* @__PURE__ */ jsx("div", {
294
+ className: "ml-5 mt-0.5 truncate text-[13px] theme-muted-text",
291
295
  children: summary
292
296
  }),
293
297
  isDiff && /* @__PURE__ */ jsx("div", {
@@ -867,7 +871,36 @@ function buildRenderItems(parts, hostDrawn = () => false) {
867
871
  });
868
872
  });
869
873
  flush();
870
- return items;
874
+ return groupRepeatedCalls(items);
875
+ }
876
+ /** A call that can share a row with the same tool's calls beside it: never one
877
+ * still running, so its progress stays in view, one waiting on an answer, or
878
+ * one whose decision line names who answered it. */
879
+ function foldableCall(item) {
880
+ return item.kind === "single" && item.part.kind === "tool_call" && (item.part.status === "ok" || item.part.status === "error") && !item.part.decision ? item.part : null;
881
+ }
882
+ function groupRepeatedCalls(items) {
883
+ const out = [];
884
+ let i = 0;
885
+ while (i < items.length) {
886
+ const item = items[i];
887
+ const first = foldableCall(item);
888
+ let j = i + 1;
889
+ while (first && j < items.length && foldableCall(items[j])?.name === first.name) j++;
890
+ if (j - i >= 2) out.push({
891
+ kind: "calls",
892
+ members: items.slice(i, j).map((m) => {
893
+ const single = m;
894
+ return {
895
+ part: single.part,
896
+ index: single.index
897
+ };
898
+ })
899
+ });
900
+ else out.push(item);
901
+ i = j;
902
+ }
903
+ return out;
871
904
  }
872
905
  function RollupGroup({ members, message }) {
873
906
  const { strings } = useChatConfig();
@@ -894,6 +927,60 @@ function RollupGroup({ members, message }) {
894
927
  })
895
928
  });
896
929
  }
930
+ /** Calls to one tool in a row, folded into one line that counts them and their
931
+ * failures. Folded, it still shows what a host drew for each call, such as a
932
+ * table, so results stay in view; calls with nothing drawn show nothing. */
933
+ function CallGroup({ members, message }) {
934
+ const { strings, slots } = useChatConfig();
935
+ const t = strings.agentTranscript;
936
+ const [open, setOpen] = useState(false);
937
+ const name = members[0]?.part.name ?? "";
938
+ const Tool = slots.toolRenderers?.[name];
939
+ const header = toolHeaderFor(name);
940
+ const failed = members.filter((m) => m.part.status === "error").length;
941
+ const status = failed === members.length ? "error" : "ok";
942
+ return /* @__PURE__ */ jsxs("div", {
943
+ className: "py-1.5 text-sm",
944
+ "data-testid": "tool-call-group",
945
+ children: [/* @__PURE__ */ jsx(Disclosure, {
946
+ open,
947
+ onOpenChange: setOpen,
948
+ keepMounted: true,
949
+ summaryClassName: rowSummaryClasses,
950
+ summary: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ToolStatusDot, { part: {
951
+ ...members[0]?.part,
952
+ status
953
+ } }), /* @__PURE__ */ jsxs("span", {
954
+ className: "font-mono text-[13px] leading-5 min-w-0 truncate",
955
+ children: [t.callGroup(header.label, members.length), failed > 0 && /* @__PURE__ */ jsxs("span", {
956
+ className: "text-(--theme-error-text)",
957
+ children: [" · ", t.failedCount(failed)]
958
+ })]
959
+ })] }),
960
+ panelClassName: "ml-1.5 mt-0.5 rounded-md bg-(--theme-muted-panel-bg)/40 px-2",
961
+ children: members.map((m) => /* @__PURE__ */ jsx(PartDispatch, {
962
+ part: m.part,
963
+ message
964
+ }, m.index))
965
+ }), !open && Tool && members.map(({ part, index }) => {
966
+ const args = summarizeToolArgs(header.arg, part.args);
967
+ return /* @__PURE__ */ jsxs("div", {
968
+ className: "mt-1.5 min-w-0 sm:ml-5 has-[>[data-drawing]:empty]:hidden",
969
+ children: [args && /* @__PURE__ */ jsx("div", {
970
+ className: "truncate font-mono text-xs theme-muted-text",
971
+ children: args
972
+ }), /* @__PURE__ */ jsx("div", {
973
+ "data-drawing": true,
974
+ className: "mt-1",
975
+ children: /* @__PURE__ */ jsx(Tool, {
976
+ part,
977
+ message
978
+ })
979
+ })]
980
+ }, index);
981
+ })]
982
+ });
983
+ }
897
984
  function AgentMessageParts({ message, isStreaming, onApprovalAnswer }) {
898
985
  const parts = message.parts ?? [];
899
986
  const { slots } = useChatConfig();
@@ -906,6 +993,9 @@ function AgentMessageParts({ message, isStreaming, onApprovalAnswer }) {
906
993
  children: items.map((item) => item.kind === "rollup" ? /* @__PURE__ */ jsx(RollupGroup, {
907
994
  members: item.members,
908
995
  message
996
+ }, item.members[0]?.index ?? 0) : item.kind === "calls" ? /* @__PURE__ */ jsx(CallGroup, {
997
+ members: item.members,
998
+ message
909
999
  }, item.members[0]?.index ?? 0) : item.kind === "reasoning" ? /* @__PURE__ */ jsx(ReasoningBlock, {
910
1000
  part: mergeReasoning(item.parts),
911
1001
  isStreaming: (isStreaming ?? false) && item.index + item.parts.length - 1 === lastIndex
@@ -69,6 +69,9 @@ export interface ChatStream {
69
69
  streamingParts: MessagePart[];
70
70
  thinkingStartTime: number | null;
71
71
  }
72
+ /** A model's display name by its id, when the adapter listed one; undefined
73
+ * outside a ChatProvider or for a model it doesn't list. */
74
+ export declare function useChatModelName(id: string | null | undefined): string | undefined;
72
75
  export declare function useChat(): ChatContextValue;
73
76
  /** True while the adapter lists tools of its own, which the composer's +
74
77
  * menu offers; false outside a ChatProvider. */
@@ -61,6 +61,13 @@ function historyMessages(messages, selectedProvider) {
61
61
  var NO_TOOLS = [];
62
62
  var ChatContext = createContext(void 0);
63
63
  var ChatStreamContext = createContext(void 0);
64
+ var ChatModelsContext = createContext([]);
65
+ /** A model's display name by its id, when the adapter listed one; undefined
66
+ * outside a ChatProvider or for a model it doesn't list. */
67
+ function useChatModelName(id) {
68
+ const models = useContext(ChatModelsContext);
69
+ return id ? models.find((m) => m.id === id)?.name || void 0 : void 0;
70
+ }
64
71
  function useChat() {
65
72
  const context = useContext(ChatContext);
66
73
  if (!context) throw new Error("useChat must be used within a ChatProvider");
@@ -984,13 +991,16 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
984
991
  value: reportSidebarPresentation,
985
992
  children: /* @__PURE__ */ jsx(ChatContext.Provider, {
986
993
  value,
987
- children: /* @__PURE__ */ jsx(ChatStreamContext.Provider, {
988
- value: stream,
989
- children
994
+ children: /* @__PURE__ */ jsx(ChatModelsContext.Provider, {
995
+ value: state.models,
996
+ children: /* @__PURE__ */ jsx(ChatStreamContext.Provider, {
997
+ value: stream,
998
+ children
999
+ })
990
1000
  })
991
1001
  })
992
1002
  })
993
1003
  });
994
1004
  }
995
1005
  //#endregion
996
- export { ChatProvider, useChat, useChatStream, useHasChatTools };
1006
+ export { ChatProvider, useChat, useChatModelName, useChatStream, useHasChatTools };
@@ -7,8 +7,9 @@ import type { MessagePart } from '../types.js';
7
7
  * rather than invented — an end without a start has nothing to render. */
8
8
  export declare function applyPartDelta(parts: MessagePart[], delta: PartDelta): MessagePart[];
9
9
  /** The wire history for an assistant turn whose tool calls the gateway ran:
10
- * the calls, each result, then the answer. A turn with no finished tool
11
- * calls comes back as the answer alone. */
10
+ * each round's calls, with the text that came before them, and each result,
11
+ * then the answer. A turn with no finished tool calls comes back as the
12
+ * answer alone. */
12
13
  export declare function toolTurnMessages(parts: MessagePart[] | null | undefined, answer: CompletionMessage): CompletionMessage[];
13
14
  /** A turn can end (completed or stopped) with tool calls still marked
14
15
  * running — the transport died before their tool_end arrived. A stored
@@ -99,31 +99,54 @@ function isStreamedText(part) {
99
99
  return part?.kind === "text" || part?.kind === "reasoning";
100
100
  }
101
101
  /** The wire history for an assistant turn whose tool calls the gateway ran:
102
- * the calls, each result, then the answer. A turn with no finished tool
103
- * calls comes back as the answer alone. */
102
+ * each round's calls, with the text that came before them, and each result,
103
+ * then the answer. A turn with no finished tool calls comes back as the
104
+ * answer alone. */
104
105
  function toolTurnMessages(parts, answer) {
105
- const calls = (parts ?? []).filter((p) => p.kind === "tool_call" && p.status !== "running" && p.result !== void 0);
106
- if (calls.length === 0) return answer.content || answer.tool_calls?.length ? [answer] : [];
107
- return [
108
- {
109
- role: "assistant",
110
- content: null,
111
- tool_calls: calls.map((c) => ({
112
- id: c.id,
113
- type: "function",
114
- function: {
115
- name: c.name,
116
- arguments: c.args
117
- }
118
- }))
119
- },
120
- ...calls.map((c) => ({
121
- role: "tool",
122
- tool_call_id: c.id,
123
- content: c.result
124
- })),
125
- ...answer.content ? [answer] : []
126
- ];
106
+ const rounds = [];
107
+ let text = "";
108
+ let calls = [];
109
+ let hasText = false;
110
+ for (const p of parts ?? []) if (p.kind === "text") {
111
+ hasText = true;
112
+ if (calls.length > 0) {
113
+ rounds.push({
114
+ text,
115
+ calls
116
+ });
117
+ text = "";
118
+ calls = [];
119
+ }
120
+ text += p.text;
121
+ } else if (p.kind === "tool_call" && p.status !== "running" && p.result !== void 0) calls.push(p);
122
+ if (calls.length > 0) {
123
+ rounds.push({
124
+ text,
125
+ calls
126
+ });
127
+ text = "";
128
+ }
129
+ if (rounds.length === 0) return answer.content || answer.tool_calls?.length ? [answer] : [];
130
+ const final = hasText ? {
131
+ ...answer,
132
+ content: text || null
133
+ } : answer;
134
+ return [...rounds.flatMap((round) => [{
135
+ role: "assistant",
136
+ content: round.text || null,
137
+ tool_calls: round.calls.map((c) => ({
138
+ id: c.id,
139
+ type: "function",
140
+ function: {
141
+ name: c.name,
142
+ arguments: c.args
143
+ }
144
+ }))
145
+ }, ...round.calls.map((c) => ({
146
+ role: "tool",
147
+ tool_call_id: c.id,
148
+ content: c.result
149
+ }))]), ...final.content ? [final] : []];
127
150
  }
128
151
  /** A turn can end (completed or stopped) with tool calls still marked
129
152
  * running — the transport died before their tool_end arrived. A stored
@@ -226,6 +226,12 @@ export interface ChatStrings {
226
226
  running: string;
227
227
  done: string;
228
228
  failed: string;
229
+ /** A failed call's row, with the first line of its error. */
230
+ failedWith: (reason: string) => string;
231
+ /** Consecutive calls to one tool, folded into one row. */
232
+ callGroup: (tool: string, count: number) => string;
233
+ /** Beside a folded row: how many of its calls failed. */
234
+ failedCount: (count: number) => string;
229
235
  interrupted: string;
230
236
  noOutput: string;
231
237
  diffTruncated: string;
@@ -241,6 +241,9 @@ var defaultChatStrings = {
241
241
  running: "Running…",
242
242
  done: "Done",
243
243
  failed: "Failed",
244
+ failedWith: (reason) => `Failed: ${reason}`,
245
+ callGroup: (tool, count) => `Ran ${tool} ${countOf(count, "time", "times")}`,
246
+ failedCount: (count) => `${count} failed`,
244
247
  interrupted: "Interrupted",
245
248
  noOutput: "(no output)",
246
249
  diffTruncated: "Diff truncated",
@@ -10,7 +10,7 @@ import { jsx } from "react/jsx-runtime";
10
10
  import { code } from "@streamdown/code";
11
11
  import { math } from "@streamdown/math";
12
12
  import "katex/dist/katex.min.css";
13
- import { Streamdown, defaultComponents, defaultRehypePlugins, useIsCodeFenceIncomplete } from "streamdown";
13
+ import { Streamdown, defaultComponents, defaultRehypePlugins, defaultRemarkPlugins, useIsCodeFenceIncomplete } from "streamdown";
14
14
  //#region src/components/MarkdownImpl.tsx
15
15
  var basePlugins = {
16
16
  code,
@@ -35,6 +35,7 @@ function loadMermaid() {
35
35
  return mermaidLoad;
36
36
  }
37
37
  var rehypePlugins = [...Object.values(defaultRehypePlugins), rehypeCodeBreaks];
38
+ var remarkPlugins = Object.entries(defaultRemarkPlugins).map(([name, plugin]) => name === "gfm" ? [Array.isArray(plugin) ? plugin[0] : plugin, { singleTilde: false }] : plugin);
38
39
  var httpsImagesRehypePlugins = [...Object.entries(defaultRehypePlugins).flatMap(([name, plugin]) => name === "harden" ? [rehypeHttpsImagesOnly, plugin] : [plugin]), rehypeCodeBreaks];
39
40
  function codeControls(controls) {
40
41
  if (controls === false) return {
@@ -146,6 +147,7 @@ function MarkdownImpl({ children, isStreaming, controls, components, httpsImages
146
147
  } : {},
147
148
  ...controls ? { controls } : {},
148
149
  plugins,
150
+ remarkPlugins,
149
151
  rehypePlugins: httpsImagesOnly ? httpsImagesRehypePlugins : rehypePlugins,
150
152
  shikiTheme: CODE_THEMES,
151
153
  components: {