@jini-ai/chat 0.5.6 → 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 (127) 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/durable-projection.d.ts +3 -1
  5. package/dist/core/durable-projection.js +40 -7
  6. package/dist/core/events.d.ts +3 -0
  7. package/dist/core/history-tool-metadata.d.ts +23 -0
  8. package/dist/core/history-tool-metadata.js +13 -0
  9. package/dist/core/index.d.ts +4 -0
  10. package/dist/core/index.js +4 -0
  11. package/dist/core/persistence/title.d.ts +24 -0
  12. package/dist/core/persistence/title.js +60 -9
  13. package/dist/core/run-activity.d.ts +15 -2
  14. package/dist/core/run-activity.js +6 -4
  15. package/dist/core/run-events/entry.d.ts +12 -1
  16. package/dist/core/run-events/entry.js +19 -6
  17. package/dist/core/tool-call-detail.d.ts +51 -0
  18. package/dist/core/tool-call-detail.js +67 -0
  19. package/dist/core/turn-usage.d.ts +50 -0
  20. package/dist/core/turn-usage.js +114 -0
  21. package/dist/core/usage-format.d.ts +10 -0
  22. package/dist/core/usage-format.js +15 -0
  23. package/dist/embed-widget/browser/lazy-loader.d.ts +34 -0
  24. package/dist/embed-widget/browser/lazy-loader.js +116 -0
  25. package/dist/embed-widget/react/dialog.hooks.d.ts +28 -0
  26. package/dist/embed-widget/react/dialog.hooks.js +54 -0
  27. package/dist/embed-widget/react/embed.d.ts +1 -0
  28. package/dist/embed-widget/react/embed.js +5 -3
  29. package/dist/embed-widget/react/header.d.ts +3 -1
  30. package/dist/embed-widget/react/header.hooks.d.ts +13 -0
  31. package/dist/embed-widget/react/header.hooks.js +32 -0
  32. package/dist/embed-widget/react/header.js +4 -14
  33. package/dist/embed-widget/vanilla/index.d.ts +63 -0
  34. package/dist/embed-widget/vanilla/index.js +319 -0
  35. package/dist/embed-widget/vanilla/state.d.ts +48 -0
  36. package/dist/embed-widget/vanilla/state.js +120 -0
  37. package/dist/embed-widget/vanilla/style.css +29 -0
  38. package/dist/react/chat-pane.d.ts +2 -2
  39. package/dist/react/chat-pane.js +1 -1
  40. package/dist/react/components/A2uiSurfaceCard.d.ts +5 -1
  41. package/dist/react/components/A2uiSurfaceCard.js +9 -9
  42. package/dist/react/components/AttachmentPreviewModal.hooks.d.ts +7 -0
  43. package/dist/react/components/AttachmentPreviewModal.hooks.js +1 -1
  44. package/dist/react/components/ChatFab.d.ts +4 -1
  45. package/dist/react/components/ChatFab.js +2 -2
  46. package/dist/react/components/Composer.d.ts +2 -0
  47. package/dist/react/components/Composer.js +5 -2
  48. package/dist/react/components/ConversationList.d.ts +8 -1
  49. package/dist/react/components/ConversationList.hooks.d.ts +18 -0
  50. package/dist/react/components/ConversationList.hooks.js +25 -0
  51. package/dist/react/components/ConversationList.js +12 -5
  52. package/dist/react/components/ConversationList.rules.d.ts +25 -0
  53. package/dist/react/components/ConversationList.rules.js +19 -0
  54. package/dist/react/components/Markdown.js +6 -6
  55. package/dist/react/components/McpUiSurfaceCard.d.ts +1 -1
  56. package/dist/react/components/McpUiSurfaceCard.js +8 -4
  57. package/dist/react/components/MessageList.d.ts +8 -13
  58. package/dist/react/components/MessageList.hooks.d.ts +4 -0
  59. package/dist/react/components/MessageList.hooks.js +6 -1
  60. package/dist/react/components/MessageList.js +23 -4
  61. package/dist/react/components/MessageRow.d.ts +23 -1
  62. package/dist/react/components/MessageRow.js +61 -36
  63. package/dist/react/components/MessageRow.usage.d.ts +48 -0
  64. package/dist/react/components/MessageRow.usage.js +47 -0
  65. package/dist/react/components/MidRunUserMessage.d.ts +17 -0
  66. package/dist/react/components/MidRunUserMessage.js +41 -0
  67. package/dist/react/components/SentAttachment.d.ts +19 -0
  68. package/dist/react/components/SentAttachment.hooks.d.ts +44 -0
  69. package/dist/react/components/SentAttachment.hooks.js +56 -0
  70. package/dist/react/components/SentAttachment.js +14 -0
  71. package/dist/react/components/ToolCallGroup.d.ts +12 -0
  72. package/dist/react/components/ToolCallGroup.hooks.d.ts +20 -0
  73. package/dist/react/components/ToolCallGroup.hooks.js +23 -0
  74. package/dist/react/components/ToolCallGroup.js +13 -0
  75. package/dist/react/components/ToolCard.js +41 -22
  76. package/dist/react/components/mcp-ui-surface-state.d.ts +21 -0
  77. package/dist/react/components/mcp-ui-surface-state.js +26 -0
  78. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  79. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  80. package/dist/react/features/chat-pane/components/ChatPane.js +20 -7
  81. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +44 -0
  82. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +69 -0
  83. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -1
  84. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +65 -10
  85. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.d.ts +25 -0
  86. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +70 -0
  87. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +4 -0
  88. package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.d.ts +9 -0
  89. package/dist/react/features/chat-pane/hooks/useChatPaneSuggestion.hooks.js +11 -0
  90. package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.d.ts +17 -0
  91. package/dist/react/features/chat-pane/hooks/useChatPaneSurface.hooks.js +15 -0
  92. package/dist/react/features/chat-pane/index.d.ts +2 -1
  93. package/dist/react/features/chat-pane/index.js +1 -0
  94. package/dist/react/features/chat-pane/rules.d.ts +27 -0
  95. package/dist/react/features/chat-pane/rules.js +32 -0
  96. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  97. package/dist/react/features/chat-pane/styles.js +92 -1
  98. package/dist/react/features/chat-pane/types.d.ts +39 -1
  99. package/dist/react/hooks/composer-draft-cache.d.ts +15 -0
  100. package/dist/react/hooks/composer-draft-cache.js +20 -0
  101. package/dist/react/hooks/useAnsweredCards.d.ts +20 -0
  102. package/dist/react/hooks/useAnsweredCards.js +23 -0
  103. package/dist/react/hooks/useComposer.d.ts +14 -0
  104. package/dist/react/hooks/useComposer.js +78 -6
  105. package/dist/react/hooks/useConversation.d.ts +8 -0
  106. package/dist/react/hooks/useConversation.js +34 -0
  107. package/dist/react/hooks/useRunActivity.d.ts +5 -3
  108. package/dist/react/hooks/useRunActivity.js +4 -3
  109. package/dist/react/index.d.ts +8 -2
  110. package/dist/react/index.js +6 -1
  111. package/dist/react/message-blocks.d.ts +22 -3
  112. package/dist/react/message-blocks.js +25 -3
  113. package/dist/react/tool-call-display.d.ts +90 -0
  114. package/dist/react/tool-call-display.js +97 -0
  115. package/dist/server/durable-runs/recover.js +19 -11
  116. package/dist/server/run-finalizer/entry.js +2 -1
  117. package/dist/store/ports.d.ts +25 -2
  118. package/dist/store/runtime/durable-run-store.js +5 -2
  119. package/dist/store/runtime/entry.d.ts +1 -0
  120. package/dist/store/runtime/entry.js +1 -0
  121. package/dist/store/runtime/history-tools.d.ts +27 -0
  122. package/dist/store/runtime/history-tools.js +169 -0
  123. package/dist/store/runtime/run-ledger.js +5 -2
  124. package/dist/store/sql/store.js +52 -0
  125. package/dist/testing/index.d.ts +7 -0
  126. package/dist/testing/index.js +22 -0
  127. package/package.json +48 -18
@@ -54,7 +54,7 @@ function extractSurfaceId(message) {
54
54
  return undefined;
55
55
  }
56
56
  /** Renders one component and its children by walking the surface's component map from `componentId` — identical degrade-sanely behavior to `A2uiLab.tsx`'s `RenderComponent`. */
57
- function RenderComponent({ interpreter, surfaceId, componentId, ancestors, onAction, }) {
57
+ function RenderComponent({ interpreter, surfaceId, componentId, ancestors, onAction, registry, }) {
58
58
  if (ancestors.has(componentId)) {
59
59
  return (_jsxs("span", { className: "a2ui-placeholder a2ui-placeholder-cycle", "data-a2ui-status": "cycle", "data-a2ui-component-id": componentId, children: ["\u27F2 circular reference at \u201C", componentId, "\u201D"] }));
60
60
  }
@@ -65,7 +65,7 @@ function RenderComponent({ interpreter, surfaceId, componentId, ancestors, onAct
65
65
  }
66
66
  const nextAncestors = new Set(ancestors);
67
67
  nextAncestors.add(componentId);
68
- const childProps = { interpreter, surfaceId, ancestors: nextAncestors, onAction };
68
+ const childProps = { interpreter, surfaceId, ancestors: nextAncestors, onAction, registry };
69
69
  function resolveText(value) {
70
70
  // Text props already passed the catalog schema. Parsing preserves that wire type at this
71
71
  // Record<string, unknown> boundary without asserting a shape the compiler cannot verify.
@@ -108,9 +108,9 @@ function RenderComponent({ interpreter, surfaceId, componentId, ancestors, onAct
108
108
  // one of the 13 basic types this switch has no case for, or a registry-sourced component
109
109
  // (`shadcn.*`/`recharts.*`) merged into the catalog by `A2uiSurfaceCard`'s own
110
110
  // `buildA2uiCatalogFromRegistry` call below. Resolved against the SAME registry
111
- // `@jini-ai/ui`'s own `A2uiSurfaceRenderer` uses, so a type addable to the catalog and
111
+ // the catalog below uses, so a type addable to the catalog and
112
112
  // renderable there is addable and renderable here too.
113
- const entry = DEFAULT_INTERACTIVE_UI_REGISTRY.resolveById({ id: component.component });
113
+ const entry = registry.resolveById({ id: component.component });
114
114
  if (!entry) {
115
115
  return (_jsxs("span", { className: "a2ui-placeholder", "data-a2ui-status": "unrenderable-type", children: ["\u26A0 no renderer yet for component type \u201C", component.component, "\u201D"] }));
116
116
  }
@@ -139,16 +139,16 @@ function isThenable(value) {
139
139
  return typeof value === 'object' && value !== null && typeof value.then === 'function';
140
140
  }
141
141
  /** Registered against `ext-event-renderer-registry.ts`'s `'a2ui'` name — see module doc. */
142
- export function A2uiSurfaceCard({ events, runId, onAgentAction }) {
142
+ export function A2uiSurfaceCard({ events, runId, onAgentAction, registry = DEFAULT_INTERACTIVE_UI_REGISTRY }) {
143
143
  const t = useT();
144
- // Merges `DEFAULT_INTERACTIVE_UI_REGISTRY` (shadcn/recharts) into the lab catalog, keyed under
144
+ // Merges the host registry (shadcn/recharts by default) into the lab catalog, keyed under
145
145
  // the SAME `catalogId` the lab catalog already uses — so a tool built against
146
146
  // `createLabCatalog({}).catalogId` (e.g. `demo-a2ui-tool.ts`) still validates unchanged, and now
147
147
  // ALSO gains every registry component as a real, addable/removable catalog entry.
148
148
  const catalog = useMemo(() => {
149
149
  const base = createLabCatalog({});
150
- return buildA2uiCatalogFromRegistry({ registry: DEFAULT_INTERACTIVE_UI_REGISTRY, catalogId: base.catalogId }, { base });
151
- }, []);
150
+ return buildA2uiCatalogFromRegistry({ registry, catalogId: base.catalogId }, { base });
151
+ }, [registry]);
152
152
  const interpreterRef = useRef(null);
153
153
  if (!interpreterRef.current) {
154
154
  const clock = createSystemClock();
@@ -283,7 +283,7 @@ export function A2uiSurfaceCard({ events, runId, onAgentAction }) {
283
283
  if (!root) {
284
284
  return refusalNotice ? (_jsx("div", { className: "a2ui-surface-card a2ui-surface-refused", role: "status", children: t('A2UI surface refused: {reason}', { reason: refusalNotice }) })) : (_jsx("div", { className: "a2ui-surface-card a2ui-surface-waiting", role: "status", children: t('Waiting for the agent to send a root component…') }));
285
285
  }
286
- return (_jsxs("div", { className: "a2ui-surface-card", "data-a2ui-surface-id": surfaceId, children: [_jsx(RenderComponent, { interpreter: interpreter, surfaceId: surfaceId, componentId: "root", ancestors: new Set(), onAction: handleAction }), refusalNotice ? (_jsx("div", { className: "a2ui-surface-notice a2ui-surface-notice-refused", role: "status", children: t('Action refused: {reason}', { reason: refusalNotice }) })) : null, pendingAgentAction ? (_jsx("div", { className: "a2ui-surface-notice a2ui-surface-notice-unwired", role: "status", children: t('This action is meant for the agent, but this host has not wired up a live agent-action relay yet.') })) : null, deliveryFailureNotice ? (
286
+ return (_jsxs("div", { className: "a2ui-surface-card", "data-a2ui-surface-id": surfaceId, children: [_jsx(RenderComponent, { interpreter: interpreter, surfaceId: surfaceId, componentId: "root", ancestors: new Set(), onAction: handleAction, registry: registry }), refusalNotice ? (_jsx("div", { className: "a2ui-surface-notice a2ui-surface-notice-refused", role: "status", children: t('Action refused: {reason}', { reason: refusalNotice }) })) : null, pendingAgentAction ? (_jsx("div", { className: "a2ui-surface-notice a2ui-surface-notice-unwired", role: "status", children: t('This action is meant for the agent, but this host has not wired up a live agent-action relay yet.') })) : null, deliveryFailureNotice ? (
287
287
  // Deliberately its own notice, not folded into `refusalNotice` above: that one fires
288
288
  // *before* anything is sent (the interpreter said no); this one fires *after* — the message
289
289
  // left this component but the host reports it never arrived. Conflating them would tell the
@@ -58,6 +58,13 @@ export declare function readFileAsText({ file }: {
58
58
  }, { createReader }?: {
59
59
  createReader?: () => FileReader;
60
60
  }): Promise<string>;
61
+ /**
62
+ * `false` in an environment with no Blob-URL support — real browsers have shipped this for well
63
+ * over a decade, but jsdom (this package's own test environment) never implemented it, and this
64
+ * package cannot assume every embedding host is a browser tab. Feature-detected the same way this
65
+ * file already feature-detects `<dialog>`'s `showModal`/`close`, rather than assumed.
66
+ */
67
+ export declare function canCreateObjectUrl(): boolean;
61
68
  export interface AttachmentTextPreview {
62
69
  content: string;
63
70
  truncated: boolean;
@@ -78,7 +78,7 @@ export function readFileAsText({ file }, { createReader = () => new FileReader()
78
78
  * package cannot assume every embedding host is a browser tab. Feature-detected the same way this
79
79
  * file already feature-detects `<dialog>`'s `showModal`/`close`, rather than assumed.
80
80
  */
81
- function canCreateObjectUrl() {
81
+ export function canCreateObjectUrl() {
82
82
  return typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function';
83
83
  }
84
84
  /**
@@ -3,6 +3,9 @@ export interface ChatFabProps {
3
3
  readonly onToggle: () => void;
4
4
  /** Rendered as the accessible name, and announced as a state change to assistive tech. */
5
5
  readonly label?: string;
6
+ readonly 'aria-label'?: string;
7
+ readonly 'aria-controls'?: string;
8
+ readonly 'aria-haspopup'?: 'dialog';
6
9
  }
7
- export declare function ChatFab({ open, onToggle, label }: ChatFabProps): import("react").JSX.Element;
10
+ export declare function ChatFab({ open, onToggle, label, 'aria-label': accessibleLabel, 'aria-controls': controls, 'aria-haspopup': popup }: ChatFabProps): import("react").JSX.Element;
8
11
  //# sourceMappingURL=ChatFab.d.ts.map
@@ -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
@@ -19,6 +19,8 @@ export interface ComposerProps {
19
19
  * separately placed cancel affordance elsewhere in the pane. Always clickable when true,
20
20
  * regardless of `disabled`/`sendDisabled`/`composer.canSubmit`: those all gate *submitting a new
21
21
  * draft*, which has nothing to do with whether a caller may cancel the run already in flight.
22
+ * Once the draft is submittable (and neither gate refuses it), Send comes back beside Stop, so a
23
+ * message can go out mid-run without stopping anything.
22
24
  */
23
25
  running?: boolean;
24
26
  /** Cancels the in-flight run. Required when `running` is true; ignored otherwise. */
@@ -338,7 +338,10 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
338
338
  composer.setDraft(e.target.value);
339
339
  setSlashActiveIndex(0);
340
340
  setDismissedSlashDraft(null);
341
- }, onKeyDown: handleKeyDown, onPaste: handleAttachmentPaste, "aria-controls": slashOpen ? 'jini-composer-slash-menu' : undefined, "aria-expanded": slashOpen, "aria-activedescendant": slashOpen
341
+ }, onKeyDown: handleKeyDown,
342
+ // Draft persistence is debounced (see `DRAFT_PERSIST_DEBOUNCE_MS`); leaving the field is a
343
+ // natural point to make it durable now.
344
+ onBlur: () => composer.flushDraft?.(), onPaste: handleAttachmentPaste, "aria-controls": slashOpen ? 'jini-composer-slash-menu' : undefined, "aria-expanded": slashOpen, "aria-activedescendant": slashOpen
342
345
  ? `jini-composer-slash-option-${Math.min(slashActiveIndex, slashMatches.length - 1)}`
343
346
  : undefined, rows: 3 }), slashOpen ? (_jsx(ComposerSlashMenu, { matches: slashMatches, activeIndex: Math.min(slashActiveIndex, slashMatches.length - 1), onSelect: (item) => selectSlashItem(slashMatches.findIndex((match) => match.item === item)), showFilterHint: showSlashFilterHint, style: slashMenuPosition, t: t })) : null, _jsxs("div", { className: "jini-composer-footer", children: [attachmentPicker && !hasDiscoveryItems ? (_jsxs("div", { className: "jini-composer-attachment-picker", children: [_jsx("input", { ref: attachmentInputRef, className: "jini-composer-file-input", "data-testid": "composer-attachment-input", type: "file", multiple: true, "aria-label": t('Attach files'), accept: attachmentPicker.accept, disabled: disabled || attachmentPicker.uploading, onChange: handleAttachmentChange }), _jsx("button", { type: "button", className: "jini-composer-attach", disabled: disabled || attachmentPicker.uploading, onClick: () => attachmentInputRef.current?.click(), title: t(attachmentPicker.uploading ? 'Attaching files…' : 'Attach files'), "aria-label": t(attachmentPicker.uploading ? 'Attaching files…' : 'Attach files'), children: _jsx(RemixIcon, { name: attachmentPicker.uploading ? 'refresh-line' : 'add-line', size: 20, ...(attachmentPicker.uploading ? { className: 'jini-composer-spinner' } : {}) }) })] })) : null, hasDiscoveryItems ? (_jsx(ComposerDiscoveryMenu, { groups: discoveryGroups, open: discoveryMenuOpen, disabled: disabled, ...(attachmentPicker ? { attachmentPicker } : {}), attachmentInputRef: attachmentInputRef, onToggle: () => setDiscoveryMenuOpen((open) => !open), onClose: () => setDiscoveryMenuOpen(false), onSelect: selectPlusItem, onAttachmentChange: handleAttachmentChange, style: discoveryMenuPosition, t: t })) : null, slots?.footerLeadingAccessory ? (_jsx("div", { className: "jini-composer-footer-leading", children: slots.footerLeadingAccessory })) : null, onChangeWorkingDirectory || onPickWorkingDirectory ? (_jsxs("div", { className: "jini-composer-workdir", "data-testid": "composer-workdir", children: [_jsx("button", { type: "button", className: "jini-composer-attach", "data-testid": "composer-workdir-trigger", disabled: disabled, onClick: onPickWorkingDirectory ? () => onPickWorkingDirectory() : toggleWorkdir, ...(onPickWorkingDirectory ? {} : { 'aria-haspopup': 'dialog', 'aria-expanded': workdirOpen }), title: workingDirectory ?? t('Set working directory'), "aria-label": workingDirectory
344
347
  ? t('Working directory: {dir}', { dir: workingDirectory })
@@ -351,6 +354,6 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
351
354
  e.preventDefault();
352
355
  setWorkdirOpen(false);
353
356
  }
354
- }, placeholder: t('Working directory path'), "aria-label": t('Working directory path'), autoFocus: true }), _jsx("button", { type: "button", className: "jini-composer-workdir-confirm", "data-testid": "composer-workdir-confirm", onClick: confirmWorkdir, children: t('Set') })] })) : null] })) : null, slots?.footerAccessories ? (_jsx("div", { className: "jini-composer-footer-accessories", children: slots.footerAccessories })) : null, slots?.plusMenuItems && slots.plusMenuItems.length > 0 ? (_jsx("div", { className: "jini-composer-plus-menu", children: slots.plusMenuItems.map((item) => (_jsxs("button", { type: "button", className: "jini-composer-plus-item", onClick: () => runComposerHostEffect('plusMenuItems.onSelect', () => item.onSelect()), title: t(item.label), children: [item.icon ?? null, _jsx("span", { children: t(item.label) })] }, item.id))) })) : null, running ? (_jsx("button", { type: "button", className: "jini-composer-send jini-composer-send--stop", onClick: onCancel, title: t('Stop run'), "aria-label": t('Stop run'), children: _jsx(RemixIcon, { name: "stop-fill", size: 14 }) })) : (_jsx("button", { type: "button", className: "jini-composer-send", disabled: disabled || sendDisabled || !composer.canSubmit, onClick: onSend, title: t('Send'), "aria-label": t('Send'), children: _jsx(RemixIcon, { name: "send-plane-2-line", size: 16 }) }))] })] }));
357
+ }, placeholder: t('Working directory path'), "aria-label": t('Working directory path'), autoFocus: true }), _jsx("button", { type: "button", className: "jini-composer-workdir-confirm", "data-testid": "composer-workdir-confirm", onClick: confirmWorkdir, children: t('Set') })] })) : null] })) : null, slots?.footerAccessories ? (_jsx("div", { className: "jini-composer-footer-accessories", children: slots.footerAccessories })) : null, slots?.plusMenuItems && slots.plusMenuItems.length > 0 ? (_jsx("div", { className: "jini-composer-plus-menu", children: slots.plusMenuItems.map((item) => (_jsxs("button", { type: "button", className: "jini-composer-plus-item", onClick: () => runComposerHostEffect('plusMenuItems.onSelect', () => item.onSelect()), title: t(item.label), children: [item.icon ?? null, _jsx("span", { children: t(item.label) })] }, item.id))) })) : null, _jsxs("span", { className: "jini-composer-send-group", children: [!running || (!disabled && !sendDisabled && composer.canSubmit) ? (_jsx("button", { type: "button", className: "jini-composer-send", disabled: disabled || sendDisabled || !composer.canSubmit, onClick: onSend, title: t('Send'), "aria-label": t('Send'), children: _jsx(RemixIcon, { name: "send-plane-2-line", size: 16 }) })) : null, running ? (_jsx("button", { type: "button", className: "jini-composer-send jini-composer-send--stop", onClick: onCancel, title: t('Stop run'), "aria-label": t('Stop run'), children: _jsx(RemixIcon, { name: "stop-fill", size: 14 }) })) : null] })] })] }));
355
358
  }
356
359
  //# sourceMappingURL=Composer.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
@@ -389,16 +389,16 @@ const INLINE_RE = new RegExp(String.raw `(\x60[^\x60]+\x60)|(${MD_LINK})|(\*\*[^
389
389
  // Only these targets become anchors; anything else (`javascript:`, `data:`, protocol-relative
390
390
  // `//host`) stays literal text, since the text is untrusted model output.
391
391
  const SAFE_LINK_HREF = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/iu;
392
- // Off-site links open in a new tab; a same-origin path stays in the tab so the host's in-app link
393
- // interceptor (an SPA router) can take it without a reload.
394
- const OFFSITE_LINK_HREF = /^(?:https?:|mailto:)/iu;
392
+ // Every link opens in a new tab — an in-app path too, so following one never navigates the tab the
393
+ // chat lives in (owner 2026-10-08). Only a `#` same-document anchor stays in place. ChatPane's
394
+ // click guard (`useChatPaneNewTabLinks`) covers links this renderer does not draw.
395
+ const NEW_TAB = { target: '_blank', rel: 'noopener noreferrer' };
395
396
  function renderMarkdownLink(source, key) {
396
397
  // `MD_LINK` already matched `source`, so these two groups always capture.
397
398
  const [, label, href] = MD_LINK_PARTS.exec(source);
398
399
  if (!SAFE_LINK_HREF.test(href))
399
400
  return source;
400
- const offsite = OFFSITE_LINK_HREF.test(href);
401
- return (_jsx("a", { href: href, ...(offsite ? { target: '_blank', rel: 'noreferrer' } : {}), children: renderInline(label) }, key));
401
+ return (_jsx("a", { href: href, ...(href.startsWith('#') ? {} : NEW_TAB), children: renderInline(label) }, key));
402
402
  }
403
403
  function renderInline(text) {
404
404
  const nodes = [];
@@ -421,7 +421,7 @@ function renderInline(text) {
421
421
  else if (italic)
422
422
  nodes.push(_jsx("em", { children: italic.slice(1, -1) }, key++));
423
423
  else if (link)
424
- nodes.push(_jsx("a", { href: link, target: "_blank", rel: "noreferrer", children: link }, key++));
424
+ nodes.push(_jsx("a", { href: link, ...NEW_TAB, children: link }, key++));
425
425
  // `whole` is `match[0]` — a successful `RegExpExecArray` always has a
426
426
  // defined (if possibly empty) full-match string at index 0, so this
427
427
  // fallback is unreachable (same dead-fallback reasoning as above).
@@ -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,17 +1,6 @@
1
- /**
2
- * @module MessageList
3
- *
4
- * Renders a conversation's messages via `<MessageRow>`, and — matching
5
- * `useConversation`'s `scrollIntent` flag (see
6
- * `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4: "message array, optimistic
7
- * user message, scroll-intent flag") — auto-scrolls to the newest content
8
- * whenever `scrollIntent` is true, calling `onScrolled` once it has. The
9
- * scroll-anchoring state lives in `useMessageListAutoScroll`
10
- * (`MessageList.hooks.ts`); see that file for why it is presentational.
11
- */
12
1
  import type { ChatMessage } from '../../core/index.js';
13
2
  import { type MessageRowProps } from './MessageRow.js';
14
- export interface MessageListProps extends Pick<MessageRowProps, 'projectFileNames' | 'onRequestOpenFile' | 'renderAttachment'> {
3
+ export interface MessageListProps extends Pick<MessageRowProps, 'projectFileNames' | 'onRequestOpenFile' | 'renderAttachment' | 'showUsageTotal' | 'showUsageLine' | 'toolCallDisplay' | 'showThinking'> {
15
4
  messages: ChatMessage[];
16
5
  isStreaming?: boolean;
17
6
  /** Mirrors `useConversation().scrollIntent` — when `true`, this component scrolls to the bottom on mount/update. */
@@ -35,5 +24,11 @@ export interface MessageListProps extends Pick<MessageRowProps, 'projectFileName
35
24
  onCancel: () => void;
36
25
  } | null;
37
26
  }
38
- export declare function MessageList({ messages, isStreaming, scrollIntent, onScrolled, activeQuestionFormMessageId, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, pendingPrompt, }: MessageListProps): import("react").JSX.Element;
27
+ /**
28
+ * Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
29
+ * (hundreds of tool rows, MCP-UI cards, markdown) per key made typing lag (owner report
30
+ * 2026-10-08). Callers must pass stable props — `ChatPane` memoizes `pendingPrompt` and
31
+ * `projectFileNames` for exactly this.
32
+ */
33
+ export declare const MessageList: import("react").NamedExoticComponent<MessageListProps>;
39
34
  //# sourceMappingURL=MessageList.d.ts.map
@@ -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
@@ -1,15 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @module MessageList
4
+ *
5
+ * Renders a conversation's messages via `<MessageRow>`, and — matching
6
+ * `useConversation`'s `scrollIntent` flag (see
7
+ * `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4: "message array, optimistic
8
+ * user message, scroll-intent flag") — auto-scrolls to the newest content
9
+ * whenever `scrollIntent` is true, calling `onScrolled` once it has. The
10
+ * scroll-anchoring state lives in `useMessageListAutoScroll`
11
+ * (`MessageList.hooks.ts`); see that file for why it is presentational.
12
+ */
13
+ import { memo } from 'react';
2
14
  import { isTerminalRunStatus } from '../../core/index.js';
3
15
  import { useT } from '../hooks/context.js';
4
- import { useMessageListAutoScroll } from './MessageList.hooks.js';
16
+ import { useMessageListAutoScroll, useConversationUsage } from './MessageList.hooks.js';
5
17
  import { MessageRow } from './MessageRow.js';
6
- export function MessageList({ messages, isStreaming = false, scrollIntent = false, onScrolled, activeQuestionFormMessageId = null, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, pendingPrompt = null, }) {
18
+ /**
19
+ * Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
20
+ * (hundreds of tool rows, MCP-UI cards, markdown) per key made typing lag (owner report
21
+ * 2026-10-08). Callers must pass stable props — `ChatPane` memoizes `pendingPrompt` and
22
+ * `projectFileNames` for exactly this.
23
+ */
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, }) {
7
25
  const t = useT();
26
+ const usageByMessage = useConversationUsage({ messages });
8
27
  const { containerRef, onScroll } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
9
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) => {
10
29
  const isLast = message.id === messages[messages.length - 1]?.id;
11
30
  const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
12
- 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));
13
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] }));
14
- }
33
+ });
15
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;
@@ -64,5 +81,10 @@ export interface MessageRowProps {
64
81
  /** Host-supplied renderer for a `ChatAttachment` chip. Falls back to a plain filename chip. */
65
82
  renderAttachment?: (attachment: ChatAttachment) => ReactNode;
66
83
  }
67
- export declare function MessageRow({ message, runStreaming, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, renderAttachment, }: MessageRowProps): React.JSX.Element;
84
+ /**
85
+ * Memoized so a transcript re-render (a streaming update to the newest message, or a parent that
86
+ * re-renders per composer keystroke) only re-renders rows whose props actually changed. Messages
87
+ * are replaced, never mutated, so identity is a sound change signal.
88
+ */
89
+ export declare const MessageRow: React.NamedExoticComponent<MessageRowProps>;
68
90
  //# sourceMappingURL=MessageRow.d.ts.map