@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.
- package/CHANGELOG.md +18 -0
- package/dist/core/composer-history.d.ts +54 -0
- package/dist/core/composer-history.js +57 -0
- package/dist/core/durable-projection.d.ts +28 -0
- package/dist/core/durable-projection.js +71 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +2 -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/core/transport.d.ts +4 -0
- package/dist/react/components/Composer.js +6 -0
- 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 +26 -11
- 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/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +5 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +3 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +42 -9
- package/dist/react/features/chat-pane/rules.d.ts +6 -3
- package/dist/react/features/chat-pane/rules.js +6 -3
- package/dist/react/features/chat-pane/types.d.ts +5 -0
- 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/composer-history-storage.d.ts +8 -0
- package/dist/react/hooks/composer-history-storage.js +40 -0
- 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/useComposer.d.ts +10 -1
- package/dist/react/hooks/useComposer.js +14 -2
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
- package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
- package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
- package/dist/react/hooks/useConversation.js +6 -2
- package/dist/react/hooks/useRunStream.js +12 -2
- package/dist/react/hooks/useToolTimeline.js +6 -3
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +6 -0
- 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
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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
|
package/dist/core/index.js
CHANGED
|
@@ -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
|
|
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
package/dist/core/transport.d.ts
CHANGED
|
@@ -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();
|