@jini-ai/chat 0.5.2 → 0.5.4

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 (47) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/core/composer-history.d.ts +54 -0
  3. package/dist/core/composer-history.js +57 -0
  4. package/dist/core/durable-projection.d.ts +28 -0
  5. package/dist/core/durable-projection.js +71 -0
  6. package/dist/core/index.d.ts +3 -0
  7. package/dist/core/index.js +2 -0
  8. package/dist/core/run-activity.d.ts +12 -20
  9. package/dist/core/run-activity.js +59 -7
  10. package/dist/core/tool-events.d.ts +34 -0
  11. package/dist/core/tool-events.js +61 -0
  12. package/dist/core/tools.d.ts +1 -1
  13. package/dist/core/tools.js +1 -1
  14. package/dist/core/transport.d.ts +4 -0
  15. package/dist/react/components/Composer.js +6 -0
  16. package/dist/react/components/Markdown.js +37 -6
  17. package/dist/react/components/MessageList.hooks.js +8 -2
  18. package/dist/react/components/MessageList.rules.d.ts +18 -0
  19. package/dist/react/components/MessageList.rules.js +15 -0
  20. package/dist/react/components/MessageRow.js +26 -11
  21. package/dist/react/components/ToolCard.js +10 -7
  22. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  23. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  24. package/dist/react/features/chat-pane/components/ChatPane.js +5 -3
  25. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -0
  26. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +42 -9
  27. package/dist/react/features/chat-pane/rules.d.ts +6 -3
  28. package/dist/react/features/chat-pane/rules.js +6 -3
  29. package/dist/react/features/chat-pane/types.d.ts +5 -0
  30. package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
  31. package/dist/react/hooks/composer-draft-cache.js +56 -6
  32. package/dist/react/hooks/composer-history-storage.d.ts +8 -0
  33. package/dist/react/hooks/composer-history-storage.js +40 -0
  34. package/dist/react/hooks/last-conversation-store.d.ts +58 -0
  35. package/dist/react/hooks/last-conversation-store.js +112 -0
  36. package/dist/react/hooks/useComposer.d.ts +10 -1
  37. package/dist/react/hooks/useComposer.js +14 -2
  38. package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
  39. package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
  40. package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
  41. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
  42. package/dist/react/hooks/useConversation.js +6 -2
  43. package/dist/react/hooks/useRunStream.js +12 -2
  44. package/dist/react/hooks/useToolTimeline.js +6 -3
  45. package/dist/react/index.d.ts +8 -0
  46. package/dist/react/index.js +6 -0
  47. package/package.json +5 -5
@@ -34,7 +34,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
34
34
  * renderer covers:
35
35
  * ATX headings (`#`…`###`), fenced code blocks, ordered/unordered lists,
36
36
  * blockquotes, a horizontal rule, paragraphs, and inline `` `code` ``/
37
- * `**bold**`/`*italic*`/bare autolinks — including inside table cells, via
37
+ * `**bold**`/`*italic*`/`[label](url)` links/bare autolinks — including inside table cells, via
38
38
  * the same `renderInline` pass every other block uses. TODO(follow-up): port
39
39
  * the code-comment directive syntax once a host needs it.
40
40
  */
@@ -370,21 +370,52 @@ function TableBlock(props) {
370
370
  setExpanded(false);
371
371
  }, children: [_jsx("button", { type: "button", className: "jini-md-table-modal-close", onClick: () => setExpanded(false), "aria-label": "Close", children: "\u00D7" }), _jsx("div", { className: "jini-md-table-modal-body", children: expanded ? renderTableElement(props) : null })] })] }));
372
372
  }
373
- // Inline pass: `code`, **bold**, *italic*/_italic_, bare http(s) autolinks.
373
+ // Inline pass: `code`, [label](url) links, **bold**, *italic*/_italic_, bare http(s) autolinks.
374
374
  // Processes left-to-right with a single regex alternation so spans never
375
375
  // nest incorrectly across kinds.
376
- const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*|_[^_]+_)|(https?:\/\/[^\s)]+)/g;
376
+ //
377
+ // `_italic_` follows CommonMark's word-boundary rule: an `_` with a letter,
378
+ // digit or `_` on its outer side never opens or closes emphasis, and an `_`
379
+ // between two word characters inside the span is content. The plain
380
+ // `_[^_]+_` this replaced matched intraword, so a tool-result name like
381
+ // `ANALYTICS_ROOT_KEY_SEED` rendered as `ANALYTICS<em>ROOT</em>KEY_SEED`.
382
+ // `*` stays permissive — CommonMark allows intraword `*` emphasis.
383
+ const UNDERSCORE_EM = String.raw `(?<![\p{L}\p{N}_])_(?![\s_])(?:[^_]|_(?=[\p{L}\p{N}]))+?(?<!\s)_(?![\p{L}\p{N}_])`;
384
+ // `[label](url)`: the URL may hold one level of balanced parentheses (`Foo_(bar)`), and the whole
385
+ // link may itself sit inside parentheses (`([edit](/admin/pages/x))`) — the outer `)` is left as text.
386
+ const MD_LINK = String.raw `\[[^\]\n]+\]\((?:[^()\s]|\([^()\s]*\))+\)`;
387
+ const MD_LINK_PARTS = /^\[([^\]\n]+)\]\((.+)\)$/u;
388
+ const INLINE_RE = new RegExp(String.raw `(\x60[^\x60]+\x60)|(${MD_LINK})|(\*\*[^*]+\*\*)|(\*[^*]+\*|${UNDERSCORE_EM})|(https?:\/\/[^\s)]+)`, 'gu');
389
+ // Only these targets become anchors; anything else (`javascript:`, `data:`, protocol-relative
390
+ // `//host`) stays literal text, since the text is untrusted model output.
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;
395
+ function renderMarkdownLink(source, key) {
396
+ // `MD_LINK` already matched `source`, so these two groups always capture.
397
+ const [, label, href] = MD_LINK_PARTS.exec(source);
398
+ if (!SAFE_LINK_HREF.test(href))
399
+ 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));
402
+ }
377
403
  function renderInline(text) {
378
404
  const nodes = [];
379
405
  let lastIndex = 0;
380
406
  let key = 0;
381
- let match = INLINE_RE.exec(text);
407
+ // A fresh copy per call, not the shared `g` regex: a link label recurses into this function
408
+ // mid-loop, and a shared `lastIndex` would be reset under the outer scan.
409
+ const inlineRe = new RegExp(INLINE_RE);
410
+ let match = inlineRe.exec(text);
382
411
  while (match !== null) {
383
412
  if (match.index > lastIndex)
384
413
  nodes.push(text.slice(lastIndex, match.index));
385
- const [whole, code, bold, italic, link] = match;
414
+ const [whole, code, mdLink, bold, italic, link] = match;
386
415
  if (code)
387
416
  nodes.push(_jsx("code", { children: code.slice(1, -1) }, key++));
417
+ else if (mdLink)
418
+ nodes.push(renderMarkdownLink(mdLink, key++));
388
419
  else if (bold)
389
420
  nodes.push(_jsx("strong", { children: bold.slice(2, -2) }, key++));
390
421
  else if (italic)
@@ -395,7 +426,7 @@ function renderInline(text) {
395
426
  // defined (if possibly empty) full-match string at index 0, so this
396
427
  // fallback is unreachable (same dead-fallback reasoning as above).
397
428
  lastIndex = match.index + whole.length;
398
- match = INLINE_RE.exec(text);
429
+ match = inlineRe.exec(text);
399
430
  }
400
431
  if (lastIndex < text.length)
401
432
  nodes.push(text.slice(lastIndex));
@@ -5,7 +5,7 @@
5
5
  * "transport-agnostic hooks never touch DOM" contract.
6
6
  */
7
7
  import { useEffect, useRef } from 'react';
8
- import { isScrolledToBottom, startsNewTurn } from './MessageList.rules.js';
8
+ import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
9
9
  export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled } = {}) {
10
10
  const containerRef = useRef(null);
11
11
  // Whether the transcript was scrolled to its bottom as of the last user scroll or programmatic
@@ -14,6 +14,10 @@ export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolle
14
14
  // Starts true: a freshly mounted pane opens on the latest turn, same as scrollIntent's own default.
15
15
  const stickToBottomRef = useRef(true);
16
16
  const previousMessagesRef = useRef(messages);
17
+ // `scrollTop` at the previous `scroll` event — `nextStickToBottom` needs the direction of travel.
18
+ // Only scroll events write it (a browser fires one for programmatic scrolls too). Starts at
19
+ // Infinity, "no position seen yet", so a first event away from the bottom unsticks as it always did.
20
+ const lastScrollTopRef = useRef(Number.POSITIVE_INFINITY);
17
21
  // Runs BEFORE the scroll effect below (effects run in declaration order), so a send re-sticks in
18
22
  // time for that same commit's scroll. The user's own send (or retry) is an explicit "take me to
19
23
  // the latest turn": without this, a user who had scrolled up to read history and then sent stayed
@@ -69,7 +73,9 @@ export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolle
69
73
  return () => observer.disconnect();
70
74
  }, [messages]);
71
75
  const onScroll = (event) => {
72
- stickToBottomRef.current = isScrolledToBottom(event.currentTarget);
76
+ const el = event.currentTarget;
77
+ stickToBottomRef.current = nextStickToBottom(el, { wasSticking: stickToBottomRef.current, previousScrollTop: lastScrollTopRef.current });
78
+ lastScrollTopRef.current = el.scrollTop;
73
79
  };
74
80
  return { containerRef, onScroll };
75
81
  }
@@ -11,6 +11,24 @@ export declare function isScrolledToBottom(el: {
11
11
  scrollTop: number;
12
12
  clientHeight: number;
13
13
  }): boolean;
14
+ /**
15
+ * The sticky-follow flag after a `scroll` event.
16
+ *
17
+ * At the bottom always re-sticks. Otherwise only a move UP (the position went down since the last
18
+ * event) unsticks. A `scroll` event is dispatched a frame after the scroll that caused it, so the
19
+ * event for an auto-scroll can arrive after more streamed rows already grew the content: the
20
+ * position is unchanged but no longer at the bottom. Reading that as "the user scrolled away" is
21
+ * what left a tool-heavy run's final reply below the fold (demo dry-run, 2026-10-05). Content growth
22
+ * never lowers `scrollTop`; a user scrolling up always does.
23
+ */
24
+ export declare function nextStickToBottom(el: {
25
+ scrollHeight: number;
26
+ scrollTop: number;
27
+ clientHeight: number;
28
+ }, { wasSticking, previousScrollTop }: {
29
+ wasSticking: boolean;
30
+ previousScrollTop: number;
31
+ }): boolean;
14
32
  /**
15
33
  * Whether `next` starts a turn the user themself asked for, relative to `previous`: a user message
16
34
  * that was not there before (a send — `useConversation.sendMessage` appends it optimistically), or
@@ -4,6 +4,21 @@ export const AT_BOTTOM_THRESHOLD_PX = 4;
4
4
  export function isScrolledToBottom(el) {
5
5
  return el.scrollHeight - el.scrollTop - el.clientHeight <= AT_BOTTOM_THRESHOLD_PX;
6
6
  }
7
+ /**
8
+ * The sticky-follow flag after a `scroll` event.
9
+ *
10
+ * At the bottom always re-sticks. Otherwise only a move UP (the position went down since the last
11
+ * event) unsticks. A `scroll` event is dispatched a frame after the scroll that caused it, so the
12
+ * event for an auto-scroll can arrive after more streamed rows already grew the content: the
13
+ * position is unchanged but no longer at the bottom. Reading that as "the user scrolled away" is
14
+ * what left a tool-heavy run's final reply below the fold (demo dry-run, 2026-10-05). Content growth
15
+ * never lowers `scrollTop`; a user scrolling up always does.
16
+ */
17
+ export function nextStickToBottom(el, { wasSticking, previousScrollTop }) {
18
+ if (isScrolledToBottom(el))
19
+ return true;
20
+ return el.scrollTop < previousScrollTop ? false : wasSticking;
21
+ }
7
22
  /**
8
23
  * Whether `next` starts a turn the user themself asked for, relative to `previous`: a user message
9
24
  * that was not there before (a send — `useConversation.sendMessage` appends it optimistically), or
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
2
3
  /**
3
4
  * @module MessageRow
4
5
  *
@@ -50,7 +51,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
50
51
  * chunk stopped arriving.
51
52
  */
52
53
  import React, { useState } from 'react';
53
- import { isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
54
+ import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
54
55
  import { useToolTimeline } from '../hooks/useToolTimeline.js';
55
56
  import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
56
57
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
@@ -120,6 +121,23 @@ function UsageSummary({ usage }) {
120
121
  return null;
121
122
  return (_jsxs("div", { className: "jini-message-usage", children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t('Done'), " \u00B7 ", parts.join(' · ')] }));
122
123
  }
124
+ function messageBlocks({ visibleContent, message, rows }, _optional = {}) {
125
+ // Interleaving is skipped outright when an artifact was stripped. `stripArtifact` operates on the
126
+ // whole string and trims, so the surviving text no longer lines up with the per-run offsets the
127
+ // event walk produces, and an artifact block could legitimately span a tool call. Rather than
128
+ // reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
129
+ // messages are dominated by the artifact panel anyway, so the ordering matters least there.
130
+ if (visibleContent !== message.content)
131
+ return null;
132
+ return interleaveMessageBlocks({ events: message.events, content: message.content, rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) });
133
+ }
134
+ function messageAwaitingAnswer({ runStreaming, runInProgress, events }, _optional = {}) {
135
+ return runStreaming === true && runInProgress && isAwaitingAnswer({ events });
136
+ }
137
+ function AssistantMessageFooter({ message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent }, _optional = {}) {
138
+ const t = useT();
139
+ return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress) }) : null] });
140
+ }
123
141
  /**
124
142
  * Quiet, icon-only copy affordance shared by the user- and assistant-message
125
143
  * action rows below. Delegates the actual clipboard write (and its
@@ -147,19 +165,14 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
147
165
  if (message.role === 'user') {
148
166
  return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx("button", { type: "button", className: "jini-message-attachment-chip", onClick: () => setOpenAttachment(a), title: t('Open {name}', { name: a.name }), "aria-label": t('Open {name}', { name: a.name }), children: renderAttachment ? renderAttachment(a) : a.name }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), openAttachment ? (_jsx(AttachmentPreviewModal, { attachment: openAttachment, onClose: () => setOpenAttachment(null) })) : null] }));
149
167
  }
168
+ const terminalNotice = terminalMessageNotice({ message }, {});
150
169
  const visibleContent = stripArtifact({ content: message.content });
151
170
  const segments = splitOnQuestionForms({ input: visibleContent });
152
171
  const usageEvent = message.events?.filter((ev) => ev.kind === 'usage').pop();
153
- // Interleaving is skipped outright when an artifact was stripped. `stripArtifact` operates on the
154
- // whole string and trims, so the surviving text no longer lines up with the per-run offsets the
155
- // event walk produces, and an artifact block could legitimately span a tool call. Rather than
156
- // reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
157
- // messages are dominated by the artifact panel anyway, so the ordering matters least there.
158
- const blocks = visibleContent === message.content
159
- ? interleaveMessageBlocks({ events: message.events, content: message.content, rows: timeline.rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) })
160
- : null;
172
+ const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows }, {});
161
173
  const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
162
174
  const runInProgress = isRunInProgress(message.runStatus);
175
+ const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, {});
163
176
  const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
164
177
  const renderExtGroup = (group) => {
165
178
  const renderer = getExtEventRenderer({ name: group.name });
@@ -170,6 +183,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
170
183
  events: group.events,
171
184
  runStreaming,
172
185
  runSucceeded,
186
+ awaitingAnswer,
173
187
  runId: message.runId,
174
188
  ...(group.call === undefined ? {} : { call: group.call }),
175
189
  });
@@ -195,7 +209,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
195
209
  return null;
196
210
  return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
197
211
  })
198
- : (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, message.runStatus === 'failed' ? _jsx("div", { className: "jini-message-error", children: t('This turn failed.') }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress) })] }));
212
+ : (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent })] }));
199
213
  }
200
214
  /**
201
215
  * The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
@@ -203,7 +217,8 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
203
217
  * Kept a separate component so its one-second tick re-renders only this line, not the whole row.
204
218
  */
205
219
  function RunActivityLine({ events, active }) {
206
- const label = useRunActivity({ events: events, active: active });
220
+ const activityLabel = useRunActivity({ events: events, active: active });
221
+ const label = continuingRunNotice({ events, active }, {}) ?? activityLabel;
207
222
  if (label === null)
208
223
  return null;
209
224
  return (_jsx("div", { className: "jini-message-pending jini-run-activity", "aria-live": "off", children: label }));
@@ -21,14 +21,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
21
21
  * generic rendering for it.
22
22
  */
23
23
  import { useState } from 'react';
24
- import { formatToolOutputForDisplay, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
24
+ import { formatToolOutputForDisplay, isDelegatedWrapperToolName, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
25
25
  import { useT } from '../hooks/context.js';
26
26
  import { getToolRenderer } from '../tool-renderer-registry.js';
27
27
  import { Icon } from './Icon.js';
28
28
  import { TodoCard } from './TodoCard.js';
29
29
  /** A Jini registry tool id: dotted, lowercase, snake_case segments — `page.fill`, `daemon.db.vacuum`, `chat.send_message`. Never matches an agent-vendor tool name (`Bash`, `Write`, `mcp__jini__execute_delegated_tool`), which is the point — this is how a canonical delegated-tool event is told apart from everything else. */
30
30
  const JINI_TOOL_ID_PATTERN = /^[a-z][a-z_]*(\.[a-z][a-z_]*)+$/;
31
- const EXECUTE_DELEGATED_TOOL_NAMES = new Set(['execute_delegated_tool', 'mcp__jini__execute_delegated_tool']);
32
31
  const SEARCH_TOOLS_NAMES = new Set(['search_tools', 'mcp__jini__search_tools']);
33
32
  const DESCRIBE_TOOL_NAMES = new Set(['describe_tool', 'mcp__jini__describe_tool']);
34
33
  export function ToolCard({ use, result, runStreaming, runSucceeded, projectFileNames, onRequestOpenFile }) {
@@ -64,8 +63,12 @@ function renderToolCardBody(name, use, result, isStreaming, isSucceeded, fileCtx
64
63
  // action twice. Suppressed only on a *successful* result: a wrapper-level failure (a genuine
65
64
  // transport error, not a tool-execution failure — those come back as a normal 'completed' result
66
65
  // with status:'failed' inside it) has no matching canonical row to fall back on, so it must stay
67
- // visible rather than disappear silently.
68
- if (EXECUTE_DELEGATED_TOOL_NAMES.has(name) && result && !result.isError)
66
+ // visible rather than disappear silently. (A wrapper that failed AFTER the bridge emitted its
67
+ // canonical row — e.g. a 400 on a refused input — never reaches here when rendered through
68
+ // `useToolTimeline`: `foldDelegatedWrapperCalls` drops it in favour of that canonical row. This
69
+ // check still covers a host that renders `<ToolCard>` straight from raw events.)
70
+ // Covers the read-only gateway (`execute_readonly_delegated_tool`) too.
71
+ if (isDelegatedWrapperToolName({ name: name }) && result && !result.isError)
69
72
  return null;
70
73
  if (isTodoWriteToolName({ name: name }))
71
74
  return _jsx(TodoCard, { todos: parseTodoWriteInput({ input: use.input }), runStreaming: isStreaming });
@@ -85,7 +88,7 @@ function renderToolCardBody(name, use, result, isStreaming, isSucceeded, fileCtx
85
88
  return _jsx(WebFetchCard, { input: use.input, result: result, runStreaming: isStreaming, runSucceeded: isSucceeded });
86
89
  if (name === 'WebSearch' || name === 'web_search')
87
90
  return _jsx(WebSearchCard, { input: use.input, result: result, runStreaming: isStreaming, runSucceeded: isSucceeded });
88
- if (JINI_TOOL_ID_PATTERN.test(name) || EXECUTE_DELEGATED_TOOL_NAMES.has(name)) {
91
+ if (JINI_TOOL_ID_PATTERN.test(name) || isDelegatedWrapperToolName({ name: name })) {
89
92
  // The unsuppressed (failed-wrapper) case falls through here too: `name` is still
90
93
  // `execute_delegated_tool`, so `jiniToolIdFromInput` recovers the real id from
91
94
  // `{toolId, input}` instead of `DelegatedToolCard` receiving its own wrapper name as the title.
@@ -133,8 +136,8 @@ function DelegatedToolCard({ name, input, result, runStreaming, runSucceeded })
133
136
  // Wrapper form (only reached on a failed wrapper call, per ToolCard's suppression comment):
134
137
  // the real id and args are nested under {toolId, input}, not `input` itself.
135
138
  const wrapper = (input ?? {});
136
- const toolId = EXECUTE_DELEGATED_TOOL_NAMES.has(name) ? (wrapper.toolId ?? name) : name;
137
- const args = EXECUTE_DELEGATED_TOOL_NAMES.has(name) ? wrapper.input : input;
139
+ const toolId = isDelegatedWrapperToolName({ name: name }) ? (wrapper.toolId ?? name) : name;
140
+ const args = isDelegatedWrapperToolName({ name: name }) ? wrapper.input : input;
138
141
  const target = primaryTarget(args);
139
142
  const isRunning = runStreaming && !result;
140
143
  return (_jsxs("div", { className: "op-card op-jini-tool", children: [_jsxs("button", { type: "button", className: "op-card-head", onClick: () => setOpen((o) => !o), children: [_jsx(ResultBadge, { result: result, runStreaming: runStreaming, runSucceeded: runSucceeded }), _jsxs("span", { className: `op-title${isRunning ? ' shimmer-text' : ''}`, children: [t('Tool call'), " \u00B7 ", humanizeToolId(toolId)] }), target ? _jsx("span", { className: "op-meta", children: target }) : null, _jsx("span", { className: "op-expand-chev", "aria-hidden": true, children: _jsx(Icon, { name: open ? 'chevron-down' : 'chevron-right', size: 11 }) })] }), _jsx("div", { className: `accordion-collapsible${open ? ' open' : ''}`, children: _jsx("div", { className: "accordion-collapsible-inner", children: _jsxs("div", { className: "op-card-detail", children: [_jsx("pre", { className: "op-command", children: JSON.stringify(args ?? {}) }), result?.content ? _jsx("pre", { className: "op-output", children: truncate(formatToolOutputForDisplay({ text: result.content }), 2000) }) : null, _jsx(ToolResultMedia, { result: result })] }) }) }), _jsx(FileErrorDetail, { result: result })] }));
@@ -40,6 +40,13 @@ export interface ExtEventRenderProps {
40
40
  /** Whether the owning run is still streaming — more events with this `name` may still arrive. */
41
41
  runStreaming: boolean;
42
42
  runSucceeded: boolean;
43
+ /**
44
+ * Whether the owning run is waiting on the person — a card (form, confirm, sign-in) sits on a
45
+ * still-open tool call (`isAwaitingAnswer` in `core/run-activity.ts`). A "still working" notice
46
+ * should hide while this is true. Always `false` once the run stops streaming. Optional so a host
47
+ * that builds these props itself (tests, wrappers) keeps compiling; `MessageRow` always sets it.
48
+ */
49
+ awaitingAnswer?: boolean;
43
50
  /** The owning message's `ChatMessage.runId`, if known — a renderer that needs to send something back to this specific run (e.g. an A2UI action) needs this; omitted (`undefined`) for a message with no run association. */
44
51
  runId: string | undefined;
45
52
  /** See {@link ExtEventCall}. Absent when the group's first event arrived outside any tool call. */
@@ -6,5 +6,5 @@ import type { ChatPaneProps } from '../types.js';
6
6
  * @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
7
7
  * @overallScore 100/100
8
8
  */
9
- export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
9
+ export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
10
10
  //# sourceMappingURL=ChatPane.d.ts.map
@@ -73,8 +73,8 @@ function ChatPaneNoUsableCliMessage({ executionMode, apiModeAvailable, onExecuti
73
73
  }
74
74
  /** The pane's stack of mutually-independent error/status banners — each condition is its own
75
75
  * source of truth, so they are rendered as siblings rather than folded into one derived state. */
76
- function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, conversationError, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, onSendAsNewMessage, t, }) {
77
- return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, conversationError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: conversationError.message })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError && !connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null, typedAnswerNotice ? (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t(describeTypedAnswerNotice({ notice: typedAnswerNotice })), typedAnswerNotice === 'not-pending' ? (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: onSendAsNewMessage, children: t('Send as a new message') })] })) : null] })) : null] }));
76
+ function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, onSendAsNewMessage, t, }) {
77
+ return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError && !connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null, typedAnswerNotice ? (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t(describeTypedAnswerNotice({ notice: typedAnswerNotice })), typedAnswerNotice === 'not-pending' ? (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: onSendAsNewMessage, children: t('Send as a new message') })] })) : null] })) : null] }));
78
78
  }
79
79
  function ChatPaneWorkingDirectoryBlock({ workingDirectoryAccess, workingDirectoryControlPlacement, pane, t, }) {
80
80
  if (workingDirectoryAccess && workingDirectoryControlPlacement === 'below') {
@@ -178,7 +178,7 @@ function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, una
178
178
  * @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
179
179
  * @overallScore 100/100
180
180
  */
181
- export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
181
+ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
182
182
  const t = useT();
183
183
  const composerPlaceholder = useChatPaneComposerPlaceholder({ placeholder: placeholder, placeholders: placeholders });
184
184
  const inventory = useChatPaneRuntimeInventory(definedProps({ source: {
@@ -201,6 +201,8 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
201
201
  onSelectionChange,
202
202
  runContext,
203
203
  initialDraft,
204
+ composerHistoryScope,
205
+ composerHistoryStorage,
204
206
  validateAttachments,
205
207
  uploadAttachments,
206
208
  onActivityChange,
@@ -1,6 +1,7 @@
1
1
  import { type ChatPaneSendBlocker } from '../rules.js';
2
2
  import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
3
3
  import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
4
+ import type { ComposerHistoryStoragePort } from '../../../../core/composer-history.js';
4
5
  import type { ChatTransport } from '@jini-ai/chat';
5
6
  import { type UseComposerResult } from '../../../hooks/useComposer.js';
6
7
  import { type UseConversationResult } from '../../../hooks/useConversation.js';
@@ -15,6 +16,8 @@ export interface UseChatPaneOptions {
15
16
  onSelectionChange?: (selection: ChatPaneAgentSelection) => void;
16
17
  runContext?: ChatPaneRunContext;
17
18
  initialDraft?: string;
19
+ composerHistoryScope?: string;
20
+ composerHistoryStorage?: ComposerHistoryStoragePort;
18
21
  /** Forwarded verbatim to `useComposer`; see `validateAttachments` there for the contract. */
19
22
  validateAttachments?: (attachments: readonly ChatAttachment[]) => Promise<readonly ChatAttachment[]>;
20
23
  uploadAttachments?: (files: File[], options?: ChatPaneAttachmentUploadOptions) => Promise<ChatAttachment[]>;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, isChatPaneQueueableBlocker, isTypedAnswerTurn, resolveChatPaneSelection, } from '../rules.js';
3
3
  import { definedProps } from '../../../util/defined-props.js';
4
4
  import { cacheAttachmentPreviewSource } from '../../../hooks/attachment-preview-cache.js';
5
+ import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../../../hooks/composer-draft-cache.js';
5
6
  import { useComposer } from '../../../hooks/useComposer.js';
6
7
  import { useConversation, } from '../../../hooks/useConversation.js';
7
8
  import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
@@ -96,7 +97,12 @@ export function useChatPane(options) {
96
97
  const queuedConversationIdRef = useRef(undefined);
97
98
  const mountedRef = useRef(true);
98
99
  const attachmentGenerationRef = useRef(0);
99
- const attachmentBatchIdRef = useRef(createAttachmentBatchId());
100
+ // Every attachment staged for one turn must share one upload batch (the daemon refuses a turn
101
+ // spanning two). So a mount that will restore cached attachments starts in THEIR batch rather
102
+ // than a new one; see `readCachedAttachmentBatchId`.
103
+ const [initialAttachmentBatchId] = useState(() => readCachedAttachmentBatchId({ conversationId: options.conversationId }) ?? createAttachmentBatchId());
104
+ const attachmentBatchIdRef = useRef(initialAttachmentBatchId);
105
+ const batchConversationIdRef = useRef(options.conversationId);
100
106
  const activeUploadsRef = useRef(new Set());
101
107
  const [internalSelection, setInternalSelection] = useState(options.initialSelection ?? { agentId: '' });
102
108
  const workingDirectoryState = useChatPaneWorkingDirectory(definedProps({ source: {
@@ -108,12 +114,6 @@ export function useChatPane(options) {
108
114
  const requestedSelection = options.selection ?? internalSelection;
109
115
  const selection = useMemo(() => resolveChatPaneSelection({ agents: options.agents, requested: requestedSelection }), [options.agents, requestedSelection]);
110
116
  const selectedAgent = options.agents.find((agent) => agent.id === selection.agentId);
111
- const composer = useComposer(definedProps({ source: {
112
- initialDraft: options.initialDraft,
113
- initialAgent: selection,
114
- conversationId: options.conversationId,
115
- validateAttachments: options.validateAttachments,
116
- } }));
117
117
  const conversation = useConversation(definedProps({ source: {
118
118
  transport: options.transport,
119
119
  initialMessages: options.initialMessages,
@@ -122,6 +122,15 @@ export function useChatPane(options) {
122
122
  // absent), so only the falsy "no agent selected" case should omit the key.
123
123
  agentId: selection.agentId || undefined,
124
124
  } }));
125
+ const composer = useComposer(definedProps({ source: {
126
+ initialDraft: options.initialDraft,
127
+ historyMessages: conversation.messages,
128
+ historyScope: options.composerHistoryScope,
129
+ historyStorage: options.composerHistoryStorage,
130
+ initialAgent: selection,
131
+ conversationId: options.conversationId,
132
+ validateAttachments: options.validateAttachments,
133
+ } }));
125
134
  const activity = resolveChatPaneActivity(selectedAgent, conversation);
126
135
  useEffect(() => {
127
136
  options.onActivityChange?.(activity);
@@ -199,10 +208,31 @@ export function useChatPane(options) {
199
208
  typedAnswerGenerationRef.current += 1;
200
209
  heldAnswerQuestionIdRef.current = null;
201
210
  }, [options.conversationId]);
211
+ // A conversation switch without a remount: adopt the batch of the attachments the composer is
212
+ // about to restore for the new conversation, else start a fresh one so two conversations never
213
+ // share a batch directory (the agent is granted the whole directory, not just the claimed files).
214
+ // An id arriving where there was none is the same conversation finally getting a key — the
215
+ // composer keeps what is staged (see `useComposer`), so the batch stays too.
216
+ useEffect(() => {
217
+ const previousConversationId = batchConversationIdRef.current;
218
+ if (options.conversationId === previousConversationId)
219
+ return;
220
+ batchConversationIdRef.current = options.conversationId;
221
+ const restoredBatchId = readCachedAttachmentBatchId({ conversationId: options.conversationId });
222
+ if (restoredBatchId !== null) {
223
+ attachmentBatchIdRef.current = restoredBatchId;
224
+ return;
225
+ }
226
+ if (previousConversationId == null)
227
+ return;
228
+ attachmentBatchIdRef.current = createAttachmentBatchId();
229
+ }, [options.conversationId]);
202
230
  const addAttachments = useCallback(async (files) => {
203
231
  if (files.length === 0 || options.uploadAttachments === undefined)
204
232
  return;
205
233
  const generation = attachmentGenerationRef.current;
234
+ const batchId = attachmentBatchIdRef.current;
235
+ const conversationId = options.conversationId;
206
236
  const controller = new AbortController();
207
237
  activeUploadsRef.current.add(controller);
208
238
  setActiveUploadCount(activeUploadsRef.current.size);
@@ -211,7 +241,7 @@ export function useChatPane(options) {
211
241
  await uploadAttachmentBatch(files, {
212
242
  upload: options.uploadAttachments,
213
243
  signal: controller.signal,
214
- batchId: attachmentBatchIdRef.current,
244
+ batchId,
215
245
  stillWanted: () => mountedRef.current
216
246
  && !controller.signal.aborted
217
247
  && generation === attachmentGenerationRef.current,
@@ -221,6 +251,9 @@ export function useChatPane(options) {
221
251
  // `attachment-preview-cache.ts`'s module doc for why this is the only place that copy can
222
252
  // ever be captured.
223
253
  onAttachment: (attachment, file) => {
254
+ // Recorded before staging so the cache entry that persists this attachment carries its
255
+ // batch, which is what lets a later mount restore the batch along with the attachment.
256
+ writeCachedAttachmentBatchId({ conversationId, batchId });
224
257
  composer.addAttachment(attachment);
225
258
  if (file)
226
259
  cacheAttachmentPreviewSource({ path: attachment.path, file });
@@ -233,7 +266,7 @@ export function useChatPane(options) {
233
266
  setActiveUploadCount(activeUploadsRef.current.size);
234
267
  }
235
268
  }
236
- }, [composer, options.uploadAttachments]);
269
+ }, [composer, options.conversationId, options.uploadAttachments]);
237
270
  const sendPrompt = useCallback(async (prompt) => {
238
271
  const trimmed = prompt.trim();
239
272
  if (!trimmed)
@@ -83,9 +83,12 @@ export declare function isChatPaneQueueableBlocker({ blocker }: {
83
83
  * Only the question tool counts. Other tools park on cards too — a delete confirm, an approval —
84
84
  * but they take no typed answer: the server routes typed text only to the question tool, so treating
85
85
  * those as waiting would refuse the text with "no longer waiting" and drop it instead of queueing.
86
- * A call is the question tool when ANY `tool_use` for its id names it, since a vendor's wrapper row
87
- * (`execute_delegated_tool`) and the daemon's canonical row share the id and may arrive in either
88
- * order.
86
+ * A call is the question tool when ANY `tool_use` for its id names it, since the same call's
87
+ * `tool_use` can arrive more than once (the CLI re-emits it). A vendor's wrapper row
88
+ * (`execute_delegated_tool`) and the daemon's canonical row do NOT share an id — the wrapper carries
89
+ * the CLI's `toolu_…` id and the canonical row a fresh UUID from `@jini-ai/mcp`. The canonical row
90
+ * opens after its wrapper, so it is the newest open call when the surface arrives, and that is
91
+ * what attributes the surface to the question tool.
89
92
  *
90
93
  * A surface is attributed to the newest call still open when it arrived — the same rule
91
94
  * `useExtEventGroups` uses to give a card its `call` — so an older call returning does not settle
@@ -116,9 +116,12 @@ export function isChatPaneQueueableBlocker({ blocker }) {
116
116
  * Only the question tool counts. Other tools park on cards too — a delete confirm, an approval —
117
117
  * but they take no typed answer: the server routes typed text only to the question tool, so treating
118
118
  * those as waiting would refuse the text with "no longer waiting" and drop it instead of queueing.
119
- * A call is the question tool when ANY `tool_use` for its id names it, since a vendor's wrapper row
120
- * (`execute_delegated_tool`) and the daemon's canonical row share the id and may arrive in either
121
- * order.
119
+ * A call is the question tool when ANY `tool_use` for its id names it, since the same call's
120
+ * `tool_use` can arrive more than once (the CLI re-emits it). A vendor's wrapper row
121
+ * (`execute_delegated_tool`) and the daemon's canonical row do NOT share an id — the wrapper carries
122
+ * the CLI's `toolu_…` id and the canonical row a fresh UUID from `@jini-ai/mcp`. The canonical row
123
+ * opens after its wrapper, so it is the newest open call when the surface arrives, and that is
124
+ * what attributes the surface to the question tool.
122
125
  *
123
126
  * A surface is attributed to the newest call still open when it arrived — the same rule
124
127
  * `useExtEventGroups` uses to give a card its `call` — so an older call returning does not settle
@@ -1,6 +1,7 @@
1
1
  import type { CSSProperties, ReactNode, RefObject } from 'react';
2
2
  import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
3
3
  import type { ComposerSlots } from '../../slots.js';
4
+ import type { ComposerHistoryStoragePort } from '../../../core/composer-history.js';
4
5
  import type { ChatTransport, RunContext } from '@jini-ai/chat';
5
6
  export interface ChatPaneAgentOption {
6
7
  id: string;
@@ -172,6 +173,10 @@ export interface ChatPaneProps {
172
173
  /** Passed straight through to the runtime picker. */
173
174
  onByokModelChange?: (model: string) => void;
174
175
  initialDraft?: string;
176
+ /** Principal-scoped recent prompt recall; defaults to the origin-local default scope. */
177
+ composerHistoryScope?: string;
178
+ /** Optional storage effects; omitted uses bounded browser storage with memory fallback. */
179
+ composerHistoryStorage?: ComposerHistoryStoragePort;
175
180
  /**
176
181
  * Confirms which previously staged attachments still exist, so a draft restored after a reload
177
182
  * never shows a chip for a file the backend has since garbage-collected — that looks intact and
@@ -115,6 +115,34 @@ export declare function clearCachedDraft({ conversationId }: {
115
115
  export declare function readCachedAttachments({ conversationId }: {
116
116
  conversationId: string | null | undefined;
117
117
  }): readonly ChatAttachment[] | null;
118
+ /**
119
+ * The upload batch `conversationId`'s cached attachments were uploaded under, so a pane that
120
+ * restores those attachments uploads the next file into the SAME batch.
121
+ *
122
+ * Why this exists: the daemon grants the agent exactly one batch directory per turn and refuses a
123
+ * turn whose attachments span two ("Attachments must belong to one batch"). A pane that restored an
124
+ * attachment after a remount, reload, or conversation switch used to upload the next file under a
125
+ * brand-new batch id, so every such turn failed before the agent started (2026-10-06).
126
+ *
127
+ * @returns The batch id, or `null` when there are no cached attachments to stay consistent with
128
+ * (none staged, attachment persistence off because the host supplied no validator, or an entry
129
+ * written before batch ids were recorded) — the caller then starts a fresh batch.
130
+ * @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored count otherwise.
131
+ */
132
+ export declare function readCachedAttachmentBatchId({ conversationId }: {
133
+ conversationId: string | null | undefined;
134
+ }): string | null;
135
+ /**
136
+ * Records the upload batch of an attachment about to be staged in `conversationId`'s composer.
137
+ * Memory only: the stored envelope picks it up on the {@link writeCachedAttachments} that persists
138
+ * that attachment, so it is never written ahead of the references it describes.
139
+ *
140
+ * @complexity Time/space: O(1) amortized.
141
+ */
142
+ export declare function writeCachedAttachmentBatchId({ conversationId, batchId }: {
143
+ conversationId: string | null | undefined;
144
+ batchId: string;
145
+ }): void;
118
146
  /**
119
147
  * Stores `attachments` for `conversationId` in both tiers.
120
148
  *