@jini-ai/chat 0.6.10 → 0.6.12

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 +17 -0
  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/persistence/title.d.ts +8 -16
  7. package/dist/core/persistence/title.js +20 -63
  8. package/dist/core/question-answer.js +2 -2
  9. package/dist/core/run-activity.js +4 -0
  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.js +13 -8
  17. package/dist/react/components/MessageRow.usage.d.ts +9 -1
  18. package/dist/react/components/MessageRow.usage.js +22 -5
  19. package/dist/react/components/MidRunUserMessage.d.ts +1 -1
  20. package/dist/react/components/MidRunUserMessage.js +8 -5
  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 +1 -1
  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 +88 -27
  27. package/dist/react/features/chat-pane/rules.d.ts +5 -2
  28. package/dist/react/features/chat-pane/rules.js +8 -2
  29. package/dist/react/features/chat-pane/types.d.ts +7 -2
  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 +9 -9
@@ -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
  *
@@ -111,12 +111,13 @@ 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
122
  function messageBlocks({ message, rows, registry, content = message.content }, { toolCallDisplay = 'show', includeThinking = false } = {}) {
122
123
  const blocks = interleaveMessageBlocks({ events: message.events, content, rows }, { slotOf: (ev) => registry.slot({ name: ev.name, data: ev.data }), includeThinking });
@@ -150,7 +151,7 @@ function messageAwaitingAnswer({ runStreaming, runInProgress, events }, { isAnsw
150
151
  }
151
152
  function AssistantMessageFooter({ message, usageEvent, showUsageTotal, terminalNotice, pending, runInProgress, runStreaming, visibleContent, isAnsweredCard }, _optional = {}) {
152
153
  const t = useT();
153
- 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] });
154
155
  }
155
156
  /**
156
157
  * Quiet, icon-only copy affordance shared by the user- and assistant-message
@@ -207,8 +208,11 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
207
208
  const pending = isPendingWithNoContent(message, visibleContent, timeline.rows.length);
208
209
  const runInProgress = isRunInProgress(message.runStatus);
209
210
  const awaitingAnswer = messageAwaitingAnswer({ runStreaming, runInProgress, events: message.events }, { isAnsweredCard: answeredCards.isAnsweredCard });
210
- 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
+ });
211
214
  const footerUsage = showUsageLine ? usage.footer : undefined;
215
+ const lastFinishedReply = !runInProgress && !usage.footer ? usage.replies.findLastIndex(value => value !== undefined) : -1;
212
216
  const renderToolCard = (row) => (_jsx(ToolCard, { use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
213
217
  // Hidden mode reuses the same disclosure and card renderer for unrecovered failures.
214
218
  const renderTools = (rows, key) => (toolCallDisplay !== 'show'
@@ -218,7 +222,7 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
218
222
  const renderExtGroup = (group) => {
219
223
  const renderer = registry.get({ name: group.name });
220
224
  if (!renderer && group.name === MID_RUN_USER_MESSAGE_EXT_EVENT_NAME)
221
- return _jsx(MidRunUserMessage, { events: group.events });
225
+ return _jsx(MidRunUserMessage, { events: group.events, runStreaming: answeredCards.runStreaming }, group.slot);
222
226
  if (!renderer)
223
227
  return null;
224
228
  const answerText = answeredCards.answerText(group.slot);
@@ -251,7 +255,8 @@ function AssistantMessageRow({ message, usageSummary, showUsageTotal, showUsageL
251
255
  }
252
256
  if (block.kind === 'reply-end') {
253
257
  const reply = usage.replies[block.index];
254
- 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;
255
260
  }
256
261
  if (block.kind === 'tools')
257
262
  return renderTools(block.rows, block.key);
@@ -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)
@@ -7,7 +7,7 @@ export declare function midRunUserMessageSlotKey({ data }: {
7
7
  data: unknown;
8
8
  }): string | undefined;
9
9
  /** The bubble for one mid-run message — the same classes as a sent user message, so it reads as one. */
10
- export declare function MidRunUserMessage({ events }: Pick<ExtEventRenderProps, 'events'>): import("react").JSX.Element | null;
10
+ export declare function MidRunUserMessage({ events, runStreaming }: Pick<ExtEventRenderProps, 'events'> & Partial<Pick<ExtEventRenderProps, 'runStreaming'>>): import("react").JSX.Element | null;
11
11
  /**
12
12
  * Registers {@link MidRunUserMessage} against the ext-event registry.
13
13
  *
@@ -14,6 +14,7 @@ import { acceptedInputEvent } from '../../core/accepted-input.js';
14
14
  import { SentAttachmentButton } from './SentAttachment.js';
15
15
  import { AttachmentPreviewModal } from './AttachmentPreviewModal.js';
16
16
  import { useAttachmentGallery } from './AttachmentGallery.hooks.js';
17
+ import { useT } from '../hooks/context.js';
17
18
  import { MID_RUN_USER_MESSAGE_EVENT_NAME } from '../../core/turn-usage.js';
18
19
  import { registerExtEventRenderer } from '../ext-event-renderer-registry.js';
19
20
  /** The ext-event name the daemon's mid-run `user_message` run event arrives under. One source with
@@ -28,7 +29,7 @@ export function midRunUserMessageSlotKey({ data }) {
28
29
  return field(data, 'id');
29
30
  }
30
31
  /** The bubble for one mid-run message — the same classes as a sent user message, so it reads as one. */
31
- export function MidRunUserMessage({ events }) {
32
+ export function MidRunUserMessage({ events, runStreaming = false }) {
32
33
  const data = events.at(-1);
33
34
  const text = field(data, 'text');
34
35
  if (text === undefined)
@@ -36,12 +37,14 @@ export function MidRunUserMessage({ events }) {
36
37
  const receipt = acceptedInputEvent({ event: { kind: 'ext', name: MID_RUN_USER_MESSAGE_EVENT_NAME, data } });
37
38
  // MOVE: A single live delivery may contain several queued inputs; display their original receipts.
38
39
  const inputs = receipt?.data.inputs ?? [{ id: field(data, 'id') ?? 'mid-run', text }];
39
- return _jsx(_Fragment, { children: inputs.map(input => _jsx(MidRunInput, { input: input }, input.id)) });
40
+ // A run that ended without reading the message hands it to the next turn; it is no longer queued here.
41
+ return _jsx(_Fragment, { children: inputs.map(input => _jsx(MidRunInput, { input: input, queued: runStreaming && (input.deliveryState ?? receipt?.data.deliveryState) === 'queued' }, input.id)) });
40
42
  }
41
- function MidRunInput({ input }) {
43
+ function MidRunInput({ input, queued }) {
44
+ const t = useT();
42
45
  const attachments = input.attachments ?? [];
43
46
  const gallery = useAttachmentGallery({ attachments });
44
- return (_jsxs("div", { className: "jini-message jini-message-user jini-message-user--mid-run", "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [_jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
47
+ return (_jsxs("div", { className: "jini-message jini-message-user jini-message-user--mid-run", "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
45
48
  }
46
49
  /**
47
50
  * Registers {@link MidRunUserMessage} against the ext-event registry.
@@ -49,6 +52,6 @@ function MidRunInput({ input }) {
49
52
  * @returns An unregister handle, so a test or a hot reload can dispose cleanly.
50
53
  */
51
54
  export function registerMidRunUserMessageRenderer() {
52
- return registerExtEventRenderer({ name: MID_RUN_USER_MESSAGE_EXT_EVENT_NAME, renderer: (props) => _jsx(MidRunUserMessage, { events: props.events }) }, { slotKey: midRunUserMessageSlotKey });
55
+ return registerExtEventRenderer({ name: MID_RUN_USER_MESSAGE_EXT_EVENT_NAME, renderer: (props) => _jsx(MidRunUserMessage, { events: props.events, runStreaming: props.runStreaming }) }, { slotKey: midRunUserMessageSlotKey });
53
56
  }
54
57
  //# sourceMappingURL=MidRunUserMessage.js.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
  };
@@ -10,6 +10,6 @@ import { Icon } from './Icon.js';
10
10
  export function ToolCallGroup({ rows, renderRow, failureOnly = false }) {
11
11
  const group = useToolCallGroup({ rows });
12
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 }), _jsx(Icon, { name: group.open ? 'chevron-down' : 'chevron-right', size: 12 })] }), group.open ? _jsx("div", { className: "jini-message-tools", children: rows.map(renderRow) }) : null] }));
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] }));
14
14
  }
15
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';
@@ -1,5 +1,6 @@
1
1
  import { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../../../../core/accepted-input.js';
2
2
  import { useUserTextGuard } from '../../../hooks/useUserTextGuard.js';
3
+ import { isAwaitingAnswer } from '../../../../core/run-activity.js';
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
5
  import { appendQueuedTurn, describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, findMidRunMessageRunId, isChatPaneQueueableBlocker, resolveChatPaneSelection, } from '../rules.js';
5
6
  import { definedProps } from '../../../util/defined-props.js';
@@ -8,6 +9,35 @@ import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../.
8
9
  import { useComposer } from '../../../hooks/useComposer.js';
9
10
  import { useConversation, } from '../../../hooks/useConversation.js';
10
11
  import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
12
+ /** Filter during render so a consumed receipt followed by end in the same batch cannot flush twice.
13
+ * Legacy host receipts can replace optimistic IDs; the accepted-input owner retains their aliases.
14
+ * @complexity O(e + q) time and space for receipt events and locally queued inputs.
15
+ */
16
+ function unconsumedQueuedInputs({ inputs, messages }) {
17
+ const consumed = new Set();
18
+ for (const message of messages) {
19
+ for (const event of projectAcceptedInputEvents({ events: message.events ?? [] })) {
20
+ const receipt = acceptedInputEvent({ event });
21
+ if (receipt?.data.deliveryState !== 'consumed')
22
+ continue;
23
+ for (const input of [receipt.data, ...(receipt.data.inputs ?? [])]) {
24
+ consumed.add(input.id);
25
+ for (const alias of input.aliases ?? [])
26
+ consumed.add(alias);
27
+ }
28
+ }
29
+ }
30
+ return inputs.filter(input => !consumed.has(input.liveMessageId ?? input.id));
31
+ }
32
+ /** The conversation copies run events onto its messages one effect after streaming stops, so a
33
+ * consumed receipt can still be missing from the transcript when the send blocker clears. A
34
+ * live-accepted copy therefore waits until the run's own message reaches a terminal status. */
35
+ function awaitingRunReceipts({ inputs, messages }) {
36
+ if (!inputs.some(input => input.liveMessageId !== undefined))
37
+ return false;
38
+ const status = messages.at(-1)?.runStatus;
39
+ return status === 'queued' || status === 'running';
40
+ }
11
41
  /** The stand-in prompt for a turn that carries only attachments. */
12
42
  const ATTACHMENTS_ONLY_PROMPT = 'Review the attached file(s).';
13
43
  /**
@@ -79,11 +109,17 @@ function resolveChatPaneActivity(selectedAgent, conversation) {
79
109
  return 'streaming';
80
110
  return 'ready';
81
111
  }
112
+ /** Hold new input until its uploads or human gate settle; the existing queue owns the retry.
113
+ * @complexity O(e) in the newest message's events, with no I/O.
114
+ */
115
+ function waitingForLiveInput({ uploadPending, messages }, _options = {}) {
116
+ return uploadPending || isAwaitingAnswer({ events: messages.at(-1)?.events });
117
+ }
82
118
  /**
83
119
  * The host delivery and live run a send can go INTO instead of queueing behind, or `null` when the
84
120
  * host offers no mid-run delivery or no run can take it ({@link findMidRunMessageRunId}).
85
121
  *
86
- * @complexity O(1).
122
+ * @complexity O(e) in the newest message's events.
87
123
  */
88
124
  function findMidRunTarget({ deliver, blocker, attachmentCount, messages }) {
89
125
  if (deliver === undefined)
@@ -149,15 +185,11 @@ export function useChatPane(options) {
149
185
  const [activeUploadCount, setActiveUploadCount] = useState(0);
150
186
  const [attachmentError, setAttachmentError] = useState(null);
151
187
  // 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]);
188
+ const [storedQueuedInputs, setQueuedInputs] = useState([]);
157
189
  // MOVE: Uploads belong to the input that submitted them, even if a later message is queued first.
158
190
  const queuedUploadOwnersRef = useRef(new Map());
159
- // A send during upload resumes once pixels arrive; a rejected live delivery waits for run end.
160
- const queuedForUploadRef = useRef(false);
191
+ // A send held for uploads or a human card resumes live; a refused delivery waits for run end.
192
+ const queuedForLiveDeliveryRef = useRef(false);
161
193
  const submittedComposerRef = useRef(null);
162
194
  const startingPromptRef = useRef(false);
163
195
  const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
@@ -221,6 +253,16 @@ export function useChatPane(options) {
221
253
  validateAttachments: options.validateAttachments,
222
254
  } }));
223
255
  const activity = resolveChatPaneActivity(selectedAgent, conversation);
256
+ const queuedInputs = useMemo(() => unconsumedQueuedInputs({ inputs: storedQueuedInputs, messages: conversation.messages }), [storedQueuedInputs, conversation.messages]);
257
+ const queuedTurn = useMemo(() => queuedInputs.reduce((queued, input) => appendQueuedTurn({
258
+ queued, next: { text: input.text, secretRedacted: input.secretRedaction?.secretRedacted ?? false,
259
+ count: input.secretRedaction?.count ?? 0 },
260
+ }), null), [queuedInputs]);
261
+ // Release consumed text/files locally as well as hiding them; next-turn fallback uses this same projection.
262
+ useEffect(() => {
263
+ if (queuedInputs.length !== storedQueuedInputs.length)
264
+ setQueuedInputs(queuedInputs);
265
+ }, [queuedInputs, storedQueuedInputs.length]);
224
266
  useEffect(() => {
225
267
  options.onActivityChange?.(activity);
226
268
  }, [activity, options.onActivityChange]);
@@ -363,7 +405,7 @@ export function useChatPane(options) {
363
405
  }
364
406
  }, [composer, options.conversationId, options.uploadAttachments]);
365
407
  /** Acceptance is the only writer of local recall and optimistic in-run bubbles. */
366
- const recordAcceptedInput = useCallback(({ inputs, bubble }, _options = {}) => {
408
+ const recordAcceptedInput = useCallback(({ inputs, bubble }, { receipt = 'optimistic' } = {}) => {
367
409
  composer.history?.record({ inputs });
368
410
  submittedComposerRef.current = composer;
369
411
  if (bubble === undefined)
@@ -372,8 +414,9 @@ export function useChatPane(options) {
372
414
  const last = messages.at(-1);
373
415
  if (last?.role !== 'assistant')
374
416
  return messages;
375
- const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text },
376
- inputs, ...bubble });
417
+ const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text,
418
+ ...(inputs[0].deliveryState ? { deliveryState: inputs[0].deliveryState } : {}) },
419
+ inputs, ...bubble }, { receipt });
377
420
  return [...messages.slice(0, -1), { ...last,
378
421
  events: projectAcceptedInputEvents({ events: [...(last.events ?? []), event] }) }];
379
422
  });
@@ -495,24 +538,37 @@ export function useChatPane(options) {
495
538
  if (inputs === undefined && latestDraftRef.current === submittedDraft)
496
539
  latestComposerRef.current.setDraft('');
497
540
  }, []);
541
+ /** HTTP acceptance retains fallback ownership; only an actual consumption receipt removes it. */
542
+ const retainAcceptedMidRunInput = useCallback(({ outcome, inputs, turn, attachments, afterInputId }, _options = {}) => {
543
+ const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
544
+ ...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
545
+ const id = typeof outcome === 'object' ? outcome.id : accepted[0].id;
546
+ // A known HTTP id is already authoritative. Do not let equal text reconcile an unrelated
547
+ // wire receipt into this bubble; legacy hosts alone still need optimistic reconciliation.
548
+ const retained = accepted.map((input, index) => ({ ...input,
549
+ ...(index === 0 ? { id, aliases: [...(input.aliases ?? []), input.id] } : {}),
550
+ liveMessageId: id, deliveryState: 'queued',
551
+ }));
552
+ queuedConversationIdRef.current = options.conversationId;
553
+ setQueuedInputs(queued => [...queued, ...retained]);
554
+ recordAcceptedInput({ inputs: retained,
555
+ bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } }, typeof outcome === 'object' ? { receipt: 'accepted' } : {});
556
+ }, [options.conversationId, recordAcceptedInput]);
498
557
  const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
499
558
  if (outcome === 'failed') {
500
559
  restoreQueuedDraft({ inputs, text: turn.text, attachments });
501
560
  setTypedAnswerNotice('failed');
502
561
  return;
503
562
  }
504
- if (outcome === 'delivered') {
505
- recordAcceptedInput({ inputs: inputs ?? [{ id: createChatLocalId({}), text: turn.text }],
506
- bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } });
563
+ if (outcome === 'delivered' || typeof outcome === 'object') {
564
+ retainAcceptedMidRunInput({ outcome, inputs, turn, attachments, afterInputId });
507
565
  clearDeliveredDraft({ inputs, submittedDraft });
508
566
  consumeDeliveredAttachments({ attachments });
509
567
  return;
510
568
  }
511
569
  enqueueTurn({ turn, attachments, ...(inputs === undefined ? {} : { inputs }) });
512
570
  clearDeliveredDraft({ inputs, submittedDraft });
513
- if (outcome === 'unsupported' && attachments.length === 0)
514
- conversation.cancel();
515
- }, [conversation.cancel, enqueueTurn, recordAcceptedInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
571
+ }, [enqueueTurn, retainAcceptedMidRunInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
516
572
  // A delivered steer belongs to the existing run; unsupported turns use the same accepted queue.
517
573
  // A failed delivery keeps the working draft so retry cannot silently become another run.
518
574
  const sendIntoRunningTurn = useCallback(async ({ deliver, runId, turn, inputs, attachments = composer.attachments }, _options = {}) => {
@@ -541,10 +597,12 @@ export function useChatPane(options) {
541
597
  }
542
598
  const turn = guard.redact({ text: prompt }, {});
543
599
  const uploadPending = activeUploadsRef.current.size > 0 || attachmentError !== null;
544
- if (uploadPending)
545
- queuedForUploadRef.current = queuedTurn === null || queuedForUploadRef.current;
600
+ // Retry live delivery after the human gate settles, using the same queue as pending uploads.
601
+ if (waitingForLiveInput({ uploadPending, messages: conversation.messages })) {
602
+ queuedForLiveDeliveryRef.current = queuedTurn === null || queuedForLiveDeliveryRef.current;
603
+ }
546
604
  // 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({
605
+ const target = uploadPending || queuedInputs.some(input => input.liveMessageId === undefined) ? null : findMidRunTarget({
548
606
  deliver: options.deliverMidRunMessage,
549
607
  blocker: sendBlocker,
550
608
  attachmentCount: composer.attachments.length,
@@ -565,6 +623,7 @@ export function useChatPane(options) {
565
623
  enqueueTurn,
566
624
  options.deliverMidRunMessage,
567
625
  queuedTurn,
626
+ queuedInputs,
568
627
  sendBlocker,
569
628
  sendIntoRunningTurn,
570
629
  sendPrompt,
@@ -629,7 +688,7 @@ export function useChatPane(options) {
629
688
  }, []);
630
689
  const cancelQueued = useCallback(() => {
631
690
  submittedComposerRef.current = null;
632
- queuedForUploadRef.current = false;
691
+ queuedForLiveDeliveryRef.current = false;
633
692
  queuedConversationIdRef.current = undefined;
634
693
  discardQueuedUploads({});
635
694
  setQueuedInputs([]);
@@ -643,7 +702,7 @@ export function useChatPane(options) {
643
702
  // clear blocker. Clear the slot before awaiting so re-renders cannot send it twice.
644
703
  useEffect(() => {
645
704
  if (queuedTurn === null) {
646
- queuedForUploadRef.current = false;
705
+ queuedForLiveDeliveryRef.current = false;
647
706
  return;
648
707
  }
649
708
  if (queuedConversationIdRef.current !== options.conversationId) {
@@ -655,13 +714,13 @@ export function useChatPane(options) {
655
714
  }
656
715
  if (activeUploadCount > 0 || attachmentError !== null)
657
716
  return;
658
- const target = queuedForUploadRef.current ? findMidRunTarget({
717
+ const target = queuedForLiveDeliveryRef.current ? findMidRunTarget({
659
718
  deliver: options.deliverMidRunMessage,
660
719
  blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
661
720
  }) : null;
662
- if (sendBlocker !== null && target === null)
721
+ if (target === null && (sendBlocker !== null || awaitingRunReceipts({ inputs: queuedInputs, messages: conversation.messages })))
663
722
  return;
664
- queuedForUploadRef.current = false;
723
+ queuedForLiveDeliveryRef.current = false;
665
724
  const inputs = queuedInputs;
666
725
  const attachments = inputs.flatMap(input => input.attachments);
667
726
  setQueuedInputs([]);
@@ -690,7 +749,7 @@ export function useChatPane(options) {
690
749
  // `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
691
750
  // the just-reset conversation instead of discarding it like the rest of this turn's state.
692
751
  queuedConversationIdRef.current = undefined;
693
- queuedForUploadRef.current = false;
752
+ queuedForLiveDeliveryRef.current = false;
694
753
  queuedUploadOwnersRef.current.clear();
695
754
  setQueuedInputs([]);
696
755
  }, [composer, conversation, options.initialMessages]);
@@ -712,7 +771,9 @@ export function useChatPane(options) {
712
771
  send,
713
772
  sendPrompt,
714
773
  queuedPrompt: queuedTurn?.text ?? null,
715
- queuedMessages: queuedInputs,
774
+ // Accepted live messages already have queued bubbles in the transcript. Their private fallback
775
+ // copy must not create a second composer row whose Cancel button cannot revoke daemon input.
776
+ queuedMessages: queuedInputs.filter(input => input.liveMessageId === undefined),
716
777
  cancelQueuedMessage,
717
778
  cancelQueued,
718
779
  typedAnswerNotice,
@@ -146,13 +146,16 @@ export declare function isTypedAnswerTurn({ blocker, attachmentCount, messages,
146
146
  * turn with a known `runId`.
147
147
  * Attachment support is decided by the host's delivery, never by dropping them here.
148
148
  *
149
- * @complexity O(1).
149
+ * A native live-input transport can reject a pending human gate when given another user turn.
150
+ * Keep ordinary text in the pane's queue until the card's tool settles; typed question answers
151
+ * have their own delivery path and never pass this guard.
152
+ * @complexity O(e) in the newest message's events.
150
153
  */
151
154
  export declare function findMidRunMessageRunId({ blocker, messages }: {
152
155
  blocker: ChatPaneSendBlocker | null;
153
156
  attachmentCount: number;
154
157
  messages: readonly ChatMessage[];
155
- }): string | null;
158
+ }, _options?: {}): string | null;
156
159
  /**
157
160
  * MOVE: Display is per-message; delivery is ONE queued turn. A second message behind the same run
158
161
  * is appended to that delivery (a blank line apart) rather than replacing it:
@@ -1,6 +1,7 @@
1
1
  import { configuredPickerModel, pickerModelOptions, isConcretePickerModel } from './model-options.js';
2
2
  import { isTerminalRunStatus } from '../../../core/messages.js';
3
3
  import { acceptedInputEvent } from '../../../core/accepted-input.js';
4
+ import { isAwaitingAnswer } from '../../../core/run-activity.js';
4
5
  function preferredOptionId(options) {
5
6
  return options?.find((option) => option.id === 'default')?.id ?? options?.[0]?.id;
6
7
  }
@@ -234,12 +235,17 @@ export function isTypedAnswerTurn({ blocker, attachmentCount, messages, toolName
234
235
  * turn with a known `runId`.
235
236
  * Attachment support is decided by the host's delivery, never by dropping them here.
236
237
  *
237
- * @complexity O(1).
238
+ * A native live-input transport can reject a pending human gate when given another user turn.
239
+ * Keep ordinary text in the pane's queue until the card's tool settles; typed question answers
240
+ * have their own delivery path and never pass this guard.
241
+ * @complexity O(e) in the newest message's events.
238
242
  */
239
- export function findMidRunMessageRunId({ blocker, messages }) {
243
+ export function findMidRunMessageRunId({ blocker, messages }, _options = {}) {
240
244
  if (!isChatPaneQueueableBlocker({ blocker }))
241
245
  return null;
242
246
  const last = messages.at(-1);
247
+ if (isAwaitingAnswer({ events: last?.events }))
248
+ return null;
243
249
  return last?.role === 'assistant' && last.runId ? last.runId : null;
244
250
  }
245
251
  /**
@@ -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;