@jini-ai/chat 0.6.6 → 0.6.10

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 (160) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +187 -98
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.d.ts +1 -1
  77. package/dist/react/components/MessageRow.js +75 -47
  78. package/dist/react/components/MessageRow.usage.js +16 -11
  79. package/dist/react/components/MidRunUserMessage.js +16 -3
  80. package/dist/react/components/QuestionForm.js +148 -76
  81. package/dist/react/components/QuestionsPanel.js +1 -1
  82. package/dist/react/components/TodoCard.js +12 -6
  83. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  84. package/dist/react/components/ToolCallGroup.js +7 -5
  85. package/dist/react/components/ToolCard.js +47 -40
  86. package/dist/react/components/composer-discovery.js +6 -1
  87. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  88. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  89. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  90. package/dist/react/ext-event-renderer-registry.js +5 -2
  91. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  93. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  94. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  95. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  96. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  98. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  99. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  100. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  102. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  104. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  106. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  108. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  109. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  110. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  112. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  114. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  115. package/dist/react/features/chat-pane/model-options.js +45 -25
  116. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  117. package/dist/react/features/chat-pane/rules.js +84 -46
  118. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  119. package/dist/react/features/chat-pane/styles.js +152 -36
  120. package/dist/react/features/chat-pane/types.d.ts +19 -9
  121. package/dist/react/hooks/assistant-chats.js +12 -5
  122. package/dist/react/hooks/composer-draft-cache.js +116 -118
  123. package/dist/react/hooks/composer-history-storage.js +5 -5
  124. package/dist/react/hooks/fab-position.js +7 -4
  125. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  126. package/dist/react/hooks/useAnsweredCards.js +38 -11
  127. package/dist/react/hooks/useArtifactStream.js +28 -22
  128. package/dist/react/hooks/useComposer.d.ts +20 -0
  129. package/dist/react/hooks/useComposer.js +22 -9
  130. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  131. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  132. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  133. package/dist/react/hooks/useConversation.d.ts +7 -0
  134. package/dist/react/hooks/useConversation.js +23 -10
  135. package/dist/react/hooks/useExtEventGroups.js +39 -39
  136. package/dist/react/hooks/useQuestionForms.js +21 -26
  137. package/dist/react/hooks/useRunStream.js +10 -2
  138. package/dist/react/index.d.ts +7 -1
  139. package/dist/react/index.js +9 -0
  140. package/dist/react/message-blocks.d.ts +6 -4
  141. package/dist/react/message-blocks.js +94 -72
  142. package/dist/react/slots.d.ts +2 -2
  143. package/dist/react/tool-call-display.d.ts +12 -6
  144. package/dist/react/tool-call-display.js +53 -24
  145. package/dist/server/durable-runs/recover.js +132 -80
  146. package/dist/server/run-finalizer/entry.js +25 -15
  147. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  148. package/dist/store/legacy/sqlite/conversations.js +69 -98
  149. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  150. package/dist/store/legacy/sqlite/messages.js +113 -71
  151. package/dist/store/runtime/chat-history-store.js +9 -3
  152. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  153. package/dist/store/runtime/durable-run-store.js +165 -117
  154. package/dist/store/runtime/history-tools.js +118 -82
  155. package/dist/store/runtime/run-ledger.d.ts +4 -1
  156. package/dist/store/runtime/run-ledger.js +56 -16
  157. package/dist/store/sql/cursor.js +38 -18
  158. package/dist/store/sql/store.d.ts +18 -0
  159. package/dist/store/sql/store.js +46 -15
  160. package/package.json +18 -16
@@ -1,48 +1,28 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const CROSS = _jsxs(_Fragment, { children: [_jsx("path", { d: "M20 4 4 20" }), _jsx("path", { d: "m4 4 16 16" })] });
3
+ // Each glyph's SVG children; `Icon` wraps them in the shared `<svg>` shell.
4
+ const ICON_GLYPHS = {
5
+ spinner: _jsx("path", { d: "M21 12a9 9 0 1 1-6.22-8.56" }),
6
+ check: _jsx("path", { d: "M20 6 9 17l-5-5" }),
7
+ close: CROSS,
8
+ x: CROSS,
9
+ 'chevron-down': _jsx("path", { d: "m6 9 6 6 6-6" }),
10
+ /** Conversation-switcher trigger — a speech bubble reads as "chat history" without a label. */
11
+ comment: (_jsx("path", { d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" })),
12
+ search: _jsxs(_Fragment, { children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("path", { d: "m20 20-3.5-3.5" })] }),
13
+ plus: _jsxs(_Fragment, { children: [_jsx("path", { d: "M12 5v14" }), _jsx("path", { d: "M5 12h14" })] }),
14
+ 'chevron-left': _jsx("path", { d: "m15 18-6-6 6-6" }),
15
+ 'chevron-right': _jsx("path", { d: "m9 18 6-6-6-6" }),
16
+ attach: _jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" }),
17
+ file: _jsxs(_Fragment, { children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" }), _jsx("path", { d: "M14 2v6h6" }), _jsx("path", { d: "M8 13h8M8 17h6" })] }),
18
+ image: _jsxs(_Fragment, { children: [_jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }), _jsx("circle", { cx: "9", cy: "9", r: "2" }), _jsx("path", { d: "m21 15-5-5L5 21" })] }),
19
+ pencil: _jsxs(_Fragment, { children: [_jsx("path", { d: "m16 3 5 5L8 21H3v-5L16 3Z" }), _jsx("path", { d: "m14 5 5 5" })] }),
20
+ /** Per-message copy affordance (MessageRow.tsx). Two overlapping squares -- the conventional copy glyph. */
21
+ copy: _jsxs(_Fragment, { children: [_jsx("rect", { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" }), _jsx("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }),
22
+ };
2
23
  export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }) {
3
- const common = {
4
- width: size,
5
- height: size,
6
- viewBox: '0 0 24 24',
7
- fill: 'none',
8
- stroke: 'currentColor',
9
- strokeWidth,
10
- strokeLinecap: 'round',
11
- strokeLinejoin: 'round',
12
- 'aria-hidden': true,
13
- focusable: 'false',
14
- ...rest,
15
- };
16
- switch (name) {
17
- case 'spinner':
18
- return (_jsx("svg", { ...common, className: `jini-icon-spin ${rest.className ?? ''}`.trim(), children: _jsx("path", { d: "M21 12a9 9 0 1 1-6.22-8.56" }) }));
19
- case 'check':
20
- return (_jsx("svg", { ...common, children: _jsx("path", { d: "M20 6 9 17l-5-5" }) }));
21
- case 'close':
22
- case 'x':
23
- return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M20 4 4 20" }), _jsx("path", { d: "m4 4 16 16" })] }));
24
- case 'chevron-down':
25
- return (_jsx("svg", { ...common, children: _jsx("path", { d: "m6 9 6 6 6-6" }) }));
26
- /** Conversation-switcher trigger — a speech bubble reads as "chat history" without a label. */
27
- case 'comment':
28
- return (_jsx("svg", { ...common, children: _jsx("path", { d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" }) }));
29
- case 'search':
30
- return (_jsxs("svg", { ...common, children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("path", { d: "m20 20-3.5-3.5" })] }));
31
- case 'plus':
32
- return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M12 5v14" }), _jsx("path", { d: "M5 12h14" })] }));
33
- case 'chevron-left':
34
- return (_jsx("svg", { ...common, children: _jsx("path", { d: "m15 18-6-6 6-6" }) }));
35
- case 'chevron-right':
36
- return (_jsx("svg", { ...common, children: _jsx("path", { d: "m9 18 6-6-6-6" }) }));
37
- case 'attach':
38
- return (_jsx("svg", { ...common, children: _jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" }) }));
39
- case 'file':
40
- return (_jsxs("svg", { ...common, children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" }), _jsx("path", { d: "M14 2v6h6" }), _jsx("path", { d: "M8 13h8M8 17h6" })] }));
41
- case 'image':
42
- return (_jsxs("svg", { ...common, children: [_jsx("rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }), _jsx("circle", { cx: "9", cy: "9", r: "2" }), _jsx("path", { d: "m21 15-5-5L5 21" })] }));
43
- /** Per-message copy affordance (MessageRow.tsx). Two overlapping squares -- the conventional copy glyph. */
44
- case 'copy':
45
- return (_jsxs("svg", { ...common, children: [_jsx("rect", { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" }), _jsx("path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" })] }));
46
- }
24
+ // The spinner alone puts its spin class in front of any caller-supplied one.
25
+ const className = name === 'spinner' ? `jini-icon-spin ${rest.className ?? ''}`.trim() : rest.className;
26
+ return (_jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: strokeWidth, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, focusable: "false", ...rest, className: className, children: ICON_GLYPHS[name] }));
47
27
  }
48
28
  //# sourceMappingURL=Icon.js.map
@@ -46,45 +46,38 @@ export function Markdown({ children }) {
46
46
  const blocks = parseBlocks(children);
47
47
  return (_jsx(_Fragment, { children: blocks.map((block, i) => (_jsx(Fragment, { children: renderBlock(block) }, i))) }));
48
48
  }
49
- // GFM pipe-table cell split, ported verbatim (logic-for-logic) from OD's
50
- // `runtime/markdown.tsx`. Walks char-by-char rather than a naive `split('|')`
49
+ // GFM pipe-table cell split, ported logic-for-logic from OD's
50
+ // `runtime/markdown.tsx`. Tokenizes rather than a naive `split('|')`
51
51
  // so it can honor three cell-content rules without placeholder substitution:
52
52
  // `\|` is a literal pipe inside a cell, a `|` inside a backtick code span is
53
53
  // cell content rather than a column boundary, and a single optional leading
54
54
  // `|` plus an unescaped trailing `|` are row terminators, not empty cells.
55
+ // Always returns at least one cell (an empty row yields `['']`).
56
+ const TABLE_ROW_LEAD = /^ *\|?/;
57
+ const TABLE_CELL_TOKEN = /\\\||`|\||[^`|\\]+|\\/g;
55
58
  function splitTableCells(line) {
56
59
  const cells = [];
57
60
  let cur = '';
58
61
  let inCode = false;
59
- let i = 0;
60
- while (i < line.length && line[i] === ' ')
61
- i++;
62
- if (line[i] === '|')
63
- i++;
64
- for (; i < line.length; i++) {
65
- const ch = line[i];
66
- if (ch === '\\' && line[i + 1] === '|') {
67
- cur += '|';
68
- i++;
69
- continue;
70
- }
71
- if (ch === '`') {
72
- inCode = !inCode;
73
- cur += ch;
74
- continue;
75
- }
76
- if (ch === '|' && !inCode) {
62
+ for (const token of line.replace(TABLE_ROW_LEAD, '').match(TABLE_CELL_TOKEN) ?? []) {
63
+ if (token === '|' && !inCode) {
77
64
  cells.push(cur.trim());
78
65
  cur = '';
79
66
  continue;
80
67
  }
81
- cur += ch;
68
+ if (token === '`')
69
+ inCode = !inCode;
70
+ cur += token === '\\|' ? '|' : token;
82
71
  }
83
72
  const tail = cur.trim();
84
73
  if (cells.length === 0 || tail !== '')
85
74
  cells.push(tail);
86
75
  return cells;
87
76
  }
77
+ const TABLE_ALIGN_CELL = /^:?-{1,}:?$/;
78
+ // Every cell `TABLE_ALIGN_CELL` accepts starts and ends with `:` or `-`, so its
79
+ // first + last character is always exactly one of these four keys.
80
+ const COLUMN_ALIGN = { '::': 'center', '-:': 'right', ':-': 'left', '--': null };
88
81
  // Recognizes a GFM alignment row (`| --- | :---: | ---: |`) and returns the
89
82
  // per-column alignment, or `null` if `line` isn't one — used both to detect
90
83
  // where a table starts and to read its column alignment.
@@ -92,26 +85,17 @@ function parseTableAlignRow(line) {
92
85
  if (!line.includes('|'))
93
86
  return null;
94
87
  const cells = splitTableCells(line);
95
- if (cells.length === 0)
88
+ if (!cells.every((cell) => TABLE_ALIGN_CELL.test(cell)))
96
89
  return null;
97
- const aligns = [];
98
- for (const cell of cells) {
99
- if (!/^:?-{1,}:?$/.test(cell))
100
- return null;
101
- const left = cell.startsWith(':');
102
- const right = cell.endsWith(':');
103
- aligns.push(left && right ? 'center' : right ? 'right' : left ? 'left' : null);
104
- }
105
- return aligns;
90
+ return cells.map((cell) => COLUMN_ALIGN[`${cell[0]}${cell.at(-1)}`]);
106
91
  }
107
92
  // A table starts at `i` when `lines[i]` is a pipe-bearing header row
108
93
  // immediately followed by a valid alignment row at `lines[i + 1]`.
109
94
  function isTableStartAt(lines, i) {
110
- const header = lines[i];
111
95
  const sep = lines[i + 1];
112
- if (header === undefined || sep === undefined)
96
+ if (sep === undefined)
113
97
  return false;
114
- if (!header.includes('|'))
98
+ if (!lines[i].includes('|'))
115
99
  return false;
116
100
  return parseTableAlignRow(sep) !== null;
117
101
  }
@@ -124,7 +108,7 @@ function collectTableRows(lines, start) {
124
108
  let i = start;
125
109
  while (i < lines.length) {
126
110
  const row = lines[i];
127
- if (row === undefined || row.trim() === '' || !row.includes('|'))
111
+ if (row.trim() === '' || !row.includes('|'))
128
112
  break;
129
113
  rows.push(splitTableCells(row));
130
114
  i++;
@@ -132,95 +116,122 @@ function collectTableRows(lines, start) {
132
116
  return { rows, next: i };
133
117
  }
134
118
  const ORDERED_ITEM = /^\s*\d+[.)]\s+/;
119
+ const FENCE = /^```/;
120
+ const HEADING = /^(#{1,3})\s+(.*)$/;
121
+ const RULE = /^(-{3,}|\*{3,}|_{3,})\s*$/;
122
+ const QUOTE_LINE = /^>\s?/;
123
+ const BULLET_ITEM = /^\s*[-*]\s+/;
124
+ function parseFence(lines, i) {
125
+ const line = lines[i];
126
+ if (!FENCE.test(line))
127
+ return null;
128
+ const lang = line.slice(3).trim() || null;
129
+ const body = [];
130
+ let j = i + 1;
131
+ while (j < lines.length && !FENCE.test(lines[j])) {
132
+ body.push(lines[j]);
133
+ j += 1;
134
+ }
135
+ // `j + 1` skips the closing fence (or end of input if unterminated).
136
+ return { block: { kind: 'code', lang, body: body.join('\n') }, next: j + 1 };
137
+ }
138
+ function parseHeading(lines, i) {
139
+ const heading = HEADING.exec(lines[i]);
140
+ if (!heading)
141
+ return null;
142
+ // Both capture groups are mandatory (non-optional) in this pattern, so
143
+ // whenever `heading` itself is truthy, `heading[1]`/`heading[2]` are
144
+ // always matched strings (never `undefined`) — same dead-fallback
145
+ // reasoning as the `lines[i]` accesses above.
146
+ return { block: { kind: 'h', level: heading[1].length, text: heading[2] }, next: i + 1 };
147
+ }
148
+ function parseRule(lines, i) {
149
+ return RULE.test(lines[i]) ? { block: { kind: 'hr' }, next: i + 1 } : null;
150
+ }
151
+ // Collects the run of consecutive lines matching `marker`, each with that
152
+ // marker stripped — the shared shape of blockquotes and bullet lists.
153
+ function collectMarked(lines, start, marker) {
154
+ const items = [];
155
+ let i = start;
156
+ while (i < lines.length && marker.test(lines[i])) {
157
+ items.push(lines[i].replace(marker, ''));
158
+ i += 1;
159
+ }
160
+ return { items, next: i };
161
+ }
162
+ function parseQuote(lines, i) {
163
+ if (!QUOTE_LINE.test(lines[i]))
164
+ return null;
165
+ const { items, next } = collectMarked(lines, i, QUOTE_LINE);
166
+ return { block: { kind: 'bq', text: items.join('\n') }, next };
167
+ }
168
+ function parseBulletList(lines, i) {
169
+ if (!BULLET_ITEM.test(lines[i]))
170
+ return null;
171
+ const { items, next } = collectMarked(lines, i, BULLET_ITEM);
172
+ return { block: { kind: 'ul', items }, next };
173
+ }
174
+ function parseTable(lines, i) {
175
+ if (!isTableStartAt(lines, i))
176
+ return null;
177
+ const aligns = parseTableAlignRow(lines[i + 1]);
178
+ const headers = splitTableCells(lines[i]);
179
+ const { rows, next } = collectTableRows(lines, i + 2);
180
+ return { block: { kind: 'table', aligns, headers, rows }, next };
181
+ }
182
+ function parseOrderedList(lines, i) {
183
+ const line = lines[i];
184
+ if (!ORDERED_ITEM.test(line))
185
+ return null;
186
+ const start = Number(/\d+/.exec(line)[0]);
187
+ const items = [];
188
+ let j = i;
189
+ while (j < lines.length && ORDERED_ITEM.test(lines[j])) {
190
+ items.push(lines[j].replace(ORDERED_ITEM, ''));
191
+ j = nextLooseListLine(lines, j + 1);
192
+ }
193
+ return { block: { kind: 'ol', start, items }, next: j };
194
+ }
195
+ // Tried in this order at each non-blank line; the first match wins, and a
196
+ // line none of them claims starts a paragraph.
197
+ const BLOCK_PARSERS = [
198
+ parseFence,
199
+ parseHeading,
200
+ parseRule,
201
+ parseQuote,
202
+ parseBulletList,
203
+ parseTable,
204
+ parseOrderedList,
205
+ ];
206
+ function parseParagraph(lines, i) {
207
+ const paragraph = [];
208
+ let j = i;
209
+ while (j < lines.length && lines[j].trim() !== '' && !isBlockStart(lines, j)) {
210
+ paragraph.push(lines[j]);
211
+ j += 1;
212
+ }
213
+ return { block: { kind: 'p', text: paragraph.join('\n') }, next: j };
214
+ }
215
+ function parseBlockAt(lines, i) {
216
+ for (const parse of BLOCK_PARSERS) {
217
+ const parsed = parse(lines, i);
218
+ if (parsed)
219
+ return parsed;
220
+ }
221
+ return parseParagraph(lines, i);
222
+ }
135
223
  function parseBlocks(input) {
136
224
  const lines = input.split(/\r?\n/);
137
225
  const blocks = [];
138
226
  let i = 0;
139
- // Every `lines[i]` access below is guarded by an `i < lines.length` check
140
- // (either this loop's own condition or an inner `while`'s left-hand
141
- // operand, short-circuited before the index is read) — `lines` is a
142
- // `string[]` from `String.prototype.split`, which never contains
143
- // `undefined` entries, so the index is always in range and always a
144
- // string. The `?? ''`/non-null-assertion sites below are TS-only
145
- // fallbacks for `noUncheckedIndexedAccess`-style indexing with no real
146
- // runtime path; using `!` documents that instead of instrumenting an
147
- // unreachable branch (same convention as `ToolCard.tsx`'s `baseName`
148
- // comment).
149
227
  while (i < lines.length) {
150
- const line = lines[i];
151
- if (line.trim() === '') {
152
- i += 1;
153
- continue;
154
- }
155
- if (/^```/.test(line)) {
156
- const lang = line.slice(3).trim() || null;
157
- const body = [];
158
- i += 1;
159
- while (i < lines.length && !/^```/.test(lines[i])) {
160
- body.push(lines[i]);
161
- i += 1;
162
- }
163
- i += 1; // skip closing fence (or end of input if unterminated)
164
- blocks.push({ kind: 'code', lang, body: body.join('\n') });
165
- continue;
166
- }
167
- const heading = /^(#{1,3})\s+(.*)$/.exec(line);
168
- if (heading) {
169
- // Both capture groups are mandatory (non-optional) in this pattern, so
170
- // whenever `heading` itself is truthy, `heading[1]`/`heading[2]` are
171
- // always matched strings (never `undefined`) — same dead-fallback
172
- // reasoning as the `lines[i]` accesses above.
173
- blocks.push({ kind: 'h', level: heading[1].length, text: heading[2] });
228
+ if (lines[i].trim() === '') {
174
229
  i += 1;
175
230
  continue;
176
231
  }
177
- if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
178
- blocks.push({ kind: 'hr' });
179
- i += 1;
180
- continue;
181
- }
182
- if (/^>\s?/.test(line)) {
183
- const quoted = [];
184
- while (i < lines.length && /^>\s?/.test(lines[i])) {
185
- quoted.push(lines[i].replace(/^>\s?/, ''));
186
- i += 1;
187
- }
188
- blocks.push({ kind: 'bq', text: quoted.join('\n') });
189
- continue;
190
- }
191
- if (/^\s*[-*]\s+/.test(line)) {
192
- const items = [];
193
- while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
194
- items.push(lines[i].replace(/^\s*[-*]\s+/, ''));
195
- i += 1;
196
- }
197
- blocks.push({ kind: 'ul', items });
198
- continue;
199
- }
200
- if (isTableStartAt(lines, i)) {
201
- const aligns = parseTableAlignRow(lines[i + 1]);
202
- const headers = splitTableCells(line);
203
- const { rows, next } = collectTableRows(lines, i + 2);
204
- blocks.push({ kind: 'table', aligns, headers, rows });
205
- i = next;
206
- continue;
207
- }
208
- if (ORDERED_ITEM.test(line)) {
209
- const start = Number(/\d+/.exec(line)[0]);
210
- const items = [];
211
- while (i < lines.length && ORDERED_ITEM.test(lines[i])) {
212
- items.push(lines[i].replace(ORDERED_ITEM, ''));
213
- i = nextLooseListLine(lines, i + 1);
214
- }
215
- blocks.push({ kind: 'ol', start, items });
216
- continue;
217
- }
218
- const paragraph = [];
219
- while (i < lines.length && lines[i].trim() !== '' && !isBlockStart(lines, i)) {
220
- paragraph.push(lines[i]);
221
- i += 1;
222
- }
223
- blocks.push({ kind: 'p', text: paragraph.join('\n') });
232
+ const { block, next } = parseBlockAt(lines, i);
233
+ blocks.push(block);
234
+ i = next;
224
235
  }
225
236
  return blocks;
226
237
  }
@@ -238,11 +249,11 @@ function nextLooseListLine(lines, i) {
238
249
  }
239
250
  function isBlockStart(lines, i) {
240
251
  const line = lines[i];
241
- return (/^```/.test(line)
252
+ return (FENCE.test(line)
242
253
  || /^(#{1,3})\s+/.test(line)
243
- || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)
244
- || /^>\s?/.test(line)
245
- || /^\s*[-*]\s+/.test(line)
254
+ || RULE.test(line)
255
+ || QUOTE_LINE.test(line)
256
+ || BULLET_ITEM.test(line)
246
257
  || ORDERED_ITEM.test(line)
247
258
  || isTableStartAt(lines, i));
248
259
  }
@@ -310,8 +321,9 @@ function useTableOverflow() {
310
321
  const wrapRef = useRef(null);
311
322
  const [isOverflowing, setIsOverflowing] = useState(false);
312
323
  useEffect(() => {
324
+ // The wrap div renders unconditionally, so its ref is attached by the time this effect runs.
313
325
  const el = wrapRef.current;
314
- if (!el || typeof ResizeObserver !== 'function')
326
+ if (typeof ResizeObserver !== 'function')
315
327
  return;
316
328
  const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth + TABLE_OVERFLOW_SLACK_PX);
317
329
  const observer = new ResizeObserver(check);
@@ -354,9 +366,8 @@ function TableBlock(props) {
354
366
  const [expanded, setExpanded] = useState(false);
355
367
  const dialogRef = useRef(null);
356
368
  useEffect(() => {
369
+ // The `<dialog>` renders unconditionally, so its ref is attached by the time this effect runs.
357
370
  const dialog = dialogRef.current;
358
- if (!dialog)
359
- return;
360
371
  if (expanded)
361
372
  openTableDialog(dialog);
362
373
  else
@@ -22,7 +22,7 @@ export interface McpUiSurfaceCardProps extends ExtEventRenderProps {
22
22
  * scrolling for content taller than its `preferredFrameSize` guess. A host embedding this card in
23
23
  * a fixed-width sidebar should pass a cap sized to its own viewport.
24
24
  */
25
- maxHeight?: number;
25
+ maxHeight?: number | undefined;
26
26
  /**
27
27
  * Time source for a card's answer deadline (`MCP_UI_EXPIRES_AT_META_KEY`). Omit for the wall clock;
28
28
  * a test injects a fake one to step through the countdown.
@@ -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, answered, onAnswered }: McpUiSurfaceCardProps): import("react").JSX.Element;
33
+ export declare function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock, answered, onAnswered, onAnswerText, answerText, answerRecorded }: 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
@@ -61,6 +61,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
61
61
  * `visible` field in per-element STATE when a caller opts into it — see the regression tests added
62
62
  * against the real driver in `@jini-ai/agentic`.
63
63
  */
64
+ import { questionAnswerFromResult } from '../../core/question-answer.js';
65
+ import { MidRunUserMessage } from './MidRunUserMessage.js';
64
66
  import { Fragment, useMemo } from 'react';
65
67
  import { McpUiHost, parseUIResource, readActionPlan, readExpiresAt, readPreferredFrameSize, } from '@jini-ai/ui/mcp-ui';
66
68
  import { defaultExtEventRendererRegistry } from '../ext-event-renderer-registry.js';
@@ -93,8 +95,10 @@ function latestResourcesByUri(events) {
93
95
  * has teeth for a producer whose id scheme this package has never seen.
94
96
  */
95
97
  function pendingMirrorHandle(uri) {
98
+ // Never empty: only resources whose URI starts with `ui://` reach a card (`@jini-ai/ui/mcp-ui`'s
99
+ // resource parser rejects every other scheme), so the sanitized handle always keeps at least `ui`.
96
100
  const sanitized = uri.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
97
- return `mcp-ui-pending-${sanitized === '' ? 'surface' : sanitized}`;
101
+ return `mcp-ui-pending-${sanitized}`;
98
102
  }
99
103
  /** The one label a mirror region carries — what is pending, and what an agent could report or wait on. */
100
104
  function pendingMirrorLabel(t, plan) {
@@ -119,23 +123,40 @@ function ClosedSurface({ call, label }) {
119
123
  const title = call?.input?.title;
120
124
  return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' && title.trim() !== '' ? `${title}: ${label}` : label }));
121
125
  }
126
+ /** The live card's answer lifecycle: its deadline and delivery notification, using their existing owners. */
127
+ function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnswerText, call }, _options = {}) {
128
+ // An answered card has no deadline left: its countdown goes, and the deadline passing while the
129
+ // answer's work still runs must not relabel it "This question expired".
130
+ const expiresAtMs = resource === undefined || answered === true ? undefined : readExpiresAt(resource);
131
+ // Reports the person's delivered answer to the row, which stops the "Waiting for your answer" line.
132
+ const toolCall = onToolCall === undefined || onAnswered === undefined ? onToolCall : reportDeliveredAnswer({ onToolCall, onAnswered, onAnswerText, input: call?.input });
133
+ return { expiresAtMs, toolCall };
134
+ }
122
135
  /** 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, answered, onAnswered }) {
136
+ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock, answered, onAnswered, onAnswerText, answerText, answerRecorded }) {
124
137
  const t = useT();
125
138
  const resources = useMemo(() => latestResourcesByUri(events), [events]);
126
139
  // One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
127
140
  // that card. A group holding several URIs has no single call to judge them by, and stays live.
128
141
  const single = resources.length === 1 ? resources[0] : undefined;
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 });
142
+ const { expiresAtMs, toolCall } = surfaceInteraction({ resource: single, answered, onToolCall, onAnswered, onAnswerText, call });
143
+ const expiry = useSurfaceExpiry({ expiresAtMs }, expiryClock === undefined ? {} : { clock: expiryClock });
132
144
  const closed = single === undefined
133
145
  ? undefined
134
146
  : closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
147
+ const answer = answerText ?? questionAnswerFromResult({ result: call?.result, input: call?.input });
148
+ return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: call, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
149
+ }
150
+ /** Rendering priority stays receipt, answer, closure, then the live documents. */
151
+ function SurfaceCardContent({ resources, single, answerRecorded, answer, closed, call, t, sandboxProxyUrl, toolCall, onOpenLink, maxHeight, expiry }) {
152
+ // MOVE: The accepted-input receipt owns its bubble; rendering the closed card too duplicates typed answers.
153
+ if (single !== undefined && answerRecorded)
154
+ return null;
155
+ if (single !== undefined && answer !== undefined) {
156
+ return _jsx(MidRunUserMessage, { events: [{ text: answer === 'Declined' ? t('Declined') : answer }] });
157
+ }
135
158
  if (closed !== undefined)
136
159
  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 });
139
160
  if (resources.length === 0) {
140
161
  // Visible rather than silent: an `ext` event named `mcp-ui` that carries no parseable resource
141
162
  // is a producer bug, and a transcript that renders nothing for it hides the bug behind what
@@ -154,7 +175,7 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
154
175
  // the previous iframe session, so the handshake never re-ran and the frame kept serving
155
176
  // the OLD document's state. The URI stays in the key because it is real identity; the
156
177
  // length was never a substitute for content.
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));
178
+ sessionKey: `${resource.resource.uri}:${resource.resource.text}`, ...(toolCall === undefined ? {} : { onToolCall: toolCall }), ...(onOpenLink === undefined ? {} : { onOpenLink }), ...(height === undefined || Number.isNaN(height) ? {} : { initialHeight: height }), maxHeight: maxHeight }), plan !== undefined && _jsx(PendingSurfaceMirror, { uri: resource.resource.uri, plan: plan, t: t })] }, resource.resource.uri));
158
179
  }), expiry?.expired === false && (_jsx("p", { className: "mcpui-surface-expiry", role: "timer", children: t('Expires in {time}', { time: expiry.remaining }) }))] }));
159
180
  }
160
181
  /**
@@ -185,6 +206,6 @@ export function mcpUiSurfaceSlotKey({ data }) {
185
206
  * @returns An unregister handle, so a test or a hot reload can dispose cleanly.
186
207
  */
187
208
  export function registerMcpUiSurfaceRenderer(options) {
188
- return (options.registry ?? defaultExtEventRendererRegistry).register({ name: options.name ?? MCP_UI_EXT_EVENT_NAME, renderer: (props) => (_jsx(McpUiSurfaceCard, { ...props, sandboxProxyUrl: options.sandboxProxyUrl, ...(options.onToolCall === undefined ? {} : { onToolCall: options.onToolCall }), ...(options.onOpenLink === undefined ? {} : { onOpenLink: options.onOpenLink }), ...(options.maxHeight === undefined ? {} : { maxHeight: options.maxHeight }) })) }, { slotKey: mcpUiSurfaceSlotKey });
209
+ return (options.registry ?? defaultExtEventRendererRegistry).register({ name: options.name ?? MCP_UI_EXT_EVENT_NAME, renderer: (props) => (_jsx(McpUiSurfaceCard, { ...props, sandboxProxyUrl: options.sandboxProxyUrl, ...(options.onToolCall === undefined ? {} : { onToolCall: options.onToolCall }), ...(options.onOpenLink === undefined ? {} : { onOpenLink: options.onOpenLink }), maxHeight: options.maxHeight })) }, { slotKey: mcpUiSurfaceSlotKey });
189
210
  }
190
211
  //# sourceMappingURL=McpUiSurfaceCard.js.map
@@ -1,5 +1,11 @@
1
- import type { ChatMessage } from '../../core/index.js';
1
+ import type { ChatAttachment, ChatMessage } from '../../core/index.js';
2
2
  import { type MessageRowProps } from './MessageRow.js';
3
+ interface PendingPrompt {
4
+ id?: string;
5
+ text: string;
6
+ attachments?: ChatAttachment[];
7
+ onCancel: () => void;
8
+ }
3
9
  export interface MessageListProps extends Pick<MessageRowProps, 'projectFileNames' | 'onRequestOpenFile' | 'renderAttachment' | 'showUsageTotal' | 'showUsageLine' | 'toolCallDisplay' | 'showThinking'> {
4
10
  messages: ChatMessage[];
5
11
  isStreaming?: boolean;
@@ -11,24 +17,18 @@ export interface MessageListProps extends Pick<MessageRowProps, 'projectFileName
11
17
  questionFormSubmittedAnswersByMessageId?: Record<string, Record<string, string | string[]>>;
12
18
  onQuestionFormSubmit?: (messageId: string, text: string, answers: Record<string, string | string[]>) => void;
13
19
  /**
14
- * A composer-driven turn typed while a run is in flight, mirroring `useChatPane().queuedPrompt` —
15
- * rendered as the newest transcript entry (like Claude Code/ChatGPT's own pending-turn UI)
16
- * instead of a banner bolted above the composer. Deliberately NOT a `ChatMessage`: it has no
17
- * persisted id (nothing here could mint one without risking collision with a real message once
18
- * this prompt is actually sent) and is never written to `messages`/storage — purely a rendering
19
- * of `useChatPane`'s own in-memory queue slot. `null`/omitted renders nothing, matching the old
20
- * strip's "shown only once something is actually queued" behavior.
20
+ * MOVE: Pending inputs render as the newest transcript entries, never persisted ChatMessages.
21
+ * Their accepted ids identify cancellations; the singular prop remains a host compatibility seam.
21
22
  */
22
- pendingPrompt?: {
23
- text: string;
24
- onCancel: () => void;
25
- } | null;
23
+ pendingPrompt?: PendingPrompt | null;
24
+ pendingPrompts?: readonly PendingPrompt[];
26
25
  }
27
26
  /**
28
27
  * Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
29
28
  * (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
29
+ * 2026-10-08). Callers must pass stable props — `ChatPane` memoizes `pendingPrompts` and
31
30
  * `projectFileNames` for exactly this.
32
31
  */
33
32
  export declare const MessageList: import("react").NamedExoticComponent<MessageListProps>;
33
+ export {};
34
34
  //# sourceMappingURL=MessageList.d.ts.map
@@ -9,6 +9,10 @@ import type { ChatMessage } from '../../core/index.js';
9
9
  export interface MessageListAutoScroll {
10
10
  containerRef: RefObject<HTMLDivElement | null>;
11
11
  onScroll: (event: UIEvent<HTMLElement>) => void;
12
+ /** True while the user has scrolled up away from the newest messages. */
13
+ awayFromLatest: boolean;
14
+ /** Scrolls to the newest message and resumes following new output. */
15
+ jumpToLatest: () => void;
12
16
  }
13
17
  /** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
14
18
  export declare function useConversationUsage({ messages }: {
@@ -17,7 +21,8 @@ export declare function useConversationUsage({ messages }: {
17
21
  export declare function useMessageListAutoScroll({ messages, scrollIntent }: {
18
22
  messages: ChatMessage[];
19
23
  scrollIntent: boolean;
20
- }, { onScrolled }?: {
24
+ }, { onScrolled, reducedMotion }?: {
21
25
  onScrolled?: () => void;
26
+ reducedMotion?: () => boolean;
22
27
  }): MessageListAutoScroll;
23
28
  //# sourceMappingURL=MessageList.hooks.d.ts.map
@@ -4,14 +4,15 @@
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, useMemo, useRef } from 'react';
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
  import { conversationUsage } from '../../core/turn-usage.js';
9
9
  import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
10
+ const prefersReducedMotion = () => typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
10
11
  /** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
11
12
  export function useConversationUsage({ messages }, _optional = {}) {
12
13
  return useMemo(() => conversationUsage({ messages }), [messages]);
13
14
  }
14
- export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled } = {}) {
15
+ export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled, reducedMotion = prefersReducedMotion } = {}) {
15
16
  const containerRef = useRef(null);
16
17
  // Whether the transcript was scrolled to its bottom as of the last user scroll or programmatic
17
18
  // scroll-to-bottom — read by the ResizeObserver effect below, which only ever sees the DOM AFTER a
@@ -23,6 +24,9 @@ export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolle
23
24
  // Only scroll events write it (a browser fires one for programmatic scrolls too). Starts at
24
25
  // Infinity, "no position seen yet", so a first event away from the bottom unsticks as it always did.
25
26
  const lastScrollTopRef = useRef(Number.POSITIVE_INFINITY);
27
+ // State mirror of `!stickToBottomRef.current` for the "Jump to latest" button; the ref stays the
28
+ // source of truth for the scroll effects, which must not wait for a render.
29
+ const [awayFromLatest, setAwayFromLatest] = useState(false);
26
30
  // Runs BEFORE the scroll effect below (effects run in declaration order), so a send re-sticks in
27
31
  // time for that same commit's scroll. The user's own send (or retry) is an explicit "take me to
28
32
  // the latest turn": without this, a user who had scrolled up to read history and then sent stayed
@@ -31,6 +35,7 @@ export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolle
31
35
  useEffect(() => {
32
36
  if (startsNewTurn({ previous: previousMessagesRef.current, next: messages })) {
33
37
  stickToBottomRef.current = true;
38
+ setAwayFromLatest(false);
34
39
  }
35
40
  previousMessagesRef.current = messages;
36
41
  }, [messages]);
@@ -81,7 +86,18 @@ export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolle
81
86
  const el = event.currentTarget;
82
87
  stickToBottomRef.current = nextStickToBottom(el, { wasSticking: stickToBottomRef.current, previousScrollTop: lastScrollTopRef.current });
83
88
  lastScrollTopRef.current = el.scrollTop;
89
+ setAwayFromLatest(!stickToBottomRef.current);
84
90
  };
85
- return { containerRef, onScroll };
91
+ const jumpToLatest = useCallback(() => {
92
+ const el = containerRef.current;
93
+ if (!el)
94
+ return;
95
+ // Re-stick first: a smooth scroll's own events move DOWN, which never unsticks, so output that
96
+ // streams in during the animation is followed too.
97
+ stickToBottomRef.current = true;
98
+ setAwayFromLatest(false);
99
+ el.scrollTo({ top: el.scrollHeight, behavior: reducedMotion() ? 'auto' : 'smooth' });
100
+ }, [reducedMotion]);
101
+ return { containerRef, onScroll, awayFromLatest, jumpToLatest };
86
102
  }
87
103
  //# sourceMappingURL=MessageList.hooks.js.map