@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
|
@@ -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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
|
|
2
3
|
/**
|
|
3
4
|
* @module MessageRow
|
|
4
5
|
*
|
|
@@ -50,7 +51,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
50
51
|
* chunk stopped arriving.
|
|
51
52
|
*/
|
|
52
53
|
import React, { useState } from 'react';
|
|
53
|
-
import { isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
|
|
54
|
+
import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
|
|
54
55
|
import { useToolTimeline } from '../hooks/useToolTimeline.js';
|
|
55
56
|
import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
|
|
56
57
|
import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
|
|
@@ -120,6 +121,23 @@ function UsageSummary({ usage }) {
|
|
|
120
121
|
return null;
|
|
121
122
|
return (_jsxs("div", { className: "jini-message-usage", children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t('Done'), " \u00B7 ", parts.join(' · ')] }));
|
|
122
123
|
}
|
|
124
|
+
function messageBlocks({ visibleContent, message, rows }, _optional = {}) {
|
|
125
|
+
// Interleaving is skipped outright when an artifact was stripped. `stripArtifact` operates on the
|
|
126
|
+
// whole string and trims, so the surviving text no longer lines up with the per-run offsets the
|
|
127
|
+
// event walk produces, and an artifact block could legitimately span a tool call. Rather than
|
|
128
|
+
// reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
|
|
129
|
+
// messages are dominated by the artifact panel anyway, so the ordering matters least there.
|
|
130
|
+
if (visibleContent !== message.content)
|
|
131
|
+
return null;
|
|
132
|
+
return interleaveMessageBlocks({ events: message.events, content: message.content, rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) });
|
|
133
|
+
}
|
|
134
|
+
function messageAwaitingAnswer({ runStreaming, runInProgress, events }, _optional = {}) {
|
|
135
|
+
return runStreaming === true && runInProgress && isAwaitingAnswer({ events });
|
|
136
|
+
}
|
|
137
|
+
function AssistantMessageFooter({ message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent }, _optional = {}) {
|
|
138
|
+
const t = useT();
|
|
139
|
+
return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent }) : null, terminalNotice ? _jsx("div", { className: "jini-message-error", role: "status", children: t(terminalNotice) }) : null, !pending && !runInProgress ? (_jsx("div", { className: "jini-message-actions jini-message-actions--assistant", children: _jsx(CopyMessageButton, { text: visibleContent, label: t('Copy message') }) })) : null, runInProgress ? _jsx(RunActivityLine, { events: message.events, active: pending || (runStreaming === true && runInProgress) }) : null] });
|
|
140
|
+
}
|
|
123
141
|
/**
|
|
124
142
|
* Quiet, icon-only copy affordance shared by the user- and assistant-message
|
|
125
143
|
* action rows below. Delegates the actual clipboard write (and its
|
|
@@ -147,19 +165,14 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
147
165
|
if (message.role === 'user') {
|
|
148
166
|
return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [message.attachments && message.attachments.length > 0 ? (_jsx("div", { className: "jini-message-attachments", children: message.attachments.map((a) => (_jsx("button", { type: "button", className: "jini-message-attachment-chip", onClick: () => setOpenAttachment(a), title: t('Open {name}', { name: a.name }), "aria-label": t('Open {name}', { name: a.name }), children: renderAttachment ? renderAttachment(a) : a.name }, a.path))) })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), openAttachment ? (_jsx(AttachmentPreviewModal, { attachment: openAttachment, onClose: () => setOpenAttachment(null) })) : null] }));
|
|
149
167
|
}
|
|
168
|
+
const terminalNotice = terminalMessageNotice({ message }, {});
|
|
150
169
|
const visibleContent = stripArtifact({ content: message.content });
|
|
151
170
|
const segments = splitOnQuestionForms({ input: visibleContent });
|
|
152
171
|
const usageEvent = message.events?.filter((ev) => ev.kind === 'usage').pop();
|
|
153
|
-
|
|
154
|
-
// whole string and trims, so the surviving text no longer lines up with the per-run offsets the
|
|
155
|
-
// event walk produces, and an artifact block could legitimately span a tool call. Rather than
|
|
156
|
-
// reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
|
|
157
|
-
// messages are dominated by the artifact panel anyway, so the ordering matters least there.
|
|
158
|
-
const blocks = visibleContent === message.content
|
|
159
|
-
? interleaveMessageBlocks({ events: message.events, content: message.content, rows: timeline.rows }, { slotOf: (ev) => extEventSlot({ name: ev.name, data: ev.data }) })
|
|
160
|
-
: null;
|
|
172
|
+
const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows }, {});
|
|
161
173
|
const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
|
|
162
174
|
const runInProgress = isRunInProgress(message.runStatus);
|
|
175
|
+
const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, {});
|
|
163
176
|
const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
|
|
164
177
|
const renderExtGroup = (group) => {
|
|
165
178
|
const renderer = getExtEventRenderer({ name: group.name });
|
|
@@ -170,6 +183,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
170
183
|
events: group.events,
|
|
171
184
|
runStreaming,
|
|
172
185
|
runSucceeded,
|
|
186
|
+
awaitingAnswer,
|
|
173
187
|
runId: message.runId,
|
|
174
188
|
...(group.call === undefined ? {} : { call: group.call }),
|
|
175
189
|
});
|
|
@@ -195,7 +209,7 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
195
209
|
return null;
|
|
196
210
|
return (_jsx("div", { className: "jini-message-ext-events", children: renderExtGroup(group) }, block.key));
|
|
197
211
|
})
|
|
198
|
-
: (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })),
|
|
212
|
+
: (_jsxs(_Fragment, { children: [segments.map((segment, i) => segment.kind === 'text' ? (segment.text.trim() ? (_jsx("div", { className: "jini-message-content", children: _jsx(Markdown, { children: segment.text }) }, i)) : null) : (_jsx(QuestionForm, { form: segment.form, interactive: questionFormInteractive, ...(questionFormSubmittedAnswers !== undefined ? { submittedAnswers: questionFormSubmittedAnswers } : {}), ...(onQuestionFormSubmit !== undefined ? { onSubmit: onQuestionFormSubmit } : {}) }, i))), timeline.rows.length > 0 ? (_jsx("div", { className: "jini-message-tools", children: timeline.rows.map(renderToolCard) })) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent, terminalNotice, pending, runInProgress, runStreaming, visibleContent })] }));
|
|
199
213
|
}
|
|
200
214
|
/**
|
|
201
215
|
* The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
|
|
@@ -203,7 +217,8 @@ export function MessageRow({ message, runStreaming = false, runSucceeded = false
|
|
|
203
217
|
* Kept a separate component so its one-second tick re-renders only this line, not the whole row.
|
|
204
218
|
*/
|
|
205
219
|
function RunActivityLine({ events, active }) {
|
|
206
|
-
const
|
|
220
|
+
const activityLabel = useRunActivity({ events: events, active: active });
|
|
221
|
+
const label = continuingRunNotice({ events, active }, {}) ?? activityLabel;
|
|
207
222
|
if (label === null)
|
|
208
223
|
return null;
|
|
209
224
|
return (_jsx("div", { className: "jini-message-pending jini-run-activity", "aria-live": "off", children: label }));
|
|
@@ -21,14 +21,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
* generic rendering for it.
|
|
22
22
|
*/
|
|
23
23
|
import { useState } from 'react';
|
|
24
|
-
import { formatToolOutputForDisplay, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
|
|
24
|
+
import { formatToolOutputForDisplay, isDelegatedWrapperToolName, isTodoWriteToolName, parseTodoWriteInput, toRenderProps } from '../../core/index.js';
|
|
25
25
|
import { useT } from '../hooks/context.js';
|
|
26
26
|
import { getToolRenderer } from '../tool-renderer-registry.js';
|
|
27
27
|
import { Icon } from './Icon.js';
|
|
28
28
|
import { TodoCard } from './TodoCard.js';
|
|
29
29
|
/** A Jini registry tool id: dotted, lowercase, snake_case segments — `page.fill`, `daemon.db.vacuum`, `chat.send_message`. Never matches an agent-vendor tool name (`Bash`, `Write`, `mcp__jini__execute_delegated_tool`), which is the point — this is how a canonical delegated-tool event is told apart from everything else. */
|
|
30
30
|
const JINI_TOOL_ID_PATTERN = /^[a-z][a-z_]*(\.[a-z][a-z_]*)+$/;
|
|
31
|
-
const EXECUTE_DELEGATED_TOOL_NAMES = new Set(['execute_delegated_tool', 'mcp__jini__execute_delegated_tool']);
|
|
32
31
|
const SEARCH_TOOLS_NAMES = new Set(['search_tools', 'mcp__jini__search_tools']);
|
|
33
32
|
const DESCRIBE_TOOL_NAMES = new Set(['describe_tool', 'mcp__jini__describe_tool']);
|
|
34
33
|
export function ToolCard({ use, result, runStreaming, runSucceeded, projectFileNames, onRequestOpenFile }) {
|
|
@@ -64,8 +63,12 @@ function renderToolCardBody(name, use, result, isStreaming, isSucceeded, fileCtx
|
|
|
64
63
|
// action twice. Suppressed only on a *successful* result: a wrapper-level failure (a genuine
|
|
65
64
|
// transport error, not a tool-execution failure — those come back as a normal 'completed' result
|
|
66
65
|
// with status:'failed' inside it) has no matching canonical row to fall back on, so it must stay
|
|
67
|
-
// visible rather than disappear silently.
|
|
68
|
-
|
|
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. */
|
|
@@ -6,5 +6,5 @@ import type { ChatPaneProps } from '../types.js';
|
|
|
6
6
|
* @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
|
|
7
7
|
* @overallScore 100/100
|
|
8
8
|
*/
|
|
9
|
-
export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
|
|
9
|
+
export declare function ChatPane({ transport, agents: injectedAgents, runtimeAccess, runtimeStatusPollMs, title, variant, initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents, daemonOnline, executionMode, apiModeAvailable, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions, workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement, projectFileNames, uploadAttachments, attachmentAccept, disabled, runtimePickerPlacement, composerSlots, header, leadingAccessory, footer, className, style, }: ChatPaneProps): import("react").JSX.Element;
|
|
10
10
|
//# sourceMappingURL=ChatPane.d.ts.map
|
|
@@ -73,8 +73,8 @@ function ChatPaneNoUsableCliMessage({ executionMode, apiModeAvailable, onExecuti
|
|
|
73
73
|
}
|
|
74
74
|
/** The pane's stack of mutually-independent error/status banners — each condition is its own
|
|
75
75
|
* source of truth, so they are rendered as siblings rather than folded into one derived state. */
|
|
76
|
-
function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange,
|
|
77
|
-
return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null,
|
|
76
|
+
function ChatPaneStatusMessages({ unavailable, scanningAgents, connectingAgents, executionMode, apiModeAvailable, onExecutionModeChange, attachmentError, dropReadError, workingDirectoryError, workingDirectoryPending, workingDirectoryInvalid, runtimeInventoryError, typedAnswerNotice, onSendAsNewMessage, t, }) {
|
|
77
|
+
return (_jsxs(_Fragment, { children: [unavailable && connectingAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Connecting to the assistant…') })) : unavailable && scanningAgents ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Loading available CLIs') })) : unavailable ? (_jsx(ChatPaneNoUsableCliMessage, { executionMode: executionMode, apiModeAvailable: apiModeAvailable, onExecutionModeChange: onExecutionModeChange, t: t })) : null, attachmentError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: attachmentError.message })) : null, dropReadError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: dropReadError })) : null, workingDirectoryError ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: workingDirectoryError.message })) : null, workingDirectoryPending ? (_jsx("div", { className: "jini-chat-pane__status", role: "status", children: t('Checking working directory…') })) : workingDirectoryInvalid ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: t('Working directory is unavailable.') })) : null, runtimeInventoryError && !connectingAgents ? (_jsx("div", { className: "jini-chat-pane__error", role: "alert", children: runtimeInventoryError.message })) : null, typedAnswerNotice ? (_jsxs("div", { className: "jini-chat-pane__error", role: "alert", children: [t(describeTypedAnswerNotice({ notice: typedAnswerNotice })), typedAnswerNotice === 'not-pending' ? (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: "jini-chat-pane__error-action", onClick: onSendAsNewMessage, children: t('Send as a new message') })] })) : null] })) : null] }));
|
|
78
78
|
}
|
|
79
79
|
function ChatPaneWorkingDirectoryBlock({ workingDirectoryAccess, workingDirectoryControlPlacement, pane, t, }) {
|
|
80
80
|
if (workingDirectoryAccess && workingDirectoryControlPlacement === 'below') {
|
|
@@ -178,7 +178,7 @@ function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, una
|
|
|
178
178
|
* @complexity Time/space: O(n) in rendered messages, agents, and suggestions.
|
|
179
179
|
* @overallScore 100/100
|
|
180
180
|
*/
|
|
181
|
-
export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
|
|
181
|
+
export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, runtimeAccess, runtimeStatusPollMs = 5_000, title, variant = 'workspace', initialMessages, conversationId, initialSelection, selection: controlledSelection, onSelectionChange, runContext, agentControl, onActivityChange, onMessagesChange, onRescanAgents, scanningAgents = false, daemonOnline = true, executionMode = 'local', apiModeAvailable = false, onExecutionModeChange, byokRuntime, onByokModelChange, initialDraft, composerHistoryScope, composerHistoryStorage, validateAttachments, deliverTypedAnswer, composerHandle, placeholder, placeholders, suggestions = [], workingDirectory, initialWorkingDirectory, onChangeWorkingDirectory, workingDirectoryAccess, workingDirectoryControlPlacement = 'below', projectFileNames, uploadAttachments, attachmentAccept, disabled = false, runtimePickerPlacement = 'up', composerSlots, header, leadingAccessory, footer, className, style, }) {
|
|
182
182
|
const t = useT();
|
|
183
183
|
const composerPlaceholder = useChatPaneComposerPlaceholder({ placeholder: placeholder, placeholders: placeholders });
|
|
184
184
|
const inventory = useChatPaneRuntimeInventory(definedProps({ source: {
|
|
@@ -201,6 +201,8 @@ export function ChatPane({ transport, agents: injectedAgents = EMPTY_AGENTS, run
|
|
|
201
201
|
onSelectionChange,
|
|
202
202
|
runContext,
|
|
203
203
|
initialDraft,
|
|
204
|
+
composerHistoryScope,
|
|
205
|
+
composerHistoryStorage,
|
|
204
206
|
validateAttachments,
|
|
205
207
|
uploadAttachments,
|
|
206
208
|
onActivityChange,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ChatPaneSendBlocker } from '../rules.js';
|
|
2
2
|
import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
|
|
3
3
|
import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
|
|
4
|
+
import type { ComposerHistoryStoragePort } from '../../../../core/composer-history.js';
|
|
4
5
|
import type { ChatTransport } from '@jini-ai/chat';
|
|
5
6
|
import { type UseComposerResult } from '../../../hooks/useComposer.js';
|
|
6
7
|
import { type UseConversationResult } from '../../../hooks/useConversation.js';
|
|
@@ -15,6 +16,8 @@ export interface UseChatPaneOptions {
|
|
|
15
16
|
onSelectionChange?: (selection: ChatPaneAgentSelection) => void;
|
|
16
17
|
runContext?: ChatPaneRunContext;
|
|
17
18
|
initialDraft?: string;
|
|
19
|
+
composerHistoryScope?: string;
|
|
20
|
+
composerHistoryStorage?: ComposerHistoryStoragePort;
|
|
18
21
|
/** Forwarded verbatim to `useComposer`; see `validateAttachments` there for the contract. */
|
|
19
22
|
validateAttachments?: (attachments: readonly ChatAttachment[]) => Promise<readonly ChatAttachment[]>;
|
|
20
23
|
uploadAttachments?: (files: File[], options?: ChatPaneAttachmentUploadOptions) => Promise<ChatAttachment[]>;
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import { describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, isChatPaneQueueableBlocker, isTypedAnswerTurn, resolveChatPaneSelection, } from '../rules.js';
|
|
3
3
|
import { definedProps } from '../../../util/defined-props.js';
|
|
4
4
|
import { cacheAttachmentPreviewSource } from '../../../hooks/attachment-preview-cache.js';
|
|
5
|
+
import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../../../hooks/composer-draft-cache.js';
|
|
5
6
|
import { useComposer } from '../../../hooks/useComposer.js';
|
|
6
7
|
import { useConversation, } from '../../../hooks/useConversation.js';
|
|
7
8
|
import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
|
|
@@ -96,7 +97,12 @@ export function useChatPane(options) {
|
|
|
96
97
|
const queuedConversationIdRef = useRef(undefined);
|
|
97
98
|
const mountedRef = useRef(true);
|
|
98
99
|
const attachmentGenerationRef = useRef(0);
|
|
99
|
-
|
|
100
|
+
// Every attachment staged for one turn must share one upload batch (the daemon refuses a turn
|
|
101
|
+
// spanning two). So a mount that will restore cached attachments starts in THEIR batch rather
|
|
102
|
+
// than a new one; see `readCachedAttachmentBatchId`.
|
|
103
|
+
const [initialAttachmentBatchId] = useState(() => readCachedAttachmentBatchId({ conversationId: options.conversationId }) ?? createAttachmentBatchId());
|
|
104
|
+
const attachmentBatchIdRef = useRef(initialAttachmentBatchId);
|
|
105
|
+
const batchConversationIdRef = useRef(options.conversationId);
|
|
100
106
|
const activeUploadsRef = useRef(new Set());
|
|
101
107
|
const [internalSelection, setInternalSelection] = useState(options.initialSelection ?? { agentId: '' });
|
|
102
108
|
const workingDirectoryState = useChatPaneWorkingDirectory(definedProps({ source: {
|
|
@@ -108,12 +114,6 @@ export function useChatPane(options) {
|
|
|
108
114
|
const requestedSelection = options.selection ?? internalSelection;
|
|
109
115
|
const selection = useMemo(() => resolveChatPaneSelection({ agents: options.agents, requested: requestedSelection }), [options.agents, requestedSelection]);
|
|
110
116
|
const selectedAgent = options.agents.find((agent) => agent.id === selection.agentId);
|
|
111
|
-
const composer = useComposer(definedProps({ source: {
|
|
112
|
-
initialDraft: options.initialDraft,
|
|
113
|
-
initialAgent: selection,
|
|
114
|
-
conversationId: options.conversationId,
|
|
115
|
-
validateAttachments: options.validateAttachments,
|
|
116
|
-
} }));
|
|
117
117
|
const conversation = useConversation(definedProps({ source: {
|
|
118
118
|
transport: options.transport,
|
|
119
119
|
initialMessages: options.initialMessages,
|
|
@@ -122,6 +122,15 @@ export function useChatPane(options) {
|
|
|
122
122
|
// absent), so only the falsy "no agent selected" case should omit the key.
|
|
123
123
|
agentId: selection.agentId || undefined,
|
|
124
124
|
} }));
|
|
125
|
+
const composer = useComposer(definedProps({ source: {
|
|
126
|
+
initialDraft: options.initialDraft,
|
|
127
|
+
historyMessages: conversation.messages,
|
|
128
|
+
historyScope: options.composerHistoryScope,
|
|
129
|
+
historyStorage: options.composerHistoryStorage,
|
|
130
|
+
initialAgent: selection,
|
|
131
|
+
conversationId: options.conversationId,
|
|
132
|
+
validateAttachments: options.validateAttachments,
|
|
133
|
+
} }));
|
|
125
134
|
const activity = resolveChatPaneActivity(selectedAgent, conversation);
|
|
126
135
|
useEffect(() => {
|
|
127
136
|
options.onActivityChange?.(activity);
|
|
@@ -199,10 +208,31 @@ export function useChatPane(options) {
|
|
|
199
208
|
typedAnswerGenerationRef.current += 1;
|
|
200
209
|
heldAnswerQuestionIdRef.current = null;
|
|
201
210
|
}, [options.conversationId]);
|
|
211
|
+
// A conversation switch without a remount: adopt the batch of the attachments the composer is
|
|
212
|
+
// about to restore for the new conversation, else start a fresh one so two conversations never
|
|
213
|
+
// share a batch directory (the agent is granted the whole directory, not just the claimed files).
|
|
214
|
+
// An id arriving where there was none is the same conversation finally getting a key — the
|
|
215
|
+
// composer keeps what is staged (see `useComposer`), so the batch stays too.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const previousConversationId = batchConversationIdRef.current;
|
|
218
|
+
if (options.conversationId === previousConversationId)
|
|
219
|
+
return;
|
|
220
|
+
batchConversationIdRef.current = options.conversationId;
|
|
221
|
+
const restoredBatchId = readCachedAttachmentBatchId({ conversationId: options.conversationId });
|
|
222
|
+
if (restoredBatchId !== null) {
|
|
223
|
+
attachmentBatchIdRef.current = restoredBatchId;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (previousConversationId == null)
|
|
227
|
+
return;
|
|
228
|
+
attachmentBatchIdRef.current = createAttachmentBatchId();
|
|
229
|
+
}, [options.conversationId]);
|
|
202
230
|
const addAttachments = useCallback(async (files) => {
|
|
203
231
|
if (files.length === 0 || options.uploadAttachments === undefined)
|
|
204
232
|
return;
|
|
205
233
|
const generation = attachmentGenerationRef.current;
|
|
234
|
+
const batchId = attachmentBatchIdRef.current;
|
|
235
|
+
const conversationId = options.conversationId;
|
|
206
236
|
const controller = new AbortController();
|
|
207
237
|
activeUploadsRef.current.add(controller);
|
|
208
238
|
setActiveUploadCount(activeUploadsRef.current.size);
|
|
@@ -211,7 +241,7 @@ export function useChatPane(options) {
|
|
|
211
241
|
await uploadAttachmentBatch(files, {
|
|
212
242
|
upload: options.uploadAttachments,
|
|
213
243
|
signal: controller.signal,
|
|
214
|
-
batchId
|
|
244
|
+
batchId,
|
|
215
245
|
stillWanted: () => mountedRef.current
|
|
216
246
|
&& !controller.signal.aborted
|
|
217
247
|
&& generation === attachmentGenerationRef.current,
|
|
@@ -221,6 +251,9 @@ export function useChatPane(options) {
|
|
|
221
251
|
// `attachment-preview-cache.ts`'s module doc for why this is the only place that copy can
|
|
222
252
|
// ever be captured.
|
|
223
253
|
onAttachment: (attachment, file) => {
|
|
254
|
+
// Recorded before staging so the cache entry that persists this attachment carries its
|
|
255
|
+
// batch, which is what lets a later mount restore the batch along with the attachment.
|
|
256
|
+
writeCachedAttachmentBatchId({ conversationId, batchId });
|
|
224
257
|
composer.addAttachment(attachment);
|
|
225
258
|
if (file)
|
|
226
259
|
cacheAttachmentPreviewSource({ path: attachment.path, file });
|
|
@@ -233,7 +266,7 @@ export function useChatPane(options) {
|
|
|
233
266
|
setActiveUploadCount(activeUploadsRef.current.size);
|
|
234
267
|
}
|
|
235
268
|
}
|
|
236
|
-
}, [composer, options.uploadAttachments]);
|
|
269
|
+
}, [composer, options.conversationId, options.uploadAttachments]);
|
|
237
270
|
const sendPrompt = useCallback(async (prompt) => {
|
|
238
271
|
const trimmed = prompt.trim();
|
|
239
272
|
if (!trimmed)
|
|
@@ -83,9 +83,12 @@ export declare function isChatPaneQueueableBlocker({ blocker }: {
|
|
|
83
83
|
* Only the question tool counts. Other tools park on cards too — a delete confirm, an approval —
|
|
84
84
|
* but they take no typed answer: the server routes typed text only to the question tool, so treating
|
|
85
85
|
* those as waiting would refuse the text with "no longer waiting" and drop it instead of queueing.
|
|
86
|
-
* A call is the question tool when ANY `tool_use` for its id names it, since
|
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
|
2
2
|
import type { ChatAttachment, ChatMessage } from '@jini-ai/chat';
|
|
3
3
|
import type { ComposerSlots } from '../../slots.js';
|
|
4
|
+
import type { ComposerHistoryStoragePort } from '../../../core/composer-history.js';
|
|
4
5
|
import type { ChatTransport, RunContext } from '@jini-ai/chat';
|
|
5
6
|
export interface ChatPaneAgentOption {
|
|
6
7
|
id: string;
|
|
@@ -172,6 +173,10 @@ export interface ChatPaneProps {
|
|
|
172
173
|
/** Passed straight through to the runtime picker. */
|
|
173
174
|
onByokModelChange?: (model: string) => void;
|
|
174
175
|
initialDraft?: string;
|
|
176
|
+
/** Principal-scoped recent prompt recall; defaults to the origin-local default scope. */
|
|
177
|
+
composerHistoryScope?: string;
|
|
178
|
+
/** Optional storage effects; omitted uses bounded browser storage with memory fallback. */
|
|
179
|
+
composerHistoryStorage?: ComposerHistoryStoragePort;
|
|
175
180
|
/**
|
|
176
181
|
* Confirms which previously staged attachments still exist, so a draft restored after a reload
|
|
177
182
|
* never shows a chip for a file the backend has since garbage-collected — that looks intact and
|
|
@@ -115,6 +115,34 @@ export declare function clearCachedDraft({ conversationId }: {
|
|
|
115
115
|
export declare function readCachedAttachments({ conversationId }: {
|
|
116
116
|
conversationId: string | null | undefined;
|
|
117
117
|
}): readonly ChatAttachment[] | null;
|
|
118
|
+
/**
|
|
119
|
+
* The upload batch `conversationId`'s cached attachments were uploaded under, so a pane that
|
|
120
|
+
* restores those attachments uploads the next file into the SAME batch.
|
|
121
|
+
*
|
|
122
|
+
* Why this exists: the daemon grants the agent exactly one batch directory per turn and refuses a
|
|
123
|
+
* turn whose attachments span two ("Attachments must belong to one batch"). A pane that restored an
|
|
124
|
+
* attachment after a remount, reload, or conversation switch used to upload the next file under a
|
|
125
|
+
* brand-new batch id, so every such turn failed before the agent started (2026-10-06).
|
|
126
|
+
*
|
|
127
|
+
* @returns The batch id, or `null` when there are no cached attachments to stay consistent with
|
|
128
|
+
* (none staged, attachment persistence off because the host supplied no validator, or an entry
|
|
129
|
+
* written before batch ids were recorded) — the caller then starts a fresh batch.
|
|
130
|
+
* @complexity Time/space: O(1) on an in-memory hit, O(n) in the stored count otherwise.
|
|
131
|
+
*/
|
|
132
|
+
export declare function readCachedAttachmentBatchId({ conversationId }: {
|
|
133
|
+
conversationId: string | null | undefined;
|
|
134
|
+
}): string | null;
|
|
135
|
+
/**
|
|
136
|
+
* Records the upload batch of an attachment about to be staged in `conversationId`'s composer.
|
|
137
|
+
* Memory only: the stored envelope picks it up on the {@link writeCachedAttachments} that persists
|
|
138
|
+
* that attachment, so it is never written ahead of the references it describes.
|
|
139
|
+
*
|
|
140
|
+
* @complexity Time/space: O(1) amortized.
|
|
141
|
+
*/
|
|
142
|
+
export declare function writeCachedAttachmentBatchId({ conversationId, batchId }: {
|
|
143
|
+
conversationId: string | null | undefined;
|
|
144
|
+
batchId: string;
|
|
145
|
+
}): void;
|
|
118
146
|
/**
|
|
119
147
|
* Stores `attachments` for `conversationId` in both tiers.
|
|
120
148
|
*
|