@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 +11 -0
- package/dist/core/run-activity.d.ts +12 -20
- package/dist/core/run-activity.js +59 -7
- package/dist/core/tool-events.d.ts +34 -0
- package/dist/core/tool-events.js +61 -0
- package/dist/core/tools.d.ts +1 -1
- package/dist/core/tools.js +1 -1
- package/dist/react/components/Markdown.js +37 -6
- package/dist/react/components/MessageList.hooks.js +8 -2
- package/dist/react/components/MessageList.rules.d.ts +18 -0
- package/dist/react/components/MessageList.rules.js +15 -0
- package/dist/react/components/MessageRow.js +3 -1
- package/dist/react/components/ToolCard.js +10 -7
- package/dist/react/ext-event-renderer-registry.d.ts +7 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +33 -3
- package/dist/react/features/chat-pane/rules.d.ts +6 -3
- package/dist/react/features/chat-pane/rules.js +6 -3
- package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
- package/dist/react/hooks/composer-draft-cache.js +56 -6
- package/dist/react/hooks/last-conversation-store.d.ts +58 -0
- package/dist/react/hooks/last-conversation-store.js +112 -0
- package/dist/react/hooks/useToolTimeline.js +6 -3
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +5 -0
- package/package.json +7 -7
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
|
|
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
|
-
|
|
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' &&
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/core/tool-events.js
CHANGED
|
@@ -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
|
package/dist/core/tools.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/tools.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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 =
|
|
137
|
-
const args =
|
|
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
|
-
|
|
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
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/react/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
149
|
-
"@jini-ai/ui": "^0.4.
|
|
150
|
-
"@jini-ai/
|
|
151
|
-
"@jini-ai/
|
|
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/
|
|
242
|
-
"@jini-ai/
|
|
241
|
+
"@jini-ai/db": "0.3.0",
|
|
242
|
+
"@jini-ai/agent-runtime": "0.4.0"
|
|
243
243
|
},
|
|
244
244
|
"typesVersions": {
|
|
245
245
|
"*": {
|