@jini-ai/chat 0.6.7 → 0.6.11

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 (33) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/dist/core/accepted-input.d.ts +2 -0
  3. package/dist/core/accepted-input.js +34 -3
  4. package/dist/core/assistant-client/durable-run-subscription.d.ts +4 -2
  5. package/dist/core/assistant-client/durable-run-subscription.js +36 -7
  6. package/dist/core/assistant-client/transport.js +3 -1
  7. package/dist/core/persistence/title.d.ts +8 -16
  8. package/dist/core/persistence/title.js +20 -63
  9. package/dist/core/question-answer.js +2 -2
  10. package/dist/core/run-events/entry.d.ts +1 -1
  11. package/dist/core/run-events/entry.js +2 -1
  12. package/dist/core/turn-usage.js +19 -2
  13. package/dist/react/components/Markdown.js +2 -2
  14. package/dist/react/components/McpUiSurfaceCard.js +10 -7
  15. package/dist/react/components/MessageList.hooks.js +5 -1
  16. package/dist/react/components/MessageRow.d.ts +1 -1
  17. package/dist/react/components/MessageRow.js +41 -24
  18. package/dist/react/components/MessageRow.usage.d.ts +9 -1
  19. package/dist/react/components/MessageRow.usage.js +22 -5
  20. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  21. package/dist/react/components/ToolCallGroup.hooks.d.ts +3 -1
  22. package/dist/react/components/ToolCallGroup.hooks.js +2 -1
  23. package/dist/react/components/ToolCallGroup.js +7 -5
  24. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +3 -3
  25. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -5
  26. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +69 -17
  27. package/dist/react/features/chat-pane/types.d.ts +9 -3
  28. package/dist/react/tool-call-display.d.ts +12 -6
  29. package/dist/react/tool-call-display.js +42 -15
  30. package/dist/server/durable-runs/recover.js +59 -23
  31. package/dist/store/sql/store.d.ts +2 -2
  32. package/dist/store/sql/store.js +11 -3
  33. package/package.json +15 -15
@@ -428,9 +428,9 @@ function renderInline(text) {
428
428
  else if (mdLink)
429
429
  nodes.push(renderMarkdownLink(mdLink, key++));
430
430
  else if (bold)
431
- nodes.push(_jsx("strong", { children: bold.slice(2, -2) }, key++));
431
+ nodes.push(_jsx("strong", { children: renderInline(bold.slice(2, -2)) }, key++));
432
432
  else if (italic)
433
- nodes.push(_jsx("em", { children: italic.slice(1, -1) }, key++));
433
+ nodes.push(_jsx("em", { children: renderInline(italic.slice(1, -1)) }, key++));
434
434
  else if (link)
435
435
  nodes.push(_jsx("a", { href: link, ...NEW_TAB, children: link }, key++));
436
436
  // `whole` is `match[0]` — a successful `RegExpExecArray` always has a
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * @module components/McpUiSurfaceCard
4
4
  *
@@ -120,8 +120,10 @@ function PendingSurfaceMirror({ uri, plan, t }) {
120
120
  }
121
121
  /** A closed card: the question's title, when the call gave one, and why it is closed. */
122
122
  function ClosedSurface({ call, label }) {
123
- const title = call?.input?.title;
124
- return (_jsx("div", { className: "mcpui-surface-card mcpui-surface-card-closed", role: "status", children: typeof title === 'string' && title.trim() !== '' ? `${title}: ${label}` : 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 }));
125
127
  }
126
128
  /** The live card's answer lifecycle: its deadline and delivery notification, using their existing owners. */
127
129
  function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnswerText, call }, _options = {}) {
@@ -144,16 +146,17 @@ export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLi
144
146
  const closed = single === undefined
145
147
  ? undefined
146
148
  : closedSurfaceLabel({ t, call, runStreaming, documentsForUri: events.length, expired: expiry?.expired === true });
147
- const answer = answerText ?? questionAnswerFromResult({ result: call?.result, input: call?.input });
149
+ const answer = questionAnswerFromResult({ result: call?.result, input: call?.input }) ?? answerText;
148
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 });
149
151
  }
150
152
  /** Rendering priority stays receipt, answer, closure, then the live documents. */
151
153
  function SurfaceCardContent({ resources, single, answerRecorded, answer, closed, call, t, sandboxProxyUrl, toolCall, onOpenLink, maxHeight, expiry }) {
152
- // MOVE: The accepted-input receipt owns its bubble; rendering the closed card too duplicates typed answers.
154
+ // MOVE: The accepted-input receipt owns its bubble; the closed card retains the question's context.
153
155
  if (single !== undefined && answerRecorded)
154
- return null;
156
+ return _jsx(ClosedSurface, { call: call, label: answer ?? closed ?? t('Answered') });
155
157
  if (single !== undefined && answer !== undefined) {
156
- return _jsx(MidRunUserMessage, { events: [{ text: answer === 'Declined' ? t('Declined') : answer }] });
158
+ const label = answer === 'Declined' ? t('Declined') : answer;
159
+ return _jsxs(_Fragment, { children: [_jsx(ClosedSurface, { call: call, label: label }), _jsx(MidRunUserMessage, { events: [{ text: label }] })] });
157
160
  }
158
161
  if (closed !== undefined)
159
162
  return _jsx(ClosedSurface, { call: call, label: closed });
@@ -6,11 +6,15 @@
6
6
  */
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
  import { conversationUsage } from '../../core/turn-usage.js';
9
+ import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
9
10
  import { nextStickToBottom, startsNewTurn } from './MessageList.rules.js';
10
11
  const prefersReducedMotion = () => typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
11
12
  /** Recompute costs once per immutable transcript snapshot, including saved history on reload. */
12
13
  export function useConversationUsage({ messages }, _optional = {}) {
13
- return useMemo(() => conversationUsage({ messages }), [messages]);
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
18
  }
15
19
  export function useMessageListAutoScroll({ messages, scrollIntent }, { onScrolled, reducedMotion = prefersReducedMotion } = {}) {
16
20
  const containerRef = useRef(null);
@@ -63,7 +63,7 @@ export interface MessageRowProps {
63
63
  * included (a host's "Done line" display choice, 2026-10-11). */
64
64
  showUsageLine?: boolean;
65
65
  /** How tool calls show (`../tool-call-display.ts`): every card (`'show'`, the default), one
66
- * fold-up line per turn (`'collapsed'`), or only failed ones (`'hidden'`). */
66
+ * fold-up line per turn (`'collapsed'`), or compact unrecovered failures (`'hidden'`). */
67
67
  toolCallDisplay?: ToolCallDisplay;
68
68
  /** Show the model's reasoning (`thinking` events) as a fold-up block where it happened. Off by
69
69
  * default — the transcript never showed it before this option. Needs the interleaved layout; a
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { projectAcceptedInputEvents } from '../../core/accepted-input.js';
3
3
  import { MidRunUserMessage, MID_RUN_USER_MESSAGE_EXT_EVENT_NAME } from './MidRunUserMessage.js';
4
4
  import { terminalMessageNotice, continuingRunNotice } from '../../core/durable-projection.js';
5
- import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary } from './MessageRow.usage.js';
5
+ import { usageEndLabel, usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary } from './MessageRow.usage.js';
6
6
  /**
7
7
  * @module MessageRow
8
8
  *
@@ -54,7 +54,7 @@ import { usageLayout, usageLineMeetsActions, useMessageUsage, useUsageSummary }
54
54
  * chunk stopped arriving.
55
55
  */
56
56
  import React, { memo, useMemo } from 'react';
57
- import { isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
57
+ import { assistantContentFromEvents, isAwaitingAnswer, isTerminalRunStatus, splitOnQuestionForms, stripArtifact } from '../../core/index.js';
58
58
  import { useToolTimeline } from '../hooks/useToolTimeline.js';
59
59
  import { useExtEventGroups } from '../hooks/useExtEventGroups.js';
60
60
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
@@ -111,23 +111,33 @@ function isRunInProgress(runStatus) {
111
111
  /** One "Done · 6m 29s · $0.41 · total $2.50" line from reported usage, never estimated (the total is
112
112
  * left off when `showTotal` is false). Partial usage stays partial; output tokens are available to
113
113
  * assistive technology and in the tooltip. */
114
- function UsageSummary({ usage, showTotal }) {
114
+ function UsageSummary({ usage, showTotal, message, awaitingAnswer }) {
115
115
  const t = useT();
116
- const { text, title, ariaLabel } = useUsageSummary({ usage }, { showTotal });
116
+ const label = usageEndLabel({ usage, ...(message === undefined ? {} : { message }), ...(awaitingAnswer === undefined ? {} : { awaitingAnswer }) });
117
+ const { text, title, ariaLabel } = useUsageSummary({ usage }, { showTotal, label });
117
118
  if (!text)
118
119
  return null;
119
- return (_jsxs("div", { className: "jini-message-usage", title: title, role: "note", "aria-label": ariaLabel, children: [_jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }), t('Done'), " \u00B7 ", text] }));
120
+ return (_jsxs("div", { className: "jini-message-usage", title: title, role: "note", "aria-label": ariaLabel, children: [label === 'Done' ? _jsx("span", { className: "jini-message-usage-dot", "aria-hidden": true, children: "\u25CF" }) : null, t(label), " \u00B7 ", text] }));
120
121
  }
121
- function messageBlocks({ visibleContent, message, rows, registry }, { toolCallDisplay = 'show', includeThinking = false } = {}) {
122
- // Interleaving is skipped outright when an artifact was stripped. `stripArtifact` operates on the
123
- // whole string and trims, so the surviving text no longer lines up with the per-run offsets the
124
- // event walk produces, and an artifact block could legitimately span a tool call. Rather than
125
- // reason about partial overlaps, an artifact-bearing message keeps the flat layout — those
126
- // messages are dominated by the artifact panel anyway, so the ordering matters least there.
127
- if (visibleContent !== message.content)
128
- return null;
129
- const blocks = interleaveMessageBlocks({ events: message.events, content: message.content, rows }, { slotOf: (ev) => registry.slot({ name: ev.name, data: ev.data }), includeThinking });
130
- return blocks ? applyToolCallDisplay({ blocks, mode: toolCallDisplay }) : null;
122
+ function messageBlocks({ message, rows, registry, content = message.content }, { toolCallDisplay = 'show', includeThinking = false } = {}) {
123
+ const blocks = interleaveMessageBlocks({ events: message.events, content, rows }, { slotOf: (ev) => registry.slot({ name: ev.name, data: ev.data }), includeThinking });
124
+ return blocks ? applyToolCallDisplay({ blocks, mode: toolCallDisplay }, { runFailed: message.runStatus === 'failed' }) : null;
125
+ }
126
+ /**
127
+ * The flat layout's tool groups. Even when host text prevents interleaving, event ordering can still
128
+ * prove tool recovery and reply boundaries, so hidden mode reconstructs the blocks from the events —
129
+ * only for this display decision; the host's text is never replaced. Otherwise one flat list.
130
+ * @complexity O(n) in events + rows.
131
+ */
132
+ function flatToolGroups({ message, rows, registry, displayBlocks }, { toolCallDisplay }) {
133
+ if (toolCallDisplay === 'hidden') {
134
+ const recovery = displayBlocks
135
+ ?? messageBlocks({ message, rows, registry, content: assistantContentFromEvents({ events: message.events ?? [] }) }, { toolCallDisplay });
136
+ if (recovery)
137
+ return recovery.flatMap((block) => (block.kind === 'tools' ? [{ rows: block.rows, key: block.key }] : []));
138
+ }
139
+ const flat = toolRowsForDisplay({ rows, mode: toolCallDisplay }, { runFailed: message.runStatus === 'failed' });
140
+ return flat.length > 0 ? [{ rows: flat, key: 'tools' }] : [];
131
141
  }
132
142
  /** The model's reasoning, folded under a "Thinking" summary so it never crowds the answer. */
133
143
  function ThinkingBlock({ text }) {
@@ -141,7 +151,7 @@ function messageAwaitingAnswer({ runStreaming, runInProgress, events }, { isAnsw
141
151
  }
142
152
  function AssistantMessageFooter({ message, usageEvent, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard }, _optional = {}) {
143
153
  const t = useT();
144
- return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent, showTotal: showUsageTotal }) : 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), isAnsweredCard: isAnsweredCard }) : null] });
154
+ return _jsxs(_Fragment, { children: [usageEvent ? _jsx(UsageSummary, { usage: usageEvent, showTotal: showUsageTotal, message: message, awaitingAnswer: isAwaitingAnswer({ events: message.events }, { isAnsweredCard }) }) : 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), isAnsweredCard: isAnsweredCard }) : null] });
145
155
  }
146
156
  /**
147
157
  * Quiet, icon-only copy affordance shared by the user- and assistant-message
@@ -191,18 +201,24 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
191
201
  const answeredCards = useAnsweredCards({ slotOf: registry.slot, events: message.events ?? [], groups: extGroups, runStreaming, runStatus: message.runStatus });
192
202
  const terminalNotice = terminalMessageNotice({ message }, {});
193
203
  const visibleContent = stripArtifact({ content: message.content });
194
- const blocks = messageBlocks({ visibleContent, message, rows: timeline.rows, registry }, { toolCallDisplay, includeThinking: showThinking });
204
+ const displayBlocks = messageBlocks({ message, rows: timeline.rows, registry }, { toolCallDisplay, includeThinking: showThinking });
205
+ // Artifact stripping trims/replaces text, invalidating the event offsets for rendering. Keep the
206
+ // flat layout there; displayBlocks still supplies the recovery decision from the original events.
207
+ const blocks = visibleContent === message.content ? displayBlocks : null;
195
208
  const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
196
209
  const runInProgress = isRunInProgress(message.runStatus);
197
210
  const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, { isAnsweredCard: answeredCards.isAnsweredCard });
198
- const usage = usageLayout({ usage: messageUsage, interleaved: blocks !== null, runInProgress }, { runStreaming });
211
+ const usage = usageLayout({ usage: messageUsage, interleaved: blocks !== null, runInProgress }, {
212
+ runStreaming: runStreaming && !isTerminalRunStatus({ status: message.runStatus }),
213
+ });
199
214
  const footerUsage = showUsageLine ? usage.footer : undefined;
215
+ const lastFinishedReply = !runInProgress && !usage.footer ? usage.replies.findLastIndex(value => value !== undefined) : -1;
200
216
  const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
201
- // One turn's cards: a fold-up line when collapsed, the plain card list otherwise.
202
- const renderTools = (rows, key) => (toolCallDisplay === 'collapsed'
203
- ? _jsx(ToolCallGroup, { rows: rows, renderRow: renderToolCard }, key)
217
+ // Hidden mode reuses the same disclosure and card renderer for unrecovered failures.
218
+ const renderTools = (rows, key) => (toolCallDisplay !== 'show'
219
+ ? _jsx(ToolCallGroup, { rows: rows, renderRow: renderToolCard, failureOnly: toolCallDisplay === 'hidden' }, key)
204
220
  : _jsx("div", { className: "jini-message-tools", children: rows.map(renderToolCard) }, key));
205
- const flatToolRows = toolRowsForDisplay({ rows: timeline.rows, mode: toolCallDisplay });
221
+ const flatTools = flatToolGroups({ message, rows: timeline.rows, registry, displayBlocks }, { toolCallDisplay });
206
222
  const renderExtGroup = (group) => {
207
223
  const renderer = registry.get({ name: group.name });
208
224
  if (!renderer && group.name === MID_RUN_USER_MESSAGE_EXT_EVENT_NAME)
@@ -239,7 +255,8 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
239
255
  }
240
256
  if (block.kind === 'reply-end') {
241
257
  const reply = usage.replies[block.index];
242
- return reply && showUsageLine ? _jsx(UsageSummary, { usage: reply, showTotal: showUsageTotal }, block.key) : null;
258
+ // A trailing input may have no reply: its predecessor's usage belongs above that input.
259
+ return reply && showUsageLine ? _jsx(UsageSummary, { usage: reply, showTotal: showUsageTotal, ...(block.index === lastFinishedReply ? { message } : {}) }, block.key) : null;
243
260
  }
244
261
  if (block.kind === 'tools')
245
262
  return renderTools(block.rows, block.key);
@@ -254,7 +271,7 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
254
271
  ? 'jini-message jini-message-assistant jini-message-assistant--usage-actions'
255
272
  : 'jini-message jini-message-assistant', "data-message-id": message.id, "data-run-status": message.runStatus, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A reply from the assistant", children: [message.agentName ? _jsx("div", { className: "jini-message-agent", children: message.agentName }) : null, blocks
256
273
  ? blocks.map(renderBlock)
257
- : (_jsxs(_Fragment, { children: [renderTextSegments(visibleContent, 'text'), flatToolRows.length > 0 ? renderTools(flatToolRows) : null, extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent: footerUsage, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard: answeredCards.isAnsweredCard })] }));
274
+ : (_jsxs(_Fragment, { children: [renderTextSegments(visibleContent, 'text'), flatTools.map((group) => renderTools(group.rows, group.key)), extGroups.length > 0 ? _jsx("div", { className: "jini-message-ext-events", children: extGroups.map(renderExtGroup) }) : null] })), _jsx(AssistantMessageFooter, { message, usageEvent: footerUsage, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard: answeredCards.isAnsweredCard })] }));
258
275
  }
259
276
  /**
260
277
  * The live "what is it doing" line under a running reply (see `core/run-activity.ts`). Replaces the
@@ -1,5 +1,12 @@
1
1
  import type { ChatMessage } from '../../core/messages.js';
2
2
  import { type ChatUsageSummary } from '../../core/turn-usage.js';
3
+ /** A run's terminal state wins over its last model response; interrupted usage never claims success.
4
+ * Missing lifecycle data keeps the legacy completed label. O(n) in terminal notice events. */
5
+ export declare function usageEndLabel({ usage, message, awaitingAnswer }: {
6
+ usage: ChatUsageSummary;
7
+ message?: ChatMessage;
8
+ awaitingAnswer?: boolean;
9
+ }, _options?: {}): string;
3
10
  /** A standalone row has one turn; MessageList supplies the full conversation projection. */
4
11
  export declare function useMessageUsage({ message, summary }: {
5
12
  message: ChatMessage;
@@ -41,8 +48,9 @@ export declare function usageLineMeetsActions({ footer, terminalNotice, pending,
41
48
  * the current turn's cost is unknown. `showTotal: false` turns the total off. */
42
49
  export declare function useUsageSummary({ usage }: {
43
50
  usage: ChatUsageSummary;
44
- }, { showTotal }?: {
51
+ }, { showTotal, label }?: {
45
52
  showTotal?: boolean;
53
+ label?: string;
46
54
  }): {
47
55
  text: string;
48
56
  title: string | undefined;
@@ -1,7 +1,24 @@
1
1
  import { useMemo } from 'react';
2
+ import { terminalMessageNotice } from '../../core/durable-projection.js';
2
3
  import { conversationUsage } from '../../core/turn-usage.js';
3
4
  import { formatUsageCost } from '../../core/usage-format.js';
4
- import { useT } from '../hooks/context.js';
5
+ import { useI18n } from '../hooks/context.js';
6
+ /** A run's terminal state wins over its last model response; interrupted usage never claims success.
7
+ * Missing lifecycle data keeps the legacy completed label. O(n) in terminal notice events. */
8
+ export function usageEndLabel({ usage, message, awaitingAnswer = false }, _options = {}) {
9
+ const status = message?.runStatus;
10
+ if (status === 'failed')
11
+ return 'Failed';
12
+ if (status === 'canceled') {
13
+ return /stopped/i.test(terminalMessageNotice({ message: message }) ?? '') ? 'Stopped' : 'Canceled';
14
+ }
15
+ if (awaitingAnswer)
16
+ return 'Waiting for your answer';
17
+ const reason = usage.stopReason ?? '';
18
+ if (/error|fail/i.test(reason))
19
+ return 'Failed';
20
+ return /abort|interrupt|cancel|max_tokens|max_tool_turns|length|tool_use/i.test(reason) ? 'Interrupted' : 'Done';
21
+ }
5
22
  /** A standalone row has one turn; MessageList supplies the full conversation projection. */
6
23
  export function useMessageUsage({ message, summary }, _optional = {}) {
7
24
  return useMemo(() => summary ?? conversationUsage({ messages: [message] }).get(message.id), [message, summary]);
@@ -31,13 +48,13 @@ export function usageLineMeetsActions({ footer, terminalNotice, pending, runInPr
31
48
  * Each line carries the chat's running total as of that reply, "Done · 16s · $0.39 · total $3.95" (owner,
32
49
  * 2026-10-10: keep the total, always shown, even on the first turn). Known totals remain visible when
33
50
  * the current turn's cost is unknown. `showTotal: false` turns the total off. */
34
- export function useUsageSummary({ usage }, { showTotal = true } = {}) {
35
- const t = useT();
51
+ export function useUsageSummary({ usage }, { showTotal = true, label = 'Done' } = {}) {
52
+ const { t, locale } = useI18n();
36
53
  const parts = [usageDuration({ durationMs: usage.durationMs }), usageCost({ usage }, { showTotal })]
37
54
  .filter((part) => part !== undefined);
38
55
  const text = parts.join(' · ');
39
- const title = usage.outputTokens === undefined ? undefined : t('{n} out', { n: usage.outputTokens });
40
- return { text, title, ariaLabel: title === undefined ? undefined : `${t('Done')} · ${text} · ${title}` };
56
+ const title = usage.outputTokens === undefined ? undefined : t('{n} output tokens', { n: usage.outputTokens.toLocaleString(locale) });
57
+ return { text, title, ariaLabel: title === undefined ? undefined : `${t(label)} · ${text} · ${title}` };
41
58
  }
42
59
  function usageDuration({ durationMs }, _options = {}) {
43
60
  if (durationMs === undefined)
@@ -2,11 +2,12 @@ import type { ReactNode } from 'react';
2
2
  import type { ToolDisplayRow } from '../tool-call-display.js';
3
3
  /**
4
4
  * One turn's tool cards folded into a single line ("Used 3 tools ▸") that opens on click — the
5
- * `toolCallDisplay: 'collapsed'` layout. Props in, JSX out; state and copy are in
6
- * `useToolCallGroup`.
5
+ * `toolCallDisplay: 'collapsed'` layout, or the compact failure notice in 'hidden'. The disclosure
6
+ * state lives in `useToolCallGroup`; copy uses the host's translator.
7
7
  */
8
- export declare function ToolCallGroup<Row extends ToolDisplayRow>({ rows, renderRow }: {
8
+ export declare function ToolCallGroup<Row extends ToolDisplayRow>({ rows, renderRow, failureOnly }: {
9
9
  rows: readonly Row[];
10
+ failureOnly?: boolean;
10
11
  renderRow: (row: Row) => ReactNode;
11
12
  }): import("react").JSX.Element;
12
13
  //# sourceMappingURL=ToolCallGroup.d.ts.map
@@ -3,8 +3,10 @@ export interface ToolCallGroupState {
3
3
  /** Whether the turn's tool cards are showing under the line. Starts closed. */
4
4
  open: boolean;
5
5
  toggle: () => void;
6
- /** "Used 3 tools", "Used 3 tools · 1 failed", or "Running Bash…" while a tool runs. */
6
+ /** "Used 3 tools", or "Running Bash…" while a tool runs. */
7
7
  label: string;
8
+ /** The failure suffix, styled independently of the tool count. */
9
+ failureLabel: string | undefined;
8
10
  running: boolean;
9
11
  failed: boolean;
10
12
  }
@@ -15,7 +15,8 @@ export function useToolCallGroup({ rows }, _optional = {}) {
15
15
  return {
16
16
  open,
17
17
  toggle,
18
- label: toolGroupLabel({ summary, t }),
18
+ label: toolGroupLabel({ summary: summary.kind === 'done' ? { ...summary, failed: 0 } : summary, t }),
19
+ failureLabel: summary.kind === 'done' && summary.failed > 0 ? t('{count} failed', { count: summary.failed }) : undefined,
19
20
  running: summary.kind === 'running',
20
21
  failed: summary.kind === 'done' && summary.failed > 0,
21
22
  };
@@ -1,13 +1,15 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useToolCallGroup } from './ToolCallGroup.hooks.js';
3
+ import { useT } from '../hooks/context.js';
3
4
  import { Icon } from './Icon.js';
4
5
  /**
5
6
  * One turn's tool cards folded into a single line ("Used 3 tools ▸") that opens on click — the
6
- * `toolCallDisplay: 'collapsed'` layout. Props in, JSX out; state and copy are in
7
- * `useToolCallGroup`.
7
+ * `toolCallDisplay: 'collapsed'` layout, or the compact failure notice in 'hidden'. The disclosure
8
+ * state lives in `useToolCallGroup`; copy uses the host's translator.
8
9
  */
9
- export function ToolCallGroup({ rows, renderRow }) {
10
+ export function ToolCallGroup({ rows, renderRow, failureOnly = false }) {
10
11
  const group = useToolCallGroup({ rows });
11
- return (_jsxs("div", { className: "jini-tool-group", "data-failed": group.failed ? 'true' : undefined, children: [_jsxs("button", { type: "button", className: "jini-tool-group__toggle", "aria-expanded": group.open, onClick: group.toggle, children: [_jsx("span", { className: `jini-tool-group__label${group.running ? ' shimmer-text' : ''}`, children: group.label }), _jsx(Icon, { name: group.open ? 'chevron-down' : 'chevron-right', size: 12 })] }), group.open ? _jsx("div", { className: "jini-message-tools", children: rows.map(renderRow) }) : null] }));
12
+ const t = useT();
13
+ return (_jsxs("div", { className: "jini-tool-group", "data-failed": group.failed ? 'true' : undefined, children: [failureOnly ? _jsxs("span", { className: "jini-tool-group__label", children: [t('A step failed'), " "] }) : null, _jsxs("button", { type: "button", className: "jini-tool-group__toggle", "aria-expanded": group.open, onClick: group.toggle, children: [_jsx("span", { className: `jini-tool-group__label${group.running ? ' shimmer-text' : ''}`, children: failureOnly ? t('Show') : group.label }), !failureOnly && group.failureLabel ? _jsxs("span", { className: "jini-tool-group__failed", children: [" \u00B7 ", group.failureLabel] }) : null, _jsx(Icon, { name: group.open ? 'chevron-down' : 'chevron-right', size: 12 })] }), group.open ? _jsx("div", { className: "jini-message-tools", children: rows.map(renderRow) }) : null] }));
12
14
  }
13
15
  //# sourceMappingURL=ToolCallGroup.js.map
@@ -15,11 +15,11 @@
15
15
  *
16
16
  * ## Why it resolves an outcome instead of throwing
17
17
  *
18
- * The pane acts differently on each outcome and must never drop the text: `'unsupported'` stops the
19
- * run for text-only turns, or queues an attachment turn intact; `'not-running'` queues behind the
18
+ * The pane acts differently on each outcome and must never drop the text: `'unsupported'` queues
19
+ * the whole turn; `'not-running'` queues behind the
20
20
  * ending run; `'failed'` retains the draft for retry. A 404 means
21
21
  * the daemon never saw this run (a host-side run such as a BYOK API turn), so it is `'unsupported'`
22
- * too: no live process to steer, but an interrupt still works.
22
+ * too: no live process to steer. Accepted inputs retain their fallback copy until consumed.
23
23
  */
24
24
  import { type UserTextRedactionOptions } from '../../../core/user-text-redaction.js';
25
25
  import type { DeliverMidRunMessage } from './types.js';
@@ -15,11 +15,11 @@
15
15
  *
16
16
  * ## Why it resolves an outcome instead of throwing
17
17
  *
18
- * The pane acts differently on each outcome and must never drop the text: `'unsupported'` stops the
19
- * run for text-only turns, or queues an attachment turn intact; `'not-running'` queues behind the
18
+ * The pane acts differently on each outcome and must never drop the text: `'unsupported'` queues
19
+ * the whole turn; `'not-running'` queues behind the
20
20
  * ending run; `'failed'` retains the draft for retry. A 404 means
21
21
  * the daemon never saw this run (a host-side run such as a BYOK API turn), so it is `'unsupported'`
22
- * too: no live process to steer, but an interrupt still works.
22
+ * too: no live process to steer. Accepted inputs retain their fallback copy until consumed.
23
23
  */
24
24
  import { redactUserText } from '../../../core/user-text-redaction.js';
25
25
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -31,8 +31,11 @@ async function conflictDelivery(response) {
31
31
  }
32
32
  /** Maps the route's response onto an outcome. Only 202 is a delivery. */
33
33
  async function deliveryFor(response) {
34
- if (response.status === 202)
35
- return 'delivered';
34
+ if (response.status === 202) {
35
+ const body = await response.json().catch(() => null);
36
+ const id = body?.id;
37
+ return typeof id === 'string' && id.length > 0 ? { delivery: 'delivered', id } : 'delivered';
38
+ }
36
39
  if (response.status === 409)
37
40
  return conflictDelivery(response);
38
41
  return response.status === 404 ? 'unsupported' : 'failed';
@@ -8,6 +8,35 @@ import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../.
8
8
  import { useComposer } from '../../../hooks/useComposer.js';
9
9
  import { useConversation, } from '../../../hooks/useConversation.js';
10
10
  import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
11
+ /** Filter during render so a consumed receipt followed by end in the same batch cannot flush twice.
12
+ * Legacy host receipts can replace optimistic IDs; the accepted-input owner retains their aliases.
13
+ * @complexity O(e + q) time and space for receipt events and locally queued inputs.
14
+ */
15
+ function unconsumedQueuedInputs({ inputs, messages }) {
16
+ const consumed = new Set();
17
+ for (const message of messages) {
18
+ for (const event of projectAcceptedInputEvents({ events: message.events ?? [] })) {
19
+ const receipt = acceptedInputEvent({ event });
20
+ if (receipt?.data.deliveryState !== 'consumed')
21
+ continue;
22
+ for (const input of [receipt.data, ...(receipt.data.inputs ?? [])]) {
23
+ consumed.add(input.id);
24
+ for (const alias of input.aliases ?? [])
25
+ consumed.add(alias);
26
+ }
27
+ }
28
+ }
29
+ return inputs.filter(input => !consumed.has(input.liveMessageId ?? input.id));
30
+ }
31
+ /** The conversation copies run events onto its messages one effect after streaming stops, so a
32
+ * consumed receipt can still be missing from the transcript when the send blocker clears. A
33
+ * live-accepted copy therefore waits until the run's own message reaches a terminal status. */
34
+ function awaitingRunReceipts({ inputs, messages }) {
35
+ if (!inputs.some(input => input.liveMessageId !== undefined))
36
+ return false;
37
+ const status = messages.at(-1)?.runStatus;
38
+ return status === 'queued' || status === 'running';
39
+ }
11
40
  /** The stand-in prompt for a turn that carries only attachments. */
12
41
  const ATTACHMENTS_ONLY_PROMPT = 'Review the attached file(s).';
13
42
  /**
@@ -149,11 +178,7 @@ export function useChatPane(options) {
149
178
  const [activeUploadCount, setActiveUploadCount] = useState(0);
150
179
  const [attachmentError, setAttachmentError] = useState(null);
151
180
  // MOVE: Keep the signal with each sanitized input; re-redaction cannot detect the original paste.
152
- const [queuedInputs, setQueuedInputs] = useState([]);
153
- const queuedTurn = useMemo(() => queuedInputs.reduce((queued, input) => appendQueuedTurn({
154
- queued, next: { text: input.text, secretRedacted: input.secretRedaction?.secretRedacted ?? false,
155
- count: input.secretRedaction?.count ?? 0 },
156
- }), null), [queuedInputs]);
181
+ const [storedQueuedInputs, setQueuedInputs] = useState([]);
157
182
  // MOVE: Uploads belong to the input that submitted them, even if a later message is queued first.
158
183
  const queuedUploadOwnersRef = useRef(new Map());
159
184
  // A send during upload resumes once pixels arrive; a rejected live delivery waits for run end.
@@ -221,6 +246,16 @@ export function useChatPane(options) {
221
246
  validateAttachments: options.validateAttachments,
222
247
  } }));
223
248
  const activity = resolveChatPaneActivity(selectedAgent, conversation);
249
+ const queuedInputs = useMemo(() => unconsumedQueuedInputs({ inputs: storedQueuedInputs, messages: conversation.messages }), [storedQueuedInputs, conversation.messages]);
250
+ const queuedTurn = useMemo(() => queuedInputs.reduce((queued, input) => appendQueuedTurn({
251
+ queued, next: { text: input.text, secretRedacted: input.secretRedaction?.secretRedacted ?? false,
252
+ count: input.secretRedaction?.count ?? 0 },
253
+ }), null), [queuedInputs]);
254
+ // Release consumed text/files locally as well as hiding them; next-turn fallback uses this same projection.
255
+ useEffect(() => {
256
+ if (queuedInputs.length !== storedQueuedInputs.length)
257
+ setQueuedInputs(queuedInputs);
258
+ }, [queuedInputs, storedQueuedInputs.length]);
224
259
  useEffect(() => {
225
260
  options.onActivityChange?.(activity);
226
261
  }, [activity, options.onActivityChange]);
@@ -363,7 +398,7 @@ export function useChatPane(options) {
363
398
  }
364
399
  }, [composer, options.conversationId, options.uploadAttachments]);
365
400
  /** Acceptance is the only writer of local recall and optimistic in-run bubbles. */
366
- const recordAcceptedInput = useCallback(({ inputs, bubble }, _options = {}) => {
401
+ const recordAcceptedInput = useCallback(({ inputs, bubble }, { receipt = 'optimistic' } = {}) => {
367
402
  composer.history?.record({ inputs });
368
403
  submittedComposerRef.current = composer;
369
404
  if (bubble === undefined)
@@ -372,8 +407,9 @@ export function useChatPane(options) {
372
407
  const last = messages.at(-1);
373
408
  if (last?.role !== 'assistant')
374
409
  return messages;
375
- const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text },
376
- inputs, ...bubble });
410
+ const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text,
411
+ ...(inputs[0].deliveryState ? { deliveryState: inputs[0].deliveryState } : {}) },
412
+ inputs, ...bubble }, { receipt });
377
413
  return [...messages.slice(0, -1), { ...last,
378
414
  events: projectAcceptedInputEvents({ events: [...(last.events ?? []), event] }) }];
379
415
  });
@@ -495,24 +531,37 @@ export function useChatPane(options) {
495
531
  if (inputs === undefined && latestDraftRef.current === submittedDraft)
496
532
  latestComposerRef.current.setDraft('');
497
533
  }, []);
534
+ /** HTTP acceptance retains fallback ownership; only an actual consumption receipt removes it. */
535
+ const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId }, _options = {}) => {
536
+ const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
537
+ ...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
538
+ const id = typeof outcome === 'object' ? outcome.id : accepted[0].id;
539
+ // A known HTTP id is already authoritative. Do not let equal text reconcile an unrelated
540
+ // wire receipt into this bubble; legacy hosts alone still need optimistic reconciliation.
541
+ const retained = accepted.map((input, index) => ({ ...input,
542
+ ...(index === 0 ? { id, aliases: [...(input.aliases ?? []), input.id] } : {}),
543
+ liveMessageId: id, deliveryState: 'queued',
544
+ }));
545
+ queuedConversationIdRef.current = options.conversationId;
546
+ setQueuedInputs(queued => [...queued, ...retained]);
547
+ recordAcceptedInput({ inputs: retained,
548
+ bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
549
+ }, [options.conversationId, recordAcceptedInput]);
498
550
  const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
499
551
  if (outcome === 'failed') {
500
552
  restoreQueuedDraft({ inputs, text: turn.text, attachments });
501
553
  setTypedAnswerNotice('failed');
502
554
  return;
503
555
  }
504
- if (outcome === 'delivered') {
505
- recordAcceptedInput({ inputs: inputs ?? [{ id: createChatLocalId({}), text: turn.text }],
506
- bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } });
556
+ if (outcome === 'delivered' || typeof outcome === 'object') {
557
+ retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId });
507
558
  clearDeliveredDraft({ inputs, submittedDraft });
508
559
  consumeDeliveredAttachments({ attachments });
509
560
  return;
510
561
  }
511
562
  enqueueTurn({ turn, attachments, ...(inputs === undefined ? {} : { inputs }) });
512
563
  clearDeliveredDraft({ inputs, submittedDraft });
513
- if (outcome === 'unsupported' && attachments.length === 0)
514
- conversation.cancel();
515
- }, [conversation.cancel, enqueueTurn, recordAcceptedInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
564
+ }, [enqueueTurn, retainAcceptedMidRunInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
516
565
  // A delivered steer belongs to the existing run; unsupported turns use the same accepted queue.
517
566
  // A failed delivery keeps the working draft so retry cannot silently become another run.
518
567
  const sendIntoRunningTurn = useCallback(async ({ deliver, runId, turn, inputs, attachments = composer.attachments }, _options = {}) => {
@@ -544,7 +593,7 @@ export function useChatPane(options) {
544
593
  if (uploadPending)
545
594
  queuedForUploadRef.current = queuedTurn === null || queuedForUploadRef.current;
546
595
  // MOVE: The existing queue owns its files; a later steer must join it to preserve send order.
547
- const target = uploadPending || queuedTurn !== null ? null : findMidRunTarget({
596
+ const target = uploadPending || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
548
597
  deliver: options.deliverMidRunMessage,
549
598
  blocker: sendBlocker,
550
599
  attachmentCount: composer.attachments.length,
@@ -565,6 +614,7 @@ export function useChatPane(options) {
565
614
  enqueueTurn,
566
615
  options.deliverMidRunMessage,
567
616
  queuedTurn,
617
+ queuedInputs,
568
618
  sendBlocker,
569
619
  sendIntoRunningTurn,
570
620
  sendPrompt,
@@ -659,7 +709,7 @@ export function useChatPane(options) {
659
709
  deliver: options.deliverMidRunMessage,
660
710
  blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
661
711
  }) : null;
662
- if (sendBlocker !== null && target === null)
712
+ if (target === null && (sendBlocker !== null || awaitingRunReceipts({ inputs: queuedInputs, messages: conversation.messages })))
663
713
  return;
664
714
  queuedForUploadRef.current = false;
665
715
  const inputs = queuedInputs;
@@ -712,7 +762,9 @@ export function useChatPane(options) {
712
762
  send,
713
763
  sendPrompt,
714
764
  queuedPrompt: queuedTurn?.text ?? null,
715
- queuedMessages: queuedInputs,
765
+ // Accepted live messages already have queued bubbles in the transcript. Their private fallback
766
+ // copy must not create a second composer row whose Cancel button cannot revoke daemon input.
767
+ queuedMessages: queuedInputs.filter(input => input.liveMessageId === undefined),
716
768
  cancelQueuedMessage,
717
769
  cancelQueued,
718
770
  typedAnswerNotice,
@@ -176,11 +176,16 @@ export type DeliverTypedAnswer = ((input: {
176
176
  };
177
177
  /**
178
178
  * What happened to a message sent while a run streams (see {@link ChatPaneProps.deliverMidRunMessage}):
179
- * `'delivered'` — the live agent took it into the running turn; `'unsupported'` — this agent cannot
179
+ * `{delivery:'delivered', id}` — accepted by the live agent; keep the local copy until an id-matched
180
+ * consumed event. Legacy hosts may still return `'delivered'`; their receipts reconcile optimistic ids.
181
+ * `'unsupported'` — this agent cannot
180
182
  * take input mid-run (it reads one prompt per turn); `'not-running'` — the run has no live process
181
183
  * any more (it is ending); `'failed'` — the host could not tell (network error, unexpected status).
182
184
  */
183
- export type MidRunMessageDelivery = 'delivered' | 'unsupported' | 'not-running' | 'failed';
185
+ export type MidRunMessageDelivery = 'delivered' | 'unsupported' | 'not-running' | 'failed' | {
186
+ readonly delivery: 'delivered';
187
+ readonly id: string;
188
+ };
184
189
  /** Delivers a complete turn to the live agent; unsupported attachments must reject the whole turn. */
185
190
  export type DeliverMidRunMessage = (input: {
186
191
  runId: string;
@@ -312,7 +317,8 @@ export interface ChatPaneProps extends UserTextRedactionOptions {
312
317
  /**
313
318
  * Transcript display choices (a host's chat settings, 2026-10-11), passed straight to
314
319
  * `MessageList`: how tool calls show (`'show'` default, `'collapsed'` one line per turn, `'hidden'`
315
- * all but failed ones — see `tool-call-display.ts`), whether the "Done · time · cost" lines show
320
+ * hides recovered calls and folds unrecovered failures — see `tool-call-display.ts`), whether the
321
+ * "Done · time · cost" lines show
316
322
  * (default `true`), and whether the model's reasoning shows (default `false`).
317
323
  */
318
324
  toolCallDisplay?: ToolCallDisplay;