@jini-ai/chat 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/core/composer-history.d.ts +54 -0
  3. package/dist/core/composer-history.js +57 -0
  4. package/dist/core/durable-projection.d.ts +28 -0
  5. package/dist/core/durable-projection.js +71 -0
  6. package/dist/core/index.d.ts +3 -0
  7. package/dist/core/index.js +2 -0
  8. package/dist/core/run-activity.d.ts +12 -20
  9. package/dist/core/run-activity.js +59 -7
  10. package/dist/core/tool-events.d.ts +34 -0
  11. package/dist/core/tool-events.js +61 -0
  12. package/dist/core/tools.d.ts +1 -1
  13. package/dist/core/tools.js +1 -1
  14. package/dist/core/transport.d.ts +4 -0
  15. package/dist/react/components/Composer.js +6 -0
  16. package/dist/react/components/Markdown.js +37 -6
  17. package/dist/react/components/MessageList.hooks.js +8 -2
  18. package/dist/react/components/MessageList.rules.d.ts +18 -0
  19. package/dist/react/components/MessageList.rules.js +15 -0
  20. package/dist/react/components/MessageRow.js +26 -11
  21. package/dist/react/components/ToolCard.js +10 -7
  22. package/dist/react/ext-event-renderer-registry.d.ts +7 -0
  23. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  24. package/dist/react/features/chat-pane/components/ChatPane.js +5 -3
  25. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -0
  26. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +42 -9
  27. package/dist/react/features/chat-pane/rules.d.ts +6 -3
  28. package/dist/react/features/chat-pane/rules.js +6 -3
  29. package/dist/react/features/chat-pane/types.d.ts +5 -0
  30. package/dist/react/hooks/composer-draft-cache.d.ts +28 -0
  31. package/dist/react/hooks/composer-draft-cache.js +56 -6
  32. package/dist/react/hooks/composer-history-storage.d.ts +8 -0
  33. package/dist/react/hooks/composer-history-storage.js +40 -0
  34. package/dist/react/hooks/last-conversation-store.d.ts +58 -0
  35. package/dist/react/hooks/last-conversation-store.js +112 -0
  36. package/dist/react/hooks/useComposer.d.ts +10 -1
  37. package/dist/react/hooks/useComposer.js +14 -2
  38. package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
  39. package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
  40. package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
  41. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
  42. package/dist/react/hooks/useConversation.js +6 -2
  43. package/dist/react/hooks/useRunStream.js +12 -2
  44. package/dist/react/hooks/useToolTimeline.js +6 -3
  45. package/dist/react/index.d.ts +8 -0
  46. package/dist/react/index.js +6 -0
  47. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 0.5.4 — 2026-10-06
2
+
3
+ - Prompt history in the composer: ↑ with the caret on the first line recalls earlier sent messages and ↓ on the last line walks forward; the first ↑ stashes the current draft (empty or not) and ↓ past the newest entry (or Escape) restores it exactly. Recalled entries are never mutated; consecutive duplicates collapse. New pure `createComposerHistoryState` / `transitionComposerHistory` / `composerHistoryKeyAction` / `mergeComposerHistory` / `normalizeComposerHistory` (core) and `createBrowserComposerHistoryStorage` (react, a `ComposerHistoryStoragePort` default). `useComposer`/`ChatPane` take `historyScope`, `historyStorage` and `historyMessages`.
4
+ - Durable runs: new `recoveredRunEvents` (core) projects a recovered run's saved events so a continued answer completes the same message; a run notice shows only on a real resume.
5
+ - Message attachments: the transport sends each message's own attachment refs, and attachment chips keep their kind across reloads.
6
+ - Requires `@jini-ai/protocol` ^0.4.1.
7
+
8
+ ## 0.5.3 — 2026-10-06
9
+
10
+ - 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.
11
+ - 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").
12
+ - A display-only A2UI surface (`surfaceProperties.displayOnly`, e.g. a drawn chart) no longer reads as "Waiting for your answer above".
13
+ - A run held on an open form reads as waiting, not working: new `isAwaitingAnswer`, passed to ext event renderers as `awaitingAnswer`.
14
+ - 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`).
15
+ - 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.
16
+ - A late scroll event no longer unsticks the transcript from the bottom; only a move up does.
17
+ - Requires `@jini-ai/agentic` ^0.4.2 (`displayOnlySurfaceIdOf`).
18
+
1
19
  ## 0.5.2 — 2026-10-05
2
20
 
3
21
  - `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`.
@@ -0,0 +1,54 @@
1
+ /** Text-only prompt recall. Browsing owns a snapshot so incoming turns cannot move its cursor. */
2
+ export interface ComposerHistoryState {
3
+ entries: readonly string[];
4
+ index: number | null;
5
+ draft: string | null;
6
+ }
7
+ export interface ComposerHistoryStoragePort {
8
+ read: (required: {
9
+ scope: string;
10
+ }, optional: {}) => Promise<readonly string[]>;
11
+ write: (required: {
12
+ scope: string;
13
+ entries: readonly string[];
14
+ }, optional: {}) => Promise<void>;
15
+ }
16
+ export interface ComposerHistoryKeyInput {
17
+ key: string;
18
+ text: string;
19
+ selectionStart: number;
20
+ selectionEnd: number;
21
+ shiftKey?: boolean;
22
+ ctrlKey?: boolean;
23
+ altKey?: boolean;
24
+ metaKey?: boolean;
25
+ isComposing?: boolean;
26
+ popupOpen?: boolean;
27
+ /** A DOM host can refine logical newline boundaries with measured soft-wrap boundaries. */
28
+ firstLine?: boolean;
29
+ lastLine?: boolean;
30
+ }
31
+ export declare function normalizeComposerHistory({ entries }: {
32
+ entries: readonly string[];
33
+ }, { cap }?: {
34
+ cap?: number;
35
+ }): string[];
36
+ export declare function mergeComposerHistory({ recent, current }: {
37
+ recent: readonly string[];
38
+ current: readonly string[];
39
+ }, { cap }?: {
40
+ cap?: number;
41
+ }): string[];
42
+ export declare function createComposerHistoryState(_required: {}, _optional?: {}): ComposerHistoryState;
43
+ export declare function composerHistoryKeyAction(input: ComposerHistoryKeyInput, _optional?: {}): 'previous' | 'next' | 'restore' | null;
44
+ export declare function transitionComposerHistory({ state, action, text, entries }: {
45
+ state: ComposerHistoryState;
46
+ action: 'previous' | 'next' | 'restore' | 'edit' | 'reset';
47
+ text: string;
48
+ entries: readonly string[];
49
+ }, _optional?: {}): {
50
+ state: ComposerHistoryState;
51
+ text: string;
52
+ handled: boolean;
53
+ };
54
+ //# sourceMappingURL=composer-history.d.ts.map
@@ -0,0 +1,57 @@
1
+ export function normalizeComposerHistory({ entries }, { cap = 100 } = {}) {
2
+ const result = [];
3
+ for (const text of entries) {
4
+ // Blank/attachment-only turns are not useful prompts; meaningful whitespace stays exact.
5
+ if (text.trim() && text !== result[result.length - 1])
6
+ result.push(text);
7
+ }
8
+ return result.slice(-Math.max(1, Math.floor(cap)));
9
+ }
10
+ export function mergeComposerHistory({ recent, current }, { cap = 100 } = {}) {
11
+ const left = normalizeComposerHistory({ entries: recent }, { cap });
12
+ const right = normalizeComposerHistory({ entries: current }, { cap });
13
+ // A mounted conversation is usually already the recent list's suffix. Do not append its
14
+ // entire transcript again on every streamed render; preserve nonconsecutive repetitions.
15
+ let overlap = Math.min(left.length, right.length);
16
+ while (overlap > 0 && !right.slice(0, overlap).every((text, i) => text === left[left.length - overlap + i]))
17
+ overlap -= 1;
18
+ return normalizeComposerHistory({ entries: [...left, ...right.slice(overlap)] }, { cap });
19
+ }
20
+ export function createComposerHistoryState(_required, _optional = {}) {
21
+ return { entries: [], index: null, draft: null };
22
+ }
23
+ export function composerHistoryKeyAction(input, _optional = {}) {
24
+ if (input.shiftKey || input.ctrlKey || input.altKey || input.metaKey || input.isComposing || input.popupOpen)
25
+ return null;
26
+ if (input.key === 'Escape')
27
+ return 'restore';
28
+ const start = Math.min(input.selectionStart, input.selectionEnd);
29
+ const end = Math.max(input.selectionStart, input.selectionEnd);
30
+ if (input.text.slice(start, end).includes('\n'))
31
+ return null;
32
+ const firstLine = !input.text.slice(0, end).includes('\n') && input.firstLine !== false;
33
+ const lastLine = !input.text.slice(start).includes('\n') && input.lastLine !== false;
34
+ if (input.key === 'ArrowUp' && firstLine)
35
+ return 'previous';
36
+ if (input.key === 'ArrowDown' && lastLine)
37
+ return 'next';
38
+ return null;
39
+ }
40
+ export function transitionComposerHistory({ state, action, text, entries }, _optional = {}) {
41
+ if (action === 'edit' || action === 'reset') {
42
+ return { state: createComposerHistoryState({}), text, handled: false };
43
+ }
44
+ if (state.index === null) {
45
+ if (action !== 'previous' || entries.length === 0)
46
+ return { state, text, handled: false };
47
+ const snapshot = [...entries];
48
+ const index = snapshot.length - 1;
49
+ return { state: { entries: snapshot, index, draft: text }, text: snapshot[index], handled: true };
50
+ }
51
+ if (action === 'restore' || (action === 'next' && state.index === state.entries.length - 1)) {
52
+ return { state: createComposerHistoryState({}), text: state.draft ?? '', handled: true };
53
+ }
54
+ const index = action === 'previous' ? Math.max(0, state.index - 1) : state.index + 1;
55
+ return { state: { ...state, index }, text: state.entries[index], handled: true };
56
+ }
57
+ //# sourceMappingURL=composer-history.js.map
@@ -0,0 +1,28 @@
1
+ import type { AgentEvent } from './events.js';
2
+ import type { ChatMessage } from './messages.js';
3
+ /** Saved checkpoints can coalesce deltas and a completion may replay only a prefix. Keep the
4
+ * saved projection, merge identifiable events, and append only text beyond its saved prefix. */
5
+ export declare function mergeRunEvents({ saved, incoming }: {
6
+ saved?: readonly AgentEvent[] | undefined;
7
+ incoming: readonly AgentEvent[];
8
+ }, _optional?: {}): AgentEvent[];
9
+ /** A recovered attempt stays visibly in progress until new text or tool work supersedes its marker. */
10
+ export declare function continuingRunNotice({ events, active }: {
11
+ events: readonly AgentEvent[] | undefined;
12
+ active: boolean;
13
+ }, _optional?: {}): string | null;
14
+ export declare function preserveAssistantContent({ saved, incoming }: {
15
+ saved: string;
16
+ incoming: string;
17
+ }, _optional?: {}): string;
18
+ /** Terminal failure belongs to the message. Legacy resend instructions are display-only;
19
+ * changing this copy must never execute or reopen an old terminal run. */
20
+ export declare function terminalMessageNotice({ message }: {
21
+ message: ChatMessage;
22
+ }, _optional?: {}): string | null;
23
+ /** The first answer has no earlier segment to divide, even if startup was retried. Only saved
24
+ * visible text can justify a Continued divider; the recovery marker still records the attempt. */
25
+ export declare function recoveredRunEvents({ saved }: {
26
+ saved: readonly AgentEvent[];
27
+ }, _optional?: {}): AgentEvent[];
28
+ //# sourceMappingURL=durable-projection.d.ts.map
@@ -0,0 +1,71 @@
1
+ function identity(event) {
2
+ const wire = event;
3
+ if (wire.eventId !== undefined)
4
+ return `event:${wire.eventId}`;
5
+ if (wire.cursor !== undefined)
6
+ return `cursor:${wire.cursor}`;
7
+ if (event.kind === 'tool_use')
8
+ return `use:${event.id}`;
9
+ if (event.kind === 'tool_result')
10
+ return `result:${event.toolUseId}`;
11
+ return JSON.stringify(event);
12
+ }
13
+ function textOf(events) {
14
+ return events.filter((event) => event.kind === 'text').map((event) => event.text).join('');
15
+ }
16
+ /** Saved checkpoints can coalesce deltas and a completion may replay only a prefix. Keep the
17
+ * saved projection, merge identifiable events, and append only text beyond its saved prefix. */
18
+ export function mergeRunEvents({ saved, incoming }, _optional = {}) {
19
+ const previous = saved ?? [];
20
+ const result = [...previous];
21
+ const keys = new Set(previous.map(identity));
22
+ const before = textOf(previous);
23
+ const after = textOf(incoming);
24
+ let skip = after.startsWith(before) ? before.length : after.length;
25
+ for (const event of incoming) {
26
+ if (event.kind === 'text') {
27
+ const text = event.text.slice(skip);
28
+ skip = Math.max(0, skip - event.text.length);
29
+ if (text)
30
+ result.push({ ...event, text });
31
+ }
32
+ else if (!keys.has(identity(event))) {
33
+ keys.add(identity(event));
34
+ result.push(event);
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+ /** A recovered attempt stays visibly in progress until new text or tool work supersedes its marker. */
40
+ export function continuingRunNotice({ events, active }, _optional = {}) {
41
+ if (!active)
42
+ return null;
43
+ const list = events ?? [];
44
+ const marker = list.findLastIndex((event) => event.kind === 'status' && event.code === 'run_recovering');
45
+ if (marker < 0)
46
+ return null;
47
+ const progressed = list.slice(marker + 1).some((event) => event.kind === 'text' || event.kind === 'tool_use' || event.kind === 'tool_result');
48
+ return progressed ? null : 'Continuing…';
49
+ }
50
+ export function preserveAssistantContent({ saved, incoming }, _optional = {}) {
51
+ return incoming.length >= saved.length ? incoming : saved;
52
+ }
53
+ /** Terminal failure belongs to the message. Legacy resend instructions are display-only;
54
+ * changing this copy must never execute or reopen an old terminal run. */
55
+ export function terminalMessageNotice({ message }, _optional = {}) {
56
+ if (message.runStatus !== 'failed' && message.runStatus !== 'canceled')
57
+ return null;
58
+ const notice = message.events?.filter((event) => event.kind === 'status' &&
59
+ (event.code === 'run_terminal' || /failed|stopped|restarted|could not|not logged in|cancell?ed/i.test(event.label))).at(-1);
60
+ if (notice?.kind === 'status')
61
+ return [notice.label, notice.detail].filter(Boolean).join(' ').replace(/Send your message again to retry\.?/gi, '').trim();
62
+ return message.runStatus === 'failed' ? 'This turn failed.' : 'Stopped.';
63
+ }
64
+ /** The first answer has no earlier segment to divide, even if startup was retried. Only saved
65
+ * visible text can justify a Continued divider; the recovery marker still records the attempt. */
66
+ export function recoveredRunEvents({ saved }, _optional = {}) {
67
+ const hasAnswer = saved.some(event => event.kind === 'text' && event.text.trim().length > 0);
68
+ return [...saved, ...(hasAnswer ? [{ kind: 'text', text: '\n\n---\n\nContinued\n\n' }] : []),
69
+ { kind: 'status', code: 'run_recovering', label: 'Continuing…' }];
70
+ }
71
+ //# sourceMappingURL=durable-projection.js.map
@@ -42,4 +42,7 @@ export * from './agentic/index.js';
42
42
  * module, and a host binds it to its own authentication.
43
43
  */
44
44
  export * from './persistence/index.js';
45
+ export { recoveredRunEvents } from './durable-projection.js';
46
+ export { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from './composer-history.js';
47
+ export type { ComposerHistoryKeyInput, ComposerHistoryState, ComposerHistoryStoragePort } from './composer-history.js';
45
48
  //# sourceMappingURL=index.d.ts.map
@@ -42,4 +42,6 @@ export * from './agentic/index.js';
42
42
  * module, and a host binds it to its own authentication.
43
43
  */
44
44
  export * from './persistence/index.js';
45
+ export { recoveredRunEvents } from './durable-projection.js';
46
+ export { composerHistoryKeyAction, createComposerHistoryState, mergeComposerHistory, normalizeComposerHistory, transitionComposerHistory, } from './composer-history.js';
45
47
  //# sourceMappingURL=index.js.map
@@ -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
  *
@@ -30,6 +30,8 @@ import type { ChatAttachment, ChatMessage, ChatRunStatus } from './messages.js';
30
30
  * via `extends`.
31
31
  */
32
32
  export interface RunHandlers {
33
+ /** Authoritative saved projection; may advance an attempt without ending its message. */
34
+ onCheckpoint?: (message: ChatMessage) => void;
33
35
  /** Fired once per renderable unit of agent output. */
34
36
  onEvent: (ev: AgentEvent) => void;
35
37
  /**
@@ -50,6 +52,8 @@ export interface RunHandlers {
50
52
  */
51
53
  export type RunContext = Record<string, unknown>;
52
54
  export interface StartRunInput {
55
+ /** The logical assistant message identity, allocated before acceptance. */
56
+ assistantMessageId?: string;
53
57
  history: ChatMessage[];
54
58
  agentId?: string;
55
59
  conversationId?: string | null;
@@ -15,6 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  import { useEffect, useLayoutEffect, useRef, useState, } from 'react';
16
16
  import { RemixIcon } from '@jini-ai/ui';
17
17
  import { useT } from '../hooks/context.js';
18
+ import { useComposerHistoryKeys } from '../hooks/useComposerHistoryKeys.hooks.js';
18
19
  import { AttachmentTray } from './AttachmentTray.js';
19
20
  import { ComposerDiscoveryMenu, ComposerSlashMenu } from './ComposerDiscovery.js';
20
21
  import { appendComposerDiscovery, composerDiscoveryMenuPosition, composerSlashMenuPosition, filterComposerDiscovery, parseComposerSlashQuery, replaceComposerSlashTrigger, resolveComposerSlashInvocation, resolveComposerSlashKeyAction, } from './composer-discovery.js';
@@ -103,6 +104,9 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
103
104
  * point the shrinking list is its own feedback.
104
105
  */
105
106
  const showSlashFilterHint = slashQuery !== null && slashQuery.command === '' && slashQuery.argument === null;
107
+ const handleHistoryKey = useComposerHistoryKeys({ composer, textareaRef }, {
108
+ popupOpen: slashOpen || discoveryMenuOpen || workdirOpen || composer.mention.open,
109
+ });
106
110
  function restoreComposerFocus() {
107
111
  textareaRef.current?.focus();
108
112
  }
@@ -286,6 +290,8 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
286
290
  return;
287
291
  }
288
292
  }
293
+ if (handleHistoryKey(event))
294
+ return;
289
295
  if (event.key !== 'Enter' || event.shiftKey)
290
296
  return;
291
297
  event.preventDefault();