@jini-ai/chat 0.6.12 → 0.6.15-rc.1
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 +30 -0
- package/dist/contracts/chat-bubble-style.d.ts +3 -2
- package/dist/contracts/chat-bubble-style.js +3 -2
- package/dist/core/accepted-input.d.ts +20 -1
- package/dist/core/accepted-input.js +65 -17
- package/dist/core/ag-ui/entry.d.ts +2 -4
- package/dist/core/ag-ui/entry.js +11 -5
- package/dist/core/assistant-client/ag-ui-transport.js +6 -1
- package/dist/core/assistant-client/durable-run-subscription.d.ts +2 -1
- package/dist/core/assistant-client/durable-run-subscription.js +3 -1
- package/dist/core/assistant-client/transport.js +5 -1
- package/dist/core/assistant-content.d.ts +8 -1
- package/dist/core/assistant-content.js +17 -4
- package/dist/core/durable-runs/continuation.js +6 -2
- package/dist/core/events.d.ts +3 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/question-answer.d.ts +8 -0
- package/dist/core/question-answer.js +16 -4
- package/dist/core/run-activity.d.ts +1 -1
- package/dist/core/run-activity.js +46 -4
- package/dist/core/run-events/entry.js +5 -2
- package/dist/embed-widget/vanilla/style.css +1 -1
- package/dist/react/components/CopyMessageButton.d.ts +18 -0
- package/dist/react/components/CopyMessageButton.js +22 -0
- package/dist/react/components/Markdown.js +24 -5
- package/dist/react/components/McpUiSurfaceCard.js +50 -17
- package/dist/react/components/MessageList.hooks.d.ts +2 -1
- package/dist/react/components/MessageList.hooks.js +14 -4
- package/dist/react/components/MessageList.js +5 -3
- package/dist/react/components/MessageRow.d.ts +3 -1
- package/dist/react/components/MessageRow.js +66 -38
- package/dist/react/components/MidRunUserMessage.d.ts +1 -1
- package/dist/react/components/MidRunUserMessage.js +11 -7
- package/dist/react/components/QuestionForm.js +22 -2
- package/dist/react/components/ToolCard.js +21 -8
- package/dist/react/components/mcp-ui-question-input.d.ts +20 -0
- package/dist/react/components/mcp-ui-question-input.js +72 -0
- package/dist/react/ext-event-renderer-registry.d.ts +2 -0
- package/dist/react/features/chat-pane/components/ChatPane.js +10 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +6 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +70 -15
- package/dist/react/hooks/useAnsweredCards.js +36 -20
- package/dist/react/hooks/useConversation.d.ts +6 -0
- package/dist/react/hooks/useConversation.js +10 -3
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +4 -0
- package/dist/react/message-blocks.d.ts +6 -0
- package/dist/react/message-blocks.js +27 -1
- package/dist/server/durable-runs/accept.d.ts +4 -0
- package/dist/server/durable-runs/accept.js +8 -1
- package/dist/server/durable-runs/recover.js +5 -4
- package/package.json +15 -15
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
import { a2uiSurfaceIdOf, displayOnlySurfaceIdOf } from '@jini-ai/agentic/a2ui';
|
|
21
21
|
import { isDelegatedWrapperToolName } from './tool-events.js';
|
|
22
22
|
import { MID_RUN_USER_MESSAGE_EVENT_NAME } from './turn-usage.js';
|
|
23
|
+
import { SURFACE_QUESTION_META_KEY } from './question-answer.js';
|
|
24
|
+
import { recordedSurfaceAnswers } from './accepted-input.js';
|
|
23
25
|
/** Tool names whose only job is finding the right tool (MCP tool search / schema lookup). */
|
|
24
26
|
const DISCOVERY_TOOL_NAMES = new Set([
|
|
25
27
|
'ToolSearch',
|
|
@@ -68,6 +70,8 @@ function isVisible(event) {
|
|
|
68
70
|
function isAnswerCard(scan, name, data) {
|
|
69
71
|
if (!CARD_EXT_NAMES.has(name))
|
|
70
72
|
return false;
|
|
73
|
+
if (name === 'mcp-ui' && scan.answeredSurfaces.has(data))
|
|
74
|
+
return false;
|
|
71
75
|
if (scan.isAnsweredCard?.({ name, data }) === true)
|
|
72
76
|
return false;
|
|
73
77
|
if (name !== A2UI_EXT_NAME)
|
|
@@ -92,25 +96,48 @@ function scanEvent(scan, event, index) {
|
|
|
92
96
|
// Queue acceptance/consumption is user input, not new agent activity. Neither restarts its clock.
|
|
93
97
|
if (event.kind === 'ext' && event.name === MID_RUN_USER_MESSAGE_EVENT_NAME)
|
|
94
98
|
return;
|
|
99
|
+
if (WORK_EVENT_KINDS.has(event.kind))
|
|
100
|
+
scan.lastWorkIndex = index;
|
|
95
101
|
if (isVisible(event)) {
|
|
96
102
|
scan.lastVisible = { index, kind: event.kind };
|
|
97
103
|
scan.visibleCount += 1;
|
|
98
104
|
}
|
|
105
|
+
// Starting tool work after an explicit question proves the run moved past it (expired, skipped or aborted
|
|
106
|
+
// without a receipt); otherwise its waiting line would outlive the question for the whole run.
|
|
99
107
|
if (event.kind === 'tool_use') {
|
|
108
|
+
scan.pendingQuestionIndex = -1;
|
|
100
109
|
scan.open = scan.open.filter((tool) => tool.id !== event.id);
|
|
101
110
|
scan.open.push({ id: event.id, name: event.name, input: event.input, index });
|
|
102
111
|
}
|
|
103
112
|
else if (event.kind === 'tool_result') {
|
|
113
|
+
// Completion may be unrelated background work or the ask tool returning before human input.
|
|
104
114
|
scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
|
|
105
115
|
}
|
|
106
116
|
else if (event.kind === 'ext') {
|
|
107
|
-
|
|
108
|
-
scan.lastCardIndex = index;
|
|
117
|
+
scanCard({ scan, event, index });
|
|
109
118
|
}
|
|
110
119
|
else if (event.kind === 'status') {
|
|
111
120
|
scanStatus({ scan, event, index });
|
|
112
121
|
}
|
|
113
122
|
}
|
|
123
|
+
/** Work after a standalone form proves the run has moved past that form. */
|
|
124
|
+
const WORK_EVENT_KINDS = new Set(['text', 'tool_use', 'tool_result']);
|
|
125
|
+
/** Standalone approval forms have no open tool call. Require interactive markup so outcome
|
|
126
|
+
* documents do not acquire a waiting status. Inspect text only; this never executes a document. */
|
|
127
|
+
function scanCard({ scan, event, index }, _options = {}) {
|
|
128
|
+
if (!isAnswerCard(scan, event.name, event.data))
|
|
129
|
+
return;
|
|
130
|
+
scan.lastCardIndex = index;
|
|
131
|
+
const resource = isRecord(event.data) ? event.data.resource : undefined;
|
|
132
|
+
const html = isRecord(resource) ? resource.text : undefined;
|
|
133
|
+
const meta = isRecord(resource) ? resource._meta : undefined;
|
|
134
|
+
// A producer can retain its question before flattening it into a document (including custom forms).
|
|
135
|
+
const question = isRecord(meta) ? meta[SURFACE_QUESTION_META_KEY] : undefined;
|
|
136
|
+
if (isRecord(question))
|
|
137
|
+
scan.pendingQuestionIndex = index;
|
|
138
|
+
if (typeof html === 'string' && /<form\b|data-mcpui-action\s*=/i.test(html))
|
|
139
|
+
scan.standaloneCardIndex = index;
|
|
140
|
+
}
|
|
114
141
|
/** `{ a: 1, b: undefined }` → `{ a: 1 }`, so optional activity fields are absent rather than undefined. */
|
|
115
142
|
function definedFields({ fields }, _optional = {}) {
|
|
116
143
|
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
@@ -146,6 +173,11 @@ function openToolSignal({ scan, newestOpen }, _optional = {}) {
|
|
|
146
173
|
};
|
|
147
174
|
}
|
|
148
175
|
function latestSignal({ scan }, _optional = {}) {
|
|
176
|
+
// Explicit standalone questions wait for their receipt; unrelated startup/model activity cannot
|
|
177
|
+
// establish that a person answered. Markup-only legacy cards retain their existing work heuristic.
|
|
178
|
+
if (scan.pendingQuestionIndex >= 0) {
|
|
179
|
+
return { activity: { kind: 'awaiting-answer' }, key: `card:${scan.pendingQuestionIndex}` };
|
|
180
|
+
}
|
|
149
181
|
const lastVisibleIndex = scan.lastVisible?.index ?? -1;
|
|
150
182
|
if (scan.retry && scan.retry.index > lastVisibleIndex) {
|
|
151
183
|
return { activity: retryingActivity({ data: scan.retry.data }), key: `retry:${lastVisibleIndex}` };
|
|
@@ -153,6 +185,14 @@ function latestSignal({ scan }, _optional = {}) {
|
|
|
153
185
|
const newestOpen = scan.open[scan.open.length - 1];
|
|
154
186
|
if (newestOpen)
|
|
155
187
|
return openToolSignal({ scan, newestOpen });
|
|
188
|
+
return idleSignal({ scan });
|
|
189
|
+
}
|
|
190
|
+
/** The no-open-tool fallback; legacy markup alone stops waiting when work follows it. O(1). */
|
|
191
|
+
function idleSignal({ scan }, _options = {}) {
|
|
192
|
+
const lastVisibleIndex = scan.lastVisible?.index ?? -1;
|
|
193
|
+
if (scan.standaloneCardIndex > scan.lastWorkIndex) {
|
|
194
|
+
return { activity: { kind: 'awaiting-answer' }, key: `card:${scan.standaloneCardIndex}` };
|
|
195
|
+
}
|
|
156
196
|
if (scan.lastVisible?.kind === 'text') {
|
|
157
197
|
return { activity: { kind: 'writing' }, key: `writing:${lastVisibleIndex}` };
|
|
158
198
|
}
|
|
@@ -166,10 +206,12 @@ function latestSignal({ scan }, _optional = {}) {
|
|
|
166
206
|
* card); the newest open tool call; else
|
|
167
207
|
* writing (last visible event is text) or thinking.
|
|
168
208
|
*
|
|
169
|
-
* @complexity O(n) in events, O(open tool calls) extra space.
|
|
209
|
+
* @complexity O(n) time in events, O(open tool calls + answered surfaces) extra space.
|
|
170
210
|
*/
|
|
171
211
|
export function deriveRunActivity({ events }, { isAnsweredCard } = {}) {
|
|
172
|
-
|
|
212
|
+
// A replayed standalone receipt closes only the document it followed, not every use of its URI.
|
|
213
|
+
const answeredSurfaces = recordedSurfaceAnswers({ events: events ?? [] });
|
|
214
|
+
const scan = { open: [], lastVisible: null, lastCardIndex: -1, standaloneCardIndex: -1, pendingQuestionIndex: -1, lastWorkIndex: -1, retry: null, progress: null, visibleCount: 0, displayOnlySurfaces: new Set(), isAnsweredCard, answeredSurfaces };
|
|
173
215
|
(events ?? []).forEach((event, index) => scanEvent(scan, event, index));
|
|
174
216
|
return { ...latestSignal({ scan }), visibleCount: scan.visibleCount };
|
|
175
217
|
}
|
|
@@ -107,6 +107,8 @@ function toolResultEvent(payload) {
|
|
|
107
107
|
// is a rendering concern (`ToolCard`'s own `ToolResultMedia` already ignores anything that
|
|
108
108
|
// isn't a recognized block), not a transport one.
|
|
109
109
|
...(Array.isArray(payload.media) ? { media: payload.media } : {}),
|
|
110
|
+
// Saved into events_json so a recovered run still blocks a repeat of this call.
|
|
111
|
+
...(payload.outcome === "unknown" ? { outcome: "unknown" } : {}),
|
|
110
112
|
};
|
|
111
113
|
}
|
|
112
114
|
/** The payload types with no chat-core event of their own: UI envelopes, dropped types, and `ext`. */
|
|
@@ -559,8 +561,9 @@ function agentFrameEvents(raw) {
|
|
|
559
561
|
// host's start refusals reach the user.
|
|
560
562
|
function errorFrameOutcome(raw) {
|
|
561
563
|
const frame = parseWire(raw);
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
+
const payload = frame?.payload;
|
|
565
|
+
const message = wireString(payload?.message) || "agent run failed";
|
|
566
|
+
return { events: [{ kind: "status", label: message, ...(typeof payload?.error?.code === "string" ? { code: payload.error.code } : {}) }], error: new Error(message) };
|
|
564
567
|
}
|
|
565
568
|
function isRecord(value) {
|
|
566
569
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:grid;place-items:center}
|
|
1
|
+
.jini-chat-vanilla{display:var(--jini-floating-ui-display,block)}.jini-chat-bubble{position:fixed;right:var(--jini-embed-right,24px);bottom:24px;width:56px;height:56px;border:0;border-radius:50%;background:var(--jini-chat-accent,var(--color-primary,#2563eb));color:var(--jini-chat-accent-text,#fff);box-shadow:0 4px 16px #0003;cursor:pointer;z-index:var(--jini-embed-z-index,1000);display:var(--jini-floating-ui-display,grid);place-items:center}
|
|
2
2
|
.jini-chat-bubble[hidden]{display:none!important}
|
|
3
3
|
.jini-chat-bubble svg{width:24px;height:24px}
|
|
4
4
|
.jini-chat-bubble:focus-visible{outline:3px solid var(--jini-chat-accent,#2563eb);outline-offset:3px}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface CopyMessageButtonProps {
|
|
2
|
+
/** The raw markdown source to place on the clipboard — never rendered/flattened text. */
|
|
3
|
+
text: string;
|
|
4
|
+
/** Accessible name while idle; swaps to a "Copied" label once the copy lands. */
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Quiet, icon-only copy affordance shared by the user- and assistant-message
|
|
9
|
+
* action rows. Delegates the actual clipboard write (and its
|
|
10
|
+
* secure-context fallback) to `useCopyToClipboard`; this component only owns
|
|
11
|
+
* the icon swap and the accessible announcement of the transient state.
|
|
12
|
+
* @param props.text - Exact string to copy, verbatim.
|
|
13
|
+
* @param props.label - Idle accessible name (already translated by the caller).
|
|
14
|
+
* @complexity O(1) — one child hook call, no branching beyond the copied-state read.
|
|
15
|
+
*/
|
|
16
|
+
export declare function CopyMessageButton({ text, label }: CopyMessageButtonProps): import("react").JSX.Element;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=CopyMessageButton.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** Shared message-copy affordance for ordinary messages and mid-run user receipts. */
|
|
3
|
+
import { useT } from '../hooks/context.js';
|
|
4
|
+
import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
|
|
5
|
+
import { Icon } from './Icon.js';
|
|
6
|
+
/**
|
|
7
|
+
* Quiet, icon-only copy affordance shared by the user- and assistant-message
|
|
8
|
+
* action rows. Delegates the actual clipboard write (and its
|
|
9
|
+
* secure-context fallback) to `useCopyToClipboard`; this component only owns
|
|
10
|
+
* the icon swap and the accessible announcement of the transient state.
|
|
11
|
+
* @param props.text - Exact string to copy, verbatim.
|
|
12
|
+
* @param props.label - Idle accessible name (already translated by the caller).
|
|
13
|
+
* @complexity O(1) — one child hook call, no branching beyond the copied-state read.
|
|
14
|
+
*/
|
|
15
|
+
export function CopyMessageButton({ text, label }) {
|
|
16
|
+
const t = useT();
|
|
17
|
+
const { copied, copy } = useCopyToClipboard();
|
|
18
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "jini-message-action-btn", "aria-label": copied ? t('Copied') : label, onClick: () => {
|
|
19
|
+
void copy(text);
|
|
20
|
+
}, children: _jsx(Icon, { name: copied ? 'check' : 'copy' }) }), _jsx("span", { className: "jini-message-copy-status", "aria-live": "polite", children: copied ? t('Copied to clipboard') : '' })] }));
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=CopyMessageButton.js.map
|
|
@@ -149,7 +149,7 @@ function parseRule(lines, i) {
|
|
|
149
149
|
return RULE.test(lines[i]) ? { block: { kind: 'hr' }, next: i + 1 } : null;
|
|
150
150
|
}
|
|
151
151
|
// Collects the run of consecutive lines matching `marker`, each with that
|
|
152
|
-
// marker stripped — the
|
|
152
|
+
// marker stripped — the shape of blockquotes.
|
|
153
153
|
function collectMarked(lines, start, marker) {
|
|
154
154
|
const items = [];
|
|
155
155
|
let i = start;
|
|
@@ -165,11 +165,29 @@ function parseQuote(lines, i) {
|
|
|
165
165
|
const { items, next } = collectMarked(lines, i, QUOTE_LINE);
|
|
166
166
|
return { block: { kind: 'bq', text: items.join('\n') }, next };
|
|
167
167
|
}
|
|
168
|
+
// A list item runs on over the following lines until a blank line or another block starts
|
|
169
|
+
// (CommonMark's lazy continuation). Without it, a wrapped item such as "- **Grafana Cloud (instance
|
|
170
|
+
// ID +\n token)**" split into an item and a paragraph, and both halves showed raw `**`.
|
|
171
|
+
function collectItem(lines, i, marker) {
|
|
172
|
+
let item = lines[i].replace(marker, '');
|
|
173
|
+
let j = i + 1;
|
|
174
|
+
while (j < lines.length && lines[j].trim() !== '' && !isBlockStart(lines, j)) {
|
|
175
|
+
item += `\n${lines[j].trim()}`;
|
|
176
|
+
j += 1;
|
|
177
|
+
}
|
|
178
|
+
return { item, next: j };
|
|
179
|
+
}
|
|
168
180
|
function parseBulletList(lines, i) {
|
|
169
181
|
if (!BULLET_ITEM.test(lines[i]))
|
|
170
182
|
return null;
|
|
171
|
-
const
|
|
172
|
-
|
|
183
|
+
const items = [];
|
|
184
|
+
let j = i;
|
|
185
|
+
while (j < lines.length && BULLET_ITEM.test(lines[j])) {
|
|
186
|
+
const { item, next } = collectItem(lines, j, BULLET_ITEM);
|
|
187
|
+
items.push(item);
|
|
188
|
+
j = next;
|
|
189
|
+
}
|
|
190
|
+
return { block: { kind: 'ul', items }, next: j };
|
|
173
191
|
}
|
|
174
192
|
function parseTable(lines, i) {
|
|
175
193
|
if (!isTableStartAt(lines, i))
|
|
@@ -187,8 +205,9 @@ function parseOrderedList(lines, i) {
|
|
|
187
205
|
const items = [];
|
|
188
206
|
let j = i;
|
|
189
207
|
while (j < lines.length && ORDERED_ITEM.test(lines[j])) {
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
const { item, next } = collectItem(lines, j, ORDERED_ITEM);
|
|
209
|
+
items.push(item);
|
|
210
|
+
j = nextLooseListLine(lines, next);
|
|
192
211
|
}
|
|
193
212
|
return { block: { kind: 'ol', start, items }, next: j };
|
|
194
213
|
}
|
|
@@ -61,7 +61,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
61
61
|
* `visible` field in per-element STATE when a caller opts into it — see the regression tests added
|
|
62
62
|
* against the real driver in `@jini-ai/agentic`.
|
|
63
63
|
*/
|
|
64
|
-
import { questionAnswerFromResult } from '../../core/question-answer.js';
|
|
64
|
+
import { questionAnswerFromResult, questionAnswerLabel } from '../../core/question-answer.js';
|
|
65
65
|
import { MidRunUserMessage } from './MidRunUserMessage.js';
|
|
66
66
|
import { Fragment, useMemo } from 'react';
|
|
67
67
|
import { McpUiHost, parseUIResource, readActionPlan, readExpiresAt, readPreferredFrameSize, } from '@jini-ai/ui/mcp-ui';
|
|
@@ -69,6 +69,7 @@ import { defaultExtEventRendererRegistry } from '../ext-event-renderer-registry.
|
|
|
69
69
|
import { useT } from '../hooks/context.js';
|
|
70
70
|
import { useSurfaceExpiry } from '../hooks/useSurfaceExpiry.js';
|
|
71
71
|
import { closedSurfaceLabel, reportDeliveredAnswer } from './mcp-ui-surface-state.js';
|
|
72
|
+
import { surfaceQuestionInput } from './mcp-ui-question-input.js';
|
|
72
73
|
/** The `kind: 'ext'` event name this renderer claims. */
|
|
73
74
|
export const MCP_UI_EXT_EVENT_NAME = 'mcp-ui';
|
|
74
75
|
/** Collapses the event stream to the newest resource per `ui://` URI, in first-appearance order. */
|
|
@@ -118,12 +119,20 @@ function pendingMirrorLabel(t, plan) {
|
|
|
118
119
|
function PendingSurfaceMirror({ uri, plan, t }) {
|
|
119
120
|
return (_jsx("div", { "aria-hidden": "true", style: { display: 'none' }, "data-agent-element": pendingMirrorHandle(uri), "data-agent-role": "status", "data-agent-label": pendingMirrorLabel(t, plan) }));
|
|
120
121
|
}
|
|
122
|
+
/** Keep one title/answer format for the collapsed card and standalone form's user bubble. */
|
|
123
|
+
function surfaceAnswerLabel({ call, label }, _options = {}) {
|
|
124
|
+
return questionAnswerLabel({ input: call?.input, text: label });
|
|
125
|
+
}
|
|
126
|
+
/** Prefer the question as the frame title; untitled resources retain their URI fallback.
|
|
127
|
+
* @complexity O(h + c) for fallback markup and controls; metadata needs O(1), no effects.
|
|
128
|
+
*/
|
|
129
|
+
function surfaceQuestionTitle({ resource }, _options = {}) {
|
|
130
|
+
const input = surfaceQuestionInput({ resource, input: undefined });
|
|
131
|
+
return typeof input?.title === 'string' ? input.title : resource.resource.uri;
|
|
132
|
+
}
|
|
121
133
|
/** A closed card: the question's title, when the call gave one, and why it is closed. */
|
|
122
134
|
function ClosedSurface({ call, label }) {
|
|
123
|
-
|
|
124
|
-
const title = [input?.title, input?.question, input?.prompt]
|
|
125
|
-
.find(value => typeof value === 'string' && value.trim() !== '');
|
|
126
|
-
return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' ? `${title}: ${label}` : label }));
|
|
135
|
+
return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: surfaceAnswerLabel({ call, label }) }));
|
|
127
136
|
}
|
|
128
137
|
/** The live card's answer lifecycle: its deadline and delivery notification, using their existing owners. */
|
|
129
138
|
function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnswerText, call }, _options = {}) {
|
|
@@ -141,23 +150,47 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
|
|
|
141
150
|
// One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
|
|
142
151
|
// that card. A group holding several URIs has no single call to judge them by, and stays live.
|
|
143
152
|
const single = resources.length === 1 ? resources[0] : undefined;
|
|
144
|
-
const {
|
|
153
|
+
const { displayCall, input, standalone } = useSurfaceQuestionCall({ events, call });
|
|
154
|
+
const { expiresAtMs, toolCall } = surfaceInteraction({ resource: single, answered, onToolCall, onAnswered, onAnswerText, call: displayCall });
|
|
145
155
|
const expiry = useSurfaceExpiry({ expiresAtMs }, expiryClock === undefined ? {} : { clock: expiryClock });
|
|
146
156
|
const closed = single === undefined
|
|
147
157
|
? undefined
|
|
148
158
|
: closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
// A completed call answered the original document; a replacement outcome or retry owns its view.
|
|
160
|
+
const answer = (events.length === 1 ? questionAnswerFromResult({ result: call?.result, input }) : undefined) ?? answerText;
|
|
161
|
+
return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: displayCall, standalone: standalone, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
|
|
151
162
|
}
|
|
152
|
-
/**
|
|
153
|
-
|
|
163
|
+
/** A standalone form's call may lack the question; its current document supplies the title and
|
|
164
|
+
* option labels, so retry answers read as labels rather than raw values from the replaced question.
|
|
165
|
+
* Receipt recovery retains the original document separately in useSurfaceAnswerContexts. */
|
|
166
|
+
function useSurfaceQuestionCall({ events, call }, _options = {}) {
|
|
167
|
+
const resource = parseUIResource(events.at(-1));
|
|
168
|
+
const input = useMemo(() => surfaceQuestionInput({ resource, input: call?.input }), [resource?.resource.text, resource?.resource._meta, call?.input]);
|
|
169
|
+
const displayCall = call === undefined ? { name: '', input } : { ...call, input };
|
|
170
|
+
return { displayCall, input, standalone: input !== call?.input };
|
|
171
|
+
}
|
|
172
|
+
/** A recorded receipt closes the card even while its readable answer is still arriving. */
|
|
173
|
+
function recordedAnswerLabel({ answer, closed, t }, _options = {}) {
|
|
174
|
+
return answer ?? closed ?? t('Answered');
|
|
175
|
+
}
|
|
176
|
+
/** A single answered card: closed with its label, plus the optimistic bubble until a receipt owns it.
|
|
177
|
+
* @complexity O(1), no effects.
|
|
178
|
+
*/
|
|
179
|
+
function answeredSurface({ answerRecorded, answer, closed, call, standalone, t }, _options = {}) {
|
|
154
180
|
// MOVE: The accepted-input receipt owns its bubble; the closed card retains the question's context.
|
|
155
|
-
if (
|
|
156
|
-
return _jsx(ClosedSurface, { call: call, label: answer
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
181
|
+
if (answerRecorded)
|
|
182
|
+
return _jsx(ClosedSurface, { call: call, label: recordedAnswerLabel({ answer, closed, t }) });
|
|
183
|
+
if (answer === undefined)
|
|
184
|
+
return undefined;
|
|
185
|
+
const label = answer === 'Declined' ? t('Declined') : answer;
|
|
186
|
+
return _jsxs(_Fragment, { children: [_jsx(ClosedSurface, { call: call, label: label }), _jsx(MidRunUserMessage, { events: [{ text: standalone ? surfaceAnswerLabel({ call, label }) : label }] })] });
|
|
187
|
+
}
|
|
188
|
+
/** Rendering priority stays receipt, answer, closure, then the live documents. */
|
|
189
|
+
function SurfaceCardContent({ resources, single, answerRecorded, answer, closed, call, standalone, t, sandboxProxyUrl, toolCall, onOpenLink, maxHeight, expiry }) {
|
|
190
|
+
const frameTitle = useMemo(() => single === undefined ? undefined : surfaceQuestionTitle({ resource: single }), [single?.resource.uri, single?.resource.text, single?.resource._meta]);
|
|
191
|
+
const answered = single === undefined ? undefined : answeredSurface({ answerRecorded, answer, closed, call, standalone, t });
|
|
192
|
+
if (answered !== undefined)
|
|
193
|
+
return answered;
|
|
161
194
|
if (closed !== undefined)
|
|
162
195
|
return _jsx(ClosedSurface, { call: call, label: closed });
|
|
163
196
|
if (resources.length === 0) {
|
|
@@ -170,7 +203,7 @@ function SurfaceCardContent({ resources, single, answerRecorded, answer, closed,
|
|
|
170
203
|
const preferred = readPreferredFrameSize(resource);
|
|
171
204
|
const height = preferred === undefined ? undefined : Number.parseInt(preferred[1], 10);
|
|
172
205
|
const plan = readActionPlan(resource);
|
|
173
|
-
return (_jsxs(Fragment, { children: [_jsx(McpUiHost, { title: resource
|
|
206
|
+
return (_jsxs(Fragment, { children: [_jsx(McpUiHost, { title: frameTitle ?? surfaceQuestionTitle({ resource }), html: resource.resource.text, sandboxProxyUrl: sandboxProxyUrl, toolName: resource.resource.uri,
|
|
174
207
|
// The whole document, not its LENGTH. `useMcpUiHost` already defaults `sessionKey` to
|
|
175
208
|
// `html` — exact by construction — and this override replaced that with a digest so
|
|
176
209
|
// coarse that any two documents of equal size under one URI collided: a genuine update
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* "transport-agnostic hooks never touch DOM" contract.
|
|
6
6
|
*/
|
|
7
7
|
import { type RefObject, type UIEvent } from 'react';
|
|
8
|
+
import { type ChatUsageSummary } from '../../core/turn-usage.js';
|
|
8
9
|
import type { ChatMessage } from '../../core/index.js';
|
|
9
10
|
export interface MessageListAutoScroll {
|
|
10
11
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
@@ -17,7 +18,7 @@ export interface MessageListAutoScroll {
|
|
|
17
18
|
/** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
|
|
18
19
|
export declare function useConversationUsage({ messages }: {
|
|
19
20
|
messages: readonly ChatMessage[];
|
|
20
|
-
}, _optional?: {}): Map<string,
|
|
21
|
+
}, _optional?: {}): Map<string, ChatUsageSummary>;
|
|
21
22
|
export declare function useMessageListAutoScroll({ messages, scrollIntent }: {
|
|
22
23
|
messages: ChatMessage[];
|
|
23
24
|
scrollIntent: boolean;
|
|
@@ -11,10 +11,20 @@ import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
|
|
|
11
11
|
const prefersReducedMotion = () => typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
|
|
12
12
|
/** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
|
|
13
13
|
export function useConversationUsage({ messages }, _optional = {}) {
|
|
14
|
-
|
|
15
|
-
return useMemo(() =>
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const previous = useRef(new Map());
|
|
15
|
+
return useMemo(() => {
|
|
16
|
+
// Usage and rendering must see the same answer boundaries, including receipts rebuilt on reload.
|
|
17
|
+
const next = conversationUsage({ messages: messages.map(message => message.events === undefined ? message : {
|
|
18
|
+
...message, events: projectAcceptedInputEvents({ events: message.events }),
|
|
19
|
+
}) });
|
|
20
|
+
// Keep an unchanged summary's identity so memoized rows skip re-rendering while a run streams.
|
|
21
|
+
for (const [id, summary] of next) {
|
|
22
|
+
const old = previous.current.get(id);
|
|
23
|
+
if (old && JSON.stringify(old) === JSON.stringify(summary))
|
|
24
|
+
next.set(id, old);
|
|
25
|
+
}
|
|
26
|
+
return previous.current = next;
|
|
27
|
+
}, [messages]);
|
|
18
28
|
}
|
|
19
29
|
export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled, reducedMotion = prefersReducedMotion } = {}) {
|
|
20
30
|
const containerRef = useRef(null);
|
|
@@ -19,9 +19,9 @@ import { MessageRow } from './MessageRow.js';
|
|
|
19
19
|
import { SentAttachmentButton } from './SentAttachment.js';
|
|
20
20
|
import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
|
|
21
21
|
import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
|
|
22
|
-
function renderMessageRow({ message, isLast, isStreaming, usageSummary }, { activeQuestionFormMessageId, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, ...rowProps } = {}) {
|
|
22
|
+
function renderMessageRow({ message, isLast, isStreaming, usageSummary, sending }, { activeQuestionFormMessageId, questionFormSubmittedAnswersByMessageId, onQuestionFormSubmit, ...rowProps } = {}) {
|
|
23
23
|
const runStreaming = isLast && isStreaming && !isTerminalRunStatus({ status: message.runStatus });
|
|
24
|
-
return (_jsx(MessageRow, { message: message, ...rowProps, ...(usageSummary === undefined ? {} : { usageSummary }), runStreaming: runStreaming, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}) }, message.id));
|
|
24
|
+
return (_jsx(MessageRow, { message: message, ...rowProps, ...(usageSummary === undefined ? {} : { usageSummary }), runStreaming: runStreaming, runSending: sending, runSucceeded: message.runStatus === 'succeeded', questionFormInteractive: message.id === activeQuestionFormMessageId, ...(questionFormSubmittedAnswersByMessageId?.[message.id] !== undefined ? { questionFormSubmittedAnswers: questionFormSubmittedAnswersByMessageId[message.id] } : {}), ...(onQuestionFormSubmit !== undefined ? { onQuestionFormSubmit: (text, answers) => onQuestionFormSubmit(message.id, text, answers) } : {}) }, message.id));
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Memoized: the pane re-renders on every composer keystroke, and re-rendering a long transcript
|
|
@@ -33,9 +33,11 @@ export const MessageList = memo(function MessageList({ messages: sourceMessages,
|
|
|
33
33
|
const messages = useMemo(() => projectAcceptedInputMessages({ messages: sourceMessages }), [sourceMessages]);
|
|
34
34
|
const usageByMessage = useConversationUsage({ messages });
|
|
35
35
|
const { containerRef, onScroll, awayFromLatest, jumpToLatest } = useMessageListAutoScroll({ messages, scrollIntent }, onScrolled !== undefined ? { onScrolled } : {});
|
|
36
|
-
return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message) => renderMessageRow({
|
|
36
|
+
return (_jsxs("div", { className: "jini-message-list", ref: containerRef, onScroll: onScroll, "data-agent-element": "chat-transcript", "data-agent-role": "list", "data-agent-label": "The conversation transcript, oldest message first", children: [messages.map((message, index) => renderMessageRow({
|
|
37
37
|
message,
|
|
38
38
|
isLast: message.id === messages[messages.length - 1]?.id,
|
|
39
|
+
// A reply's start is accepted together with its user turn (`inputPending` clears then).
|
|
40
|
+
sending: messages[index - 1]?.inputPending === true,
|
|
39
41
|
isStreaming,
|
|
40
42
|
usageSummary: usageByMessage.get(message.id),
|
|
41
43
|
}, rowProps)), (pendingPrompts ?? (pendingPrompt ? [pendingPrompt] : [])).map((prompt, index) => (_jsx(QueuedMessage, { prompt: prompt, renderAttachment: rowProps.renderAttachment }, prompt.id ?? index))), awayFromLatest && _jsx(JumpToLatest, { onJump: jumpToLatest })] }));
|
|
@@ -32,7 +32,7 @@ import type { ChatUsageSummary } from '../../core/turn-usage.js';
|
|
|
32
32
|
* see that module's header for the conditions.
|
|
33
33
|
*
|
|
34
34
|
* Both message roles also carry a per-message copy affordance
|
|
35
|
-
* (`CopyMessageButton`
|
|
35
|
+
* (`CopyMessageButton`), rendered inside a `.jini-message-actions` row.
|
|
36
36
|
* Assistant rows render that row as an extension seam: `branch`/thumbs-up/
|
|
37
37
|
* thumbs-down are meant to join `copy` there later, as siblings in the same
|
|
38
38
|
* row, once their backends (conversation branching, feedback storage) exist
|
|
@@ -71,6 +71,8 @@ export interface MessageRowProps {
|
|
|
71
71
|
showThinking?: boolean;
|
|
72
72
|
/** Whether this message's own run is still streaming. */
|
|
73
73
|
runStreaming?: boolean;
|
|
74
|
+
/** Whether the server has yet to accept this reply's run (its user turn is still pending). */
|
|
75
|
+
runSending?: boolean;
|
|
74
76
|
runSucceeded?: boolean;
|
|
75
77
|
/** Whether this message's question-form (if any) is still the active/answerable one. */
|
|
76
78
|
questionFormInteractive?: boolean;
|