@jini-ai/chat 0.6.6 → 0.6.7

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 (156) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +184 -97
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.js +47 -31
  77. package/dist/react/components/MessageRow.usage.js +16 -11
  78. package/dist/react/components/MidRunUserMessage.js +16 -3
  79. package/dist/react/components/QuestionForm.js +148 -76
  80. package/dist/react/components/QuestionsPanel.js +1 -1
  81. package/dist/react/components/TodoCard.js +12 -6
  82. package/dist/react/components/ToolCard.js +47 -40
  83. package/dist/react/components/composer-discovery.js +6 -1
  84. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  85. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  86. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  87. package/dist/react/ext-event-renderer-registry.js +5 -2
  88. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  89. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  90. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  91. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  92. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  93. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  94. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  95. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  96. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  97. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  98. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  99. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  100. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  101. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  102. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  103. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  104. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  105. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  106. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  107. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  108. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  109. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  110. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  111. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  112. package/dist/react/features/chat-pane/model-options.js +45 -25
  113. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  114. package/dist/react/features/chat-pane/rules.js +84 -46
  115. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  116. package/dist/react/features/chat-pane/styles.js +152 -36
  117. package/dist/react/features/chat-pane/types.d.ts +17 -8
  118. package/dist/react/hooks/assistant-chats.js +12 -5
  119. package/dist/react/hooks/composer-draft-cache.js +116 -118
  120. package/dist/react/hooks/composer-history-storage.js +5 -5
  121. package/dist/react/hooks/fab-position.js +7 -4
  122. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  123. package/dist/react/hooks/useAnsweredCards.js +38 -11
  124. package/dist/react/hooks/useArtifactStream.js +28 -22
  125. package/dist/react/hooks/useComposer.d.ts +20 -0
  126. package/dist/react/hooks/useComposer.js +22 -9
  127. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  128. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  129. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  130. package/dist/react/hooks/useConversation.d.ts +7 -0
  131. package/dist/react/hooks/useConversation.js +23 -10
  132. package/dist/react/hooks/useExtEventGroups.js +39 -39
  133. package/dist/react/hooks/useQuestionForms.js +21 -26
  134. package/dist/react/hooks/useRunStream.js +10 -2
  135. package/dist/react/index.d.ts +7 -1
  136. package/dist/react/index.js +9 -0
  137. package/dist/react/message-blocks.d.ts +6 -4
  138. package/dist/react/message-blocks.js +94 -72
  139. package/dist/react/slots.d.ts +2 -2
  140. package/dist/react/tool-call-display.js +19 -17
  141. package/dist/server/durable-runs/recover.js +132 -80
  142. package/dist/server/run-finalizer/entry.js +25 -15
  143. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  144. package/dist/store/legacy/sqlite/conversations.js +69 -98
  145. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  146. package/dist/store/legacy/sqlite/messages.js +113 -71
  147. package/dist/store/runtime/chat-history-store.js +9 -3
  148. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  149. package/dist/store/runtime/durable-run-store.js +165 -117
  150. package/dist/store/runtime/history-tools.js +118 -82
  151. package/dist/store/runtime/run-ledger.d.ts +4 -1
  152. package/dist/store/runtime/run-ledger.js +56 -16
  153. package/dist/store/sql/cursor.js +38 -18
  154. package/dist/store/sql/store.d.ts +18 -0
  155. package/dist/store/sql/store.js +46 -15
  156. package/package.json +12 -10
@@ -1,12 +1,15 @@
1
+ import { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../../../../core/accepted-input.js';
1
2
  import { useUserTextGuard } from '../../../hooks/useUserTextGuard.js';
2
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { appendQueuedTurn, describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, findMidRunMessageRunId, isChatPaneQueueableBlocker, isTypedAnswerTurn, resolveChatPaneSelection, } from '../rules.js';
4
+ import { appendQueuedTurn, describeChatPaneSendBlocker, findAwaitedTypedAnswerId, findChatPaneSendBlocker, findMidRunMessageRunId, isChatPaneQueueableBlocker, resolveChatPaneSelection, } from '../rules.js';
4
5
  import { definedProps } from '../../../util/defined-props.js';
5
6
  import { cacheAttachmentPreviewSource } from '../../../hooks/attachment-preview-cache.js';
6
7
  import { readCachedAttachmentBatchId, writeCachedAttachmentBatchId, } from '../../../hooks/composer-draft-cache.js';
7
8
  import { useComposer } from '../../../hooks/useComposer.js';
8
9
  import { useConversation, } from '../../../hooks/useConversation.js';
9
10
  import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hooks.js';
11
+ /** The stand-in prompt for a turn that carries only attachments. */
12
+ const ATTACHMENTS_ONLY_PROMPT = 'Review the attached file(s).';
10
13
  /**
11
14
  * The prompt a composer-driven send would carry: the trimmed draft, or a stand-in when only
12
15
  * attachments are staged. Module scope so `send()` and `interruptSend()` cannot drift apart on
@@ -14,9 +17,26 @@ import { useChatPaneWorkingDirectory, } from './useChatPaneWorkingDirectory.hook
14
17
  */
15
18
  function composerPrompt(composer) {
16
19
  return composer.draft.trim()
17
- || (composer.attachments.length > 0 ? 'Review the attached file(s).' : '');
20
+ || (composer.attachments.length > 0 ? ATTACHMENTS_ONLY_PROMPT : '');
18
21
  }
19
- function createAttachmentBatchId() {
22
+ /** History hydration can replace a controller without an edit. Compare the accepted empty draft
23
+ * and staged files so holding Enter cannot requeue an attachment-only submission on that render. */
24
+ function isRepeatedComposerSubmission({ composer, submitted }, _options = {}) {
25
+ return submitted === composer || (submitted !== null && composer.draft.trim() === '' && submitted.attachments === composer.attachments);
26
+ }
27
+ function composerDeliveryBlocked({ delivering, starting, composer, submitted }, _options = {}) {
28
+ return delivering || starting || isRepeatedComposerSubmission({ composer, submitted });
29
+ }
30
+ function resolveRunContext({ context, input }, _options = {}) {
31
+ return typeof context === 'function' ? context(input) : context;
32
+ }
33
+ function assertPromptSendable({ trimmed, sendBlocker }, _options = {}) {
34
+ if (!trimmed)
35
+ throw new Error('cannot send: the prompt is empty');
36
+ if (sendBlocker !== null)
37
+ throw new Error(`cannot send: ${describeChatPaneSendBlocker(sendBlocker)}`);
38
+ }
39
+ function createChatLocalId(_required, _options = {}) {
20
40
  return globalThis.crypto?.randomUUID?.()
21
41
  ?? `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
22
42
  }
@@ -35,14 +55,14 @@ async function deliverTypedAnswerSafely({ deliver, text }) {
35
55
  }
36
56
  }
37
57
  /**
38
- * Hands one mid-run message to the host. A throw counts as `'failed'`, which queues the text — the
39
- * host could not say whether the run took it, and a message must never be dropped.
58
+ * Hands one mid-run message to the host. A throw counts as `'failed'`: retain the draft because
59
+ * the host could not establish delivery, rather than starting another run on a network failure.
40
60
  *
41
61
  * @complexity O(1) plus the host's own request.
42
62
  */
43
- async function deliverMidRunMessageSafely({ deliver, runId, text }) {
63
+ async function deliverMidRunMessageSafely({ deliver, ...input }) {
44
64
  try {
45
- return await deliver({ runId, text });
65
+ return await deliver(input);
46
66
  }
47
67
  catch {
48
68
  return 'failed';
@@ -59,6 +79,43 @@ function resolveChatPaneActivity(selectedAgent, conversation) {
59
79
  return 'streaming';
60
80
  return 'ready';
61
81
  }
82
+ /**
83
+ * The host delivery and live run a send can go INTO instead of queueing behind, or `null` when the
84
+ * host offers no mid-run delivery or no run can take it ({@link findMidRunMessageRunId}).
85
+ *
86
+ * @complexity O(1).
87
+ */
88
+ function findMidRunTarget({ deliver, blocker, attachmentCount, messages }) {
89
+ if (deliver === undefined)
90
+ return null;
91
+ const runId = findMidRunMessageRunId({ blocker, attachmentCount, messages });
92
+ return runId === null ? null : { deliver, runId };
93
+ }
94
+ /**
95
+ * The host's typed-answer delivery and the id of the question it answers when this send answers
96
+ * the question the run is waiting on, else `null`. The same test as `isTypedAnswerTurn` in
97
+ * `rules.ts`, unfolded so the awaited question's id comes back with the deliverer — a typed answer
98
+ * therefore always carries the id of the question it answers.
99
+ *
100
+ * @complexity O(m) over the messages scanned for the open question.
101
+ */
102
+ function findTypedAnswerTarget({ deliver, blocker, attachmentCount, messages }) {
103
+ if (deliver === undefined || !isChatPaneQueueableBlocker({ blocker }) || attachmentCount !== 0)
104
+ return null;
105
+ const questionId = findAwaitedTypedAnswerId({ messages, toolName: deliver.toolName });
106
+ return questionId === null ? null : { deliver, questionId };
107
+ }
108
+ /**
109
+ * Whether a delivered mid-run turn took every staged attachment with it, so the next staged file
110
+ * starts a fresh upload batch. Not while an upload is still landing: that file joins this batch.
111
+ *
112
+ * @complexity O(s * t) over the staged and sent attachments (both a handful).
113
+ */
114
+ function deliveredEveryStagedAttachment({ sent, staged, activeUploadCount }) {
115
+ if (activeUploadCount > 0 || sent.length === 0)
116
+ return false;
117
+ return staged.every(attachment => sent.some(item => item.path === attachment.path));
118
+ }
62
119
  /**
63
120
  * Runs one attachment upload and, unless superseded/unmounted/aborted by the time it resolves,
64
121
  * hands the results (or error) to the caller's effects. Concurrent by design — unlike
@@ -91,23 +148,27 @@ export function useChatPane(options) {
91
148
  const guard = useUserTextGuard(options);
92
149
  const [activeUploadCount, setActiveUploadCount] = useState(0);
93
150
  const [attachmentError, setAttachmentError] = useState(null);
94
- // Keep the signal with sanitized queue text; re-redacting it later cannot detect the original paste.
95
- const [queuedTurn, setQueuedTurn] = useState(null);
151
+ // 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]);
157
+ // MOVE: Uploads belong to the input that submitted them, even if a later message is queued first.
158
+ 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);
161
+ const submittedComposerRef = useRef(null);
162
+ const startingPromptRef = useRef(false);
96
163
  const [typedAnswerNotice, setTypedAnswerNotice] = useState(null);
97
- // Set across the await of one typed-answer delivery so a second Enter cannot post the same text
98
- // twice — the second post would find the question already answered and wrongly report it closed.
164
+ // Shared delivery lock: a second Enter cannot submit the same answer or screenshot twice.
99
165
  const deliveringTypedAnswerRef = useRef(false);
100
- // The question (tool-use id) the current draft was typed for, or null. Set while a non-empty draft
101
- // sits in the composer as a question waits, and kept after that question closes (answered
102
- // elsewhere, expired, 409), so `send` cannot slip the draft into the queue as a new paid run.
103
- // Released only by an empty draft, a delivery, `reset()`, or `sendAsNewMessage`.
104
- const heldAnswerQuestionIdRef = useRef(null);
105
- // The question the last delivery answered: typing more while its card is still drawn open (its
106
- // tool_result has not streamed in yet) is a follow-up, not another answer to hold.
166
+ // MOVE: A delivered or definitively closed question cannot consume the next draft before tool_result arrives.
107
167
  const answeredQuestionIdRef = useRef(null);
108
168
  // Bumped by `reset()` and a conversation switch; a delivery that settles under a different value
109
169
  // belongs to a composer that no longer exists and must neither clear the draft nor set a notice.
110
170
  const typedAnswerGenerationRef = useRef(0);
171
+ const typedAnswerConversationRef = useRef(options.conversationId);
111
172
  // The conversation `queuedPrompt` was queued against. Compared with `options.conversationId` at
112
173
  // flush time so a prompt queued behind a streaming run in one conversation can never be posted
113
174
  // into a different one the caller switched to before that run finished.
@@ -117,7 +178,7 @@ export function useChatPane(options) {
117
178
  // Every attachment staged for one turn must share one upload batch (the daemon refuses a turn
118
179
  // spanning two). So a mount that will restore cached attachments starts in THEIR batch rather
119
180
  // than a new one; see `readCachedAttachmentBatchId`.
120
- const [initialAttachmentBatchId] = useState(() => readCachedAttachmentBatchId({ conversationId: options.conversationId }) ?? createAttachmentBatchId());
181
+ const [initialAttachmentBatchId] = useState(() => readCachedAttachmentBatchId({ conversationId: options.conversationId }) ?? createChatLocalId({}));
121
182
  const attachmentBatchIdRef = useRef(initialAttachmentBatchId);
122
183
  const batchConversationIdRef = useRef(options.conversationId);
123
184
  const activeUploadsRef = useRef(new Set());
@@ -154,6 +215,7 @@ export function useChatPane(options) {
154
215
  historyMessages: conversation.messages,
155
216
  historyScope: options.composerHistoryScope,
156
217
  historyStorage: options.composerHistoryStorage,
218
+ historyCap: options.composerHistoryCap,
157
219
  initialAgent: selection,
158
220
  conversationId: options.conversationId,
159
221
  validateAttachments: options.validateAttachments,
@@ -219,24 +281,19 @@ export function useChatPane(options) {
219
281
  });
220
282
  const canSend = sendBlocker === null && composer.canSubmit;
221
283
  // Read after `await` in `answerWaitingQuestion`, where the render-time `composer.draft` is stale.
284
+ const latestComposerRef = useRef(composer);
285
+ latestComposerRef.current = composer;
222
286
  const latestDraftRef = useRef(composer.draft);
223
287
  latestDraftRef.current = composer.draft;
224
- const awaitedQuestionId = options.deliverTypedAnswer === undefined
225
- ? null
226
- : findAwaitedTypedAnswerId({ messages: conversation.messages, toolName: options.deliverTypedAnswer.toolName });
227
- const hasDraft = composer.draft.trim() !== '';
288
+ const latestAttachmentsRef = useRef(composer.attachments);
289
+ latestAttachmentsRef.current = composer.attachments;
228
290
  useEffect(() => {
229
- if (!hasDraft) {
230
- heldAnswerQuestionIdRef.current = null;
291
+ const previous = typedAnswerConversationRef.current;
292
+ typedAnswerConversationRef.current = options.conversationId;
293
+ // First-turn adoption gives the existing composer its server key; it does not replace it.
294
+ if (previous === options.conversationId || (previous == null && options.conversationId != null))
231
295
  return;
232
- }
233
- if (awaitedQuestionId !== null && awaitedQuestionId !== answeredQuestionIdRef.current) {
234
- heldAnswerQuestionIdRef.current = awaitedQuestionId;
235
- }
236
- }, [awaitedQuestionId, hasDraft]);
237
- useEffect(() => {
238
296
  typedAnswerGenerationRef.current += 1;
239
- heldAnswerQuestionIdRef.current = null;
240
297
  }, [options.conversationId]);
241
298
  // A conversation switch without a remount: adopt the batch of the attachments the composer is
242
299
  // about to restore for the new conversation, else start a fresh one so two conversations never
@@ -255,11 +312,13 @@ export function useChatPane(options) {
255
312
  }
256
313
  if (previousConversationId == null)
257
314
  return;
258
- attachmentBatchIdRef.current = createAttachmentBatchId();
315
+ attachmentBatchIdRef.current = createChatLocalId({});
259
316
  }, [options.conversationId]);
260
317
  const addAttachments = useCallback(async (files) => {
261
318
  if (files.length === 0 || options.uploadAttachments === undefined)
262
319
  return;
320
+ // MOVE: A new upload is a fresh submission, including before its first attachment resolves.
321
+ submittedComposerRef.current = null;
263
322
  const generation = attachmentGenerationRef.current;
264
323
  const batchId = attachmentBatchIdRef.current;
265
324
  const conversationId = options.conversationId;
@@ -284,7 +343,12 @@ export function useChatPane(options) {
284
343
  // Recorded before staging so the cache entry that persists this attachment carries its
285
344
  // batch, which is what lets a later mount restore the batch along with the attachment.
286
345
  writeCachedAttachmentBatchId({ conversationId, batchId });
287
- composer.addAttachment(attachment);
346
+ const inputId = queuedUploadOwnersRef.current.get(controller);
347
+ if (inputId === undefined)
348
+ composer.addAttachment(attachment);
349
+ else
350
+ setQueuedInputs(inputs => inputs.map(input => input.id === inputId
351
+ ? { ...input, attachments: [...input.attachments, attachment] } : input));
288
352
  if (file)
289
353
  cacheAttachmentPreviewSource({ path: attachment.path, file });
290
354
  },
@@ -292,154 +356,246 @@ export function useChatPane(options) {
292
356
  });
293
357
  }
294
358
  finally {
359
+ queuedUploadOwnersRef.current.delete(controller);
295
360
  if (activeUploadsRef.current.delete(controller) && mountedRef.current) {
296
361
  setActiveUploadCount(activeUploadsRef.current.size);
297
362
  }
298
363
  }
299
364
  }, [composer, options.conversationId, options.uploadAttachments]);
300
- const sendGuardedPrompt = useCallback(async ({ redaction }, _optional = {}) => {
365
+ /** Acceptance is the only writer of local recall and optimistic in-run bubbles. */
366
+ const recordAcceptedInput = useCallback(({ inputs, bubble }, _options = {}) => {
367
+ composer.history?.record({ inputs });
368
+ submittedComposerRef.current = composer;
369
+ if (bubble === undefined)
370
+ return;
371
+ conversation.setMessages(messages => {
372
+ const last = messages.at(-1);
373
+ if (last?.role !== 'assistant')
374
+ return messages;
375
+ const event = acceptedInputUserEvent({ input: { id: inputs[0].id, text: bubble.text },
376
+ inputs, ...bubble });
377
+ return [...messages.slice(0, -1), { ...last,
378
+ events: projectAcceptedInputEvents({ events: [...(last.events ?? []), event] }) }];
379
+ });
380
+ }, [composer, conversation.setMessages]);
381
+ /** Both accepted send paths consume the sent files and rotate the same upload batch. */
382
+ const consumeDeliveredAttachments = useCallback(({ attachments }, _options = {}) => {
383
+ for (const attachment of attachments)
384
+ latestComposerRef.current.removeAttachment(attachment.path);
385
+ if (deliveredEveryStagedAttachment({
386
+ sent: attachments, staged: latestAttachmentsRef.current, activeUploadCount: activeUploadsRef.current.size,
387
+ }))
388
+ attachmentBatchIdRef.current = createChatLocalId({});
389
+ }, []);
390
+ /** MOVE: A rejected queue flush restores its captured files alongside the text for an honest retry. */
391
+ const restoreQueuedDraft = useCallback(({ inputs, text, attachments }, _options = {}) => {
392
+ if (inputs === undefined)
393
+ return;
394
+ latestComposerRef.current.setDraft([text, latestDraftRef.current].filter(Boolean).join('\n\n'));
395
+ const staged = new Set(latestAttachmentsRef.current.map(attachment => attachment.path));
396
+ for (const attachment of attachments) {
397
+ if (!staged.has(attachment.path))
398
+ latestComposerRef.current.addAttachment(attachment);
399
+ }
400
+ }, []);
401
+ const finishPromptSend = useCallback(({ accepted, inputs, redaction, submittedDraft, attachments }, _options = {}) => {
402
+ if (!accepted) {
403
+ restoreQueuedDraft({ inputs, text: redaction.text, attachments });
404
+ return;
405
+ }
406
+ if (inputs === undefined && latestDraftRef.current === submittedDraft) {
407
+ latestComposerRef.current.setDraft('');
408
+ latestComposerRef.current.closeMention();
409
+ }
410
+ consumeDeliveredAttachments({ attachments });
411
+ }, [consumeDeliveredAttachments, restoreQueuedDraft]);
412
+ const sendGuardedPrompt = useCallback(async ({ redaction, inputs, attachments = composer.attachments }, _options = {}) => {
301
413
  const trimmed = redaction.text.trim();
302
- if (!trimmed)
303
- throw new Error('cannot send: the prompt is empty');
304
- if (sendBlocker !== null) {
305
- throw new Error(`cannot send: ${describeChatPaneSendBlocker(sendBlocker)}`);
414
+ assertPromptSendable({ trimmed, sendBlocker });
415
+ if (startingPromptRef.current)
416
+ return;
417
+ startingPromptRef.current = true;
418
+ const generation = typedAnswerGenerationRef.current;
419
+ const submittedDraft = latestDraftRef.current;
420
+ try {
421
+ const context = resolveRunContext({ context: options.runContext,
422
+ input: { prompt: trimmed, selection, workingDirectory: workingDirectoryState.workingDirectory } });
423
+ options.onActivityChange?.('queued');
424
+ let accepted = false;
425
+ await conversation.sendMessage(trimmed, definedProps({ source: {
426
+ secretRedaction: redaction.secretRedacted ? { secretRedacted: true, count: redaction.count } : undefined,
427
+ agentId: selection.agentId,
428
+ // Empty attachment arrays remain absent at the transport boundary.
429
+ attachments: attachments.length === 0 ? undefined : attachments,
430
+ context,
431
+ acceptedInputs: inputs,
432
+ onAccepted: ({ inputs: acceptedInputs }) => {
433
+ accepted = true;
434
+ if (mountedRef.current && generation === typedAnswerGenerationRef.current)
435
+ recordAcceptedInput({ inputs: acceptedInputs });
436
+ },
437
+ } }));
438
+ if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
439
+ return;
440
+ finishPromptSend({ accepted, inputs, redaction, submittedDraft, attachments });
306
441
  }
307
- const attachments = composer.attachments;
308
- const context = typeof options.runContext === 'function'
309
- ? options.runContext({
310
- prompt: trimmed,
311
- selection,
312
- workingDirectory: workingDirectoryState.workingDirectory,
313
- })
314
- : options.runContext;
315
- composer.reset();
316
- attachmentGenerationRef.current += 1;
317
- attachmentBatchIdRef.current = createAttachmentBatchId();
318
- options.onActivityChange?.('queued');
319
- await conversation.sendMessage(trimmed, definedProps({ source: {
320
- secretRedaction: redaction.secretRedacted ? { secretRedacted: true, count: redaction.count } : undefined,
321
- agentId: selection.agentId,
322
- // Omitted when the array is EMPTY, not merely absent — an empty `attachments: []` would be a
323
- // different (valid, present) value to the transport than "no attachments key at all".
324
- attachments: attachments.length === 0 ? undefined : attachments,
325
- context,
326
- } }));
327
- }, [
328
- composer,
329
- conversation,
330
- options.onActivityChange,
331
- options.runContext,
332
- selection,
333
- sendBlocker,
334
- workingDirectoryState.workingDirectory,
335
- ]);
442
+ finally {
443
+ startingPromptRef.current = false;
444
+ }
445
+ }, [composer, conversation, options.onActivityChange, options.runContext, recordAcceptedInput, finishPromptSend,
446
+ selection, sendBlocker, workingDirectoryState.workingDirectory]);
336
447
  const sendPrompt = useCallback(async (prompt) => {
337
- await sendGuardedPrompt({ redaction: guard.redact({ text: prompt.trim() }, {}) }, {});
448
+ await sendGuardedPrompt({ redaction: guard.redact({ text: prompt.trim() }, {}) });
338
449
  }, [guard.redact, sendGuardedPrompt]);
339
- const answerWaitingQuestion = useCallback(async ({ deliver, text }) => {
450
+ // MOVE: Every queue path retains per-message receipts; appendQueuedTurn owns their one-turn delivery.
451
+ // A turn queued against another conversation is not joined — it would never be flushed there anyway.
452
+ const enqueueTurn = useCallback(({ turn, inputs, attachments = composer.attachments }, _options = {}) => {
453
+ const accepted = inputs ?? [{ id: createChatLocalId({}), text: turn.text, attachments,
454
+ ...(turn.secretRedacted ? { secretRedaction: { secretRedacted: true, count: turn.count } } : {}) }];
455
+ const sameConversation = queuedConversationIdRef.current === options.conversationId;
456
+ queuedConversationIdRef.current = options.conversationId;
457
+ for (const controller of activeUploadsRef.current) {
458
+ if (!queuedUploadOwnersRef.current.has(controller))
459
+ queuedUploadOwnersRef.current.set(controller, accepted.at(-1).id);
460
+ }
461
+ recordAcceptedInput({ inputs: accepted });
462
+ setQueuedInputs(queued => [...(sameConversation ? queued : []), ...accepted]);
463
+ // MOVE: Take files out of the composer without rotating their batch: every queued message ships together.
464
+ for (const attachment of attachments)
465
+ latestComposerRef.current.removeAttachment(attachment.path);
466
+ }, [composer.attachments, options.conversationId, recordAcceptedInput]);
467
+ const answerWaitingQuestion = useCallback(async ({ deliver, questionId, text }) => {
340
468
  const safeText = guard.redactText(text);
341
469
  const generation = typedAnswerGenerationRef.current;
342
470
  const submittedDraft = latestDraftRef.current;
343
- const questionId = awaitedQuestionId;
344
471
  deliveringTypedAnswerRef.current = true;
345
472
  const outcome = await deliverTypedAnswerSafely({ deliver, text: safeText });
346
473
  deliveringTypedAnswerRef.current = false;
347
474
  if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
348
475
  return;
349
- // Anything but a delivery keeps the draft and says why — never the queue below, whose flush
350
- // would post the answer as a brand-new (paid) run once this one ends.
351
- if (outcome !== 'delivered') {
476
+ if (outcome === 'failed') {
352
477
  setTypedAnswerNotice(outcome);
353
478
  return;
354
479
  }
480
+ if (outcome === 'not-pending') {
481
+ // MOVE: A definitive refusal accepted no answer; the existing queue owns one normal-send fallback.
482
+ enqueueTurn({ turn: guard.redact({ text: safeText }, {}), attachments: [] });
483
+ }
484
+ else {
485
+ recordAcceptedInput({ inputs: [{ id: `answer:${questionId}`, text: safeText }],
486
+ bubble: { text: safeText, toolUseId: questionId } });
487
+ }
355
488
  answeredQuestionIdRef.current = questionId;
356
- heldAnswerQuestionIdRef.current = null;
357
489
  // The textarea stays editable during delivery: clear only the text that went out, never a
358
490
  // correction or next message typed while the host answered.
359
491
  if (latestDraftRef.current === submittedDraft)
360
- composer.setDraft('');
361
- }, [awaitedQuestionId, composer, guard.redactText]);
362
- // Every queue path goes through here: a message joins the turn already queued behind this run
363
- // (`appendQueuedTurn`) instead of replacing it, which dropped the earlier message. A turn queued
364
- // against another conversation is not joined — it would never be flushed there anyway.
365
- const enqueueTurn = useCallback((turn) => {
366
- const sameConversation = queuedConversationIdRef.current === options.conversationId;
367
- queuedConversationIdRef.current = options.conversationId;
368
- setQueuedTurn((queued) => appendQueuedTurn({ queued: sameConversation ? queued : null, next: turn }));
369
- }, [options.conversationId]);
370
- // A text-only message sent while a run streams goes INTO that run when the host can deliver it.
371
- // The draft clears before the await so the human sees it go out; whatever the outcome, the text
372
- // ends up somewhere — in the run, or queued as the next turn (after stopping the run when the
373
- // agent cannot take input mid-run). Never cancel on 'not-running'/'failed': the run is ending.
374
- const sendIntoRunningTurn = useCallback(async ({ deliver, runId, turn }) => {
375
- composer.setDraft('');
376
- const outcome = await deliverMidRunMessageSafely({ deliver, runId, text: turn.text });
377
- if (outcome === 'delivered' || !mountedRef.current)
492
+ latestComposerRef.current.setDraft('');
493
+ }, [enqueueTurn, guard.redact, guard.redactText, recordAcceptedInput]);
494
+ const clearDeliveredDraft = useCallback(({ inputs, submittedDraft }, _options = {}) => {
495
+ if (inputs === undefined && latestDraftRef.current === submittedDraft)
496
+ latestComposerRef.current.setDraft('');
497
+ }, []);
498
+ const finishMidRunSend = useCallback(({ outcome, inputs, turn, attachments, afterInputId, submittedDraft }, _options = {}) => {
499
+ if (outcome === 'failed') {
500
+ restoreQueuedDraft({ inputs, text: turn.text, attachments });
501
+ setTypedAnswerNotice('failed');
502
+ return;
503
+ }
504
+ if (outcome === 'delivered') {
505
+ recordAcceptedInput({ inputs: inputs ?? [{ id: createChatLocalId({}), text: turn.text }],
506
+ bubble: { text: turn.text, ...(afterInputId === undefined ? {} : { afterInputId }) } });
507
+ clearDeliveredDraft({ inputs, submittedDraft });
508
+ consumeDeliveredAttachments({ attachments });
378
509
  return;
379
- enqueueTurn(turn);
380
- if (outcome === 'unsupported')
510
+ }
511
+ enqueueTurn({ turn, attachments, ...(inputs === undefined ? {} : { inputs }) });
512
+ clearDeliveredDraft({ inputs, submittedDraft });
513
+ if (outcome === 'unsupported' && attachments.length === 0)
381
514
  conversation.cancel();
382
- }, [composer, conversation, enqueueTurn]);
515
+ }, [conversation.cancel, enqueueTurn, recordAcceptedInput, clearDeliveredDraft, consumeDeliveredAttachments, restoreQueuedDraft]);
516
+ // A delivered steer belongs to the existing run; unsupported turns use the same accepted queue.
517
+ // A failed delivery keeps the working draft so retry cannot silently become another run.
518
+ const sendIntoRunningTurn = useCallback(async ({ deliver, runId, turn, inputs, attachments = composer.attachments }, _options = {}) => {
519
+ const submittedDraft = latestDraftRef.current;
520
+ const priorEvents = conversation.messages.at(-1)?.events ?? [];
521
+ const afterInputId = projectAcceptedInputEvents({ events: priorEvents }).flatMap(event => {
522
+ const input = acceptedInputEvent({ event });
523
+ return input ? [input.data.id] : [];
524
+ }).at(-1);
525
+ const generation = typedAnswerGenerationRef.current;
526
+ deliveringTypedAnswerRef.current = true;
527
+ const outcome = await deliverMidRunMessageSafely({ deliver, runId, text: turn.text,
528
+ ...(attachments.length ? { attachments } : {}),
529
+ });
530
+ deliveringTypedAnswerRef.current = false;
531
+ if (!mountedRef.current || generation !== typedAnswerGenerationRef.current)
532
+ return;
533
+ finishMidRunSend({ outcome, inputs, turn, attachments, afterInputId, submittedDraft });
534
+ }, [composer, conversation, finishMidRunSend]);
383
535
  // The ordinary next-turn path: into the running run, else queue behind it, else send now.
384
536
  const sendOrdinaryTurn = useCallback(async (prompt) => {
385
- if (isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
386
- const turn = guard.redact({ text: prompt }, {});
387
- const deliver = options.deliverMidRunMessage;
388
- const runId = deliver === undefined ? null : findMidRunMessageRunId({
389
- blocker: sendBlocker,
390
- attachmentCount: composer.attachments.length,
391
- messages: conversation.messages,
392
- });
393
- if (deliver !== undefined && runId !== null) {
394
- await sendIntoRunningTurn({ deliver, runId, turn });
395
- return;
396
- }
397
- // Queue instead of no-op'ing. `setDraft('')` and NOT `composer.reset()`: reset also discards
398
- // staged attachments, which this turn still needs when it finally goes out.
399
- enqueueTurn(turn);
400
- composer.setDraft('');
537
+ if (!isChatPaneQueueableBlocker({ blocker: sendBlocker })) {
538
+ if (canSend)
539
+ await sendPrompt(prompt);
401
540
  return;
402
541
  }
403
- if (!canSend)
542
+ const turn = guard.redact({ text: prompt }, {});
543
+ const uploadPending = activeUploadsRef.current.size > 0 || attachmentError !== null;
544
+ if (uploadPending)
545
+ queuedForUploadRef.current = queuedTurn === null || queuedForUploadRef.current;
546
+ // 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({
548
+ deliver: options.deliverMidRunMessage,
549
+ blocker: sendBlocker,
550
+ attachmentCount: composer.attachments.length,
551
+ messages: conversation.messages,
552
+ });
553
+ if (target !== null) {
554
+ await sendIntoRunningTurn({ ...target, turn });
404
555
  return;
405
- await sendPrompt(prompt);
556
+ }
557
+ // MOVE: Queue owns the staged attachments before clearing the draft, so neither can be lost.
558
+ enqueueTurn({ turn });
559
+ composer.setDraft('');
406
560
  }, [
407
561
  canSend,
562
+ attachmentError,
408
563
  composer,
409
564
  conversation.messages,
410
565
  enqueueTurn,
411
566
  options.deliverMidRunMessage,
567
+ queuedTurn,
412
568
  sendBlocker,
413
569
  sendIntoRunningTurn,
414
570
  sendPrompt,
415
571
  guard.redact,
416
572
  ]);
417
573
  const send = useCallback(async () => {
418
- const prompt = composerPrompt(composer);
419
- if (!prompt || deliveringTypedAnswerRef.current)
574
+ const uploadingWhileStreaming = conversation.isStreaming && activeUploadsRef.current.size > 0;
575
+ const prompt = composerPrompt(composer) || (uploadingWhileStreaming ? ATTACHMENTS_ONLY_PROMPT : '');
576
+ if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
420
577
  return;
421
578
  setTypedAnswerNotice(null);
422
- const deliver = options.deliverTypedAnswer;
423
- if (deliver !== undefined && isTypedAnswerTurn({
579
+ // Until upload completes, an empty attachment list is not evidence of a text-only answer.
580
+ if (uploadingWhileStreaming) {
581
+ await sendOrdinaryTurn(prompt);
582
+ return;
583
+ }
584
+ const typedAnswer = findTypedAnswerTarget({
585
+ deliver: options.deliverTypedAnswer,
424
586
  blocker: sendBlocker,
425
587
  attachmentCount: composer.attachments.length,
426
588
  messages: conversation.messages,
427
- toolName: deliver.toolName,
428
- })) {
429
- await answerWaitingQuestion({ deliver, text: prompt });
430
- return;
431
- }
432
- // Typed for a question that has since closed: the human meant an answer, so going on to the
433
- // queue would start a paid run they never asked for. Attachments mark a new turn (a typed answer
434
- // carries text only), the same rule `isTypedAnswerTurn` applies.
435
- if (heldAnswerQuestionIdRef.current !== null && composer.attachments.length === 0) {
436
- setTypedAnswerNotice('not-pending');
589
+ });
590
+ if (typedAnswer !== null && typedAnswer.questionId !== answeredQuestionIdRef.current) {
591
+ await answerWaitingQuestion({ ...typedAnswer, text: prompt });
437
592
  return;
438
593
  }
439
594
  await sendOrdinaryTurn(prompt);
440
595
  }, [
441
596
  answerWaitingQuestion,
442
597
  composer,
598
+ conversation.isStreaming,
443
599
  conversation.messages,
444
600
  options.deliverTypedAnswer,
445
601
  sendBlocker,
@@ -447,47 +603,79 @@ export function useChatPane(options) {
447
603
  ]);
448
604
  const sendAsNewMessage = useCallback(async () => {
449
605
  const prompt = composerPrompt(composer);
450
- if (!prompt || deliveringTypedAnswerRef.current)
606
+ if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
451
607
  return;
452
608
  setTypedAnswerNotice(null);
453
- // Mark the question answered too, so the still-non-empty draft is not re-held by the effect
454
- // while that question's card is drawn open.
455
- answeredQuestionIdRef.current = heldAnswerQuestionIdRef.current ?? answeredQuestionIdRef.current;
456
- heldAnswerQuestionIdRef.current = null;
457
609
  await sendOrdinaryTurn(prompt);
458
610
  }, [composer, sendOrdinaryTurn]);
459
611
  const interruptSend = useCallback(() => {
460
612
  const prompt = composerPrompt(composer);
461
- if (!prompt)
613
+ if (!prompt || composerDeliveryBlocked({ delivering: deliveringTypedAnswerRef.current, starting: startingPromptRef.current, composer, submitted: submittedComposerRef.current }))
462
614
  return;
463
- enqueueTurn(guard.redact({ text: prompt }, {}));
615
+ enqueueTurn({ turn: guard.redact({ text: prompt }, {}) });
464
616
  composer.setDraft('');
465
617
  conversation.cancel();
466
618
  }, [composer, conversation, enqueueTurn, guard.redact]);
619
+ /** Abort only uploads owned by the removed inputs; late completions must not refill the composer. */
620
+ const discardQueuedUploads = useCallback(({ id }, _options = {}) => {
621
+ for (const [controller, owner] of queuedUploadOwnersRef.current) {
622
+ if (id !== undefined && owner !== id)
623
+ continue;
624
+ controller.abort();
625
+ activeUploadsRef.current.delete(controller);
626
+ queuedUploadOwnersRef.current.delete(controller);
627
+ }
628
+ setActiveUploadCount(activeUploadsRef.current.size);
629
+ }, []);
467
630
  const cancelQueued = useCallback(() => {
631
+ submittedComposerRef.current = null;
632
+ queuedForUploadRef.current = false;
468
633
  queuedConversationIdRef.current = undefined;
469
- setQueuedTurn(null);
470
- }, []);
471
- // Flush on a FULLY clear blocker, not merely on streaming ending — see
472
- // `isChatPaneQueueableBlocker`'s note about `findChatPaneSendBlocker`'s ordering. Clearing the
473
- // queue slot BEFORE awaiting keeps a re-render from double-sending the same prompt.
634
+ discardQueuedUploads({});
635
+ setQueuedInputs([]);
636
+ }, [discardQueuedUploads]);
637
+ const cancelQueuedMessage = useCallback(({ id }, _options = {}) => {
638
+ submittedComposerRef.current = null;
639
+ discardQueuedUploads({ id });
640
+ setQueuedInputs(inputs => inputs.filter(input => input.id !== id));
641
+ }, [discardQueuedUploads]);
642
+ // Upload-waiting sends can steer once the image arrives. Other queued turns wait for a fully
643
+ // clear blocker. Clear the slot before awaiting so re-renders cannot send it twice.
474
644
  useEffect(() => {
475
- if (queuedTurn === null || sendBlocker !== null)
645
+ if (queuedTurn === null) {
646
+ queuedForUploadRef.current = false;
476
647
  return;
648
+ }
477
649
  if (queuedConversationIdRef.current !== options.conversationId) {
478
650
  // The conversation changed while this prompt waited behind a streaming run — sending it now
479
651
  // would post it into a conversation the user never saw it queued against. Drop it instead
480
652
  // (same as `cancelQueued`) rather than misrouting it.
481
- queuedConversationIdRef.current = undefined;
482
- setQueuedTurn(null);
653
+ cancelQueued();
654
+ return;
655
+ }
656
+ if (activeUploadCount > 0 || attachmentError !== null)
657
+ return;
658
+ const target = queuedForUploadRef.current ? findMidRunTarget({
659
+ deliver: options.deliverMidRunMessage,
660
+ blocker: sendBlocker, attachmentCount: queuedInputs.flatMap(input => input.attachments).length, messages: conversation.messages,
661
+ }) : null;
662
+ if (sendBlocker !== null && target === null)
663
+ return;
664
+ queuedForUploadRef.current = false;
665
+ const inputs = queuedInputs;
666
+ const attachments = inputs.flatMap(input => input.attachments);
667
+ setQueuedInputs([]);
668
+ if (target !== null) {
669
+ void sendIntoRunningTurn({ ...target, turn: queuedTurn, inputs, attachments });
483
670
  return;
484
671
  }
485
- setQueuedTurn(null);
486
- void sendGuardedPrompt({ redaction: queuedTurn }, {});
487
- }, [options.conversationId, queuedTurn, sendBlocker, sendGuardedPrompt]);
672
+ void sendGuardedPrompt({ redaction: queuedTurn, inputs, attachments });
673
+ }, [activeUploadCount, attachmentError, cancelQueued, queuedInputs, conversation.messages,
674
+ options.conversationId, options.deliverMidRunMessage, queuedTurn, sendBlocker, sendGuardedPrompt, sendIntoRunningTurn]);
488
675
  const reset = useCallback(() => {
676
+ submittedComposerRef.current = null;
489
677
  attachmentGenerationRef.current += 1;
490
- attachmentBatchIdRef.current = createAttachmentBatchId();
678
+ attachmentBatchIdRef.current = createChatLocalId({});
491
679
  for (const controller of activeUploadsRef.current)
492
680
  controller.abort();
493
681
  activeUploadsRef.current.clear();
@@ -498,12 +686,13 @@ export function useChatPane(options) {
498
686
  setAttachmentError(null);
499
687
  setTypedAnswerNotice(null);
500
688
  typedAnswerGenerationRef.current += 1;
501
- heldAnswerQuestionIdRef.current = null;
502
689
  // Without this, a prompt queued behind a streaming run survives `reset()` and — once
503
690
  // `conversation.cancel()` above clears the streaming blocker — the flush effect fires it into
504
691
  // the just-reset conversation instead of discarding it like the rest of this turn's state.
505
692
  queuedConversationIdRef.current = undefined;
506
- setQueuedTurn(null);
693
+ queuedForUploadRef.current = false;
694
+ queuedUploadOwnersRef.current.clear();
695
+ setQueuedInputs([]);
507
696
  }, [composer, conversation, options.initialMessages]);
508
697
  return {
509
698
  secretRedacted: guard.secretRedacted,
@@ -523,6 +712,8 @@ export function useChatPane(options) {
523
712
  send,
524
713
  sendPrompt,
525
714
  queuedPrompt: queuedTurn?.text ?? null,
715
+ queuedMessages: queuedInputs,
716
+ cancelQueuedMessage,
526
717
  cancelQueued,
527
718
  typedAnswerNotice,
528
719
  sendAsNewMessage,