@parallelworks/ui 0.83.3 → 0.84.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.
Files changed (43) 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/MarkdownImpl.d.ts +3 -0
  32. package/dist/components/MarkdownImpl.js +16 -2
  33. package/dist/components/RunStatusIcon.d.ts +5 -2
  34. package/dist/components/RunStatusIcon.js +2 -1
  35. package/dist/components/StatusBadge.d.ts +17 -11
  36. package/dist/components/StatusBadge.js +10 -5
  37. package/dist/components/Tag.d.ts +5 -3
  38. package/dist/components/Tag.js +7 -1
  39. package/dist/components/relativeTime.d.ts +12 -9
  40. package/dist/components/relativeTime.js +4 -2
  41. package/dist/index.d.ts +3 -3
  42. package/dist/styles.css +1 -1
  43. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { toast } from "../../components/Toast.js";
2
2
  import { usePersistedState } from "../../components/Provider.js";
3
- import { applyPartDelta, finalizeParts, toolTurnMessages } from "./parts.js";
3
+ import { applyPartDelta, finalizeParts, splitAnswer, toolTurnMessages } from "./parts.js";
4
4
  import { chatReducer, initialState } from "./chatReducer.js";
5
5
  import { ChatConfigProvider, resolveChatConfig } from "./config.js";
6
6
  import { providerIssueFor } from "./providerIssues.js";
@@ -88,7 +88,7 @@ function useChatStream() {
88
88
  function ChatProvider({ adapter, currentUser, navigation, activeConversationId = null, config, sidebar: sidebarProp, onSidebarChange, drawerOpen: drawerOpenProp, onDrawerOpenChange, children }) {
89
89
  const [state, dispatch] = useReducer(chatReducer, initialState);
90
90
  const hasLoadedConversationsRef = useRef(false);
91
- const { onStreamingChunk, startStreaming, stopAnimationLoop, resetAnimationRefs, targetStreamContentRef, streamActiveRef, streamingConversationIdRef, backgroundStreamsRef, abortControllerRef } = useSSEStream(dispatch);
91
+ const { onStreamingChunk, startStreaming, stopAnimationLoop, drainReveal, afterReveal, resetAnimationRefs, targetStreamContentRef, streamActiveRef, streamingConversationIdRef, backgroundStreamsRef, abortControllerRef } = useSSEStream(dispatch);
92
92
  const currentConversationIdRef = useRef(null);
93
93
  const [storedModel, storeModel] = usePersistedState("aiChat.model", null);
94
94
  const storedModelRef = useRef(storedModel);
@@ -338,9 +338,15 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
338
338
  if (thinkingStart === null) thinkingStart = Date.now();
339
339
  };
340
340
  let liveParts = [];
341
+ let liveText = "";
341
342
  const { content: assistantContent, messageId: assistantMessageId, toolCalls, model: resolvedModel, reasoning: assistantReasoning, responsesOutput } = await adapter.streamCompletion(request, {
342
343
  onContent: (chunk) => {
343
344
  markStreamStart();
345
+ liveText += chunk;
346
+ if (liveParts.length) liveParts = applyPartDelta(liveParts, {
347
+ type: "text",
348
+ text: chunk
349
+ });
344
350
  onStreamingChunk(chunk);
345
351
  },
346
352
  onReasoning: (reasoning) => {
@@ -354,6 +360,13 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
354
360
  const bgStream = backgroundStreamsRef.current.get(targetConversationId);
355
361
  if (bgStream) bgStream.reasoning += reasoning;
356
362
  },
363
+ onUsage: (usage) => {
364
+ dispatch({
365
+ type: "SET_STREAMING_USAGE",
366
+ usage,
367
+ conversationId: targetConversationId
368
+ });
369
+ },
357
370
  onMessageId: (messageId) => {
358
371
  markStreamStart();
359
372
  const bgStream = backgroundStreamsRef.current.get(targetConversationId);
@@ -362,16 +375,20 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
362
375
  onPart: (delta) => {
363
376
  markStreamStart();
364
377
  if (routePart?.(delta)) return;
365
- liveParts = applyPartDelta(liveParts, delta);
366
- dispatch({
378
+ liveParts = applyPartDelta(liveParts.length || !liveText ? liveParts : [{
379
+ kind: "text",
380
+ text: liveText
381
+ }], delta);
382
+ afterReveal(() => dispatch({
367
383
  type: "APPLY_STREAMING_PART",
368
384
  delta,
369
385
  conversationId: targetConversationId
370
- });
386
+ }));
371
387
  const bgStream = backgroundStreamsRef.current.get(targetConversationId);
372
388
  if (bgStream) bgStream.parts = liveParts;
373
389
  }
374
390
  }, controller.signal);
391
+ await drainReveal();
375
392
  const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
376
393
  const assistantMessage = {
377
394
  id: assistantMessageId || crypto.randomUUID(),
@@ -385,7 +402,7 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
385
402
  reasoning: assistantReasoning || void 0,
386
403
  reasoningDuration: thinkingDuration ?? void 0,
387
404
  responsesOutput: responsesOutput.length ? responsesOutput : void 0,
388
- ...liveParts.length ? { parts: finalizeParts(liveParts) } : {}
405
+ ...liveParts.length ? splitAnswer(finalizeParts(liveParts)) : {}
389
406
  };
390
407
  dispatch({
391
408
  type: "ADD_MESSAGE",
@@ -426,6 +443,8 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
426
443
  state.selectedProvider,
427
444
  onStreamingChunk,
428
445
  stopAnimationLoop,
446
+ drainReveal,
447
+ afterReveal,
429
448
  streamingConversationIdRef,
430
449
  backgroundStreamsRef,
431
450
  abortControllerRef
@@ -977,12 +996,14 @@ function ChatProvider({ adapter, currentUser, navigation, activeConversationId =
977
996
  streamingContent: state.streamingContent,
978
997
  streamingReasoning: state.streamingReasoning,
979
998
  streamingParts: state.streamingParts,
999
+ streamingUsage: state.streamingUsage,
980
1000
  thinkingStartTime: state.thinkingStartTime
981
1001
  }), [
982
1002
  state.isThinking,
983
1003
  state.streamingContent,
984
1004
  state.streamingReasoning,
985
1005
  state.streamingParts,
1006
+ state.streamingUsage,
986
1007
  state.thinkingStartTime
987
1008
  ]);
988
1009
  return /* @__PURE__ */ jsx(ChatConfigProvider, {
@@ -1,5 +1,5 @@
1
1
  import type { PartDelta } from '../adapter/types.js';
2
- import type { ChatMessage, ChatModel, Conversation, ConversationSummary, MessagePart, ProviderInfo, ProviderIssue, UnreachableSession } from '../types.js';
2
+ import type { ChatMessage, ChatModel, Conversation, ConversationSummary, MessagePart, ProviderInfo, ProviderIssue, TokenUsage, UnreachableSession } from '../types.js';
3
3
  export interface ChatState {
4
4
  conversations: ConversationSummary[];
5
5
  currentConversation: Conversation | null;
@@ -19,6 +19,7 @@ export interface ChatState {
19
19
  streamingContent: string;
20
20
  streamingReasoning: string;
21
21
  streamingParts: MessagePart[];
22
+ streamingUsage: TokenUsage | null;
22
23
  thinkingStartTime: number | null;
23
24
  queuedMessages: ChatMessage[];
24
25
  }
@@ -70,6 +71,10 @@ export type ChatAction = {
70
71
  } | {
71
72
  type: 'CLEAR_STREAMING_CONTENT';
72
73
  conversationId?: string;
74
+ } | {
75
+ type: 'SET_STREAMING_USAGE';
76
+ usage: TokenUsage;
77
+ conversationId: string;
73
78
  } | {
74
79
  type: 'ADD_MESSAGE';
75
80
  message: ChatMessage;
@@ -19,6 +19,7 @@ var initialState = {
19
19
  streamingContent: "",
20
20
  streamingReasoning: "",
21
21
  streamingParts: [],
22
+ streamingUsage: null,
22
23
  thinkingStartTime: null,
23
24
  queuedMessages: []
24
25
  };
@@ -53,6 +54,7 @@ function chatReducer(state, action) {
53
54
  streamingContent: "",
54
55
  streamingReasoning: "",
55
56
  streamingParts: [],
57
+ streamingUsage: null,
56
58
  isStreaming: false,
57
59
  isThinking: false,
58
60
  thinkingStartTime: null,
@@ -67,6 +69,7 @@ function chatReducer(state, action) {
67
69
  streamingContent: "",
68
70
  streamingReasoning: "",
69
71
  streamingParts: [],
72
+ streamingUsage: null,
70
73
  isStreaming: false,
71
74
  isThinking: false,
72
75
  thinkingStartTime: null,
@@ -116,6 +119,10 @@ function chatReducer(state, action) {
116
119
  return {
117
120
  ...state,
118
121
  streamingContent: state.streamingContent + action.content,
122
+ streamingParts: state.streamingParts.length ? applyPartDelta(state.streamingParts, {
123
+ type: "text",
124
+ text: action.content
125
+ }) : state.streamingParts,
119
126
  isThinking: false
120
127
  };
121
128
  case "APPEND_STREAMING_REASONING":
@@ -130,7 +137,16 @@ function chatReducer(state, action) {
130
137
  if (state.currentConversation?.id !== action.conversationId) return state;
131
138
  return {
132
139
  ...state,
133
- streamingParts: applyPartDelta(state.streamingParts, action.delta)
140
+ streamingParts: applyPartDelta(state.streamingParts.length || !state.streamingContent || action.delta.type === "text" ? state.streamingParts : [{
141
+ kind: "text",
142
+ text: state.streamingContent
143
+ }], action.delta)
144
+ };
145
+ case "SET_STREAMING_USAGE":
146
+ if (state.currentConversation?.id !== action.conversationId) return state;
147
+ return {
148
+ ...state,
149
+ streamingUsage: action.usage
134
150
  };
135
151
  case "CLEAR_STREAMING_CONTENT":
136
152
  if (action.conversationId && state.currentConversation?.id !== action.conversationId) return state;
@@ -139,6 +155,7 @@ function chatReducer(state, action) {
139
155
  streamingContent: "",
140
156
  streamingReasoning: "",
141
157
  streamingParts: [],
158
+ streamingUsage: null,
142
159
  isThinking: false,
143
160
  thinkingStartTime: null
144
161
  };
@@ -1,7 +1,7 @@
1
1
  import { type ComponentType, type MouseEventHandler, type ReactNode } from 'react';
2
2
  import type { Components } from 'streamdown';
3
3
  import { type ChatStrings } from '../strings.js';
4
- import type { ApprovalAnswerValue, ApprovalPart, ChatLinkTarget, ChatMessage, CspKind, MessagePart, MessagePartKind, ToolCallPart } from '../types.js';
4
+ import type { ApprovalAnswerValue, ApprovalPart, ChatLinkTarget, ChatMessage, CspKind, MessagePart, MessagePartKind, ToolCallPart, ToolPresentation } from '../types.js';
5
5
  export interface PartRendererProps {
6
6
  part: MessagePart;
7
7
  message: ChatMessage;
@@ -36,6 +36,7 @@ export interface ChatUIConfig {
36
36
  providerIcon?: (cspKind: CspKind, className?: string) => ReactNode;
37
37
  partRenderers?: Partial<Record<MessagePartKind, ComponentType<PartRendererProps>>>;
38
38
  toolRenderers?: Record<string, ComponentType<ToolRendererProps>>;
39
+ toolPresentation?: (part: ToolCallPart) => ToolPresentation | undefined;
39
40
  };
40
41
  strings?: {
41
42
  [S in keyof ChatStrings]?: Partial<ChatStrings[S]>;
@@ -6,12 +6,15 @@ import type { MessagePart } from '../types.js';
6
6
  * and the provider's local accumulation. Unknown target ids are dropped
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
- /** The wire history for an assistant turn whose tool calls the gateway ran:
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. */
13
9
  export declare function toolTurnMessages(parts: MessagePart[] | null | undefined, answer: CompletionMessage): CompletionMessage[];
14
10
  /** A turn can end (completed or stopped) with tool calls still marked
15
11
  * running — the transport died before their tool_end arrived. A stored
16
12
  * message must not spin forever, so close them out as interrupted. */
17
13
  export declare function finalizeParts(parts: MessagePart[]): MessagePart[];
14
+ /** A finished reply whose text was streamed among its parts: the text after
15
+ * the last part is the answer, shown below them as the message's content,
16
+ * the shape a stored conversation is read back in. */
17
+ export declare function splitAnswer(parts: MessagePart[]): {
18
+ parts: MessagePart[];
19
+ content?: string | null;
20
+ };
@@ -102,6 +102,13 @@ function isStreamedText(part) {
102
102
  * each round's calls, with the text that came before them, and each result,
103
103
  * then the answer. A turn with no finished tool calls comes back as the
104
104
  * answer alone. */
105
+ function answerAfterCalls(parts, trailing, answer) {
106
+ if (parts.at(-1)?.kind === "text") return trailing || null;
107
+ const texts = parts.flatMap((p) => p.kind === "text" ? [p.text] : []);
108
+ const content = typeof answer.content === "string" ? answer.content : null;
109
+ if (!content || content === texts.join("\n\n") || content === texts.join("")) return null;
110
+ return content;
111
+ }
105
112
  function toolTurnMessages(parts, answer) {
106
113
  const rounds = [];
107
114
  let text = "";
@@ -129,7 +136,7 @@ function toolTurnMessages(parts, answer) {
129
136
  if (rounds.length === 0) return answer.content || answer.tool_calls?.length ? [answer] : [];
130
137
  const final = hasText ? {
131
138
  ...answer,
132
- content: text || null
139
+ content: answerAfterCalls(parts ?? [], text, answer)
133
140
  } : answer;
134
141
  return [...rounds.flatMap((round) => [{
135
142
  role: "assistant",
@@ -158,5 +165,19 @@ function finalizeParts(parts) {
158
165
  result: p.result ?? "interrupted"
159
166
  } : p);
160
167
  }
168
+ /** A finished reply whose text was streamed among its parts: the text after
169
+ * the last part is the answer, shown below them as the message's content,
170
+ * the shape a stored conversation is read back in. */
171
+ function splitAnswer(parts) {
172
+ if (!parts.some((p) => p.kind === "text")) return { parts };
173
+ const last = parts.at(-1);
174
+ return last?.kind === "text" ? {
175
+ parts: parts.slice(0, -1),
176
+ content: last.text
177
+ } : {
178
+ parts,
179
+ content: null
180
+ };
181
+ }
161
182
  //#endregion
162
- export { applyPartDelta, finalizeParts, toolTurnMessages };
183
+ export { applyPartDelta, finalizeParts, splitAnswer, toolTurnMessages };
@@ -16,6 +16,10 @@ interface UseSSEStreamResult {
16
16
  stopAnimationLoop: () => {
17
17
  unrevealed: string;
18
18
  };
19
+ /** Resolves once the buffered text has been revealed, or after maxMs */
20
+ drainReveal: (maxMs?: number) => Promise<void>;
21
+ /** Runs fn once the text received so far has been revealed */
22
+ afterReveal: (fn: () => void) => void;
19
23
  /** Reset all animation refs (used when navigating away) */
20
24
  resetAnimationRefs: () => void;
21
25
  /** Ref to the target content buffer */
@@ -29,5 +33,9 @@ interface UseSSEStreamResult {
29
33
  /** AbortController ref for canceling fetch requests */
30
34
  abortControllerRef: React.MutableRefObject<AbortController | null>;
31
35
  }
36
+ /** Moves a reveal boundary to the end of the word it falls in, when that end is near. */
37
+ export declare function toWordEnd(text: string, end: number): number;
38
+ /** Characters to reveal after dt ms with remaining characters buffered. */
39
+ export declare function revealCount(remaining: number, dt: number): number;
32
40
  export declare function useSSEStream(dispatch: React.Dispatch<ChatAction>): UseSSEStreamResult;
33
41
  export {};
@@ -1,40 +1,74 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  //#region src/ai/core/useSSEStream.ts
3
+ var MIN_CHARS_PER_MS = .12;
4
+ var CATCH_UP_MS = 300;
5
+ var MIN_DISPATCH_MS = 15;
6
+ var WORD_RUN_ON = 16;
7
+ /** Moves a reveal boundary to the end of the word it falls in, when that end is near. */
8
+ function toWordEnd(text, end) {
9
+ if (end >= text.length || /\s/.test(text[end - 1] ?? " ")) return end;
10
+ const limit = Math.min(text.length, end + WORD_RUN_ON);
11
+ for (let i = end; i < limit; i++) if (/\s/.test(text[i] ?? "")) return i;
12
+ return end;
13
+ }
14
+ /** Characters to reveal after dt ms with remaining characters buffered. */
15
+ function revealCount(remaining, dt) {
16
+ if (remaining <= 0) return 0;
17
+ const rate = Math.max(MIN_CHARS_PER_MS, remaining / CATCH_UP_MS);
18
+ return Math.min(remaining, Math.max(1, Math.round(rate * dt)));
19
+ }
3
20
  function useSSEStream(dispatch) {
4
21
  const targetStreamContentRef = useRef("");
5
22
  const revealedLengthRef = useRef(0);
6
23
  const streamRafRef = useRef(null);
7
24
  const streamActiveRef = useRef(false);
8
- const lastFrameTimeRef = useRef(0);
9
- const lastDispatchTimeRef = useRef(0);
25
+ const lastRevealTimeRef = useRef(0);
26
+ const drainWaitersRef = useRef([]);
27
+ const pendingRef = useRef([]);
10
28
  const streamingConversationIdRef = useRef(null);
11
29
  const backgroundStreamsRef = useRef(/* @__PURE__ */ new Map());
12
30
  const abortControllerRef = useRef(null);
31
+ const runPending = useCallback((revealed) => {
32
+ while (pendingRef.current.length && (pendingRef.current[0]?.at ?? 0) <= revealed) pendingRef.current.shift()?.fn();
33
+ }, []);
34
+ const settleDrain = useCallback(() => {
35
+ const waiters = drainWaitersRef.current;
36
+ drainWaitersRef.current = [];
37
+ for (const resolve of waiters) resolve();
38
+ }, []);
13
39
  const animateStreamContent = useCallback(() => {
14
40
  const now = performance.now();
15
41
  const target = targetStreamContentRef.current;
16
42
  const revealed = revealedLengthRef.current;
17
43
  const remaining = target.length - revealed;
18
44
  const conversationId = streamingConversationIdRef.current;
19
- const timeSinceDispatch = now - lastDispatchTimeRef.current;
20
- if (remaining > 0 && timeSinceDispatch >= 32 && conversationId) {
21
- const dt = lastFrameTimeRef.current ? now - lastFrameTimeRef.current : 32;
22
- const newRevealed = revealed + Math.min(remaining, Math.max(1, Math.round((remaining > 360 ? .48 : .24) * dt)));
23
- revealedLengthRef.current = newRevealed;
24
- dispatch({
25
- type: "APPEND_STREAMING_CONTENT",
26
- content: target.slice(revealed, newRevealed),
27
- conversationId
28
- });
29
- lastDispatchTimeRef.current = now;
45
+ if (remaining > 0 && conversationId) {
46
+ if (!lastRevealTimeRef.current) lastRevealTimeRef.current = now - MIN_DISPATCH_MS;
47
+ const dt = now - lastRevealTimeRef.current;
48
+ if (dt >= MIN_DISPATCH_MS) {
49
+ const newRevealed = toWordEnd(target, revealed + revealCount(remaining, dt));
50
+ revealedLengthRef.current = newRevealed;
51
+ dispatch({
52
+ type: "APPEND_STREAMING_CONTENT",
53
+ content: target.slice(revealed, newRevealed),
54
+ conversationId
55
+ });
56
+ lastRevealTimeRef.current = now;
57
+ runPending(newRevealed);
58
+ }
30
59
  }
31
- lastFrameTimeRef.current = now;
32
- if (streamActiveRef.current || revealedLengthRef.current < targetStreamContentRef.current.length) streamRafRef.current = requestAnimationFrame(animateStreamContent);
33
- else {
34
- streamRafRef.current = null;
35
- lastFrameTimeRef.current = 0;
60
+ const caughtUp = revealedLengthRef.current >= targetStreamContentRef.current.length;
61
+ if (caughtUp) {
62
+ settleDrain();
63
+ lastRevealTimeRef.current = 0;
36
64
  }
37
- }, [dispatch]);
65
+ if (streamActiveRef.current || !caughtUp) streamRafRef.current = requestAnimationFrame(animateStreamContent);
66
+ else streamRafRef.current = null;
67
+ }, [
68
+ dispatch,
69
+ settleDrain,
70
+ runPending
71
+ ]);
38
72
  const onStreamingChunk = useCallback((chunk) => {
39
73
  const convId = streamingConversationIdRef.current;
40
74
  if (convId) {
@@ -55,10 +89,32 @@ function useSSEStream(dispatch) {
55
89
  cancelAnimationFrame(streamRafRef.current);
56
90
  streamRafRef.current = null;
57
91
  }
92
+ runPending(Number.POSITIVE_INFINITY);
58
93
  targetStreamContentRef.current = "";
59
94
  revealedLengthRef.current = 0;
60
- lastFrameTimeRef.current = 0;
61
- lastDispatchTimeRef.current = 0;
95
+ lastRevealTimeRef.current = 0;
96
+ settleDrain();
97
+ }, [settleDrain, runPending]);
98
+ const drainReveal = useCallback((maxMs = 1500) => {
99
+ if (streamRafRef.current === null || revealedLengthRef.current >= targetStreamContentRef.current.length) return Promise.resolve();
100
+ return new Promise((resolve) => {
101
+ const timer = setTimeout(resolve, maxMs);
102
+ drainWaitersRef.current.push(() => {
103
+ clearTimeout(timer);
104
+ resolve();
105
+ });
106
+ });
107
+ }, []);
108
+ const afterReveal = useCallback((fn) => {
109
+ const at = targetStreamContentRef.current.length;
110
+ if (!streamActiveRef.current || revealedLengthRef.current >= at && pendingRef.current.length === 0) {
111
+ fn();
112
+ return;
113
+ }
114
+ pendingRef.current.push({
115
+ at,
116
+ fn
117
+ });
62
118
  }, []);
63
119
  const stopAnimationLoop = useCallback(() => {
64
120
  const unrevealed = targetStreamContentRef.current.slice(revealedLengthRef.current);
@@ -74,6 +130,8 @@ function useSSEStream(dispatch) {
74
130
  onStreamingChunk,
75
131
  startStreaming,
76
132
  stopAnimationLoop,
133
+ drainReveal,
134
+ afterReveal,
77
135
  resetAnimationRefs,
78
136
  targetStreamContentRef,
79
137
  streamActiveRef,
@@ -83,4 +141,4 @@ function useSSEStream(dispatch) {
83
141
  };
84
142
  }
85
143
  //#endregion
86
- export { useSSEStream };
144
+ export { revealCount, toWordEnd, useSSEStream };
package/dist/ai/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ChatAdapterError } from "./adapter/types.js";
2
2
  import { classifyPseudoUserMessage } from "./agent/classifier.js";
3
3
  import { defaultToolStrings } from "./agent/toolStrings.js";
4
- import { AGENT_COLOR_NAMES, agentColorHex, bashExitCode, clip, fullToolArgs, isGroupableTool, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, summarizeToolArgs, toolHeaderFor, toolSummary } from "./agent/toolfmt.js";
4
+ import { AGENT_COLOR_NAMES, agentColorHex, bashExitCode, clip, fullToolArgs, isGroupableTool, resultFailed, rollupClauses, rollupSentence, sanitizeLabel, sanitizePanelText, summarizeToolArgs, toolHeaderFor, toolSummary } from "./agent/toolfmt.js";
5
5
  import { MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, computeEditDiff, computeWriteRows, editSummary, isEditDiffTool, lineDelta, parseEditArgs, wordEmphasisRanges } from "./agent/diff.js";
6
6
  import { languageForPath, tokenizeDiffLines } from "./agent/diffHighlight.js";
7
7
  import { DEFAULT_PERMISSION_MODE, NAMED_PERMISSION_MODES, clampPermissionMode, isNamedPermissionMode, permissionModesUpTo } from "./agent/permissions.js";
@@ -46,4 +46,4 @@ import ChatMessageList from "./components/ChatMessageList.js";
46
46
  import SessionView from "./components/SessionView.js";
47
47
  import ChatThread from "./components/ChatThread.js";
48
48
  import { NoticeBar, NoticeCard, OutputCard } from "./ui/Notice.js";
49
- export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, CHAT_COLUMN_SCOPE, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatStage, ChatThread, ComposerContext, ComposerControls, ComposerSettings, ComposerUsage, ConnectToolsLink, ConversationLayout, ConversationSidebar, DEFAULT_PERMISSION_MODE, DRAWER_BELOW_PX, DragOverlay, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ManageProvidersLink, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, SessionView, ShareButton, ShareDialog, SidebarEmpty, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarRowBody, SidebarRowTitle, SidebarToggle, StageList, StageListRowContent, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, computeEditDiff, computeWriteRows, defaultChatStrings, defaultToolStrings, editSummary, finalizeParts, firstNonEmptyLine, fullToolArgs, getGreeting, getProviderKeyFromModelId, groupConversationsByDate, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, providerIssueFor, replyText, resolveChatConfig, resultFailed, rollupSentence, sanitizeLabel, sanitizePanelText, sidebarDrawerProps, slashMatches, stageListRowClassName, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useChatStream, useConversationActions, useConversationShortcuts, useDragDrop, useKeyboardShortcuts, useRowDialogs, useSessionMenu, wordEmphasisRanges };
49
+ export { AGENT_COLOR_NAMES, ActivityLine, AgentMessageParts, AllocationSelector, ApprovalBlock, AttachmentManager, AttachmentUpload, BlockedGroupBanner, BranchNavigator, CHAT_COLUMN_SCOPE, ChatAdapterError, ChatEmptyState, ChatInput, ChatLayout, ChatMessageList, ChatMessage_default as ChatMessageView, ChatProvider, ChatSidebar, ChatStage, ChatThread, ComposerContext, ComposerControls, ComposerSettings, ComposerUsage, ConnectToolsLink, ConversationLayout, ConversationSidebar, DEFAULT_PERMISSION_MODE, DRAWER_BELOW_PX, DragOverlay, FOCUS_SIDEBAR_SEARCH_EVENT, INTERRUPTED_NOTICE, KeyboardShortcutsProvider, MAX_EDIT_DIFF_LINES, MAX_WRITE_DIFF_LINES, ManageProvidersLink, ModelSelector, NAMED_PERMISSION_MODES, NoticeBar, NoticeCard, OutputCard, PermissionPicker, ProviderIssueBanner, RenameDialog, SessionView, ShareButton, ShareDialog, SidebarEmpty, SidebarGroupHeading, SidebarPanel, SidebarRailItem, SidebarRow, SidebarRowBody, SidebarRowTitle, SidebarToggle, StageList, StageListRowContent, agentColorHex, agentTypeLabel, applyPartDelta, askQuestionFromArgs, bashExitCode, chatReducer, clampPermissionMode, classifyPseudoUserMessage, clip, computeEditDiff, computeWriteRows, defaultChatStrings, defaultToolStrings, editSummary, finalizeParts, firstNonEmptyLine, fullToolArgs, getGreeting, getProviderKeyFromModelId, groupConversationsByDate, hiddenTranscriptTool, initialState, interruptionNotice, isEditDiffTool, isGroupableTool, isMac, isNamedPermissionMode, isOrgModel, languageForPath, lineDelta, parseEditArgs, parseSubagentNotice, pasteInlineMaxBytes, permissionModesUpTo, planFromArgs, providerIssueFor, replyText, resolveChatConfig, resultFailed, rollupClauses, rollupSentence, sanitizeLabel, sanitizePanelText, sidebarDrawerProps, slashMatches, stageListRowClassName, subagentDeliveryLine, summarizeToolArgs, taskCallFromArgs, thinkingHeading, tokenizeDiffLines, toolHeaderFor, toolSummary, useChat, useChatConfig, useChatStream, useConversationActions, useConversationShortcuts, useDragDrop, useKeyboardShortcuts, useRowDialogs, useSessionMenu, wordEmphasisRanges };
@@ -70,6 +70,20 @@ export interface ToolDecision {
70
70
  /** The reader changed the call's arguments before allowing it. */
71
71
  edited?: boolean;
72
72
  }
73
+ /** How a host names one of its own tool calls in a transcript, in place of
74
+ * the raw call: words a person reads, with the arguments behind the row. */
75
+ export interface ToolPresentation {
76
+ /** The row's label, such as "Checked costs for misc-dev". */
77
+ label: string;
78
+ /** One line under a finished row, such as "3 resources, $67.50". */
79
+ summary?: string;
80
+ /** A run of this tool's calls folded into one row, such as "Checked costs for 12 allocations". */
81
+ group?: (count: number) => string;
82
+ /** This tool's clause in a row that folds different tools, such as "checked costs for 12 allocations". */
83
+ rollup?: (count: number) => string;
84
+ /** Keeps the host's drawing open under the row, as for a card that asks or shows live progress. Otherwise it opens with the row. */
85
+ open?: boolean;
86
+ }
73
87
  export interface ToolCallPart {
74
88
  kind: 'tool_call';
75
89
  id: string;
@@ -5,7 +5,9 @@ export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away';
5
5
  * its color, tells it from a person's. */
6
6
  export type AvatarKind = 'person' | 'agent';
7
7
  export type AvatarShape = 'circle' | 'square';
8
- export interface AvatarProps {
8
+ /** Any other span attribute and the ref reach the avatar's outer element, so it can be a Tooltip's
9
+ * or a popover's direct child. */
10
+ export interface AvatarProps extends Omit<ComponentPropsWithRef<'span'>, 'children' | 'className'> {
9
11
  /** Image URL. Until it loads, or when it fails, the initials of `name` show. */
10
12
  src?: string | null | undefined;
11
13
  name?: string | undefined;
@@ -50,7 +52,7 @@ export declare function avatarInitials(name: string | undefined, { twoLetters }?
50
52
  * categorical palette (the default), or by kind, where people share `--avatar-person` (with
51
53
  * `--avatar-person-text`) and agents `--avatar-agent` (with `--avatar-agent-text`, and the
52
54
  * edge `--avatar-agent-ring`). */
53
- export declare function Avatar({ src, name, kind, shape, colorKey, colorClassName, size: sizeProp, status, decorative, tooltip, className, }: AvatarProps): import("react").JSX.Element;
55
+ export declare function Avatar({ src, name, kind, shape, colorKey, colorClassName, size: sizeProp, status, decorative, tooltip, className, ...rest }: AvatarProps): import("react").JSX.Element;
54
56
  /** Any other span attribute and the ref reach the stack's element, so the group can be a
55
57
  * Tooltip's or a popover's direct child. */
56
58
  export interface AvatarGroupProps extends Omit<ComponentPropsWithRef<'span'>, 'children' | 'className'> {
@@ -70,7 +70,7 @@ var AvatarGroupContext = createContext(null);
70
70
  * categorical palette (the default), or by kind, where people share `--avatar-person` (with
71
71
  * `--avatar-person-text`) and agents `--avatar-agent` (with `--avatar-agent-text`, and the
72
72
  * edge `--avatar-agent-ring`). */
73
- function Avatar$1({ src, name, kind = "person", shape = kind === "agent" ? "square" : "circle", colorKey, colorClassName, size: sizeProp, status, decorative = false, tooltip = false, className }) {
73
+ function Avatar$1({ src, name, kind = "person", shape = kind === "agent" ? "square" : "circle", colorKey, colorClassName, size: sizeProp, status, decorative = false, tooltip = false, className, ...rest }) {
74
74
  const { common } = useStrings();
75
75
  const byKind = useAvatarColoring() === "kind";
76
76
  const group = use(AvatarGroupContext);
@@ -82,6 +82,7 @@ function Avatar$1({ src, name, kind = "person", shape = kind === "agent" ? "squa
82
82
  const radius = shape === "square" ? SQUARE_CLASSES[size] : "rounded-full";
83
83
  const fill = colorClassName ?? (byKind ? KIND_CLASSES[kind] : colorBy ? categoryClasses(colorBy) : "bg-gradient-to-br from-[var(--gradient-start,var(--theme-accent))] to-[var(--gradient-mid,var(--theme-link))] text-(--theme-accent-text)");
84
84
  const avatar = /* @__PURE__ */ jsxs("span", {
85
+ ...rest,
85
86
  ...decorative ? { "aria-hidden": true } : {
86
87
  role: "img",
87
88
  "aria-label": named
@@ -1,3 +1,4 @@
1
+ import { type ComponentPropsWithRef } from 'react';
1
2
  import { type UIStrings } from './Provider.js';
2
3
  type KeyNames = UIStrings['keys'];
3
4
  /** Whether the keyboard is an Apple one, whose Command key does what Control does elsewhere. */
@@ -21,7 +22,9 @@ export declare function useShortcutFormat(platform?: KeyPlatform): {
21
22
  };
22
23
  export type KbdVariant = 'keycap' | 'plain';
23
24
  export type KbdSize = 'xs' | 'sm';
24
- export interface KbdProps {
25
+ /** Any other attribute and the ref reach the outer `<kbd>`, so it can be a Tooltip's or a popover's
26
+ * direct child. */
27
+ export interface KbdProps extends Omit<ComponentPropsWithRef<'kbd'>, 'children' | 'className'> {
25
28
  /** A combination joined by `+`, such as `Mod+K` (Command on an Apple keyboard, Control
26
29
  * elsewhere) or `Shift+Enter`; or keys side by side, as given: `['↑', '↓']`, `['1–9']`. */
27
30
  keys: string | readonly string[];
@@ -36,5 +39,5 @@ export interface KbdProps {
36
39
  /** Keys to press, as keycaps: a muted fill, a hairline border and monospace text. Off an
37
40
  * Apple keyboard the keys of a combination are joined with a plus, as the platform writes
38
41
  * them. Renders `<kbd>`, with a `<kbd>` for each key of a combination. */
39
- export declare function Kbd({ keys, variant, size, platform, className }: KbdProps): import("react").JSX.Element;
42
+ export declare function Kbd({ keys, variant, size, platform, className, ...rest }: KbdProps): import("react").JSX.Element;
40
43
  export {};
@@ -137,22 +137,25 @@ var keycap = {
137
137
  /** Keys to press, as keycaps: a muted fill, a hairline border and monospace text. Off an
138
138
  * Apple keyboard the keys of a combination are joined with a plus, as the platform writes
139
139
  * them. Renders `<kbd>`, with a `<kbd>` for each key of a combination. */
140
- function Kbd({ keys, variant = "keycap", size = "sm", platform, className }) {
140
+ function Kbd({ keys, variant = "keycap", size = "sm", platform, className, ...rest }) {
141
141
  const { keys: names } = useStrings();
142
142
  const on = platform ?? currentPlatform();
143
143
  const parts = formatShortcut(keys, names, on);
144
144
  const chord = typeof keys === "string";
145
145
  if (variant === "plain") return /* @__PURE__ */ jsx("kbd", {
146
+ ...rest,
146
147
  className: cx("font-sans text-(--theme-muted-text-color) tabular-nums tracking-wide", size === "xs" ? "text-[10px]" : "text-[11px]", className),
147
148
  children: chord ? shortcutText(keys, names, on) : parts.join(" ")
148
149
  });
149
150
  const cap = cx("inline-flex items-center justify-center rounded border border-(--theme-border) bg-(--theme-muted-panel-bg) font-mono leading-none font-normal text-(--theme-app) not-italic", keycap[size]);
150
151
  if (parts.length === 1) return /* @__PURE__ */ jsx("kbd", {
152
+ ...rest,
151
153
  className: cx(cap, className),
152
154
  children: parts[0]
153
155
  });
154
156
  const plus = chord && on !== "apple";
155
157
  return /* @__PURE__ */ jsx("kbd", {
158
+ ...rest,
156
159
  className: cx("inline-flex flex-wrap items-center gap-0.5 align-middle", className),
157
160
  children: parts.map((part, i) => /* @__PURE__ */ jsxs(Fragment, { children: [plus && i > 0 && /* @__PURE__ */ jsx("span", {
158
161
  "aria-hidden": "true",
@@ -1,6 +1,10 @@
1
+ import type { ComponentPropsWithRef } from 'react';
1
2
  /** A compact letter badge ("AI", "PUB", "SSH") used as a type mark in place of
2
3
  * an icon. Size the box and text via className (e.g. "h-5 w-5 text-[8px]"). */
3
- export declare function LetterBadge({ text, className }: {
4
+ /** Any other span attribute and the ref reach its element, so it can be a Tooltip's or a popover's
5
+ * direct child. */
6
+ export interface LetterBadgeProps extends Omit<ComponentPropsWithRef<'span'>, 'children' | 'className'> {
4
7
  text: string;
5
- className?: string;
6
- }): import("react").JSX.Element;
8
+ className?: string | undefined;
9
+ }
10
+ export declare function LetterBadge({ text, className, ...rest }: LetterBadgeProps): import("react").JSX.Element;
@@ -1,10 +1,9 @@
1
1
  import { cx } from "../cx.js";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  //#region src/components/LetterBadge.tsx
4
- /** A compact letter badge ("AI", "PUB", "SSH") used as a type mark in place of
5
- * an icon. Size the box and text via className (e.g. "h-5 w-5 text-[8px]"). */
6
- function LetterBadge({ text, className }) {
4
+ function LetterBadge({ text, className, ...rest }) {
7
5
  return /* @__PURE__ */ jsx("span", {
6
+ ...rest,
8
7
  className: cx("inline-flex shrink-0 items-center justify-center rounded-[3px] border theme-border font-bold leading-none tracking-tight text-(--theme-app)", className),
9
8
  children: text
10
9
  });
@@ -1,3 +1,6 @@
1
1
  import 'katex/dist/katex.min.css';
2
2
  import type { MarkdownProps } from './Markdown.js';
3
+ /** While a reply streams, a table's opening rows read as stray pipes until its
4
+ * delimiter row arrives; they are held back until then. */
5
+ export declare function withoutUnfinishedTable(text: string): string;
3
6
  export default function MarkdownImpl({ children, isStreaming, controls, components, httpsImagesOnly, compact, }: MarkdownProps): import("react").JSX.Element;
@@ -112,6 +112,20 @@ function makeCode(controls, diagrams) {
112
112
  });
113
113
  };
114
114
  }
115
+ var TABLE_ROW = /^\s*\|/;
116
+ var TABLE_DELIMITER = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
117
+ /** While a reply streams, a table's opening rows read as stray pipes until its
118
+ * delimiter row arrives; they are held back until then. */
119
+ function withoutUnfinishedTable(text) {
120
+ if ((text.match(/^\s*(```|~~~)/gm)?.length ?? 0) % 2 === 1) return text;
121
+ const lines = text.split("\n");
122
+ let start = lines.length;
123
+ while (start > 0 && TABLE_ROW.test(lines[start - 1] ?? "")) start--;
124
+ if (start === lines.length) return text;
125
+ const rows = lines.slice(start);
126
+ if (rows.length >= 2 && TABLE_DELIMITER.test(rows[1] ?? "")) return text;
127
+ return lines.slice(0, start).join("\n");
128
+ }
115
129
  function MarkdownImpl({ children, isStreaming, controls, components, httpsImagesOnly, compact }) {
116
130
  const needsMermaid = !!children && children.includes("```mermaid");
117
131
  const [, setMermaidReady] = useState(() => !!pluginsWithMermaid);
@@ -158,9 +172,9 @@ function MarkdownImpl({ children, isStreaming, controls, components, httpsImages
158
172
  }),
159
173
  ...components
160
174
  },
161
- children: children || ""
175
+ children: (isStreaming ? withoutUnfinishedTable(children || "") : children) || ""
162
176
  })
163
177
  });
164
178
  }
165
179
  //#endregion
166
- export { MarkdownImpl as default };
180
+ export { MarkdownImpl as default, withoutUnfinishedTable };