@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.
- package/CHANGELOG.md +4 -0
- package/dist/core/accepted-input.d.ts +54 -0
- package/dist/core/accepted-input.js +165 -0
- package/dist/core/agent-plugin-chat-state.d.ts +43 -0
- package/dist/core/agent-plugin-chat-state.js +48 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
- package/dist/core/assistant-client/messages.d.ts +1 -1
- package/dist/core/assistant-client/messages.js +2 -2
- package/dist/core/assistant-client/transport.d.ts +2 -2
- package/dist/core/assistant-client/transport.js +184 -97
- package/dist/core/assistant-content.d.ts +2 -1
- package/dist/core/assistant-content.js +9 -11
- package/dist/core/browser/page-actions.js +4 -2
- package/dist/core/composer-history.d.ts +16 -9
- package/dist/core/composer-history.js +28 -16
- package/dist/core/conversation-title.d.ts +3 -0
- package/dist/core/conversation-title.js +3 -0
- package/dist/core/durable-projection.js +20 -13
- package/dist/core/durable-runs/continuation.d.ts +4 -0
- package/dist/core/durable-runs/continuation.js +31 -6
- package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
- package/dist/core/durable-runs/ports.d.ts +6 -2
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/partial-json.d.ts +6 -7
- package/dist/core/partial-json.js +81 -47
- package/dist/core/persistence/title.js +4 -2
- package/dist/core/question-answer.d.ts +23 -0
- package/dist/core/question-answer.js +66 -0
- package/dist/core/question-form/format.d.ts +6 -6
- package/dist/core/question-form/format.js +16 -17
- package/dist/core/question-form/normalize.d.ts +18 -0
- package/dist/core/question-form/normalize.js +112 -122
- package/dist/core/question-form/scan.js +141 -203
- package/dist/core/run-activity.js +62 -49
- package/dist/core/run-events/entry.d.ts +10 -1
- package/dist/core/run-events/entry.js +117 -87
- package/dist/core/todos.d.ts +1 -1
- package/dist/core/todos.js +60 -85
- package/dist/core/tool-events.js +14 -12
- package/dist/core/transcript.js +62 -16
- package/dist/core/turn-usage.js +54 -32
- package/dist/core/user-text-redaction.js +3 -0
- package/dist/core/util/manifest.js +77 -82
- package/dist/core/util/markdown-context.js +51 -53
- package/dist/core/util/parser.js +136 -144
- package/dist/core/util/pointer.js +16 -16
- package/dist/core/util/strip.js +57 -67
- package/dist/core/util/validate.js +15 -17
- package/dist/embed-widget/browser/session-store.js +21 -12
- package/dist/embed-widget/transports/fetch-sse.js +108 -95
- package/dist/embed-widget/vanilla/index.js +81 -70
- package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
- package/dist/react/components/AttachmentPreviewModal.js +25 -1
- package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
- package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
- package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
- package/dist/react/components/ChatSettingsDialog.js +72 -0
- package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
- package/dist/react/components/ChatSettingsSegmented.js +11 -0
- package/dist/react/components/Composer.d.ts +5 -1
- package/dist/react/components/Composer.js +126 -82
- package/dist/react/components/ConversationList.js +25 -39
- package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
- package/dist/react/components/ConversationTitleEditor.js +54 -0
- package/dist/react/components/Icon.d.ts +1 -1
- package/dist/react/components/Icon.js +25 -45
- package/dist/react/components/Markdown.js +134 -123
- package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
- package/dist/react/components/McpUiSurfaceCard.js +30 -9
- package/dist/react/components/MessageList.d.ts +13 -13
- package/dist/react/components/MessageList.hooks.d.ts +6 -1
- package/dist/react/components/MessageList.hooks.js +19 -3
- package/dist/react/components/MessageList.js +34 -10
- package/dist/react/components/MessageRow.js +47 -31
- package/dist/react/components/MessageRow.usage.js +16 -11
- package/dist/react/components/MidRunUserMessage.js +16 -3
- package/dist/react/components/QuestionForm.js +148 -76
- package/dist/react/components/QuestionsPanel.js +1 -1
- package/dist/react/components/TodoCard.js +12 -6
- package/dist/react/components/ToolCard.js +47 -40
- package/dist/react/components/composer-discovery.js +6 -1
- package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
- package/dist/react/components/mcp-ui-surface-state.js +23 -8
- package/dist/react/ext-event-renderer-registry.d.ts +8 -0
- package/dist/react/ext-event-renderer-registry.js +5 -2
- package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
- package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
- package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
- package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
- package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
- package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
- package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
- package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
- package/dist/react/features/chat-pane/model-options.d.ts +14 -0
- package/dist/react/features/chat-pane/model-options.js +45 -25
- package/dist/react/features/chat-pane/rules.d.ts +8 -9
- package/dist/react/features/chat-pane/rules.js +84 -46
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +152 -36
- package/dist/react/features/chat-pane/types.d.ts +17 -8
- package/dist/react/hooks/assistant-chats.js +12 -5
- package/dist/react/hooks/composer-draft-cache.js +116 -118
- package/dist/react/hooks/composer-history-storage.js +5 -5
- package/dist/react/hooks/fab-position.js +7 -4
- package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
- package/dist/react/hooks/useAnsweredCards.js +38 -11
- package/dist/react/hooks/useArtifactStream.js +28 -22
- package/dist/react/hooks/useComposer.d.ts +20 -0
- package/dist/react/hooks/useComposer.js +22 -9
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
- package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
- package/dist/react/hooks/useConversation.d.ts +7 -0
- package/dist/react/hooks/useConversation.js +23 -10
- package/dist/react/hooks/useExtEventGroups.js +39 -39
- package/dist/react/hooks/useQuestionForms.js +21 -26
- package/dist/react/hooks/useRunStream.js +10 -2
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +9 -0
- package/dist/react/message-blocks.d.ts +6 -4
- package/dist/react/message-blocks.js +94 -72
- package/dist/react/slots.d.ts +2 -2
- package/dist/react/tool-call-display.js +19 -17
- package/dist/server/durable-runs/recover.js +132 -80
- package/dist/server/run-finalizer/entry.js +25 -15
- package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
- package/dist/store/legacy/sqlite/conversations.js +69 -98
- package/dist/store/legacy/sqlite/messages.d.ts +10 -2
- package/dist/store/legacy/sqlite/messages.js +113 -71
- package/dist/store/runtime/chat-history-store.js +9 -3
- package/dist/store/runtime/durable-run-store.d.ts +2 -2
- package/dist/store/runtime/durable-run-store.js +165 -117
- package/dist/store/runtime/history-tools.js +118 -82
- package/dist/store/runtime/run-ledger.d.ts +4 -1
- package/dist/store/runtime/run-ledger.js +56 -16
- package/dist/store/sql/cursor.js +38 -18
- package/dist/store/sql/store.d.ts +18 -0
- package/dist/store/sql/store.js +46 -15
- 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,
|
|
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 ?
|
|
20
|
+
|| (composer.attachments.length > 0 ? ATTACHMENTS_ONLY_PROMPT : '');
|
|
18
21
|
}
|
|
19
|
-
|
|
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'
|
|
39
|
-
* host could not
|
|
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,
|
|
63
|
+
async function deliverMidRunMessageSafely({ deliver, ...input }) {
|
|
44
64
|
try {
|
|
45
|
-
return await deliver(
|
|
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
|
|
95
|
-
const [
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 }) ??
|
|
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
|
|
225
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
}, [
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
510
|
+
}
|
|
511
|
+
enqueueTurn({ turn, attachments, ...(inputs === undefined ? {} : { inputs }) });
|
|
512
|
+
clearDeliveredDraft({ inputs, submittedDraft });
|
|
513
|
+
if (outcome === 'unsupported' && attachments.length === 0)
|
|
381
514
|
conversation.cancel();
|
|
382
|
-
}, [
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
if (
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
await answerWaitingQuestion({
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|