@jini-ai/chat 0.5.2 → 0.5.3

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## 0.5.3 — 2026-10-06
2
+
3
+ - New `createLastConversationStore({ scope }, { storage? })` (react): remembers one conversation id per caller identity under a versioned localStorage key (`jini.chat.last-conversation.v1.<scope>`); corrupt or foreign entries read as nothing remembered. Selection stays host-owned.
4
+ - Restored staged attachments keep their upload batch: the draft cache records the batch id beside each conversation's attachment references, and `useChatPane` resumes that batch on mount and on a conversation switch, so a second attachment after a remount no longer fails the turn ("Attachments must belong to one batch").
5
+ - A display-only A2UI surface (`surfaceProperties.displayOnly`, e.g. a drawn chart) no longer reads as "Waiting for your answer above".
6
+ - A run held on an open form reads as waiting, not working: new `isAwaitingAnswer`, passed to ext event renderers as `awaitingAnswer`.
7
+ - One row per delegated tool call: `isDelegatedWrapperToolName` covers both delegated gateways (bare or `mcp__<server>__`-prefixed) and `foldDelegatedWrapperCalls` drops a wrapper call once its canonical call exists (`useToolTimeline`, `ToolCard`).
8
+ - Markdown: `[label](url)` links render (relative and parenthesized URLs, unsafe schemes kept as text, off-site links open in a new tab); intraword underscores never open or close emphasis.
9
+ - A late scroll event no longer unsticks the transcript from the bottom; only a move up does.
10
+ - Requires `@jini-ai/agentic` ^0.4.2 (`displayOnlySurfaceIdOf`).
11
+
1
12
  ## 0.5.2 — 2026-10-05
2
13
 
3
14
  - `useChatPane` holds a draft typed for a question that has since closed (answered elsewhere, expired, or refused with `'not-pending'`): `send` shows the `'not-pending'` notice instead of queueing it as a new paid run. Only clearing the draft or the new `sendAsNewMessage()` (the notice's "Send as a new message" button in `ChatPane`) lets it out as an ordinary turn. New pure `findAwaitedTypedAnswerId`.
@@ -1,22 +1,3 @@
1
- /**
2
- * @module run-activity
3
- *
4
- * What a running turn is doing right now, as one short line ("Running Page Fill… 1 m 20 s").
5
- *
6
- * Why: once a turn's first tool row appeared, nothing on screen showed the run was still alive —
7
- * the old "Thinking…" placeholder only showed while the message was completely empty. Model think
8
- * time, long tool calls, held cards and API overload retries all looked identical: dead.
9
- *
10
- * Shape: {@link deriveRunActivity} reads the events the pane already has and returns the latest
11
- * signal; {@link RUN_ACTIVITY_LABELS} is the ONE table that turns a signal into words. The clock
12
- * (seconds since the signal began) is the caller's: the React hook ticks it once a second. Nothing
13
- * here special-cases a particular tool.
14
- *
15
- * Status events this reads (a host translates its runtime's heartbeats into them):
16
- * - `code: 'api_retry'`, `data: { attempt, maxAttempts, service? }` — the model API is overloaded.
17
- * - `code: 'tool_progress'`, `data: { elapsedSeconds }` — a tool call is still running.
18
- * - `code: 'thinking'` — the model is reasoning (keeps the idle timer honest).
19
- */
20
1
  import type { AgentEvent } from './events.js';
21
2
  /** The latest thing a running turn is doing. */
22
3
  export type RunActivity = {
@@ -57,7 +38,8 @@ export declare function humanizeToolName({ name }: {
57
38
  * The latest signal in a running turn's events.
58
39
  *
59
40
  * Precedence, newest wins: an API retry with nothing visible after it; a card that arrived while a
60
- * tool call is still open (the tool is waiting on the person); the newest open tool call; else
41
+ * tool call is still open (the tool is waiting on the person; a display-only A2UI surface is not a
42
+ * card); the newest open tool call; else
61
43
  * writing (last visible event is text) or thinking.
62
44
  *
63
45
  * @complexity O(n) in events, O(open tool calls) extra space.
@@ -65,6 +47,16 @@ export declare function humanizeToolName({ name }: {
65
47
  export declare function deriveRunActivity({ events }: {
66
48
  events: readonly AgentEvent[] | undefined;
67
49
  }): RunActivityState;
50
+ /**
51
+ * Whether the run is waiting on the person: a card (form, confirm, sign-in) arrived while its tool
52
+ * call is still open. Hosts use it to hide "still working" notices, which read as a hung run while
53
+ * the run is really waiting for an answer.
54
+ *
55
+ * @complexity O(n) in events.
56
+ */
57
+ export declare function isAwaitingAnswer({ events }: {
58
+ events: readonly AgentEvent[] | undefined;
59
+ }): boolean;
68
60
  /** `4` → `"4 s"`, `80` → `"1 m 20 s"`, `120` → `"2 m 0 s"`. */
69
61
  export declare function formatActivityClock({ totalSeconds }: {
70
62
  totalSeconds: number;
@@ -1,3 +1,24 @@
1
+ /**
2
+ * @module run-activity
3
+ *
4
+ * What a running turn is doing right now, as one short line ("Running Page Fill… 1 m 20 s").
5
+ *
6
+ * Why: once a turn's first tool row appeared, nothing on screen showed the run was still alive —
7
+ * the old "Thinking…" placeholder only showed while the message was completely empty. Model think
8
+ * time, long tool calls, held cards and API overload retries all looked identical: dead.
9
+ *
10
+ * Shape: {@link deriveRunActivity} reads the events the pane already has and returns the latest
11
+ * signal; {@link RUN_ACTIVITY_LABELS} is the ONE table that turns a signal into words. The clock
12
+ * (seconds since the signal began) is the caller's: the React hook ticks it once a second. Nothing
13
+ * here special-cases a particular tool.
14
+ *
15
+ * Status events this reads (a host translates its runtime's heartbeats into them):
16
+ * - `code: 'api_retry'`, `data: { attempt, maxAttempts, service? }` — the model API is overloaded.
17
+ * - `code: 'tool_progress'`, `data: { elapsedSeconds }` — a tool call is still running.
18
+ * - `code: 'thinking'` — the model is reasoning (keeps the idle timer honest).
19
+ */
20
+ import { a2uiSurfaceIdOf, displayOnlySurfaceIdOf } from '@jini-ai/agentic/a2ui';
21
+ import { isDelegatedWrapperToolName } from './tool-events.js';
1
22
  /** Tool names whose only job is finding the right tool (MCP tool search / schema lookup). */
2
23
  const DISCOVERY_TOOL_NAMES = new Set([
3
24
  'ToolSearch',
@@ -6,10 +27,10 @@ const DISCOVERY_TOOL_NAMES = new Set([
6
27
  'mcp__jini__search_tools',
7
28
  'mcp__jini__describe_tool',
8
29
  ]);
9
- /** Transport wrappers whose real tool id is in `input.toolId`. */
10
- const WRAPPER_TOOL_NAMES = new Set(['execute_delegated_tool', 'mcp__jini__execute_delegated_tool']);
11
30
  /** Ext events that are a card for the person (a form, a confirm, a sign-in). */
12
31
  const CARD_EXT_NAMES = new Set(['mcp-ui', 'a2ui']);
32
+ /** The A2UI ext name: its surfaces may declare themselves display-only (`@jini-ai/agentic/a2ui`). */
33
+ const A2UI_EXT_NAME = 'a2ui';
13
34
  /** After this long with nothing new on screen, a thinking/writing line says "Still working…". */
14
35
  export const RUN_ACTIVITY_IDLE_MS = 20_000;
15
36
  function isRecord(value) {
@@ -25,7 +46,8 @@ function stringField(data, key) {
25
46
  }
26
47
  /** `"page.fill"` → `"Page Fill"`, `"mcp__supabase__list_projects"` → `"List Projects"`. Display only. */
27
48
  export function humanizeToolName({ name }, { input } = {}) {
28
- const real = WRAPPER_TOOL_NAMES.has(name) ? (stringField(input, 'toolId') ?? name) : name;
49
+ // A transport wrapper's real tool id is in `input.toolId`.
50
+ const real = isDelegatedWrapperToolName({ name: name }) ? (stringField(input, 'toolId') ?? name) : name;
29
51
  const bare = real.startsWith('mcp__') ? real.split('__').slice(2).join('__') || real : real;
30
52
  return bare
31
53
  .split(/[._\s]+/)
@@ -36,6 +58,23 @@ export function humanizeToolName({ name }, { input } = {}) {
36
58
  function isVisible(event) {
37
59
  return event.kind === 'text' ? event.text.length > 0 : event.kind === 'tool_use' || event.kind === 'tool_result' || event.kind === 'ext';
38
60
  }
61
+ /**
62
+ * Whether an ext event is a card the person must answer. A display-only A2UI surface shows
63
+ * something and asks nothing, so a tool holding its call open after drawing one (assistant_render_ui
64
+ * waits a few seconds to hear a browser refusal) must not read as "Waiting for your answer above"
65
+ * (demo V3, 2026-10-05).
66
+ */
67
+ function isAnswerCard(scan, name, data) {
68
+ if (!CARD_EXT_NAMES.has(name))
69
+ return false;
70
+ if (name !== A2UI_EXT_NAME)
71
+ return true;
72
+ const displayOnlyId = displayOnlySurfaceIdOf({ message: data });
73
+ if (displayOnlyId !== undefined)
74
+ scan.displayOnlySurfaces.add(displayOnlyId);
75
+ const surfaceId = a2uiSurfaceIdOf({ message: data });
76
+ return surfaceId === undefined || !scan.displayOnlySurfaces.has(surfaceId);
77
+ }
39
78
  function scanEvent(scan, event, index) {
40
79
  if (isVisible(event)) {
41
80
  scan.lastVisible = { index, kind: event.kind };
@@ -48,7 +87,7 @@ function scanEvent(scan, event, index) {
48
87
  else if (event.kind === 'tool_result') {
49
88
  scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
50
89
  }
51
- else if (event.kind === 'ext' && CARD_EXT_NAMES.has(event.name)) {
90
+ else if (event.kind === 'ext' && isAnswerCard(scan, event.name, event.data)) {
52
91
  scan.lastCardIndex = index;
53
92
  }
54
93
  else if (event.kind === 'status' && event.code === 'api_retry') {
@@ -64,13 +103,14 @@ function scanEvent(scan, event, index) {
64
103
  * The latest signal in a running turn's events.
65
104
  *
66
105
  * Precedence, newest wins: an API retry with nothing visible after it; a card that arrived while a
67
- * tool call is still open (the tool is waiting on the person); the newest open tool call; else
106
+ * tool call is still open (the tool is waiting on the person; a display-only A2UI surface is not a
107
+ * card); the newest open tool call; else
68
108
  * writing (last visible event is text) or thinking.
69
109
  *
70
110
  * @complexity O(n) in events, O(open tool calls) extra space.
71
111
  */
72
112
  export function deriveRunActivity({ events }) {
73
- const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0 };
113
+ const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0, displayOnlySurfaces: new Set() };
74
114
  (events ?? []).forEach((event, index) => scanEvent(scan, event, index));
75
115
  const visibleCount = scan.visibleCount;
76
116
  const lastVisibleIndex = scan.lastVisible?.index ?? -1;
@@ -114,6 +154,16 @@ export function deriveRunActivity({ events }) {
114
154
  }
115
155
  return { activity: { kind: 'thinking' }, key: `thinking:${lastVisibleIndex}`, visibleCount };
116
156
  }
157
+ /**
158
+ * Whether the run is waiting on the person: a card (form, confirm, sign-in) arrived while its tool
159
+ * call is still open. Hosts use it to hide "still working" notices, which read as a hung run while
160
+ * the run is really waiting for an answer.
161
+ *
162
+ * @complexity O(n) in events.
163
+ */
164
+ export function isAwaitingAnswer({ events }) {
165
+ return deriveRunActivity({ events }).activity.kind === 'awaiting-answer';
166
+ }
117
167
  /** `4` → `"4 s"`, `80` → `"1 m 20 s"`, `120` → `"2 m 0 s"`. */
118
168
  export function formatActivityClock({ totalSeconds }) {
119
169
  const seconds = Math.max(0, Math.floor(totalSeconds));
@@ -133,7 +183,9 @@ export const RUN_ACTIVITY_LABELS = {
133
183
  : t('Writing…'),
134
184
  'finding-tool': (_a, clock, t) => t('Finding the right tool… {time}', { time: formatActivityClock({ totalSeconds: clock.seconds }) }),
135
185
  'running-tool': (a, clock, t) => t('Running {tool}… {time}', { tool: a.tool, time: formatActivityClock({ totalSeconds: clock.seconds }) }),
136
- 'awaiting-answer': (_a, clock, t) => t('Waiting for your answer above · {time}', { time: formatActivityClock({ totalSeconds: clock.seconds }) }),
186
+ // No clock: the run is waiting on the person, not working, and a ticking timer under an open
187
+ // form read as a hung run (demo dry-run, 2026-10-05).
188
+ 'awaiting-answer': (_a, _clock, t) => t('Waiting for your answer above'),
137
189
  retrying: (a, _clock, t) => {
138
190
  const who = a.service ? t("{service}'s servers are busy", { service: a.service }) : t('The AI service is busy');
139
191
  return a.attempt !== undefined && a.maxAttempts !== undefined
@@ -17,4 +17,38 @@ import type { AgentEvent } from './events.js';
17
17
  export declare function dedupeToolUsesById({ events }: {
18
18
  events: AgentEvent[] | undefined;
19
19
  }): AgentEvent[];
20
+ /**
21
+ * Whether `name` is a delegated-tool transport wrapper — `execute_delegated_tool` or
22
+ * `execute_readonly_delegated_tool`, bare or as an MCP client names it (`mcp__<server>__…`). Its real
23
+ * tool id is in `input.toolId`. The one shared definition, so a renderer and the run-activity line
24
+ * cannot disagree about which names are wrappers (both once missed the read-only gateway).
25
+ *
26
+ * @complexity O(n) in the length of `name`.
27
+ */
28
+ export declare function isDelegatedWrapperToolName({ name }: {
29
+ name: string;
30
+ }): boolean;
31
+ /**
32
+ * Drop each delegated-tool wrapper call that the daemon also reported as its own canonical row,
33
+ * so one call shows as one row.
34
+ *
35
+ * WHY. A delegated call arrives twice: the vendor CLI's wrapper `tool_use`
36
+ * (`mcp__jini__execute_delegated_tool {toolId, input}`, with the CLI's `toolu_…` id) and the
37
+ * daemon bridge's canonical `tool_use` named after the real tool (`content_stats`, with a fresh
38
+ * UUID from `@jini-ai/mcp`). The ids differ, so {@link dedupeToolUsesById} cannot merge them, and a
39
+ * failed call showed two red rows. The canonical row is the one to keep: it carries the tool's own
40
+ * name, input and result.
41
+ *
42
+ * A wrapper folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`
43
+ * and that no earlier wrapper has claimed; its `tool_result` events go with it. A wrapper with no
44
+ * such canonical row (refused before the bridge, e.g. a read-only 403) stays, so its error is not
45
+ * hidden.
46
+ *
47
+ * @param events - The event list, normally already passed through {@link dedupeToolUsesById}.
48
+ * @returns A new array without the folded wrappers, or the original reference when none fold.
49
+ * @complexity O(n·w): n events, w wrapper calls (a handful per message).
50
+ */
51
+ export declare function foldDelegatedWrapperCalls({ events }: {
52
+ events: AgentEvent[] | undefined;
53
+ }): AgentEvent[];
20
54
  //# sourceMappingURL=tool-events.d.ts.map
@@ -33,4 +33,65 @@ export function dedupeToolUsesById({ events }) {
33
33
  }
34
34
  return deduped ?? events;
35
35
  }
36
+ /** The two delegated-tool gateways `@jini-ai/mcp` hosts: the write gateway and its read-only companion. */
37
+ const DELEGATED_WRAPPER_PATTERN = /^(?:mcp__.+__)?execute_(?:readonly_)?delegated_tool$/;
38
+ /**
39
+ * Whether `name` is a delegated-tool transport wrapper — `execute_delegated_tool` or
40
+ * `execute_readonly_delegated_tool`, bare or as an MCP client names it (`mcp__<server>__…`). Its real
41
+ * tool id is in `input.toolId`. The one shared definition, so a renderer and the run-activity line
42
+ * cannot disagree about which names are wrappers (both once missed the read-only gateway).
43
+ *
44
+ * @complexity O(n) in the length of `name`.
45
+ */
46
+ export function isDelegatedWrapperToolName({ name }) {
47
+ return DELEGATED_WRAPPER_PATTERN.test(name);
48
+ }
49
+ function wrapperToolId(event) {
50
+ if (event.kind !== 'tool_use' || !isDelegatedWrapperToolName({ name: event.name }))
51
+ return undefined;
52
+ const input = event.input;
53
+ const toolId = typeof input === 'object' && input !== null ? input.toolId : undefined;
54
+ return typeof toolId === 'string' && toolId ? toolId : undefined;
55
+ }
56
+ /**
57
+ * Drop each delegated-tool wrapper call that the daemon also reported as its own canonical row,
58
+ * so one call shows as one row.
59
+ *
60
+ * WHY. A delegated call arrives twice: the vendor CLI's wrapper `tool_use`
61
+ * (`mcp__jini__execute_delegated_tool {toolId, input}`, with the CLI's `toolu_…` id) and the
62
+ * daemon bridge's canonical `tool_use` named after the real tool (`content_stats`, with a fresh
63
+ * UUID from `@jini-ai/mcp`). The ids differ, so {@link dedupeToolUsesById} cannot merge them, and a
64
+ * failed call showed two red rows. The canonical row is the one to keep: it carries the tool's own
65
+ * name, input and result.
66
+ *
67
+ * A wrapper folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`
68
+ * and that no earlier wrapper has claimed; its `tool_result` events go with it. A wrapper with no
69
+ * such canonical row (refused before the bridge, e.g. a read-only 403) stays, so its error is not
70
+ * hidden.
71
+ *
72
+ * @param events - The event list, normally already passed through {@link dedupeToolUsesById}.
73
+ * @returns A new array without the folded wrappers, or the original reference when none fold.
74
+ * @complexity O(n·w): n events, w wrapper calls (a handful per message).
75
+ */
76
+ export function foldDelegatedWrapperCalls({ events }) {
77
+ if (!events || events.length === 0)
78
+ return [];
79
+ const claimed = new Set();
80
+ const folded = new Set();
81
+ events.forEach((event, i) => {
82
+ const toolId = wrapperToolId(event);
83
+ // A replayed wrapper (same id) must not claim a second canonical row.
84
+ if (toolId === undefined || event.kind !== 'tool_use' || folded.has(event.id))
85
+ return;
86
+ const match = events.findIndex((other, j) => j > i && !claimed.has(j) && other.kind === 'tool_use' && other.name === toolId);
87
+ if (match === -1)
88
+ return;
89
+ claimed.add(match);
90
+ folded.add(event.id);
91
+ });
92
+ if (folded.size === 0)
93
+ return events;
94
+ return events.filter((event) => !((event.kind === 'tool_use' && folded.has(event.id)) ||
95
+ (event.kind === 'tool_result' && folded.has(event.toolUseId))));
96
+ }
36
97
  //# sourceMappingURL=tool-events.js.map
@@ -10,7 +10,7 @@
10
10
  * archived provenance ledger.
11
11
  */
12
12
  import type { ToolResultEvent, ToolResultMediaBlock, ToolUseEvent } from './events.js';
13
- export { dedupeToolUsesById } from './tool-events.js';
13
+ export { dedupeToolUsesById, foldDelegatedWrapperCalls, isDelegatedWrapperToolName } from './tool-events.js';
14
14
  /** The four-state tool-call lifecycle agreed across AG-UI / CopilotKit / LangGraph render props. */
15
15
  export type ToolStatus = 'inProgress' | 'executing' | 'complete' | 'error';
16
16
  /** The render-prop payload a tool-call UI needs, independent of any framework. */
@@ -1,4 +1,4 @@
1
- export { dedupeToolUsesById } from './tool-events.js';
1
+ export { dedupeToolUsesById, foldDelegatedWrapperCalls, isDelegatedWrapperToolName } from './tool-events.js';
2
2
  /**
3
3
  * Map a tool call to its AG-UI-style lifecycle status.
4
4
  *
@@ -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
@@ -50,7 +50,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
50
50
  * chunk stopped arriving.
51
51
  */
52
52
  import React, { useState } from 'react';
53
- import { isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
53
+ import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
54
54
  import { useToolTimeline } from '../hooks/useToolTimeline.js';
55
55
  import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
56
56
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
@@ -160,6 +160,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
160
160
  : null;
161
161
  const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
162
162
  const runInProgress = isRunInProgress(message.runStatus);
163
+ const awaitingAnswer = runStreaming && runInProgress && isAwaitingAnswer({ events: message.events });
163
164
  const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
164
165
  const renderExtGroup = (group) => {
165
166
  const renderer = getExtEventRenderer({ name: group.name });
@@ -170,6 +171,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
170
171
  events: group.events,
171
172
  runStreaming,
172
173
  runSucceeded,
174
+ awaitingAnswer,
173
175
  runId: message.runId,
174
176
  ...(group.call === undefined ? {} : { call: group.call }),
175
177
  });
@@ -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. */
@@ -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: {
@@ -199,10 +205,31 @@ export function useChatPane(options) {
199
205
  typedAnswerGenerationRef.current += 1;
200
206
  heldAnswerQuestionIdRef.current = null;
201
207
  }, [options.conversationId]);
208
+ // A conversation switch without a remount: adopt the batch of the attachments the composer is
209
+ // about to restore for the new conversation, else start a fresh one so two conversations never
210
+ // share a batch directory (the agent is granted the whole directory, not just the claimed files).
211
+ // An id arriving where there was none is the same conversation finally getting a key — the
212
+ // composer keeps what is staged (see `useComposer`), so the batch stays too.
213
+ useEffect(() => {
214
+ const previousConversationId = batchConversationIdRef.current;
215
+ if (options.conversationId === previousConversationId)
216
+ return;
217
+ batchConversationIdRef.current = options.conversationId;
218
+ const restoredBatchId = readCachedAttachmentBatchId({ conversationId: options.conversationId });
219
+ if (restoredBatchId !== null) {
220
+ attachmentBatchIdRef.current = restoredBatchId;
221
+ return;
222
+ }
223
+ if (previousConversationId == null)
224
+ return;
225
+ attachmentBatchIdRef.current = createAttachmentBatchId();
226
+ }, [options.conversationId]);
202
227
  const addAttachments = useCallback(async (files) => {
203
228
  if (files.length === 0 || options.uploadAttachments === undefined)
204
229
  return;
205
230
  const generation = attachmentGenerationRef.current;
231
+ const batchId = attachmentBatchIdRef.current;
232
+ const conversationId = options.conversationId;
206
233
  const controller = new AbortController();
207
234
  activeUploadsRef.current.add(controller);
208
235
  setActiveUploadCount(activeUploadsRef.current.size);
@@ -211,7 +238,7 @@ export function useChatPane(options) {
211
238
  await uploadAttachmentBatch(files, {
212
239
  upload: options.uploadAttachments,
213
240
  signal: controller.signal,
214
- batchId: attachmentBatchIdRef.current,
241
+ batchId,
215
242
  stillWanted: () => mountedRef.current
216
243
  && !controller.signal.aborted
217
244
  && generation === attachmentGenerationRef.current,
@@ -221,6 +248,9 @@ export function useChatPane(options) {
221
248
  // `attachment-preview-cache.ts`'s module doc for why this is the only place that copy can
222
249
  // ever be captured.
223
250
  onAttachment: (attachment, file) => {
251
+ // Recorded before staging so the cache entry that persists this attachment carries its
252
+ // batch, which is what lets a later mount restore the batch along with the attachment.
253
+ writeCachedAttachmentBatchId({ conversationId, batchId });
224
254
  composer.addAttachment(attachment);
225
255
  if (file)
226
256
  cacheAttachmentPreviewSource({ path: attachment.path, file });
@@ -233,7 +263,7 @@ export function useChatPane(options) {
233
263
  setActiveUploadCount(activeUploadsRef.current.size);
234
264
  }
235
265
  }
236
- }, [composer, options.uploadAttachments]);
266
+ }, [composer, options.conversationId, options.uploadAttachments]);
237
267
  const sendPrompt = useCallback(async (prompt) => {
238
268
  const trimmed = prompt.trim();
239
269
  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
@@ -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
  *
@@ -62,6 +62,10 @@ export const COMPOSER_DRAFT_STORAGE_PREFIX = 'jini.chat.composer-draft.v1.';
62
62
  export const COMPOSER_ATTACHMENTS_STORAGE_PREFIX = 'jini.chat.composer-attachments.v1.';
63
63
  const drafts = new Map();
64
64
  const stagedAttachments = new Map();
65
+ // Kept beside `stagedAttachments` rather than inside it so `writeCachedAttachments` keeps its
66
+ // attachments-only signature: `useComposer` persists the list and never sees a batch id, while
67
+ // `useChatPane` (the one caller that uploads in batches) records the batch here.
68
+ const stagedBatchIds = new Map();
65
69
  /**
66
70
  * The origin's `localStorage`, or `null` when it cannot be used.
67
71
  *
@@ -282,10 +286,11 @@ function isChatAttachment(candidate) {
282
286
  return true;
283
287
  }
284
288
  /**
285
- * Reads the stored attachment references for `conversationId`.
289
+ * Reads the stored attachment references (and their upload batch) for `conversationId`.
286
290
  *
287
291
  * Elements that do not validate are dropped individually rather than failing the whole entry — one
288
- * malformed row should cost the operator that row, not every attachment on the turn.
292
+ * malformed row should cost the operator that row, not every attachment on the turn. A missing or
293
+ * malformed batch id costs only the batch (`batchId: null`), never the references.
289
294
  *
290
295
  * @returns The stored references, or `null` when there are none, storage is unavailable, or nothing
291
296
  * in the entry validated.
@@ -302,6 +307,7 @@ function readStoredAttachments(conversationId) {
302
307
  return null;
303
308
  const parsed = JSON.parse(raw);
304
309
  const list = parsed?.a;
310
+ const batchId = parsed?.b;
305
311
  if (!Array.isArray(list)) {
306
312
  store.removeItem(key);
307
313
  return null;
@@ -311,7 +317,7 @@ function readStoredAttachments(conversationId) {
311
317
  store.removeItem(key);
312
318
  return null;
313
319
  }
314
- return valid;
320
+ return { attachments: valid, batchId: typeof batchId === 'string' && batchId !== '' ? batchId : null };
315
321
  }
316
322
  catch {
317
323
  try {
@@ -345,8 +351,48 @@ export function readCachedAttachments({ conversationId }) {
345
351
  const stored = readStoredAttachments(conversationId);
346
352
  if (stored === null)
347
353
  return null;
348
- stagedAttachments.set(conversationId, stored);
349
- return stored;
354
+ stagedAttachments.set(conversationId, stored.attachments);
355
+ if (stored.batchId !== null)
356
+ stagedBatchIds.set(conversationId, stored.batchId);
357
+ return stored.attachments;
358
+ }
359
+ /**
360
+ * The upload batch `conversationId`'s cached attachments were uploaded under, so a pane that
361
+ * restores those attachments uploads the next file into the SAME batch.
362
+ *
363
+ * Why this exists: the daemon grants the agent exactly one batch directory per turn and refuses a
364
+ * turn whose attachments span two ("Attachments must belong to one batch"). A pane that restored an
365
+ * attachment after a remount, reload, or conversation switch used to upload the next file under a
366
+ * brand-new batch id, so every such turn failed before the agent started (2026-10-06).
367
+ *
368
+ * @returns The batch id, or `null` when there are no cached attachments to stay consistent with
369
+ * (none staged, attachment persistence off because the host supplied no validator, or an entry
370
+ * written before batch ids were recorded) — the caller then starts a fresh batch.
371
+ * @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored count otherwise.
372
+ */
373
+ export function readCachedAttachmentBatchId({ conversationId }) {
374
+ if (!conversationId)
375
+ return null;
376
+ if (readCachedAttachments({ conversationId: conversationId }) === null)
377
+ return null;
378
+ return stagedBatchIds.get(conversationId) ?? null;
379
+ }
380
+ /**
381
+ * Records the upload batch of an attachment about to be staged in `conversationId`'s composer.
382
+ * Memory only: the stored envelope picks it up on the {@link writeCachedAttachments} that persists
383
+ * that attachment, so it is never written ahead of the references it describes.
384
+ *
385
+ * @complexity Time/space: O(1) amortized.
386
+ */
387
+ export function writeCachedAttachmentBatchId({ conversationId, batchId }) {
388
+ if (!conversationId)
389
+ return;
390
+ if (!stagedBatchIds.has(conversationId) && stagedBatchIds.size >= MAX_CACHED_CONVERSATION_DRAFTS) {
391
+ const oldest = stagedBatchIds.keys().next().value;
392
+ if (oldest !== undefined)
393
+ stagedBatchIds.delete(oldest);
394
+ }
395
+ stagedBatchIds.set(conversationId, batchId);
350
396
  }
351
397
  /**
352
398
  * Stores `attachments` for `conversationId` in both tiers.
@@ -362,6 +408,8 @@ export function writeCachedAttachments({ conversationId, attachments }) {
362
408
  const store = storage();
363
409
  if (attachments.length === 0) {
364
410
  stagedAttachments.delete(conversationId);
411
+ // A sent or cleared turn's batch is spent; the next upload starts a fresh one.
412
+ stagedBatchIds.delete(conversationId);
365
413
  try {
366
414
  store?.removeItem(key);
367
415
  }
@@ -381,7 +429,8 @@ export function writeCachedAttachments({ conversationId, attachments }) {
381
429
  try {
382
430
  if (store.getItem(key) === null)
383
431
  pruneStored(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, MAX_CACHED_CONVERSATION_DRAFTS - 1);
384
- const envelope = { v: 1, t: Date.now(), a: attachments };
432
+ const batchId = stagedBatchIds.get(conversationId);
433
+ const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
385
434
  store.setItem(key, JSON.stringify(envelope));
386
435
  }
387
436
  catch {
@@ -399,6 +448,7 @@ export function writeCachedAttachments({ conversationId, attachments }) {
399
448
  export function __resetComposerDraftCacheForTests(options) {
400
449
  drafts.clear();
401
450
  stagedAttachments.clear();
451
+ stagedBatchIds.clear();
402
452
  if (options?.keepStorage === true)
403
453
  return;
404
454
  pruneStored(COMPOSER_DRAFT_STORAGE_PREFIX, 0);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @module last-conversation-store
3
+ *
4
+ * Remembers which conversation an operator last had open, so a host's chat surface can land back
5
+ * on it after a page reload, a closed tab, or a navigation away — instead of an empty new chat.
6
+ *
7
+ * Conversation SELECTION is host-owned (`ChatPane` is uncontrolled and takes a `conversationId`;
8
+ * `ConversationList` takes `activeConversationId` as a prop), so this module does not decide when
9
+ * to restore. It is only the durable slot: the host reads it once at mount, checks the id still
10
+ * exists for this caller, and writes it whenever its active conversation changes.
11
+ *
12
+ * Same storage discipline as `composer-draft-cache.ts`: a versioned key, every access wrapped, and a
13
+ * foreign or corrupt entry treated as "nothing remembered" and deleted. A private window, blocked
14
+ * site data or a quota rejection degrades this to remembering nothing — never a throw at the host.
15
+ *
16
+ * Unlike the draft cache this is NOT a module singleton. The partition here is the caller's
17
+ * identity (`scope`), which only the host knows — two users on one browser must never land in each
18
+ * other's chat — so a host constructs one store per scope and passes it down.
19
+ */
20
+ /**
21
+ * Key prefix for the stored entry. Versioned (`.v1.`) so a future change to the stored shape can be
22
+ * introduced without interpreting, or mis-interpreting, entries written by this one.
23
+ */
24
+ export declare const LAST_CONVERSATION_STORAGE_PREFIX = "jini.chat.last-conversation.v1.";
25
+ /** The subset of Web Storage this module uses — `localStorage` satisfies it; tests pass a fake. */
26
+ export interface LastConversationStorage {
27
+ getItem(key: string): string | null;
28
+ setItem(key: string, value: string): void;
29
+ removeItem(key: string): void;
30
+ }
31
+ /** The host-facing port. Every method is total: none throws, whatever storage does. */
32
+ export interface LastConversationStore {
33
+ /** The remembered conversation id, or `null` when there is none or storage is unusable. */
34
+ read(): string | null;
35
+ /** Remembers `conversationId` as the one to land on next time, replacing any earlier one. */
36
+ write(conversationId: string): void;
37
+ /** Forgets the remembered id — for a conversation that was deleted or is no longer reachable. */
38
+ clear(): void;
39
+ }
40
+ /**
41
+ * Creates the last-conversation slot for one caller identity.
42
+ *
43
+ * @param required.scope Who this memory belongs to — e.g. `${workspaceId}:${userId}`. Part of the
44
+ * key, so a different scope on the same browser reads nothing.
45
+ * @param optional.storage Defaults to the origin's `localStorage`, resolved on every call (not once)
46
+ * so a store created during SSR still works after hydration. `null` disables persistence.
47
+ * @returns A {@link LastConversationStore}; see its methods for the never-throws contract.
48
+ * @example
49
+ * const store = createLastConversationStore({ scope: `${workspaceId}:${user.id}` });
50
+ * const id = store.read(); // land here if it still exists, else store.clear()
51
+ * @complexity Time/space: O(1) per call, plus O(n) in the entry's length for the JSON parse.
52
+ */
53
+ export declare function createLastConversationStore({ scope }: {
54
+ scope: string;
55
+ }, { storage }?: {
56
+ storage?: LastConversationStorage | null;
57
+ }): LastConversationStore;
58
+ //# sourceMappingURL=last-conversation-store.d.ts.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @module last-conversation-store
3
+ *
4
+ * Remembers which conversation an operator last had open, so a host's chat surface can land back
5
+ * on it after a page reload, a closed tab, or a navigation away — instead of an empty new chat.
6
+ *
7
+ * Conversation SELECTION is host-owned (`ChatPane` is uncontrolled and takes a `conversationId`;
8
+ * `ConversationList` takes `activeConversationId` as a prop), so this module does not decide when
9
+ * to restore. It is only the durable slot: the host reads it once at mount, checks the id still
10
+ * exists for this caller, and writes it whenever its active conversation changes.
11
+ *
12
+ * Same storage discipline as `composer-draft-cache.ts`: a versioned key, every access wrapped, and a
13
+ * foreign or corrupt entry treated as "nothing remembered" and deleted. A private window, blocked
14
+ * site data or a quota rejection degrades this to remembering nothing — never a throw at the host.
15
+ *
16
+ * Unlike the draft cache this is NOT a module singleton. The partition here is the caller's
17
+ * identity (`scope`), which only the host knows — two users on one browser must never land in each
18
+ * other's chat — so a host constructs one store per scope and passes it down.
19
+ */
20
+ /**
21
+ * Key prefix for the stored entry. Versioned (`.v1.`) so a future change to the stored shape can be
22
+ * introduced without interpreting, or mis-interpreting, entries written by this one.
23
+ */
24
+ export const LAST_CONVERSATION_STORAGE_PREFIX = 'jini.chat.last-conversation.v1.';
25
+ /**
26
+ * The origin's `localStorage`, or `null` when it cannot be used — a non-browser runtime, or a
27
+ * browser that throws on the property access itself (Safari and Chrome both do with site data
28
+ * blocked), which is why this is a try/catch rather than a `typeof` check alone.
29
+ * @complexity Time/space: O(1).
30
+ */
31
+ function browserStorage() {
32
+ try {
33
+ return typeof localStorage === 'undefined' ? null : localStorage;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * Validates one parsed entry as this module's own envelope.
41
+ * @complexity Time/space: O(1).
42
+ */
43
+ function storedId(parsed) {
44
+ const entry = parsed;
45
+ if (entry?.v !== 1 || typeof entry.id !== 'string' || entry.id === '')
46
+ return null;
47
+ return entry.id;
48
+ }
49
+ /**
50
+ * Creates the last-conversation slot for one caller identity.
51
+ *
52
+ * @param required.scope Who this memory belongs to — e.g. `${workspaceId}:${userId}`. Part of the
53
+ * key, so a different scope on the same browser reads nothing.
54
+ * @param optional.storage Defaults to the origin's `localStorage`, resolved on every call (not once)
55
+ * so a store created during SSR still works after hydration. `null` disables persistence.
56
+ * @returns A {@link LastConversationStore}; see its methods for the never-throws contract.
57
+ * @example
58
+ * const store = createLastConversationStore({ scope: `${workspaceId}:${user.id}` });
59
+ * const id = store.read(); // land here if it still exists, else store.clear()
60
+ * @complexity Time/space: O(1) per call, plus O(n) in the entry's length for the JSON parse.
61
+ */
62
+ export function createLastConversationStore({ scope }, { storage } = {}) {
63
+ const key = `${LAST_CONVERSATION_STORAGE_PREFIX}${encodeURIComponent(scope)}`;
64
+ const resolve = () => (storage === undefined ? browserStorage() : storage);
65
+ const remove = (store) => {
66
+ try {
67
+ store.removeItem(key);
68
+ }
69
+ catch {
70
+ /* storage is unusable; nothing further this module can do */
71
+ }
72
+ };
73
+ return {
74
+ read() {
75
+ const store = resolve();
76
+ if (!store)
77
+ return null;
78
+ try {
79
+ const raw = store.getItem(key);
80
+ if (raw === null)
81
+ return null;
82
+ const id = storedId(JSON.parse(raw));
83
+ if (id === null)
84
+ remove(store);
85
+ return id;
86
+ }
87
+ catch {
88
+ // A parse failure or a throwing read: a corrupt entry must not survive to be read again.
89
+ remove(store);
90
+ return null;
91
+ }
92
+ },
93
+ write(conversationId) {
94
+ const store = resolve();
95
+ if (!store)
96
+ return;
97
+ try {
98
+ const envelope = { v: 1, id: conversationId };
99
+ store.setItem(key, JSON.stringify(envelope));
100
+ }
101
+ catch {
102
+ /* blocked, full, or unusable storage — remembering nothing is the documented degraded mode */
103
+ }
104
+ },
105
+ clear() {
106
+ const store = resolve();
107
+ if (store)
108
+ remove(store);
109
+ },
110
+ };
111
+ }
112
+ //# sourceMappingURL=last-conversation-store.js.map
@@ -3,17 +3,20 @@
3
3
  *
4
4
  * Per-tool lifecycle rows for a message's events: pairs each `tool_use` with
5
5
  * its `tool_result` (deduped by id via `@jini-ai/chat/core`'s
6
- * `dedupeToolUsesById`), derives a four-state status via
6
+ * `dedupeToolUsesById`, delegated wrapper calls folded onto their canonical
7
+ * rows via `foldDelegatedWrapperCalls`), derives a four-state status via
7
8
  * `deriveToolStatus`, and tracks per-row expand/collapse UI state. Pure over
8
9
  * `AgentEvent[]` — zero I/O. Per
9
10
  * `ADS-memory/reports/jini-port/recon/r4b-webui-design.md` §4.
10
11
  */
11
12
  import { useCallback, useMemo, useState } from 'react';
12
- import { dedupeToolUsesById, deriveToolStatus } from '../../core/index.js';
13
+ import { dedupeToolUsesById, deriveToolStatus, foldDelegatedWrapperCalls } from '../../core/index.js';
13
14
  export function useToolTimeline({ events }, options = {}) {
14
15
  const { runStreaming = false, runSucceeded = false, defaultExpanded = false } = options;
15
16
  const [overrides, setOverrides] = useState({});
16
- const deduped = useMemo(() => dedupeToolUsesById({ events: events }), [events]);
17
+ // A delegated call's wrapper row and the daemon's canonical row have different ids, so dedupe
18
+ // alone leaves both; the fold keeps the canonical one (see `foldDelegatedWrapperCalls`).
19
+ const deduped = useMemo(() => foldDelegatedWrapperCalls({ events: dedupeToolUsesById({ events: events }) }), [events]);
17
20
  const rows = useMemo(() => {
18
21
  const out = [];
19
22
  for (const ev of deduped) {
@@ -112,6 +112,12 @@ export type { TodoCardProps } from './components/TodoCard.js';
112
112
  */
113
113
  export { ConversationList } from './components/ConversationList.js';
114
114
  export type { ConversationListProps, ConversationListItem } from './components/ConversationList.js';
115
+ /**
116
+ * The conversation an operator last had open, per caller identity — so a host's switcher lands
117
+ * back on it after a reload instead of an empty new chat. See the module doc for the contract.
118
+ */
119
+ export { createLastConversationStore, LAST_CONVERSATION_STORAGE_PREFIX } from './hooks/last-conversation-store.js';
120
+ export type { LastConversationStorage, LastConversationStore } from './hooks/last-conversation-store.js';
115
121
  export { ToolCard } from './components/ToolCard.js';
116
122
  export type { ToolCardProps } from './components/ToolCard.js';
117
123
  export { A2uiSurfaceCard } from './components/A2uiSurfaceCard.js';
@@ -86,6 +86,11 @@ export { TodoCard } from './components/TodoCard.js';
86
86
  * `leadingAccessory` slot; it needs no changes to `ChatPane` itself.
87
87
  */
88
88
  export { ConversationList } from './components/ConversationList.js';
89
+ /**
90
+ * The conversation an operator last had open, per caller identity — so a host's switcher lands
91
+ * back on it after a reload instead of an empty new chat. See the module doc for the contract.
92
+ */
93
+ export { createLastConversationStore, LAST_CONVERSATION_STORAGE_PREFIX } from './hooks/last-conversation-store.js';
89
94
  export { ToolCard } from './components/ToolCard.js';
90
95
  export { A2uiSurfaceCard } from './components/A2uiSurfaceCard.js';
91
96
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -145,10 +145,10 @@
145
145
  "registry": "https://registry.npmjs.org"
146
146
  },
147
147
  "dependencies": {
148
- "@jini-ai/agentic": "^0.4.1",
149
- "@jini-ai/ui": "^0.4.3",
150
- "@jini-ai/core": "^0.4.1",
151
- "@jini-ai/protocol": "^0.4.0"
148
+ "@jini-ai/agentic": "^0.4.2",
149
+ "@jini-ai/ui": "^0.4.4",
150
+ "@jini-ai/protocol": "^0.4.0",
151
+ "@jini-ai/core": "^0.4.1"
152
152
  },
153
153
  "peerDependencies": {
154
154
  "react": "^18.3.0 || ^19.0.0",
@@ -238,8 +238,8 @@
238
238
  "@electric-sql/pglite": "0.5.8",
239
239
  "pg": "8.23.0",
240
240
  "@ag-ui/core": "0.0.58",
241
- "@jini-ai/agent-runtime": "0.4.0",
242
- "@jini-ai/db": "0.3.0"
241
+ "@jini-ai/db": "0.3.0",
242
+ "@jini-ai/agent-runtime": "0.4.0"
243
243
  },
244
244
  "typesVersions": {
245
245
  "*": {