@parallelworks/ui 0.83.3 → 0.84.1

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.
Files changed (56) hide show
  1. package/dist/ai/adapter/openai/createOpenAIStream.js +1 -1
  2. package/dist/ai/adapter/openai/sse.d.ts +3 -2
  3. package/dist/ai/adapter/openai/sse.js +4 -2
  4. package/dist/ai/adapter/types.d.ts +2 -1
  5. package/dist/ai/agent/index.d.ts +1 -1
  6. package/dist/ai/agent/toolfmt.d.ts +1 -0
  7. package/dist/ai/agent/toolfmt.js +6 -2
  8. package/dist/ai/ai.css +14 -0
  9. package/dist/ai/components/ChatActivity.js +6 -4
  10. package/dist/ai/components/ChatMessage.js +42 -30
  11. package/dist/ai/components/ChatMessageList.js +17 -9
  12. package/dist/ai/components/agent/AgentMessageParts.d.ts +1 -1
  13. package/dist/ai/components/agent/AgentMessageParts.js +77 -23
  14. package/dist/ai/core/ChatProvider.d.ts +3 -1
  15. package/dist/ai/core/ChatProvider.js +27 -6
  16. package/dist/ai/core/chatReducer.d.ts +6 -1
  17. package/dist/ai/core/chatReducer.js +18 -1
  18. package/dist/ai/core/config.d.ts +2 -1
  19. package/dist/ai/core/parts.d.ts +7 -4
  20. package/dist/ai/core/parts.js +23 -2
  21. package/dist/ai/core/useSSEStream.d.ts +8 -0
  22. package/dist/ai/core/useSSEStream.js +80 -22
  23. package/dist/ai/index.js +2 -2
  24. package/dist/ai/types.d.ts +14 -0
  25. package/dist/components/Avatar.d.ts +4 -2
  26. package/dist/components/Avatar.js +2 -1
  27. package/dist/components/Kbd.d.ts +5 -2
  28. package/dist/components/Kbd.js +4 -1
  29. package/dist/components/LetterBadge.d.ts +7 -3
  30. package/dist/components/LetterBadge.js +2 -3
  31. package/dist/components/Loader.d.ts +6 -4
  32. package/dist/components/Loader.js +53 -1
  33. package/dist/components/MarkdownImpl.d.ts +3 -0
  34. package/dist/components/MarkdownImpl.js +16 -2
  35. package/dist/components/RefreshButton.d.ts +3 -2
  36. package/dist/components/RefreshButton.js +25 -14
  37. package/dist/components/RunStatusIcon.d.ts +5 -2
  38. package/dist/components/RunStatusIcon.js +2 -1
  39. package/dist/components/StatusBadge.d.ts +17 -11
  40. package/dist/components/StatusBadge.js +10 -5
  41. package/dist/components/Table.d.ts +11 -1
  42. package/dist/components/Table.js +15 -4
  43. package/dist/components/Tabs.js +1 -1
  44. package/dist/components/Tag.d.ts +5 -3
  45. package/dist/components/Tag.js +7 -1
  46. package/dist/components/relativeTime.d.ts +12 -9
  47. package/dist/components/relativeTime.js +4 -2
  48. package/dist/components/stickyTop.d.ts +9 -0
  49. package/dist/components/stickyTop.js +10 -1
  50. package/dist/graph/DependencyGraph.js +1 -1
  51. package/dist/index.d.ts +3 -3
  52. package/dist/list/ActionToolbar.js +1 -1
  53. package/dist/list/ListPage.js +32 -14
  54. package/dist/list/ListTable.js +13 -4
  55. package/dist/styles.css +1 -1
  56. package/package.json +1 -1
@@ -35,7 +35,7 @@ function createOpenAICompatibleStream(config) {
35
35
  if (!res.ok) throw new ChatAdapterError(await errorMessageFromResponse(res), { status: res.status });
36
36
  const reader = res.body?.getReader();
37
37
  if (!reader) throw new ChatAdapterError("No response body");
38
- return processSSEStream(reader, handlers.onContent, void 0, void 0, handlers.onReasoning, handlers.onMessageId, useResponses ? parseResponsesSSELine : void 0, handlers.onPart);
38
+ return processSSEStream(reader, handlers.onContent, void 0, void 0, handlers.onReasoning, handlers.onMessageId, useResponses ? parseResponsesSSELine : void 0, handlers.onPart, handlers.onUsage);
39
39
  };
40
40
  }
41
41
  //#endregion
@@ -1,4 +1,4 @@
1
- import type { ToolCall, ToolCallDelta } from '../../types.js';
1
+ import type { TokenUsage, ToolCall, ToolCallDelta } from '../../types.js';
2
2
  import type { PartDelta } from '../types.js';
3
3
  import type { ToolApprovalChunk, ToolResultChunk } from './wire.js';
4
4
  export type ParsedSSELine = {
@@ -13,6 +13,7 @@ export type ParsedSSELine = {
13
13
  model?: string | undefined;
14
14
  error?: string | undefined;
15
15
  responsesOutput?: unknown[] | undefined;
16
+ usage?: TokenUsage | undefined;
16
17
  } | null;
17
18
  export type SSELineParser = (line: string) => ParsedSSELine;
18
19
  export declare function parseSSELine(line: string): ParsedSSELine;
@@ -20,7 +21,7 @@ export declare function parseSSELine(line: string): ParsedSSELine;
20
21
  * calls stream as parts; a call the gateway answered with a result is left
21
22
  * off the returned `toolCalls`, which then hold only calls the caller must
22
23
  * run. */
23
- export declare function processSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>, onContent: (content: string) => void, onToolCall?: (toolCalls: ToolCall[]) => void, onError?: (error: string) => void, onReasoning?: (reasoning: string) => void, onMessageId?: (messageId: string) => void, parseLine?: SSELineParser, onPart?: (delta: PartDelta) => void): Promise<{
24
+ export declare function processSSEStream(reader: ReadableStreamDefaultReader<Uint8Array>, onContent: (content: string) => void, onToolCall?: (toolCalls: ToolCall[]) => void, onError?: (error: string) => void, onReasoning?: (reasoning: string) => void, onMessageId?: (messageId: string) => void, parseLine?: SSELineParser, onPart?: (delta: PartDelta) => void, onUsage?: (usage: TokenUsage) => void): Promise<{
24
25
  content: string;
25
26
  messageId: string;
26
27
  toolCalls: ToolCall[];
@@ -17,7 +17,8 @@ function parseSSELine(line) {
17
17
  toolResult: chunk.toolResult,
18
18
  toolApproval: chunk.toolApproval,
19
19
  finishReason: choice?.finish_reason,
20
- model: chunk.model
20
+ model: chunk.model,
21
+ usage: chunk.usage
21
22
  };
22
23
  } catch {
23
24
  return null;
@@ -27,7 +28,7 @@ function parseSSELine(line) {
27
28
  * calls stream as parts; a call the gateway answered with a result is left
28
29
  * off the returned `toolCalls`, which then hold only calls the caller must
29
30
  * run. */
30
- async function processSSEStream(reader, onContent, onToolCall, onError, onReasoning, onMessageId, parseLine = parseSSELine, onPart) {
31
+ async function processSSEStream(reader, onContent, onToolCall, onError, onReasoning, onMessageId, parseLine = parseSSELine, onPart, onUsage) {
31
32
  const decoder = new TextDecoder();
32
33
  let assistantContent = "";
33
34
  let assistantReasoning = "";
@@ -77,6 +78,7 @@ async function processSSEStream(reader, onContent, onToolCall, onError, onReason
77
78
  onReasoning?.(parsed.reasoningContent);
78
79
  }
79
80
  if (parsed.finishReason) finishReason = parsed.finishReason;
81
+ if (parsed.usage) onUsage?.(parsed.usage);
80
82
  if (parsed.responsesOutput?.length) responsesOutput = parsed.responsesOutput;
81
83
  if (parsed.toolCalls) for (const tc of parsed.toolCalls) {
82
84
  if (tc.index === void 0) continue;
@@ -1,4 +1,4 @@
1
- import type { Allocation, ApprovalPart, AttachmentRef, ChatModel, Conversation, ConversationSummary, ProviderInfo, ProviderIssue, ShareGroup, SharePermission, SharePermissionLevel, TodoItem, ToolCall, ToolDecision, UnreachableSession } from '../types.js';
1
+ import type { Allocation, ApprovalPart, AttachmentRef, ChatModel, Conversation, ConversationSummary, ProviderInfo, ProviderIssue, ShareGroup, SharePermission, SharePermissionLevel, TodoItem, TokenUsage, ToolCall, ToolDecision, UnreachableSession } from '../types.js';
2
2
  import type { CompletionMessage } from './openai/wire.js';
3
3
  export declare class ChatAdapterError extends Error {
4
4
  status?: number | undefined;
@@ -36,6 +36,7 @@ export interface StreamHandlers {
36
36
  onReasoning?: (reasoning: string) => void;
37
37
  onMessageId?: (messageId: string) => void;
38
38
  onPart?: (delta: PartDelta) => void;
39
+ onUsage?: (usage: TokenUsage) => void;
39
40
  }
40
41
  export type PartDelta = {
41
42
  type: 'text' | 'reasoning';
@@ -2,6 +2,6 @@ export { classifyPseudoUserMessage } from './classifier.js';
2
2
  export { computeEditDiff, computeWriteRows, type DiffRow, type EditDiffModel, type EditToolArgs, editSummary, isEditDiffTool, lineDelta, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, parseEditArgs, type WriteModel, wordEmphasisRanges, } from './diff.js';
3
3
  export { type DiffToken, languageForPath, tokenizeDiffLines, } from './diffHighlight.js';
4
4
  export { clampPermissionMode, DEFAULT_PERMISSION_MODE, isNamedPermissionMode, NAMED_PERMISSION_MODES, type NamedPermissionMode, type PermissionMode, permissionModesUpTo, } from './permissions.js';
5
- export { AGENT_COLOR_NAMES, agentColorHex, bashExitCode, clip, fullToolArgs, isGroupableTool, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, summarizeToolArgs, type ToolHeader, type ToolSummaryOptions, toolHeaderFor, toolSummary, } from './toolfmt.js';
5
+ export { AGENT_COLOR_NAMES, agentColorHex, bashExitCode, clip, fullToolArgs, isGroupableTool, resultFailed, rollupClauses, rollupSentence, sanitizeLabel, sanitizePanelText, summarizeToolArgs, type ToolHeader, type ToolSummaryOptions, toolHeaderFor, toolSummary, } from './toolfmt.js';
6
6
  export { defaultToolStrings, type ToolStrings } from './toolStrings.js';
7
7
  export { agentTypeLabel, askQuestionFromArgs, firstNonEmptyLine, hiddenTranscriptTool, INTERRUPTED_NOTICE, interruptionNotice, parseSubagentNotice, planFromArgs, type SubagentNotice, subagentDeliveryLine, taskCallFromArgs, thinkingHeading, } from './transcript.js';
@@ -19,5 +19,6 @@ export interface ToolSummaryOptions {
19
19
  export declare function toolSummary(name: string, args: string, result: string, isError: boolean, { editSummary, strings: t }?: ToolSummaryOptions): string;
20
20
  export declare function isGroupableTool(name: string): boolean;
21
21
  export declare function rollupSentence(names: string[], t?: ToolStrings): string;
22
+ export declare function rollupClauses(names: string[], t?: ToolStrings): string[];
22
23
  export declare const AGENT_COLOR_NAMES: readonly ['blue', 'green', 'yellow', 'purple', 'cyan', 'orange', 'pink', 'red'];
23
24
  export declare function agentColorHex(name: string | undefined): string | null;
@@ -325,6 +325,10 @@ function isGroupableTool(name) {
325
325
  return !NON_GROUPABLE_TOOLS.has(name);
326
326
  }
327
327
  function rollupSentence(names, t = defaultToolStrings) {
328
+ const parts = rollupClauses(names, t);
329
+ return parts.length === 0 ? "" : t.rollup.sentence(parts);
330
+ }
331
+ function rollupClauses(names, t = defaultToolStrings) {
328
332
  const counts = ROLLUP_CATEGORIES.map(() => 0);
329
333
  for (const name of names) for (let i = 0; i < ROLLUP_CATEGORIES.length; i++) if (ROLLUP_CATEGORIES[i]?.[0](name)) {
330
334
  counts[i] = (counts[i] ?? 0) + 1;
@@ -338,7 +342,7 @@ function rollupSentence(names, t = defaultToolStrings) {
338
342
  if (cat) parts.push(t.rollup[cat[1]](n));
339
343
  }
340
344
  }
341
- return parts.length === 0 ? "" : t.rollup.sentence(parts);
345
+ return parts;
342
346
  }
343
347
  var AGENT_COLOR_NAMES = [
344
348
  "blue",
@@ -364,4 +368,4 @@ function agentColorHex(name) {
364
368
  return name ? AGENT_COLOR_HEX[name] ?? null : null;
365
369
  }
366
370
  //#endregion
367
- export { AGENT_COLOR_NAMES, agentColorHex, bashExitCode, clip, fullToolArgs, isGroupableTool, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, summarizeToolArgs, toolHeaderFor, toolSummary };
371
+ export { AGENT_COLOR_NAMES, agentColorHex, bashExitCode, clip, fullToolArgs, isGroupableTool, resultFailed, rollupClauses, rollupSentence, sanitizeLabel, sanitizePanelText, summarizeToolArgs, toolHeaderFor, toolSummary };
package/dist/ai/ai.css CHANGED
@@ -444,6 +444,20 @@
444
444
  color: var(--chat-accent);
445
445
  }
446
446
 
447
+ /* A list's caret follows its last item's text rather than sitting on a line
448
+ of its own under the list. */
449
+ .markdown-streaming > :is(ul, ol):last-child:not([data-sd-caret-hidden])::after {
450
+ content: none;
451
+ }
452
+ .markdown-streaming > :is(ul, ol):last-child > li:last-child::after {
453
+ display: inline;
454
+ vertical-align: baseline;
455
+ content: var(--streamdown-caret);
456
+ }
457
+ .chat-thread .markdown-streaming > :is(ul, ol):last-child > li:last-child::after {
458
+ color: var(--chat-accent);
459
+ }
460
+
447
461
  /* Selected text inside the chat takes the accent too. */
448
462
  .chat-thread ::selection,
449
463
  .chat-composer ::selection {
@@ -9,8 +9,8 @@ import { DateTime } from "luxon";
9
9
  /** An agent session's working line for the provider's reply, naming its current step. */
10
10
  function ChatActivity() {
11
11
  const { isStreaming, tools } = useChat();
12
- const { streamingParts } = useChatStream();
13
- const { strings } = useChatConfig();
12
+ const { streamingParts, streamingUsage } = useChatStream();
13
+ const { strings, slots } = useChatConfig();
14
14
  const [startedAt, setStartedAt] = useState(null);
15
15
  useEffect(() => {
16
16
  setStartedAt(isStreaming ? DateTime.now().toUTC().toISO() : null);
@@ -20,11 +20,13 @@ function ChatActivity() {
20
20
  const running = streamingParts.findLast((p) => p.kind === "tool_call" && p.status === "running");
21
21
  let word;
22
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;
23
+ else if (running?.kind === "tool_call") word = slots.toolPresentation?.(running)?.label ?? tools.find((t) => t.id === running.name)?.label ?? toolHeaderFor(running.name).label;
24
24
  return /* @__PURE__ */ jsx(ActivityLine, {
25
25
  testId: "chat-activity",
26
26
  startedAt,
27
- word
27
+ word,
28
+ inputTokens: streamingUsage?.prompt_tokens,
29
+ outputTokens: streamingUsage?.completion_tokens
28
30
  });
29
31
  }
30
32
  //#endregion
@@ -46,7 +46,8 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
46
46
  const hasReasoning = !!message.reasoning;
47
47
  const toolCallCount = message.parts?.filter((p) => p.kind === "tool_call").length ?? 0;
48
48
  const showReasoningButton = !isUser && !isStreaming && hasReasoning;
49
- const showTurnDurationLabel = !isUser && !isStreaming && !hasReasoning && (!!message.reasoningDuration || toolCallCount > 0);
49
+ const narrated = !!message.parts?.some((p) => p.kind === "text");
50
+ const showTurnDurationLabel = !isUser && !isStreaming && !hasReasoning && (!!message.reasoningDuration || toolCallCount > 0 && (!narrated || !!askerName));
50
51
  const { messageMeta: t, thinking: tThinking } = useChatConfig().strings;
51
52
  const { common } = useStrings();
52
53
  const [editContent, setEditContent] = useState(message.content || "");
@@ -114,35 +115,42 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
114
115
  const said = replyText(message);
115
116
  return /* @__PURE__ */ jsxs("div", {
116
117
  className: cx("group py-3 w-full", !flush && "sm:px-4"),
117
- children: [/* @__PURE__ */ jsx(AgentMessageParts, {
118
- message: partsMessage,
119
- isStreaming,
120
- onApprovalAnswer: approvalAnswer
121
- }), !isUser && !isStreaming && (said || onRegenerate || meta) && /* @__PURE__ */ jsxs("div", {
122
- className: "chat-actions -ml-1.5 mt-2 flex items-center gap-0.5",
123
- children: [
124
- said && /* @__PURE__ */ jsx(CopyButton, {
125
- text: said,
126
- label: t.copyLabel,
127
- quiet: true,
128
- size: "md",
129
- className: actionClasses
130
- }),
131
- onRegenerate && /* @__PURE__ */ jsx(IconButton, {
132
- label: t.regenerate,
133
- onClick: () => onRegenerate(message.id),
134
- size: "md",
135
- className: actionClasses,
136
- icon: /* @__PURE__ */ jsx(RetryIcon, {})
137
- }),
138
- onNavigateBranch && /* @__PURE__ */ jsx(BranchNavigator, {
139
- messages: allMessages,
140
- currentMessageId: message.id,
141
- onNavigate: onNavigateBranch
142
- }),
143
- meta
144
- ]
145
- })]
118
+ children: [
119
+ /* @__PURE__ */ jsx(AgentMessageParts, {
120
+ message: partsMessage,
121
+ isStreaming,
122
+ onApprovalAnswer: approvalAnswer
123
+ }),
124
+ isStreaming && !isUser && /* @__PURE__ */ jsx("div", {
125
+ "aria-hidden": true,
126
+ className: "mt-2 h-7"
127
+ }),
128
+ !isUser && !isStreaming && (said || onRegenerate || meta) && /* @__PURE__ */ jsxs("div", {
129
+ className: "chat-actions -ml-1.5 mt-2 flex items-center gap-0.5",
130
+ children: [
131
+ said && /* @__PURE__ */ jsx(CopyButton, {
132
+ text: said,
133
+ label: t.copyLabel,
134
+ quiet: true,
135
+ size: "md",
136
+ className: actionClasses
137
+ }),
138
+ onRegenerate && /* @__PURE__ */ jsx(IconButton, {
139
+ label: t.regenerate,
140
+ onClick: () => onRegenerate(message.id),
141
+ size: "md",
142
+ className: actionClasses,
143
+ icon: /* @__PURE__ */ jsx(RetryIcon, {})
144
+ }),
145
+ onNavigateBranch && /* @__PURE__ */ jsx(BranchNavigator, {
146
+ messages: allMessages,
147
+ currentMessageId: message.id,
148
+ onNavigate: onNavigateBranch
149
+ }),
150
+ meta
151
+ ]
152
+ })
153
+ ]
146
154
  });
147
155
  }
148
156
  if (isTool) return null;
@@ -266,6 +274,10 @@ function ChatMessage({ message, allMessages, isStreaming = false, latest = false
266
274
  onOpen: onOpenAttachment,
267
275
  className: isRightAligned ? "justify-end" : void 0
268
276
  }),
277
+ isStreaming && !isUser && /* @__PURE__ */ jsx("div", {
278
+ "aria-hidden": true,
279
+ className: "mt-2 h-7"
280
+ }),
269
281
  !isStreaming && !isEditing && /* @__PURE__ */ jsxs("div", {
270
282
  className: cx("chat-actions flex items-center gap-0.5 transition-opacity duration-150", isRightAligned ? "absolute right-0 top-full -mr-1.5 mt-0.5" : "-ml-1.5 mt-2", isRightAligned && "pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"),
271
283
  children: [
@@ -19,7 +19,8 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
19
19
  const { isThinking, streamingContent, streamingReasoning, streamingParts, thinkingStartTime } = useChatStream();
20
20
  const isStreaming = isStreamingProp ?? providerStreaming;
21
21
  const conversationId = messages[0]?.conversationId;
22
- const providerStreamingMessage = useMemo(() => providerStreaming && streamingContent ? {
22
+ const textInParts = streamingParts.some((part) => part.kind === "text");
23
+ const providerStreamingMessage = useMemo(() => providerStreaming && streamingContent && !textInParts ? {
23
24
  id: "streaming",
24
25
  role: "assistant",
25
26
  content: streamingContent,
@@ -28,6 +29,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
28
29
  } : null, [
29
30
  providerStreaming,
30
31
  streamingContent,
32
+ textInParts,
31
33
  conversationId
32
34
  ]);
33
35
  const streamingMessage = streamingMessageProp === void 0 ? providerStreamingMessage : streamingMessageProp;
@@ -99,7 +101,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
99
101
  }, []);
100
102
  const hasContent = messages.length > 0 || !!streamingMessage;
101
103
  const awaitingApproval = streamingParts.some((part) => part.kind === "approval" && !part.resolved);
102
- const showWorkingIndicator = workingIndicator && isStreaming && !streamingReasoning && !streamingMessage && !awaitingApproval;
104
+ const showWorkingIndicator = workingIndicator && isStreaming && !streamingReasoning && !streamingMessage && !textInParts && !awaitingApproval;
103
105
  useEffect(() => {
104
106
  const container = containerRef.current;
105
107
  if (!container) return;
@@ -145,8 +147,11 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
145
147
  isStreaming,
146
148
  hasQueuedMessages
147
149
  ]);
150
+ const wasStreamingRef = useRef(isStreaming);
148
151
  useEffect(() => {
149
- if (!containerRef.current || !isNearBottomRef.current || isStreaming || !hadMessagesRef.current) return;
152
+ const streamFinished = wasStreamingRef.current && !isStreaming;
153
+ wasStreamingRef.current = isStreaming;
154
+ if (!containerRef.current || !isNearBottomRef.current || isStreaming || streamFinished || !hadMessagesRef.current) return;
150
155
  const el = containerRef.current;
151
156
  el.scrollTo({
152
157
  top: el.scrollHeight,
@@ -224,7 +229,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
224
229
  isStreaming && streamingReasoning && /* @__PURE__ */ jsx("div", {
225
230
  className: "animate-chat-enter",
226
231
  children: /* @__PURE__ */ jsx("div", {
227
- className: cx("pt-3", !flush && "px-4"),
232
+ className: cx("pt-3", !flush && "sm:px-4"),
228
233
  children: /* @__PURE__ */ jsx(Reasoning, {
229
234
  label: /* @__PURE__ */ jsx(ThinkingLabel, {
230
235
  startedAt: isThinking ? thinkingStartTime : void 0,
@@ -253,7 +258,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
253
258
  })
254
259
  }, "thinking"),
255
260
  isStreaming && streamingParts.length > 0 && /* @__PURE__ */ jsx("div", {
256
- className: cx("animate-chat-enter py-1", !flush && "px-4"),
261
+ className: cx("animate-chat-enter py-1", !flush && "sm:px-4"),
257
262
  children: /* @__PURE__ */ jsx(AgentMessageParts, {
258
263
  message: streamingPartsMessage,
259
264
  isStreaming
@@ -262,7 +267,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
262
267
  showWorkingIndicator && /* @__PURE__ */ jsx("div", {
263
268
  className: "animate-chat-enter",
264
269
  children: /* @__PURE__ */ jsx("div", {
265
- className: cx("py-3", !flush && "px-4"),
270
+ className: cx("py-3", !flush && "sm:px-4"),
266
271
  children: /* @__PURE__ */ jsx("div", {
267
272
  className: "animate-shimmer text-shimmer text-sm",
268
273
  children: tThinking.ellipsis
@@ -294,7 +299,7 @@ function ChatMessageList({ messages, allMessages, streamingMessage: streamingMes
294
299
  onOpenAttachment
295
300
  }), /* @__PURE__ */ jsxs("div", {
296
301
  "data-testid": "queued-status",
297
- className: cx("-mt-4 mb-2 flex items-center justify-end gap-2 text-xs theme-muted-text", !flush && "px-4"),
302
+ className: cx("-mt-4 mb-2 flex items-center justify-end gap-2 text-xs theme-muted-text", !flush && "sm:px-4"),
298
303
  children: [
299
304
  removable ? /* @__PURE__ */ jsx(ClockIcon, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(CheckIcon, { className: "h-3 w-3" }),
300
305
  /* @__PURE__ */ jsx("span", { children: status }),
@@ -355,15 +360,18 @@ function ThinkingLabel({ startedAt, finalDuration }) {
355
360
  * for every streamed token, so a long conversation is not walked per token. */
356
361
  var MessageRows = memo(function MessageRows({ messages, allMessages, streaming, flush, editingMessageId, showAuthor, currentUsername, attachmentDownloadUrl, onOpenAttachment, onNavigateBranch, onEditMessage, onSaveEdit, onCancelEdit, onRegenerateMessage, onRetryMessage, onOpenReasoning, onApprovalAnswer }) {
357
362
  const shownRef = useRef(/* @__PURE__ */ new Set());
363
+ const wasStreamingRef = useRef(streaming);
358
364
  const shown = shownRef.current;
359
365
  const appended = shown.size > 0 && messages.some((m) => shown.has(m.id));
366
+ const streamFinished = wasStreamingRef.current && !streaming;
360
367
  useLayoutEffect(() => {
361
368
  shownRef.current = new Set(messages.map((m) => m.id));
362
- }, [messages]);
369
+ wasStreamingRef.current = streaming;
370
+ }, [messages, streaming]);
363
371
  const lastIndex = messages.findLastIndex((m) => !m.pseudo);
364
372
  return messages.map((message, i) => {
365
373
  if (message.pseudo) return null;
366
- const isNew = appended && !shown.has(message.id);
374
+ const isNew = appended && !shown.has(message.id) && !(streamFinished && message.role === "assistant");
367
375
  return /* @__PURE__ */ jsx("div", {
368
376
  className: isNew ? "animate-chat-enter" : void 0,
369
377
  children: /* @__PURE__ */ jsx(ChatMessage_default, {
@@ -26,7 +26,7 @@ type RenderItem = {
26
26
  index: number;
27
27
  }[];
28
28
  };
29
- export declare function buildRenderItems(parts: MessagePart[], hostDrawn?: (toolName: string) => boolean): RenderItem[];
29
+ export declare function buildRenderItems(parts: MessagePart[], hostDrawn?: (toolName: string) => boolean, joinsRollup?: (part: ToolCallPart) => boolean): RenderItem[];
30
30
  export default function AgentMessageParts({ message, isStreaming, onApprovalAnswer, }: {
31
31
  message: PartRendererProps['message'];
32
32
  isStreaming?: boolean;
@@ -9,7 +9,7 @@ import { Disclosure } from "../../../components/Disclosure.js";
9
9
  import { formatDuration } from "../../../format.js";
10
10
  import { RelativeTime } from "../../../components/relativeTime.js";
11
11
  import { LogViewer } from "../../../logviewer/LogViewer.js";
12
- import { agentColorHex, bashExitCode, fullToolArgs, isGroupableTool, rollupSentence, summarizeToolArgs, toolHeaderFor, toolSummary } from "../../agent/toolfmt.js";
12
+ import { agentColorHex, bashExitCode, fullToolArgs, isGroupableTool, rollupClauses, summarizeToolArgs, toolHeaderFor, toolSummary } from "../../agent/toolfmt.js";
13
13
  import { computeEditDiff, computeWriteRows, editSummary, isEditDiffTool, parseEditArgs } from "../../agent/diff.js";
14
14
  import { languageForPath, tokenizeDiffLines } from "../../agent/diffHighlight.js";
15
15
  import { thinkingHeading } from "../../agent/transcript.js";
@@ -232,13 +232,22 @@ function ToolStatusDot({ part }) {
232
232
  }
233
233
  /** A part's toggle row: its status mark and name, across the transcript's width. */
234
234
  var rowSummaryClasses = "min-h-6 w-full";
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. */
235
+ function RowSummary({ text }) {
236
+ return text ? /* @__PURE__ */ jsx("div", {
237
+ className: "ml-5 mt-0.5 truncate text-[13px] theme-muted-text",
238
+ children: text
239
+ }) : null;
240
+ }
241
+ /** A call's row: its status and signature, or the host's words for it, opening
242
+ * to its full arguments and result. `children` is a host's drawing of the
243
+ * call, shown under the row in place of the one-line summary, or inside it
244
+ * when the host names the call. */
238
245
  function ToolCallBlock({ part, children }) {
239
- const { strings } = useChatConfig();
246
+ const { strings, slots } = useChatConfig();
240
247
  const t = strings.agentTranscript;
241
248
  const [expanded, setExpanded] = useState(false);
249
+ const presentation = slots.toolPresentation?.(part);
250
+ const drawingInPanel = !!children && !!presentation && !presentation.open && part.status !== "waiting";
242
251
  const header = toolHeaderFor(part.name);
243
252
  const argSummary = summarizeToolArgs(header.arg, part.args);
244
253
  const isDiff = isEditDiffTool(part.name);
@@ -262,7 +271,10 @@ function ToolCallBlock({ part, children }) {
262
271
  onOpenChange: setExpanded,
263
272
  disabled: isDiff,
264
273
  summaryClassName: rowSummaryClasses,
265
- summary: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ToolStatusDot, { part }), /* @__PURE__ */ jsxs("span", {
274
+ summary: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ToolStatusDot, { part }), presentation ? /* @__PURE__ */ jsx("span", {
275
+ className: "text-[13px] leading-5 min-w-0 truncate theme-text",
276
+ children: presentation.label
277
+ }) : /* @__PURE__ */ jsxs("span", {
266
278
  className: "font-mono text-[13px] leading-5 min-w-0 truncate",
267
279
  children: [/* @__PURE__ */ jsx("span", {
268
280
  className: "font-semibold",
@@ -277,23 +289,26 @@ function ToolCallBlock({ part, children }) {
277
289
  })]
278
290
  })] }),
279
291
  panelClassName: "ml-5 mt-1 space-y-1.5",
280
- children: [fullToolArgs(header.arg, part.args) && /* @__PURE__ */ jsx("pre", {
292
+ children: [fullToolArgs(header.arg, part.args) && /* @__PURE__ */ jsxs("pre", {
281
293
  className: "overflow-x-auto rounded-md bg-(--theme-muted-panel-bg) p-2 font-mono text-xs whitespace-pre-wrap",
282
- children: fullToolArgs(header.arg, part.args)
283
- }), /* @__PURE__ */ jsx(ResultBody, { part })]
294
+ children: [presentation && /* @__PURE__ */ jsxs("span", {
295
+ className: "font-semibold",
296
+ children: [header.label, " "]
297
+ }), fullToolArgs(header.arg, part.args)]
298
+ }), drawingInPanel ? /* @__PURE__ */ jsx("div", {
299
+ className: "min-w-0",
300
+ children
301
+ }) : /* @__PURE__ */ jsx(ResultBody, { part })]
284
302
  }),
285
303
  part.status === "error" && !expanded && /* @__PURE__ */ jsx("div", {
286
304
  className: "ml-5 mt-0.5 truncate text-[13px] text-(--theme-error-text)",
287
305
  children: t.failedWith(summary)
288
306
  }),
289
- children && /* @__PURE__ */ jsx("div", {
307
+ children && !drawingInPanel && /* @__PURE__ */ jsx("div", {
290
308
  className: "mt-1.5 min-w-0 sm:ml-5",
291
309
  children
292
310
  }),
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",
295
- children: summary
296
- }),
311
+ !isDiff && !expanded && part.status !== "waiting" && part.status !== "error" && (!children || drawingInPanel) && /* @__PURE__ */ jsx(RowSummary, { text: part.status === "running" ? summary : presentation?.summary ?? (drawingInPanel ? void 0 : summary) }),
297
312
  isDiff && /* @__PURE__ */ jsx("div", {
298
313
  className: "ml-5",
299
314
  children: /* @__PURE__ */ jsx(EditDiffBlock, { part })
@@ -810,7 +825,7 @@ function mergeReasoning(parts) {
810
825
  ...durationMs > 0 ? { durationMs } : {}
811
826
  };
812
827
  }
813
- function buildRenderItems(parts, hostDrawn = () => false) {
828
+ function buildRenderItems(parts, hostDrawn = () => false, joinsRollup = () => false) {
814
829
  const items = [];
815
830
  let run = [];
816
831
  const emitThoughts = (members) => {
@@ -852,7 +867,7 @@ function buildRenderItems(parts, hostDrawn = () => false) {
852
867
  run = [];
853
868
  };
854
869
  const decided = new Set(parts.flatMap((p) => p.kind === "tool_call" && p.decision ? [p.id] : []));
855
- const drawnByHost = new Set(parts.flatMap((p) => p.kind === "tool_call" && hostDrawn(p.name) ? [p.id] : []));
870
+ const drawnByHost = new Set(parts.flatMap((p) => p.kind === "tool_call" && hostDrawn(p.name) && !joinsRollup(p) ? [p.id] : []));
856
871
  parts.forEach((part, index) => {
857
872
  if (part.kind === "approval" && part.resolved && decided.has(part.id)) return;
858
873
  if (part.kind === "approval" && drawnByHost.has(part.id)) return;
@@ -903,8 +918,36 @@ function groupRepeatedCalls(items) {
903
918
  return out;
904
919
  }
905
920
  function RollupGroup({ members, message }) {
906
- const { strings } = useChatConfig();
907
- const sentence = rollupSentence(members.flatMap((m) => m.part.kind === "tool_call" ? [m.part.name] : []), strings.tools);
921
+ const { strings, slots } = useChatConfig();
922
+ const { sentence, allNamed } = useMemo(() => {
923
+ const builtin = [];
924
+ const named = /* @__PURE__ */ new Map();
925
+ for (const m of members) {
926
+ if (m.part.kind !== "tool_call") continue;
927
+ const rollup = slots.toolPresentation?.(m.part)?.rollup;
928
+ if (!rollup) {
929
+ builtin.push(m.part.name);
930
+ continue;
931
+ }
932
+ const entry = named.get(m.part.name) ?? {
933
+ count: 0,
934
+ rollup
935
+ };
936
+ named.set(m.part.name, {
937
+ ...entry,
938
+ count: entry.count + 1
939
+ });
940
+ }
941
+ const clauses = [...[...named.values()].map((n) => n.rollup(n.count)), ...rollupClauses(builtin, strings.tools)];
942
+ return {
943
+ sentence: clauses.length ? strings.tools.rollup.sentence(clauses) : "",
944
+ allNamed: named.size > 0 && builtin.length === 0
945
+ };
946
+ }, [
947
+ members,
948
+ slots.toolPresentation,
949
+ strings.tools
950
+ ]);
908
951
  return /* @__PURE__ */ jsx("div", {
909
952
  className: "py-1.5 text-sm",
910
953
  "data-testid": "tool-rollup",
@@ -916,7 +959,7 @@ function RollupGroup({ members, message }) {
916
959
  "aria-hidden": "true",
917
960
  children: "●"
918
961
  }), /* @__PURE__ */ jsx("span", {
919
- className: "font-mono text-[13px] theme-muted-text min-w-0 truncate",
962
+ className: cx("text-[13px] min-w-0 truncate", allNamed ? "theme-text" : "font-mono theme-muted-text"),
920
963
  children: sentence
921
964
  })] }),
922
965
  panelClassName: "ml-1.5 mt-0.5 rounded-md bg-(--theme-muted-panel-bg)/40 px-2",
@@ -937,6 +980,9 @@ function CallGroup({ members, message }) {
937
980
  const name = members[0]?.part.name ?? "";
938
981
  const Tool = slots.toolRenderers?.[name];
939
982
  const header = toolHeaderFor(name);
983
+ const first = members[0]?.part;
984
+ const presentation = first ? slots.toolPresentation?.(first) : void 0;
985
+ const drawFolded = Tool && !(presentation && !presentation.open);
940
986
  const failed = members.filter((m) => m.part.status === "error").length;
941
987
  const status = failed === members.length ? "error" : "ok";
942
988
  return /* @__PURE__ */ jsxs("div", {
@@ -951,8 +997,8 @@ function CallGroup({ members, message }) {
951
997
  ...members[0]?.part,
952
998
  status
953
999
  } }), /* @__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", {
1000
+ className: cx("text-[13px] leading-5 min-w-0 truncate", presentation?.group ? "theme-text" : "font-mono"),
1001
+ children: [presentation?.group?.(members.length) ?? t.callGroup(header.label, members.length), failed > 0 && /* @__PURE__ */ jsxs("span", {
956
1002
  className: "text-(--theme-error-text)",
957
1003
  children: [" · ", t.failedCount(failed)]
958
1004
  })]
@@ -962,7 +1008,7 @@ function CallGroup({ members, message }) {
962
1008
  part: m.part,
963
1009
  message
964
1010
  }, m.index))
965
- }), !open && Tool && members.map(({ part, index }) => {
1011
+ }), !open && drawFolded && members.map(({ part, index }) => {
966
1012
  const args = summarizeToolArgs(header.arg, part.args);
967
1013
  return /* @__PURE__ */ jsxs("div", {
968
1014
  className: "mt-1.5 min-w-0 sm:ml-5 has-[>[data-drawing]:empty]:hidden",
@@ -985,7 +1031,15 @@ function AgentMessageParts({ message, isStreaming, onApprovalAnswer }) {
985
1031
  const parts = message.parts ?? [];
986
1032
  const { slots } = useChatConfig();
987
1033
  const toolRenderers = slots.toolRenderers;
988
- const items = useMemo(() => buildRenderItems(parts, (name) => !!toolRenderers?.[name]), [parts, toolRenderers]);
1034
+ const toolPresentation = slots.toolPresentation;
1035
+ const items = useMemo(() => buildRenderItems(parts, (name) => !!toolRenderers?.[name], (part) => {
1036
+ const p = toolPresentation?.(part);
1037
+ return !!p?.rollup && !p.open;
1038
+ }), [
1039
+ parts,
1040
+ toolRenderers,
1041
+ toolPresentation
1042
+ ]);
989
1043
  const lastIndex = parts.length - 1;
990
1044
  return /* @__PURE__ */ jsx("div", {
991
1045
  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 { ApprovalAnswerValue, 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, TokenUsage, 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 {
@@ -67,6 +67,8 @@ export interface ChatStream {
67
67
  streamingContent: string;
68
68
  streamingReasoning: string;
69
69
  streamingParts: MessagePart[];
70
+ /** Tokens the streaming reply has used so far, when the stream reports them. */
71
+ streamingUsage: TokenUsage | null;
70
72
  thinkingStartTime: number | null;
71
73
  }
72
74
  /** A model's display name by its id, when the adapter listed one; undefined