@jini-ai/chat 0.6.11 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/contracts/chat-bubble-style.d.ts +3 -2
  3. package/dist/contracts/chat-bubble-style.js +3 -2
  4. package/dist/core/accepted-input.d.ts +20 -1
  5. package/dist/core/accepted-input.js +65 -17
  6. package/dist/core/ag-ui/entry.d.ts +2 -4
  7. package/dist/core/ag-ui/entry.js +11 -5
  8. package/dist/core/assistant-client/ag-ui-transport.js +6 -1
  9. package/dist/core/assistant-client/durable-run-subscription.d.ts +2 -1
  10. package/dist/core/assistant-client/durable-run-subscription.js +3 -1
  11. package/dist/core/assistant-client/transport.js +5 -1
  12. package/dist/core/assistant-content.d.ts +8 -1
  13. package/dist/core/assistant-content.js +17 -4
  14. package/dist/core/durable-runs/continuation.js +6 -2
  15. package/dist/core/events.d.ts +3 -0
  16. package/dist/core/index.d.ts +1 -0
  17. package/dist/core/index.js +1 -0
  18. package/dist/core/question-answer.d.ts +8 -0
  19. package/dist/core/question-answer.js +16 -4
  20. package/dist/core/run-activity.d.ts +1 -1
  21. package/dist/core/run-activity.js +50 -4
  22. package/dist/core/run-events/entry.js +5 -2
  23. package/dist/embed-widget/vanilla/style.css +1 -1
  24. package/dist/react/components/CopyMessageButton.d.ts +18 -0
  25. package/dist/react/components/CopyMessageButton.js +22 -0
  26. package/dist/react/components/Markdown.js +24 -5
  27. package/dist/react/components/McpUiSurfaceCard.js +50 -17
  28. package/dist/react/components/MessageList.hooks.d.ts +2 -1
  29. package/dist/react/components/MessageList.hooks.js +14 -4
  30. package/dist/react/components/MessageList.js +5 -3
  31. package/dist/react/components/MessageRow.d.ts +3 -1
  32. package/dist/react/components/MessageRow.js +66 -38
  33. package/dist/react/components/MidRunUserMessage.d.ts +1 -1
  34. package/dist/react/components/MidRunUserMessage.js +14 -7
  35. package/dist/react/components/QuestionForm.js +22 -2
  36. package/dist/react/components/ToolCard.js +21 -8
  37. package/dist/react/components/mcp-ui-question-input.d.ts +20 -0
  38. package/dist/react/components/mcp-ui-question-input.js +72 -0
  39. package/dist/react/ext-event-renderer-registry.d.ts +2 -0
  40. package/dist/react/features/chat-pane/components/ChatPane.js +10 -3
  41. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +6 -0
  42. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +89 -25
  43. package/dist/react/features/chat-pane/rules.d.ts +5 -2
  44. package/dist/react/features/chat-pane/rules.js +8 -2
  45. package/dist/react/hooks/useAnsweredCards.js +36 -20
  46. package/dist/react/hooks/useConversation.d.ts +6 -0
  47. package/dist/react/hooks/useConversation.js +10 -3
  48. package/dist/react/index.d.ts +7 -0
  49. package/dist/react/index.js +4 -0
  50. package/dist/react/message-blocks.d.ts +6 -0
  51. package/dist/react/message-blocks.js +27 -1
  52. package/dist/server/durable-runs/accept.d.ts +4 -0
  53. package/dist/server/durable-runs/accept.js +8 -1
  54. package/dist/server/durable-runs/recover.js +5 -4
  55. package/package.json +15 -15
@@ -19,6 +19,9 @@
19
19
  */
20
20
  import { a2uiSurfaceIdOf, displayOnlySurfaceIdOf } from '@jini-ai/agentic/a2ui';
21
21
  import { isDelegatedWrapperToolName } from './tool-events.js';
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';
22
25
  /** Tool names whose only job is finding the right tool (MCP tool search / schema lookup). */
23
26
  const DISCOVERY_TOOL_NAMES = new Set([
24
27
  'ToolSearch',
@@ -67,6 +70,8 @@ function isVisible(event) {
67
70
  function isAnswerCard(scan, name, data) {
68
71
  if (!CARD_EXT_NAMES.has(name))
69
72
  return false;
73
+ if (name === 'mcp-ui' && scan.answeredSurfaces.has(data))
74
+ return false;
70
75
  if (scan.isAnsweredCard?.({ name, data }) === true)
71
76
  return false;
72
77
  if (name !== A2UI_EXT_NAME)
@@ -88,25 +93,51 @@ function scanStatus({ scan, event, index }, _optional = {}) {
88
93
  }
89
94
  }
90
95
  function scanEvent(scan, event, index) {
96
+ // Queue acceptance/consumption is user input, not new agent activity. Neither restarts its clock.
97
+ if (event.kind === 'ext' && event.name === MID_RUN_USER_MESSAGE_EVENT_NAME)
98
+ return;
99
+ if (WORK_EVENT_KINDS.has(event.kind))
100
+ scan.lastWorkIndex = index;
91
101
  if (isVisible(event)) {
92
102
  scan.lastVisible = { index, kind: event.kind };
93
103
  scan.visibleCount += 1;
94
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.
95
107
  if (event.kind === 'tool_use') {
108
+ scan.pendingQuestionIndex = -1;
96
109
  scan.open = scan.open.filter((tool) => tool.id !== event.id);
97
110
  scan.open.push({ id: event.id, name: event.name, input: event.input, index });
98
111
  }
99
112
  else if (event.kind === 'tool_result') {
113
+ // Completion may be unrelated background work or the ask tool returning before human input.
100
114
  scan.open = scan.open.filter((tool) => tool.id !== event.toolUseId);
101
115
  }
102
116
  else if (event.kind === 'ext') {
103
- if (isAnswerCard(scan, event.name, event.data))
104
- scan.lastCardIndex = index;
117
+ scanCard({ scan, event, index });
105
118
  }
106
119
  else if (event.kind === 'status') {
107
120
  scanStatus({ scan, event, index });
108
121
  }
109
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
+ }
110
141
  /** `{ a: 1, b: undefined }` → `{ a: 1 }`, so optional activity fields are absent rather than undefined. */
111
142
  function definedFields({ fields }, _optional = {}) {
112
143
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
@@ -142,6 +173,11 @@ function openToolSignal({ scan, newestOpen }, _optional = {}) {
142
173
  };
143
174
  }
144
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
+ }
145
181
  const lastVisibleIndex = scan.lastVisible?.index ?? -1;
146
182
  if (scan.retry && scan.retry.index > lastVisibleIndex) {
147
183
  return { activity: retryingActivity({ data: scan.retry.data }), key: `retry:${lastVisibleIndex}` };
@@ -149,6 +185,14 @@ function latestSignal({ scan }, _optional = {}) {
149
185
  const newestOpen = scan.open[scan.open.length - 1];
150
186
  if (newestOpen)
151
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
+ }
152
196
  if (scan.lastVisible?.kind === 'text') {
153
197
  return { activity: { kind: 'writing' }, key: `writing:${lastVisibleIndex}` };
154
198
  }
@@ -162,10 +206,12 @@ function latestSignal({ scan }, _optional = {}) {
162
206
  * card); the newest open tool call; else
163
207
  * writing (last visible event is text) or thinking.
164
208
  *
165
- * @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.
166
210
  */
167
211
  export function deriveRunActivity({ events }, { isAnsweredCard } = {}) {
168
- const scan = { open: [], lastVisible: null, lastCardIndex: -1, retry: null, progress: null, visibleCount: 0, displayOnlySurfaces: new Set(), isAnsweredCard };
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 };
169
215
  (events ?? []).forEach((event, index) => scanEvent(scan, event, index));
170
216
  return { ...latestSignal({ scan }), visibleCount: scan.visibleCount };
171
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 message = wireString(frame?.payload?.message) || "agent run failed";
563
- return { events: [{ kind: "status", label: message }], error: new Error(message) };
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 shared shape of blockquotes and bullet lists.
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 { items, next } = collectMarked(lines, i, BULLET_ITEM);
172
- return { block: { kind: 'ul', items }, next };
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
- items.push(lines[j].replace(ORDERED_ITEM, ''));
191
- j = nextLooseListLine(lines, j + 1);
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
- const input = call?.input;
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 { expiresAtMs, toolCall } = surfaceInteraction({ resource: single, answered, onToolCall, onAnswered, onAnswerText, call });
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
- const answer = questionAnswerFromResult({ result: call?.result, input: call?.input }) ?? answerText;
150
- return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: call, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
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
- /** Rendering priority stays receipt, answer, closure, then the live documents. */
153
- function SurfaceCardContent({ resources, single, answerRecorded, answer, closed, call, t, sandboxProxyUrl, toolCall, onOpenLink, maxHeight, expiry }) {
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 (single !== undefined && answerRecorded)
156
- return _jsx(ClosedSurface, { call: call, label: answer ?? closed ?? t('Answered') });
157
- if (single !== undefined && answer !== undefined) {
158
- const label = answer === 'Declined' ? t('Declined') : answer;
159
- return _jsxs(_Fragment, { children: [_jsx(ClosedSurface, { call: call, label: label }), _jsx(MidRunUserMessage, { events: [{ text: label }] })] });
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.resource.uri, html: resource.resource.text, sandboxProxyUrl: sandboxProxyUrl, toolName: resource.resource.uri,
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, import("../../core/turn-usage.js").ChatUsageSummary>;
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
- // Usage and rendering must see the same answer boundaries, including receipts rebuilt on reload.
15
- return useMemo(() => conversationUsage({ messages: messages.map(message => message.events === undefined ? message : {
16
- ...message, events: projectAcceptedInputEvents({ events: message.events }),
17
- }) }), [messages]);
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` below), rendered inside a `.jini-message-actions` row.
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;