@jini-ai/chat 0.5.7 → 0.5.8

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 (105) hide show
  1. package/dist/core/ag-ui/entry.js +2 -0
  2. package/dist/core/assistant-client/durable-run-subscription.d.ts +4 -1
  3. package/dist/core/assistant-client/durable-run-subscription.js +13 -4
  4. package/dist/core/events.d.ts +3 -0
  5. package/dist/core/history-tool-metadata.d.ts +23 -0
  6. package/dist/core/history-tool-metadata.js +13 -0
  7. package/dist/core/index.d.ts +3 -0
  8. package/dist/core/index.js +3 -0
  9. package/dist/core/persistence/title.d.ts +24 -0
  10. package/dist/core/persistence/title.js +60 -9
  11. package/dist/core/run-activity.d.ts +15 -2
  12. package/dist/core/run-activity.js +6 -4
  13. package/dist/core/run-events/entry.js +2 -0
  14. package/dist/core/tool-call-detail.d.ts +15 -0
  15. package/dist/core/tool-call-detail.js +18 -0
  16. package/dist/core/turn-usage.d.ts +42 -3
  17. package/dist/core/turn-usage.js +101 -4
  18. package/dist/core/usage-format.d.ts +10 -0
  19. package/dist/core/usage-format.js +15 -0
  20. package/dist/embed-widget/browser/lazy-loader.d.ts +34 -0
  21. package/dist/embed-widget/browser/lazy-loader.js +116 -0
  22. package/dist/embed-widget/react/dialog.hooks.d.ts +28 -0
  23. package/dist/embed-widget/react/dialog.hooks.js +54 -0
  24. package/dist/embed-widget/react/embed.d.ts +1 -0
  25. package/dist/embed-widget/react/embed.js +5 -3
  26. package/dist/embed-widget/react/header.d.ts +3 -1
  27. package/dist/embed-widget/react/header.hooks.d.ts +13 -0
  28. package/dist/embed-widget/react/header.hooks.js +32 -0
  29. package/dist/embed-widget/react/header.js +4 -14
  30. package/dist/embed-widget/vanilla/index.d.ts +63 -0
  31. package/dist/embed-widget/vanilla/index.js +319 -0
  32. package/dist/embed-widget/vanilla/state.d.ts +48 -0
  33. package/dist/embed-widget/vanilla/state.js +120 -0
  34. package/dist/embed-widget/vanilla/style.css +29 -0
  35. package/dist/react/components/A2uiSurfaceCard.d.ts +5 -1
  36. package/dist/react/components/A2uiSurfaceCard.js +9 -9
  37. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +7 -0
  38. package/dist/react/components/AttachmentPreviewModal.hooks.js +1 -1
  39. package/dist/react/components/ChatFab.d.ts +4 -1
  40. package/dist/react/components/ChatFab.js +2 -2
  41. package/dist/react/components/ConversationList.d.ts +8 -1
  42. package/dist/react/components/ConversationList.hooks.d.ts +18 -0
  43. package/dist/react/components/ConversationList.hooks.js +25 -0
  44. package/dist/react/components/ConversationList.js +12 -5
  45. package/dist/react/components/ConversationList.rules.d.ts +25 -0
  46. package/dist/react/components/ConversationList.rules.js +19 -0
  47. package/dist/react/components/McpUiSurfaceCard.d.ts +1 -1
  48. package/dist/react/components/McpUiSurfaceCard.js +8 -4
  49. package/dist/react/components/MessageList.d.ts +1 -1
  50. package/dist/react/components/MessageList.hooks.d.ts +4 -0
  51. package/dist/react/components/MessageList.hooks.js +6 -1
  52. package/dist/react/components/MessageList.js +4 -3
  53. package/dist/react/components/MessageRow.d.ts +17 -0
  54. package/dist/react/components/MessageRow.js +54 -35
  55. package/dist/react/components/MessageRow.usage.d.ts +48 -0
  56. package/dist/react/components/MessageRow.usage.js +47 -0
  57. package/dist/react/components/MidRunUserMessage.d.ts +2 -12
  58. package/dist/react/components/MidRunUserMessage.js +4 -2
  59. package/dist/react/components/SentAttachment.d.ts +19 -0
  60. package/dist/react/components/SentAttachment.hooks.d.ts +44 -0
  61. package/dist/react/components/SentAttachment.hooks.js +56 -0
  62. package/dist/react/components/SentAttachment.js +14 -0
  63. package/dist/react/components/ToolCallGroup.d.ts +12 -0
  64. package/dist/react/components/ToolCallGroup.hooks.d.ts +20 -0
  65. package/dist/react/components/ToolCallGroup.hooks.js +23 -0
  66. package/dist/react/components/ToolCallGroup.js +13 -0
  67. package/dist/react/components/ToolCard.js +25 -17
  68. package/dist/react/components/mcp-ui-surface-state.d.ts +21 -0
  69. package/dist/react/components/mcp-ui-surface-state.js +26 -0
  70. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  71. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  72. package/dist/react/features/chat-pane/components/ChatPane.js +11 -6
  73. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +1 -0
  74. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +16 -10
  75. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +4 -0
  76. package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.d.ts +9 -0
  77. package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.js +11 -0
  78. package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.d.ts +17 -0
  79. package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.js +15 -0
  80. package/dist/react/features/chat-pane/rules.d.ts +14 -0
  81. package/dist/react/features/chat-pane/rules.js +18 -0
  82. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  83. package/dist/react/features/chat-pane/styles.js +85 -1
  84. package/dist/react/features/chat-pane/types.d.ts +16 -1
  85. package/dist/react/hooks/useAnsweredCards.d.ts +20 -0
  86. package/dist/react/hooks/useAnsweredCards.js +23 -0
  87. package/dist/react/hooks/useConversation.d.ts +8 -0
  88. package/dist/react/hooks/useConversation.js +34 -0
  89. package/dist/react/hooks/useRunActivity.d.ts +5 -3
  90. package/dist/react/hooks/useRunActivity.js +4 -3
  91. package/dist/react/index.d.ts +4 -0
  92. package/dist/react/index.js +3 -0
  93. package/dist/react/message-blocks.d.ts +22 -3
  94. package/dist/react/message-blocks.js +25 -3
  95. package/dist/react/tool-call-display.d.ts +90 -0
  96. package/dist/react/tool-call-display.js +97 -0
  97. package/dist/store/ports.d.ts +25 -2
  98. package/dist/store/runtime/entry.d.ts +1 -0
  99. package/dist/store/runtime/entry.js +1 -0
  100. package/dist/store/runtime/history-tools.d.ts +27 -0
  101. package/dist/store/runtime/history-tools.js +169 -0
  102. package/dist/store/sql/store.js +52 -0
  103. package/dist/testing/index.d.ts +7 -0
  104. package/dist/testing/index.js +22 -0
  105. package/package.json +48 -18
@@ -7,7 +7,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
7
7
  * for the `chat-fab`/`chat-fab-open` classes.
8
8
  */
9
9
  import { useChatFabDrag } from '../hooks/useChatFabDrag.js';
10
- export function ChatFab({ open, onToggle, label = 'chat' }) {
10
+ export function ChatFab({ open, onToggle, label = 'chat', 'aria-label': accessibleLabel, 'aria-controls': controls, 'aria-haspopup': popup }) {
11
11
  const { buttonRef, position, onPointerDown, onPointerMove, onPointerUp, onPointerCancel } = useChatFabDrag({ onClick: onToggle });
12
12
  return (_jsx("button", { ref: buttonRef, type: "button", className: `chat-fab${open ? ' chat-fab-open' : ''}`,
13
13
  // Not `onClick`: the pointer gesture already decides click-versus-drag, and keeping both
@@ -19,6 +19,6 @@ export function ChatFab({ open, onToggle, label = 'chat' }) {
19
19
  event.preventDefault();
20
20
  onToggle();
21
21
  }
22
- }, style: position === null ? undefined : { left: position.x, top: position.y, right: 'auto', bottom: 'auto' }, "aria-expanded": open, "aria-label": open ? `Hide ${label}` : `Show ${label}`, children: _jsx("span", { "aria-hidden": "true", children: open ? '×' : '💬' }) }));
22
+ }, style: position === null ? undefined : { left: position.x, top: position.y, right: 'auto', bottom: 'auto' }, "aria-expanded": open, "aria-label": accessibleLabel ?? (open ? `Hide ${label}` : `Show ${label}`), "aria-controls": controls, "aria-haspopup": popup, children: _jsx("span", { "aria-hidden": "true", children: open ? '×' : '💬' }) }));
23
23
  }
24
24
  //# sourceMappingURL=ChatFab.js.map
@@ -26,6 +26,13 @@ export interface ConversationListProps {
26
26
  /** Disables the "New" action — e.g. while the current conversation is still empty. */
27
27
  createDisabled?: boolean;
28
28
  emptyState?: React.ReactNode;
29
+ /** Replaces the trigger's accessible name (default "Conversations — {active title}"). */
30
+ triggerLabel?: string;
31
+ /**
32
+ * `false` drops the trigger's native `title` tooltip, for a host that draws its own tooltip over
33
+ * the icon (the browser's dark tip would otherwise show on top of it). Defaults to true.
34
+ */
35
+ nativeTitle?: boolean;
29
36
  }
30
- export declare function ConversationList({ conversations, activeConversationId, onSelect, onCreate, onDelete, onRename, onSearch, confirmDelete, createDisabled, emptyState, }: ConversationListProps): import("react").JSX.Element;
37
+ export declare function ConversationList({ conversations: savedConversations, activeConversationId, onSelect, onCreate, onDelete, onRename, onSearch, confirmDelete, createDisabled, emptyState, triggerLabel, nativeTitle, }: ConversationListProps): import("react").JSX.Element;
31
38
  //# sourceMappingURL=ConversationList.d.ts.map
@@ -0,0 +1,18 @@
1
+ import type { ConversationListItem } from './ConversationList.js';
2
+ /** Project saved/search titles for presentation without renaming durable conversations.
3
+ * The switcher has no prompt or title provenance, so keep wording and casing while stripping
4
+ * markup through the same owner as the panel title. Caller arrays and objects stay untouched.
5
+ * @param input.conversations - The loaded conversations.
6
+ * @param input.serverResults - Optional server search results, or null before a response.
7
+ * @returns Plain-text display lists and raw titles for lossless editing. Hosts ellipsize rows in CSS.
8
+ * @complexity O(n + sum(t²)) worst-case cleanup time; O(n + sum(t)) space for n items of length t.
9
+ */
10
+ export declare function useConversationListTitles({ conversations, serverResults }: {
11
+ conversations: ConversationListItem[];
12
+ serverResults: ConversationListItem[] | null;
13
+ }, _options?: {}): {
14
+ conversations: ConversationListItem[];
15
+ serverResults: ConversationListItem[] | null;
16
+ rawTitles: ReadonlyMap<string, string | null>;
17
+ };
18
+ //# sourceMappingURL=ConversationList.hooks.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { useMemo } from 'react';
2
+ import { stripConversationTitleMarkdown } from '../../core/persistence/title.js';
3
+ /** Project saved/search titles for presentation without renaming durable conversations.
4
+ * The switcher has no prompt or title provenance, so keep wording and casing while stripping
5
+ * markup through the same owner as the panel title. Caller arrays and objects stay untouched.
6
+ * @param input.conversations - The loaded conversations.
7
+ * @param input.serverResults - Optional server search results, or null before a response.
8
+ * @returns Plain-text display lists and raw titles for lossless editing. Hosts ellipsize rows in CSS.
9
+ * @complexity O(n + sum(t²)) worst-case cleanup time; O(n + sum(t)) space for n items of length t.
10
+ */
11
+ export function useConversationListTitles({ conversations, serverResults }, _options = {}) {
12
+ return useMemo(() => {
13
+ const rawTitles = new Map();
14
+ const display = (items) => items.map(item => ({
15
+ ...item, title: item.title === null ? null : stripConversationTitleMarkdown({ text: item.title }) || null,
16
+ }));
17
+ // Search results can contain unloaded rows or newer titles for loaded ids; visible results win.
18
+ for (const item of conversations)
19
+ rawTitles.set(item.id, item.title);
20
+ for (const item of serverResults ?? [])
21
+ rawTitles.set(item.id, item.title);
22
+ return { conversations: display(conversations), serverResults: serverResults === null ? null : display(serverResults), rawTitles };
23
+ }, [conversations, serverResults]);
24
+ }
25
+ //# sourceMappingURL=ConversationList.hooks.js.map
@@ -24,6 +24,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
25
25
  import { useT } from '../hooks/context.js';
26
26
  import { Icon } from './Icon.js';
27
+ import { useConversationListTitles } from './ConversationList.hooks.js';
28
+ import { conversationTriggerAttributes } from './ConversationList.rules.js';
27
29
  /**
28
30
  * How long a click waits to see whether it is really the first half of a double-click.
29
31
  *
@@ -63,13 +65,15 @@ function relativeTime(updatedAt, now) {
63
65
  return `${hours}h ago`;
64
66
  return `${Math.round(hours / 24)}d ago`;
65
67
  }
66
- export function ConversationList({ conversations, activeConversationId, onSelect, onCreate, onDelete, onRename, onSearch, confirmDelete, createDisabled = false, emptyState, }) {
68
+ export function ConversationList({ conversations: savedConversations, activeConversationId, onSelect, onCreate, onDelete, onRename, onSearch, confirmDelete, createDisabled = false, emptyState, triggerLabel, nativeTitle = true, }) {
67
69
  const t = useT();
68
70
  const [open, setOpen] = useState(false);
69
71
  const [query, setQuery] = useState('');
70
- const [serverResults, setServerResults] = useState(null);
72
+ const [savedServerResults, setServerResults] = useState(null);
73
+ const { conversations, serverResults, rawTitles } = useConversationListTitles({ conversations: savedConversations, serverResults: savedServerResults });
71
74
  const [editingId, setEditingId] = useState(null);
72
75
  const [draft, setDraft] = useState('');
76
+ const originalDraftRef = useRef('');
73
77
  const rootRef = useRef(null);
74
78
  const deferredQuery = useDeferredValue(query);
75
79
  /**
@@ -169,7 +173,8 @@ export function ConversationList({ conversations, activeConversationId, onSelect
169
173
  // No optimistic title update happens here — the row keeps rendering `item.title` from
170
174
  // props until the host's own state updates, so a rejection cannot leave the UI showing a
171
175
  // title the server didn't accept. Reporting the failure is what closes the remaining gap.
172
- if (next)
176
+ // Merely opening and leaving the editor must not normalize a deliberately saved title.
177
+ if (next && draft !== originalDraftRef.current)
173
178
  runConversationListHostEffect('onRename', () => onRename(id, next));
174
179
  }, [draft, onRename]);
175
180
  const requestDelete = useCallback((item) => {
@@ -188,7 +193,7 @@ export function ConversationList({ conversations, activeConversationId, onSelect
188
193
  });
189
194
  }, [confirmDelete, onDelete, t]);
190
195
  const active = conversations.find((c) => c.id === activeConversationId) ?? null;
191
- return (_jsxs("div", { className: "jini-conv", ref: rootRef, children: [_jsx("button", { type: "button", className: `jini-conv-trigger${open ? ' jini-conv-trigger-open' : ''}`, "aria-haspopup": "menu", "aria-expanded": open, "aria-label": active?.title ? `${t('Conversations')} — ${active.title}` : t('Conversations'), title: active?.title ?? t('Conversations'), "data-testid": "conversation-trigger", onClick: () => setOpen((v) => !v), children: _jsx(Icon, { name: "comment", size: 16 }) }), open ? (_jsxs("div", { className: "jini-conv-menu", role: "menu", "data-testid": "conversation-menu", children: [_jsxs("div", { className: "jini-conv-head", children: [_jsxs("span", { className: "jini-conv-heading", children: [t('Conversations'), _jsx("span", { className: "jini-conv-count", "data-testid": "conversation-count", children: conversations.length })] }), _jsxs("button", { type: "button", className: "jini-conv-new", disabled: createDisabled, "data-testid": "conversation-new", onClick: () => {
196
+ return (_jsxs("div", { className: "jini-conv", ref: rootRef, children: [_jsx("button", { type: "button", className: `jini-conv-trigger${open ? ' jini-conv-trigger-open' : ''}`, "aria-haspopup": "menu", "aria-expanded": open, ...conversationTriggerAttributes({ activeTitle: active?.title, t }, { label: triggerLabel, nativeTitle }), "data-testid": "conversation-trigger", onClick: () => setOpen((v) => !v), children: _jsx(Icon, { name: "comment", size: 16 }) }), open ? (_jsxs("div", { className: "jini-conv-menu", role: "menu", "data-testid": "conversation-menu", children: [_jsxs("div", { className: "jini-conv-head", children: [_jsxs("span", { className: "jini-conv-heading", children: [t('Conversations'), _jsx("span", { className: "jini-conv-count", "data-testid": "conversation-count", children: conversations.length })] }), _jsxs("button", { type: "button", className: "jini-conv-new", disabled: createDisabled, "data-testid": "conversation-new", onClick: () => {
192
197
  if (createDisabled)
193
198
  return;
194
199
  // No optimistic row is inserted — the list still comes straight from props — so a
@@ -211,8 +216,10 @@ export function ConversationList({ conversations, activeConversationId, onSelect
211
216
  }, DOUBLE_CLICK_GRACE_MS);
212
217
  }, onDoubleClick: () => {
213
218
  cancelPendingSelect();
219
+ // Snapshot the raw seed: a host refresh during editing is not a user edit.
220
+ originalDraftRef.current = rawTitles.get(item.id) ?? '';
214
221
  setEditingId(item.id);
215
- setDraft(item.title ?? '');
222
+ setDraft(originalDraftRef.current);
216
223
  }, children: [_jsx("span", { className: "jini-conv-item-title", children: item.title ?? t('Untitled') }), _jsxs("span", { className: "jini-conv-item-meta", children: [item.messageCount === undefined ? '' : `${item.messageCount} msg · `, relativeTime(item.updatedAt, now)] })] })), _jsx("button", { type: "button", className: "jini-conv-item-delete", "aria-label": t('Delete conversation'), "data-testid": `conversation-delete-${item.id}`, onClick: (event) => {
217
224
  event.stopPropagation();
218
225
  requestDelete(item);
@@ -0,0 +1,25 @@
1
+ /** Accessible name and optional native tooltip for the conversation switcher's icon-only trigger. */
2
+ export interface ConversationTriggerAttributes {
3
+ 'aria-label': string;
4
+ title?: string;
5
+ }
6
+ /**
7
+ * Resolves the switcher trigger's `aria-label` and native `title`. By default both name the active
8
+ * conversation. A host that draws its own tooltip over the icon passes `nativeTitle: false`, so the
9
+ * browser's dark native tip does not show on top of it, and `label` to rename the control (for
10
+ * example "Chats") instead of stripping the attribute at runtime.
11
+ * @param input.activeTitle - The active conversation's display title, or null/undefined when none.
12
+ * @param input.t - The package translator.
13
+ * @param options.label - Replaces the default accessible name (and the native title, when kept).
14
+ * @param options.nativeTitle - `false` omits the native `title` attribute. Defaults to true.
15
+ * @returns Props to spread onto the trigger button.
16
+ * @complexity O(1).
17
+ */
18
+ export declare function conversationTriggerAttributes({ activeTitle, t }: {
19
+ activeTitle: string | null | undefined;
20
+ t: (key: string) => string;
21
+ }, { label, nativeTitle }?: {
22
+ label?: string | undefined;
23
+ nativeTitle?: boolean | undefined;
24
+ }): ConversationTriggerAttributes;
25
+ //# sourceMappingURL=ConversationList.rules.d.ts.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolves the switcher trigger's `aria-label` and native `title`. By default both name the active
3
+ * conversation. A host that draws its own tooltip over the icon passes `nativeTitle: false`, so the
4
+ * browser's dark native tip does not show on top of it, and `label` to rename the control (for
5
+ * example "Chats") instead of stripping the attribute at runtime.
6
+ * @param input.activeTitle - The active conversation's display title, or null/undefined when none.
7
+ * @param input.t - The package translator.
8
+ * @param options.label - Replaces the default accessible name (and the native title, when kept).
9
+ * @param options.nativeTitle - `false` omits the native `title` attribute. Defaults to true.
10
+ * @returns Props to spread onto the trigger button.
11
+ * @complexity O(1).
12
+ */
13
+ export function conversationTriggerAttributes({ activeTitle, t }, { label, nativeTitle = true } = {}) {
14
+ const ariaLabel = label ?? (activeTitle ? `${t('Conversations')} — ${activeTitle}` : t('Conversations'));
15
+ if (!nativeTitle)
16
+ return { 'aria-label': ariaLabel };
17
+ return { 'aria-label': ariaLabel, title: label ?? activeTitle ?? t('Conversations') };
18
+ }
19
+ //# sourceMappingURL=ConversationList.rules.js.map
@@ -30,7 +30,7 @@ export interface McpUiSurfaceCardProps extends ExtEventRenderProps {
30
30
  expiryClock?: SurfaceExpiryClock;
31
31
  }
32
32
  /** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
33
- export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock }: McpUiSurfaceCardProps): import("react").JSX.Element;
33
+ export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock, answered, onAnswered }: McpUiSurfaceCardProps): import("react").JSX.Element;
34
34
  /**
35
35
  * The ext-event slot key for MCP-UI: one slot per `ui://` URI. Each resource is its own card, so a
36
36
  * run's second card renders where it arrived instead of inside the first card's slot, while a
@@ -66,7 +66,7 @@ import { McpUiHost, parseUIResource, readActionPlan, readExpiresAt, readPreferre
66
66
  import { registerExtEventRenderer } from '../ext-event-renderer-registry.js';
67
67
  import { useT } from '../hooks/context.js';
68
68
  import { useSurfaceExpiry } from '../hooks/useSurfaceExpiry.js';
69
- import { closedSurfaceLabel } from './mcp-ui-surface-state.js';
69
+ import { closedSurfaceLabel, reportDeliveredAnswer } from './mcp-ui-surface-state.js';
70
70
  /** The `kind: 'ext'` event name this renderer claims. */
71
71
  export const MCP_UI_EXT_EVENT_NAME = 'mcp-ui';
72
72
  /** Collapses the event stream to the newest resource per `ui://` URI, in first-appearance order. */
@@ -120,18 +120,22 @@ function ClosedSurface({ call, label }) {
120
120
  return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' && title.trim() !== '' ? `${title}: ${label}` : label }));
121
121
  }
122
122
  /** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
123
- export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock }) {
123
+ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock, answered, onAnswered }) {
124
124
  const t = useT();
125
125
  const resources = useMemo(() => latestResourcesByUri(events), [events]);
126
126
  // One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
127
127
  // that card. A group holding several URIs has no single call to judge them by, and stays live.
128
128
  const single = resources.length === 1 ? resources[0] : undefined;
129
- const expiry = useSurfaceExpiry({ expiresAtMs: single === undefined ? undefined : readExpiresAt(single) }, expiryClock === undefined ? {} : { clock: expiryClock });
129
+ // An answered card has no deadline left: its countdown goes, and the deadline passing while the
130
+ // answer's work still runs must not relabel it "This question expired".
131
+ const expiry = useSurfaceExpiry({ expiresAtMs: single === undefined || answered === true ? undefined : readExpiresAt(single) }, expiryClock === undefined ? {} : { clock: expiryClock });
130
132
  const closed = single === undefined
131
133
  ? undefined
132
134
  : closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
133
135
  if (closed !== undefined)
134
136
  return _jsx(ClosedSurface, { call: call, label: closed });
137
+ // Reports the person's delivered answer to the row, which stops the "Waiting for your answer" line.
138
+ const toolCall = onToolCall === undefined || onAnswered === undefined ? onToolCall : reportDeliveredAnswer({ onToolCall, onAnswered });
135
139
  if (resources.length === 0) {
136
140
  // Visible rather than silent: an `ext` event named `mcp-ui` that carries no parseable resource
137
141
  // is a producer bug, and a transcript that renders nothing for it hides the bug behind what
@@ -150,7 +154,7 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
150
154
  // the previous iframe session, so the handshake never re-ran and the frame kept serving
151
155
  // the OLD document's state. The URI stays in the key because it is real identity; the
152
156
  // length was never a substitute for content.
153
- sessionKey: `${resource.resource.uri}:${resource.resource.text}`, ...(onToolCall === undefined ? {} : { onToolCall }), ...(onOpenLink === undefined ? {} : { onOpenLink }), ...(height === undefined || Number.isNaN(height) ? {} : { initialHeight: height }), ...(maxHeight === undefined ? {} : { maxHeight }) }), plan !== undefined && _jsx(PendingSurfaceMirror, { uri: resource.resource.uri, plan: plan, t: t })] }, resource.resource.uri));
157
+ sessionKey: `${resource.resource.uri}:${resource.resource.text}`, ...(toolCall === undefined ? {} : { onToolCall: toolCall }), ...(onOpenLink === undefined ? {} : { onOpenLink }), ...(height === undefined || Number.isNaN(height) ? {} : { initialHeight: height }), ...(maxHeight === undefined ? {} : { maxHeight }) }), plan !== undefined && _jsx(PendingSurfaceMirror, { uri: resource.resource.uri, plan: plan, t: t })] }, resource.resource.uri));
154
158
  }), expiry?.expired === false && (_jsx("p", { className: "mcpui-surface-expiry", role: "timer", children: t('Expires in {time}', { time: expiry.remaining }) }))] }));
155
159
  }
156
160
  /**
@@ -1,6 +1,6 @@
1
1
  import type { ChatMessage } from '../../core/index.js';
2
2
  import { type MessageRowProps } from './MessageRow.js';
3
- export interface MessageListProps extends Pick<MessageRowProps, 'projectFileNames' | 'onRequestOpenFile' | 'renderAttachment'> {
3
+ export interface MessageListProps extends Pick<MessageRowProps, 'projectFileNames' | 'onRequestOpenFile' | 'renderAttachment' | 'showUsageTotal' | 'showUsageLine' | 'toolCallDisplay' | 'showThinking'> {
4
4
  messages: ChatMessage[];
5
5
  isStreaming?: boolean;
6
6
  /** Mirrors `useConversation().scrollIntent` — when `true`, this component scrolls to the bottom on mount/update. */
@@ -10,6 +10,10 @@ export interface MessageListAutoScroll {
10
10
  containerRef: RefObject<HTMLDivElement | null>;
11
11
  onScroll: (event: UIEvent<HTMLElement>) => void;
12
12
  }
13
+ /** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
14
+ export declare function useConversationUsage({ messages }: {
15
+ messages: readonly ChatMessage[];
16
+ }, _optional?: {}): Map<string, import("../../core/turn-usage.js").ChatUsageSummary>;
13
17
  export declare function useMessageListAutoScroll({ messages, scrollIntent }: {
14
18
  messages: ChatMessage[];
15
19
  scrollIntent: boolean;
@@ -4,8 +4,13 @@
4
4
  * lives here (presentational), not in the headless `useConversation` hook, per that hook's own
5
5
  * "transport-agnostic hooks never touch DOM" contract.
6
6
  */
7
- import { useEffect, useRef } from 'react';
7
+ import { useEffect, useMemo, useRef } from 'react';
8
+ import { conversationUsage } from '../../core/turn-usage.js';
8
9
  import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
10
+ /** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
11
+ export function useConversationUsage({ messages }, _optional = {}) {
12
+ return useMemo(() => conversationUsage({ messages }), [messages]);
13
+ }
9
14
  export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled } = {}) {
10
15
  const containerRef = useRef(null);
11
16
  // Whether the transcript was scrolled to its bottom as of the last user scroll or programmatic
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  import { memo } from 'react';
14
14
  import { isTerminalRunStatus } from '../../core/index.js';
15
15
  import { useT } from '../hooks/context.js';
16
- import { useMessageListAutoScroll } from './MessageList.hooks.js';
16
+ import { useMessageListAutoScroll, useConversationUsage } from './MessageList.hooks.js';
17
17
  import { MessageRow } from './MessageRow.js';
18
18
  /**
19
19
  * Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
@@ -21,13 +21,14 @@ import { MessageRow } from './MessageRow.js';
21
21
  * 2026-10-08). Callers must pass stable props — `ChatPane` memoizes `pendingPrompt` and
22
22
  * `projectFileNames` for exactly this.
23
23
  */
24
- export const MessageList = memo(function MessageList({ messages, isStreaming = false, scrollIntent = false, onScrolled, activeQuestionFormMessageId = null, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, pendingPrompt = null, }) {
24
+ export const MessageList = memo(function MessageList({ messages, isStreaming = false, scrollIntent = false, onScrolled, activeQuestionFormMessageId = null, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, showUsageTotal = true, showUsageLine = true, toolCallDisplay = 'show', showThinking = false, pendingPrompt = null, }) {
25
25
  const t = useT();
26
+ const usageByMessage = useConversationUsage({ messages });
26
27
  const { containerRef, onScroll } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
27
28
  return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => {
28
29
  const isLast = message.id === messages[messages.length - 1]?.id;
29
30
  const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
30
- return (_jsx(MessageRow, { message: message, runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}), ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}), ...(renderAttachment !== undefined ? { renderAttachment } : {}) }, message.id));
31
+ return (_jsx(MessageRow, { message: message, ...(usageByMessage.has(message.id) ? { usageSummary: usageByMessage.get(message.id) } : {}), showUsageTotal: showUsageTotal, showUsageLine: showUsageLine, toolCallDisplay: toolCallDisplay, showThinking: showThinking, runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}), ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}), ...(renderAttachment !== undefined ? { renderAttachment } : {}) }, message.id));
31
32
  }), pendingPrompt ? (_jsxs("div", { className: "jini-chat-pane__queued", role: "status", "aria-label": t('Message queued — not yet sent, will send once the current run finishes'), "data-testid": "chat-pane-queued", "data-agent-element": "chat-message-pending", "data-agent-role": "region", "data-agent-label": "A message queued to send once the current run finishes", children: [_jsx("div", { className: "jini-chat-pane__queued-text", children: pendingPrompt.text }), _jsx("div", { className: "jini-chat-pane__queued-actions", children: _jsx("button", { type: "button", className: "jini-chat-pane__queued-cancel", onClick: pendingPrompt.onCancel, title: t('Cancel queued message'), children: t('Cancel') }) })] })) : null] }));
32
33
  });
33
34
  //# sourceMappingURL=MessageList.js.map
@@ -1,3 +1,4 @@
1
+ import type { ChatUsageSummary } from '../../core/turn-usage.js';
1
2
  /**
2
3
  * @module MessageRow
3
4
  *
@@ -50,8 +51,24 @@
50
51
  */
51
52
  import React, { type ReactNode } from 'react';
52
53
  import type { ChatAttachment, ChatMessage } from '../../core/index.js';
54
+ import { type ToolCallDisplay } from '../tool-call-display.js';
53
55
  export interface MessageRowProps {
54
56
  message: ChatMessage;
57
+ /** Per-turn charge and prefix total derived from the complete conversation's persisted events. */
58
+ usageSummary?: ChatUsageSummary;
59
+ /** Append the chat's running total as of each reply ("· total $3.95") to its Done line. On by default
60
+ * (owner, 2026-10-10: keep the total); pass `false` to show only each turn's own cost. */
61
+ showUsageTotal?: boolean;
62
+ /** Show the "Done · time · cost" line(s) at all. On by default; `false` hides every one, total
63
+ * included (a host's "Done line" display choice, 2026-10-11). */
64
+ showUsageLine?: boolean;
65
+ /** How tool calls show (`../tool-call-display.ts`): every card (`'show'`, the default), one
66
+ * fold-up line per turn (`'collapsed'`), or only failed ones (`'hidden'`). */
67
+ toolCallDisplay?: ToolCallDisplay;
68
+ /** Show the model's reasoning (`thinking` events) as a fold-up block where it happened. Off by
69
+ * default — the transcript never showed it before this option. Needs the interleaved layout; a
70
+ * message that falls back to the flat layout shows none. */
71
+ showThinking?: boolean;
55
72
  /** Whether this message's own run is still streaming. */
56
73
  runStreaming?: boolean;
57
74
  runSucceeded?: boolean;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
3
- import { turnUsage } from '../../core/turn-usage.js';
3
+ import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary } from './MessageRow.usage.js';
4
4
  /**
5
5
  * @module MessageRow
6
6
  *
@@ -57,21 +57,19 @@ import { useToolTimeline } from '../hooks/useToolTimeline.js';
57
57
  import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
58
58
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
59
59
  import { useRunActivity } from '../hooks/useRunActivity.js';
60
+ import { useAnsweredCards } from '../hooks/useAnsweredCards.js';
60
61
  import { interleaveMessageBlocks } from '../message-blocks.js';
61
62
  import { useT } from '../hooks/context.js';
62
63
  import { extEventSlot, getExtEventRenderer } from '../ext-event-renderer-registry.js';
63
64
  import { ExtEventErrorBoundary } from './ExtEventErrorBoundary.js';
64
65
  import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
66
+ import { SentAttachmentButton } from './SentAttachment.js';
65
67
  import { Icon } from './Icon.js';
66
68
  import { Markdown } from './Markdown.js';
67
69
  import { ToolCard } from './ToolCard.js';
68
70
  import { QuestionForm } from './QuestionForm.js';
69
- function formatDuration(ms) {
70
- const totalSeconds = Math.round(ms / 1000);
71
- const minutes = Math.floor(totalSeconds / 60);
72
- const seconds = totalSeconds % 60;
73
- return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
74
- }
71
+ import { ToolCallGroup } from './ToolCallGroup.js';
72
+ import { applyToolCallDisplay, toolRowsForDisplay } from '../tool-call-display.js';
75
73
  /**
76
74
  * True while an assistant message is still fully pending — no visible text
77
75
  * and no tool rows have arrived yet. Shared by the "Thinking…" placeholder
@@ -108,21 +106,17 @@ function isPendingWithNoContent(message, visibleContent, toolRowCount) {
108
106
  function isRunInProgress(runStatus) {
109
107
  return runStatus !== undefined && !isTerminalRunStatus({ status: runStatus });
110
108
  }
111
- /** One "Done · 6m 29s · 2612 out · $0.4028" summary line, from the run's own `kind:'usage'` event — never estimated client-side. Renders only the fields the event actually carries, so a transport that supplies partial usage data degrades gracefully instead of showing fabricated zeros. */
112
- function UsageSummary({ usage }) {
109
+ /** One "Done · 6m 29s · $0.41 · total $2.50" line from reported usage, never estimated (the total is
110
+ * left off when `showTotal` is false). Partial usage stays partial; output tokens are available to
111
+ * assistive technology and in the tooltip. */
112
+ function UsageSummary({ usage, showTotal }) {
113
113
  const t = useT();
114
- const parts = [];
115
- if (usage.durationMs !== undefined)
116
- parts.push(formatDuration(usage.durationMs));
117
- if (usage.outputTokens !== undefined)
118
- parts.push(t('{n} out', { n: usage.outputTokens }));
119
- if (usage.costUsd !== undefined)
120
- parts.push(`$${usage.costUsd.toFixed(4)}`);
121
- if (parts.length === 0)
114
+ const { text, title, ariaLabel } = useUsageSummary({ usage }, { showTotal });
115
+ if (!text)
122
116
  return null;
123
- return (_jsxs("div", { className: "jini-message-usage", children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t('Done'), " \u00B7 ", parts.join(' · ')] }));
117
+ return (_jsxs("div", { className: "jini-message-usage", title: title, role: "note", "aria-label": ariaLabel, children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t('Done'), " \u00B7 ", text] }));
124
118
  }
125
- function messageBlocks({ visibleContent, message, rows }, _optional = {}) {
119
+ function messageBlocks({ visibleContent, message, rows }, { toolCallDisplay = 'show', includeThinking = false } = {}) {
126
120
  // Interleaving is skipped outright when an artifact was stripped. `stripArtifact` operates on the
127
121
  // whole string and trims, so the surviving text no longer lines up with the per-run offsets the
128
122
  // event walk produces, and an artifact block could legitimately span a tool call. Rather than
@@ -130,14 +124,22 @@ function messageBlocks({ visibleContent, message, rows }, _optional = {}) {
130
124
  // messages are dominated by the artifact panel anyway, so the ordering matters least there.
131
125
  if (visibleContent !== message.content)
132
126
  return null;
133
- return interleaveMessageBlocks({ events: message.events, content: message.content, rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) });
127
+ const blocks = interleaveMessageBlocks({ events: message.events, content: message.content, rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }), includeThinking });
128
+ return blocks ? applyToolCallDisplay({ blocks, mode: toolCallDisplay }) : null;
129
+ }
130
+ /** The model's reasoning, folded under a "Thinking" summary so it never crowds the answer. */
131
+ function ThinkingBlock({ text }) {
132
+ const t = useT();
133
+ if (!text.trim())
134
+ return null;
135
+ return (_jsxs("details", { className: "jini-message-thinking", children: [_jsx("summary", { children: t('Thinking') }), _jsx("div", { className: "jini-message-thinking__text", children: text })] }));
134
136
  }
135
- function messageAwaitingAnswer({ runStreaming, runInProgress, events }, _optional = {}) {
136
- return runStreaming === true && runInProgress && isAwaitingAnswer({ events });
137
+ function messageAwaitingAnswer({ runStreaming, runInProgress, events }, { isAnsweredCard } = {}) {
138
+ return runStreaming === true && runInProgress && isAwaitingAnswer({ events }, { isAnsweredCard });
137
139
  }
138
- function AssistantMessageFooter({ message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent }, _optional = {}) {
140
+ function AssistantMessageFooter({ message, usageEvent, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard }, _optional = {}) {
139
141
  const t = useT();
140
- return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress) }) : null] });
142
+ return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent, showTotal: showUsageTotal }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress), isAnsweredCard: isAnsweredCard }) : null] });
141
143
  }
142
144
  /**
143
145
  * Quiet, icon-only copy affordance shared by the user- and assistant-message
@@ -160,26 +162,34 @@ function CopyMessageButton({ text, label }) {
160
162
  * re-renders per composer keystroke) only re-renders rows whose props actually changed. Messages
161
163
  * are replaced, never mutated, so identity is a sound change signal.
162
164
  */
163
- export const MessageRow = memo(function MessageRow({ message, runStreaming = false, runSucceeded = false, questionFormInteractive = false, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, }) {
165
+ export const MessageRow = memo(function MessageRow({ message, usageSummary, showUsageTotal = true, showUsageLine = true, toolCallDisplay = 'show', showThinking = false, runStreaming = false, runSucceeded = false, questionFormInteractive = false, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, }) {
164
166
  const t = useT();
167
+ const messageUsage = useMessageUsage({ message, ...(usageSummary === undefined ? {} : { summary: usageSummary }) });
165
168
  const timeline = useToolTimeline({ events: message.events }, { runStreaming, runSucceeded });
166
169
  const extGroups = useExtEventGroups({ events: message.events }, { slotOf: (name, data) => extEventSlot({ name, data }) });
170
+ const answeredCards = useAnsweredCards({ slotOf: extEventSlot });
167
171
  // Which attachment chip (if any) this row's preview modal is open for. Row-local, not
168
172
  // module-level: two attachments in the same message can never be "open" at once, but two
169
173
  // different messages opening their own attachments independently is fine and expected.
170
174
  const [openAttachment, setOpenAttachment] = useState(null);
171
175
  if (message.role === 'user') {
172
- return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx("button", { type: "button", className: "jini-message-attachment-chip", onClick: () => setOpenAttachment(a), title: t('Open {name}', { name: a.name }), "aria-label": t('Open {name}', { name: a.name }), children: renderAttachment ? renderAttachment(a) : a.name }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), openAttachment ? (_jsx(AttachmentPreviewModal, { attachment: openAttachment, onClose: () => setOpenAttachment(null) })) : null] }));
176
+ return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: setOpenAttachment, renderAttachment: renderAttachment }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), openAttachment ? (_jsx(AttachmentPreviewModal, { attachment: openAttachment, onClose: () => setOpenAttachment(null) })) : null] }));
173
177
  }
174
178
  const terminalNotice = terminalMessageNotice({ message }, {});
175
179
  const visibleContent = stripArtifact({ content: message.content });
176
180
  const segments = splitOnQuestionForms({ input: visibleContent });
177
- const usageEvent = turnUsage({ events: message.events }, {});
178
- const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows }, {});
181
+ const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows }, { toolCallDisplay, includeThinking: showThinking });
179
182
  const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
180
183
  const runInProgress = isRunInProgress(message.runStatus);
181
- const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, {});
184
+ const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, { isAnsweredCard: answeredCards.isAnsweredCard });
185
+ const usage = usageLayout({ usage: messageUsage, interleaved: blocks !== null, runInProgress }, { runStreaming });
186
+ const footerUsage = showUsageLine ? usage.footer : undefined;
182
187
  const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
188
+ // One turn's cards: a fold-up line when collapsed, the plain card list otherwise.
189
+ const renderTools = (rows, key) => (toolCallDisplay === 'collapsed'
190
+ ? _jsx(ToolCallGroup, { rows: rows, renderRow: renderToolCard }, key)
191
+ : _jsx("div", { className: "jini-message-tools", children: rows.map(renderToolCard) }, key));
192
+ const flatToolRows = toolRowsForDisplay({ rows: timeline.rows, mode: toolCallDisplay });
183
193
  const renderExtGroup = (group) => {
184
194
  const renderer = getExtEventRenderer({ name: group.name });
185
195
  if (!renderer)
@@ -192,6 +202,8 @@ export const MessageRow = memo(function MessageRow({ message, runStreaming = fal
192
202
  awaitingAnswer,
193
203
  runId: message.runId,
194
204
  ...(group.call === undefined ? {} : { call: group.call }),
205
+ answered: answeredCards.isAnswered(group.slot),
206
+ onAnswered: () => answeredCards.markAnswered(group.slot),
195
207
  });
196
208
  if (!node)
197
209
  return null;
@@ -202,28 +214,35 @@ export const MessageRow = memo(function MessageRow({ message, runStreaming = fal
202
214
  _jsx(ExtEventErrorBoundary, { name: group.name, children: _jsx("div", { children: node }) }, `${group.slot}:${group.events.length}`));
203
215
  };
204
216
  const renderTextSegments = (text, keyPrefix) => splitOnQuestionForms({ input: text }).map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, `${keyPrefix}-${i}`)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, `${keyPrefix}-${i}`)));
205
- return (_jsxs("div", { className: "jini-message jini-message-assistant", "data-message-id": message.id, "data-run-status": message.runStatus, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A reply from the assistant", children: [message.agentName ? _jsx("div", { className: "jini-message-agent", children: message.agentName }) : null, blocks
217
+ return (_jsxs("div", { className: usageLineMeetsActions({ footer: footerUsage, terminalNotice, pending, runInProgress })
218
+ ? 'jini-message jini-message-assistant jini-message-assistant--usage-actions'
219
+ : 'jini-message jini-message-assistant', "data-message-id": message.id, "data-run-status": message.runStatus, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A reply from the assistant", children: [message.agentName ? _jsx("div", { className: "jini-message-agent", children: message.agentName }) : null, blocks
206
220
  ? blocks.map((block) => {
207
221
  if (block.kind === 'text') {
208
222
  return _jsx(React.Fragment, { children: renderTextSegments(block.text, block.key) }, block.key);
209
223
  }
210
- if (block.kind === 'tools') {
211
- return (_jsx("div", { className: "jini-message-tools", children: block.rows.map(renderToolCard) }, block.key));
224
+ if (block.kind === 'reply-end') {
225
+ const reply = usage.replies[block.index];
226
+ return reply && showUsageLine ? _jsx(UsageSummary, { usage: reply, showTotal: showUsageTotal }, block.key) : null;
212
227
  }
228
+ if (block.kind === 'tools')
229
+ return renderTools(block.rows, block.key);
230
+ if (block.kind === 'thinking')
231
+ return _jsx(ThinkingBlock, { text: block.text }, block.key);
213
232
  const group = extGroups.find((g) => g.slot === block.slot);
214
233
  if (!group)
215
234
  return null;
216
235
  return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
217
236
  })
218
- : (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent })] }));
237
+ : (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), flatToolRows.length > 0 ? renderTools(flatToolRows) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent: footerUsage, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard: answeredCards.isAnsweredCard })] }));
219
238
  });
220
239
  /**
221
240
  * The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
222
241
  * old "Thinking…" placeholder, which vanished for good as soon as the first tool row appeared.
223
242
  * Kept a separate component so its one-second tick re-renders only this line, not the whole row.
224
243
  */
225
- function RunActivityLine({ events, active }) {
226
- const activityLabel = useRunActivity({ events: events, active: active });
244
+ function RunActivityLine({ events, active, isAnsweredCard }) {
245
+ const activityLabel = useRunActivity({ events: events, active: active }, { isAnsweredCard });
227
246
  const label = continuingRunNotice({ events, active }, {}) ?? activityLabel;
228
247
  if (label === null)
229
248
  return null;
@@ -0,0 +1,48 @@
1
+ import type { ChatMessage } from '../../core/messages.js';
2
+ import { type ChatUsageSummary } from '../../core/turn-usage.js';
3
+ /** A standalone row has one turn; MessageList supplies the full conversation projection. */
4
+ export declare function useMessageUsage({ message, summary }: {
5
+ message: ChatMessage;
6
+ summary?: ChatUsageSummary;
7
+ }, _optional?: {}): ChatUsageSummary | undefined;
8
+ /** Which "Done" lines a message shows. Each finished reply gets its own line where it ends; the reply
9
+ * still running gets none, so a stale line never sits above live work (owner, 2026-10-10). Per-reply
10
+ * lines need the ordered block layout to know where a reply ends; the flat fallback keeps one
11
+ * whole-message line under the message once the run is over. A row still streaming counts as running
12
+ * even when its `runStatus` was never set, so the previous reply's line cannot slide down under the
13
+ * live tool rows above "Running …" (owner bug, 2026-10-10). O(r) for r replies.
14
+ * @example usageLayout({ usage, interleaved: true, runInProgress: true }).footer // undefined */
15
+ export declare function usageLayout({ usage, interleaved, runInProgress }: {
16
+ usage: ChatUsageSummary | undefined;
17
+ interleaved: boolean;
18
+ runInProgress: boolean;
19
+ }, { runStreaming }?: {
20
+ runStreaming?: boolean;
21
+ }): {
22
+ footer: ChatUsageSummary | undefined;
23
+ replies: readonly (ChatUsageSummary | undefined)[];
24
+ };
25
+ /** Whether the footer's "Done" line renders directly above the copy row: a finished turn with a
26
+ * non-empty usage line and no notice between them. Rows get a modifier class from this so a host can
27
+ * lay the two out on one line with a plain class selector — a `:has(> usage + actions)` selector made
28
+ * every keystroke restyle the whole page in long chats (2026-10-11). O(1).
29
+ * @example usageLineMeetsActions({ footer: { kind: 'usage', costUsd: 0.1 }, terminalNotice: null, pending: false, runInProgress: false }) // true */
30
+ export declare function usageLineMeetsActions({ footer, terminalNotice, pending, runInProgress }: {
31
+ footer: ChatUsageSummary | undefined;
32
+ terminalNotice: string | null;
33
+ pending: boolean;
34
+ runInProgress: boolean;
35
+ }, _optional?: {}): boolean;
36
+ /** Build footer display values outside JSX; token counts remain available in the tooltip and accessible footer label.
37
+ * Each line carries the chat's running total as of that reply, "Done · 16s · $0.39 · total $3.95" (owner,
38
+ * 2026-10-10: keep the total, always shown, even on the first turn). `showTotal: false` turns it off. */
39
+ export declare function useUsageSummary({ usage }: {
40
+ usage: ChatUsageSummary;
41
+ }, { showTotal }?: {
42
+ showTotal?: boolean;
43
+ }): {
44
+ text: string;
45
+ title: string | undefined;
46
+ ariaLabel: string | undefined;
47
+ };
48
+ //# sourceMappingURL=MessageRow.usage.d.ts.map