@oar-sh/oar 0.9.1 → 0.9.3
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/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
hasPendingUserMessageDuplicate,
|
|
11
11
|
clearPendingUserMessage,
|
|
12
12
|
pendingUserMessageIds,
|
|
13
|
+
pendingUserMessageEntries,
|
|
13
14
|
trackPendingUserMessage,
|
|
14
15
|
seenMessageIds,
|
|
15
16
|
relayActivities,
|
|
@@ -43,11 +44,13 @@ import {
|
|
|
43
44
|
SHARED_CONVERSATION_TOKEN,
|
|
44
45
|
imageEditTarget,
|
|
45
46
|
setImageEditTarget as setStoredImageEditTarget,
|
|
47
|
+
getSessionWorkerState,
|
|
46
48
|
} from './store.js';
|
|
49
|
+
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
47
50
|
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
|
|
48
51
|
import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
|
|
49
|
-
import { linkifyWorkspaceMentionsInNode, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
|
|
50
|
-
import { renderAttachmentMarkup,
|
|
52
|
+
import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
|
|
53
|
+
import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
|
|
51
54
|
import { buildWorkflowRunCard } from './background-tasks-view.mjs';
|
|
52
55
|
import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
|
|
53
56
|
import { buildTranscriptPreviewCard } from './preview-cards.mjs';
|
|
@@ -61,7 +64,6 @@ import { openSettingsModal } from './settings-modal.js';
|
|
|
61
64
|
import {
|
|
62
65
|
serializeDraftAttachments,
|
|
63
66
|
hydrateDraftAttachments,
|
|
64
|
-
mergeDraftAttachmentUpdate,
|
|
65
67
|
draftAttachmentsEqual,
|
|
66
68
|
} from './composer-attachment-cache.mjs';
|
|
67
69
|
import { renderRelayQuestions } from './ask-user-view.js';
|
|
@@ -83,13 +85,26 @@ import {
|
|
|
83
85
|
relayActivityEntryText,
|
|
84
86
|
} from './activity-replay-state.mjs';
|
|
85
87
|
import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
|
|
88
|
+
import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
86
89
|
import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
|
|
87
90
|
import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
|
|
88
91
|
import { createInfiniteLoader } from './infinite-loader.js';
|
|
89
92
|
import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
|
|
93
|
+
import {
|
|
94
|
+
DRAFT_SYNC_PROTOCOL_VERSION,
|
|
95
|
+
MAX_DRAFT_TEXT_LENGTH,
|
|
96
|
+
draftTextForSync,
|
|
97
|
+
draftAttachmentsKey,
|
|
98
|
+
getSyncedDraft,
|
|
99
|
+
isDraftSaveNoop,
|
|
100
|
+
recordSyncedDraft,
|
|
101
|
+
resolveDraftConflict,
|
|
102
|
+
shouldApplyIncomingDraftToComposer,
|
|
103
|
+
} from './conversation-draft-sync.mjs';
|
|
90
104
|
import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
|
|
91
105
|
import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
92
106
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
107
|
+
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
93
108
|
|
|
94
109
|
const CONVERSATION_HISTORY_PAGE_SIZE = 20;
|
|
95
110
|
const HISTORY_LOAD_MORE_ID = 'history-load-more';
|
|
@@ -110,6 +125,7 @@ function syncSeparatorsNow() {
|
|
|
110
125
|
const el = getMessagesElement();
|
|
111
126
|
if (!el) return;
|
|
112
127
|
syncTranscriptSeparators(el);
|
|
128
|
+
syncSteeredTurnMerge(el);
|
|
113
129
|
observeTranscriptResize(el);
|
|
114
130
|
}
|
|
115
131
|
|
|
@@ -145,6 +161,12 @@ const relayStreamStateByMessageId = new Map();
|
|
|
145
161
|
const relayStreamTextByMessageId = new Map();
|
|
146
162
|
const completedMessageIds = new Set();
|
|
147
163
|
const bubbleCancelInFlight = new Set();
|
|
164
|
+
// Un-steer requests the relay acknowledged with 'cancel-requested': the worker
|
|
165
|
+
// answers asynchronously (message_status 'cancelled', or the row is answered
|
|
166
|
+
// after all). Until then the bubble reads "Cancelling…"; the timer restores
|
|
167
|
+
// the control if neither arrives.
|
|
168
|
+
const UNSTEER_ACK_FALLBACK_MS = 20_000;
|
|
169
|
+
const unsteerFallbackTimers = new Map();
|
|
148
170
|
const shareVisibilityInFlight = new Set();
|
|
149
171
|
const SUBAGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'dropped', 'done']);
|
|
150
172
|
let lastRenderedMessageSnapshotKey = '';
|
|
@@ -152,6 +174,15 @@ let sendInFlight = false;
|
|
|
152
174
|
const COMPOSER_DRAFT_DEBOUNCE_MS = 500;
|
|
153
175
|
const draftSaveTimerByConversation = new Map();
|
|
154
176
|
const draftSavePromiseByConversation = new Map();
|
|
177
|
+
// Conversations whose draft an in-flight send owns. POST /api/message clears
|
|
178
|
+
// draft_text in the same transaction that stores the message, so until that
|
|
179
|
+
// commit the server still serves the text the composer was just emptied of.
|
|
180
|
+
// Every refresh issued inside that window carries it — the 900ms live poll
|
|
181
|
+
// stays armed for the whole queue wait, and other clients can broadcast their
|
|
182
|
+
// own late save of it — and applying one re-types the queued message into the
|
|
183
|
+
// composer while its bubble sits in the transcript. Nothing but the send may
|
|
184
|
+
// speak for this conversation's draft until the send has cleared it.
|
|
185
|
+
const sendOwnedDraftConversations = new Set();
|
|
155
186
|
const activeTurnsByConversation = new Map();
|
|
156
187
|
let conversationHistoryState = {
|
|
157
188
|
conversationId: '',
|
|
@@ -192,8 +223,14 @@ export function setImageEditTarget(target) {
|
|
|
192
223
|
export function clearImageEditTarget() {
|
|
193
224
|
setStoredImageEditTarget(null);
|
|
194
225
|
renderImageEditTarget();
|
|
195
|
-
|
|
196
|
-
|
|
226
|
+
// The placeholder is provider/model-aware; bootstrap owns the sync (window
|
|
227
|
+
// global to avoid a circular import) with the static text as fallback.
|
|
228
|
+
if (typeof window.syncComposerPlaceholder === 'function') {
|
|
229
|
+
window.syncComposerPlaceholder();
|
|
230
|
+
} else {
|
|
231
|
+
const input = document.getElementById('msg-input');
|
|
232
|
+
if (input) input.placeholder = 'Message Copilot…';
|
|
233
|
+
}
|
|
197
234
|
}
|
|
198
235
|
|
|
199
236
|
export function jumpToImageParent(messageId) {
|
|
@@ -291,6 +328,9 @@ const conversationHistoryLoader = createInfiniteLoader({
|
|
|
291
328
|
// separator rows, and the height delta the restore measures has to include
|
|
292
329
|
// them or the viewport jumps.
|
|
293
330
|
syncTranscriptSeparators(el);
|
|
331
|
+
// A steering pair split across the page boundary heals here (the absorbed
|
|
332
|
+
// reply just arrived above its already-rendered steered user row).
|
|
333
|
+
syncSteeredTurnMerge(el);
|
|
294
334
|
requestAnimationFrame(() => {
|
|
295
335
|
if (!el || String(currentConvId || '').trim() !== currentId) return;
|
|
296
336
|
const nextScrollHeight = el.scrollHeight;
|
|
@@ -393,13 +433,93 @@ function getActiveTurnForConversation(conversationId) {
|
|
|
393
433
|
return activeTurnsByConversation.get(conversationKey) || null;
|
|
394
434
|
}
|
|
395
435
|
|
|
436
|
+
// The session worker id a conversation is bound to. Same hydration shapes
|
|
437
|
+
// validateSelectedConversationBeforeSend handles: camelCase, snake_case, or
|
|
438
|
+
// only the runtime session binding.
|
|
439
|
+
function sdkSessionIdForConversation(conversationId) {
|
|
440
|
+
const conversation = conversations[String(conversationId || '').trim()] || null;
|
|
441
|
+
return String(
|
|
442
|
+
conversation?.sdkSessionId
|
|
443
|
+
|| conversation?.sdk_session_id
|
|
444
|
+
|| conversation?.runtimeSession?.sdkSessionId
|
|
445
|
+
|| conversation?.runtimeSession?.sdk_session_id
|
|
446
|
+
|| '',
|
|
447
|
+
).trim();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The worker-reported steering snapshot for a conversation's session worker
|
|
451
|
+
// (rides the status payload from the worker's heartbeat), or null.
|
|
452
|
+
function workerSteeringForConversation(conversationId) {
|
|
453
|
+
const sdkSessionId = sdkSessionIdForConversation(conversationId);
|
|
454
|
+
return sdkSessionId ? (getSessionWorkerState(sdkSessionId)?.steering || null) : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Which conversations deliver a message typed mid-turn INTO the running turn
|
|
458
|
+
// (steering) rather than queueing it behind: exactly those whose session
|
|
459
|
+
// worker advertises it on its heartbeat steering snapshot (`supported` — the
|
|
460
|
+
// Claude and Copilot SDK workers both do). One rule for every provider; the
|
|
461
|
+
// provider name is not consulted (the name-based Claude rule was retired
|
|
462
|
+
// 2026-09-26 once every live Claude worker advertised the flag). Workers that
|
|
463
|
+
// do not advertise (the extension path, Cursor, Grok) stay strictly serial,
|
|
464
|
+
// so their composer keeps saying "Queue".
|
|
465
|
+
function conversationSupportsSteering(conversationId) {
|
|
466
|
+
const conversationKey = String(conversationId || '').trim();
|
|
467
|
+
if (!conversationKey) return false;
|
|
468
|
+
return workerSteeringForConversation(conversationKey)?.supported === true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Pushed rows the conversation's worker reports it can still pull back out of
|
|
472
|
+
// its runtime (Copilot's queued lane): their bubbles keep Cancel.
|
|
473
|
+
function cancellableSteerIdsForConversation(conversationId) {
|
|
474
|
+
const ids = workerSteeringForConversation(conversationId)?.cancellableIds;
|
|
475
|
+
return new Set(Array.isArray(ids) ? ids : []);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Whether steering into this conversation's live turn is momentarily held,
|
|
479
|
+
// and why. Two sources, freshest first: the client's own knowledge of an open
|
|
480
|
+
// question card (instant — the worker's heartbeat snapshot lags up to ~10s),
|
|
481
|
+
// then the worker-reported steering snapshot for the slower holds only it can
|
|
482
|
+
// see (compaction, post-compaction adoption).
|
|
483
|
+
function steeringHoldForConversation(conversationId) {
|
|
484
|
+
const conversationKey = String(conversationId || '').trim();
|
|
485
|
+
if (!conversationKey || !conversationSupportsSteering(conversationKey)) {
|
|
486
|
+
return { held: false, reason: null };
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const pendingQuestions = Number(getPendingQuestionCountsByConversation()?.[conversationKey] || 0);
|
|
490
|
+
if (pendingQuestions > 0) return { held: true, reason: 'question' };
|
|
491
|
+
} catch {}
|
|
492
|
+
const steering = workerSteeringForConversation(conversationKey);
|
|
493
|
+
if (steering && steering.turnActive && !steering.canSteer) {
|
|
494
|
+
// The client's own question knowledge is authoritative both ways: it saw
|
|
495
|
+
// the card answered instantly, while the worker snapshot lags ~10s.
|
|
496
|
+
if (steering.holdReason === 'question') return { held: false, reason: null };
|
|
497
|
+
// The 'delivery' hold covers the seconds while a turn-opening delivery is
|
|
498
|
+
// still in flight — but a heartbeat-captured copy outlives that window by
|
|
499
|
+
// up to a poll cycle and names the SAME turn, so the client cannot tell
|
|
500
|
+
// stale from live. Ignore it: a steer sent into that window just queues
|
|
501
|
+
// and rides in moments later, which was always safe.
|
|
502
|
+
if (steering.holdReason === 'delivery') return { held: false, reason: null };
|
|
503
|
+
// A snapshot naming a different turn than the one the client is watching
|
|
504
|
+
// is a leftover from the previous turn — ignore it.
|
|
505
|
+
const clientTurnMessageId = String(getActiveTurnForConversation(conversationKey)?.messageId || '').trim();
|
|
506
|
+
if (steering.messageId && clientTurnMessageId && steering.messageId !== clientTurnMessageId) {
|
|
507
|
+
return { held: false, reason: null };
|
|
508
|
+
}
|
|
509
|
+
return { held: true, reason: steering.holdReason || null };
|
|
510
|
+
}
|
|
511
|
+
return { held: false, reason: null };
|
|
512
|
+
}
|
|
513
|
+
|
|
396
514
|
function syncSendButtonState() {
|
|
397
515
|
const btn = document.getElementById('send-btn');
|
|
398
516
|
if (!btn) return;
|
|
399
517
|
const currentTurn = getActiveTurnForConversation(currentConvId);
|
|
518
|
+
const steeringHold = currentTurn
|
|
519
|
+
? steeringHoldForConversation(currentConvId)
|
|
520
|
+
: { held: false, reason: null };
|
|
400
521
|
const state = deriveComposerControlState({
|
|
401
522
|
hasActiveTurn: !!currentTurn,
|
|
402
|
-
cancelRequested: currentTurn?.cancelRequested === true,
|
|
403
523
|
hasDraft: hasComposerDraft({
|
|
404
524
|
text: document.getElementById('msg-input')?.value || '',
|
|
405
525
|
attachmentCount: selectedAttachments.length,
|
|
@@ -407,6 +527,9 @@ function syncSendButtonState() {
|
|
|
407
527
|
sendInFlight,
|
|
408
528
|
modelMetadataBlocked: window.isModelMetadataBlocked?.() === true,
|
|
409
529
|
attachmentsUploading: hasUploadingAttachments(selectedAttachments),
|
|
530
|
+
steeringSupported: conversationSupportsSteering(currentConvId),
|
|
531
|
+
steeringHeld: steeringHold.held,
|
|
532
|
+
steeringHoldReason: steeringHold.reason,
|
|
410
533
|
});
|
|
411
534
|
btn.disabled = state.disabled;
|
|
412
535
|
btn.dataset.action = state.action;
|
|
@@ -414,12 +537,45 @@ function syncSendButtonState() {
|
|
|
414
537
|
btn.title = state.title;
|
|
415
538
|
}
|
|
416
539
|
|
|
417
|
-
|
|
540
|
+
// Button-only refresh for status, poll and question paths. It must never save
|
|
541
|
+
// the draft: an idle client that saves on every poll tick writes its stale
|
|
542
|
+
// composer text over another device's draft.
|
|
543
|
+
export function syncComposerButtonState() {
|
|
418
544
|
syncSendButtonState();
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// The composer's input handler: the user edited the text, so the draft is
|
|
548
|
+
// saved (debounced) as well as the button refreshed. While a send owns the
|
|
549
|
+
// draft nothing is saved — the send transaction clears the draft
|
|
550
|
+
// unconditionally after any such save — but the text is still cached, and
|
|
551
|
+
// the send saves it when it settles, wherever the user is by then.
|
|
552
|
+
export function syncComposerAfterUserEdit() {
|
|
553
|
+
syncSendButtonState();
|
|
554
|
+
const text = document.getElementById('msg-input')?.value || '';
|
|
555
|
+
warnOnceIfDraftTooLong(text);
|
|
556
|
+
if (isDraftOwnedByPendingSend(currentConvId)) {
|
|
557
|
+
upsertConversationDraftState(currentConvId, { draftText: draftTextForSync(text) });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
void scheduleConversationDraftSave({ conversationId: currentConvId, draftText: text });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Whether a send in flight holds this conversation's draft saves back. */
|
|
564
|
+
export function isConversationDraftHeldBySend(conversationId) {
|
|
565
|
+
return isDraftOwnedByPendingSend(conversationId);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let draftLengthWarningShown = false;
|
|
569
|
+
|
|
570
|
+
function warnOnceIfDraftTooLong(text) {
|
|
571
|
+
const tooLong = String(text || '').length > MAX_DRAFT_TEXT_LENGTH;
|
|
572
|
+
if (tooLong && !draftLengthWarningShown) {
|
|
573
|
+
showTransientRelayNotice(
|
|
574
|
+
`Drafts are saved up to ${MAX_DRAFT_TEXT_LENGTH.toLocaleString('en-US')} characters; the rest stays only in this tab until you send.`,
|
|
575
|
+
7000,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
draftLengthWarningShown = tooLong;
|
|
423
579
|
}
|
|
424
580
|
|
|
425
581
|
export function isSendInFlight() {
|
|
@@ -435,11 +591,48 @@ function clearDraftTimerForConversation(conversationId) {
|
|
|
435
591
|
draftSaveTimerByConversation.delete(id);
|
|
436
592
|
}
|
|
437
593
|
|
|
594
|
+
function isDraftOwnedByPendingSend(conversationId) {
|
|
595
|
+
return sendOwnedDraftConversations.has(String(conversationId || '').trim());
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function isDraftSavePending(conversationId) {
|
|
599
|
+
const id = String(conversationId || '').trim();
|
|
600
|
+
return draftSaveTimerByConversation.has(id) || draftSavePromiseByConversation.has(id);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Called by openConversation once the composer belongs to the next
|
|
605
|
+
* conversation (after the previous draft was flushed): replaces the previous
|
|
606
|
+
* conversation's text with the next one's cached draft, so no blur, keystroke
|
|
607
|
+
* or flush can save the old text under the new conversation — and so a load
|
|
608
|
+
* that fails still leaves that conversation's known draft on screen rather
|
|
609
|
+
* than an empty composer a keystroke would save over it. The load's hydrate
|
|
610
|
+
* then reconciles against it like any other incoming draft.
|
|
611
|
+
*/
|
|
612
|
+
export function beginConversationDraftSwitch(nextConversationId) {
|
|
613
|
+
const id = String(nextConversationId || '').trim();
|
|
614
|
+
const cached = id ? conversations[id] : null;
|
|
615
|
+
const text = String(cached?.draftText || '');
|
|
616
|
+
const attachments = Array.isArray(cached?.draftAttachments) ? cached.draftAttachments : [];
|
|
617
|
+
if (cached && !getSyncedDraft(id)) {
|
|
618
|
+
recordSyncedDraft(id, { text: draftTextForSync(text), attachments, updatedAt: cached.draftUpdatedAt || null });
|
|
619
|
+
}
|
|
620
|
+
if (attachments.length) setComposerAttachments(hydrateDraftAttachments(attachments));
|
|
621
|
+
const input = document.getElementById('msg-input');
|
|
622
|
+
if (input && input.value !== text) {
|
|
623
|
+
input.value = text;
|
|
624
|
+
autoResize(input);
|
|
625
|
+
}
|
|
626
|
+
syncSendButtonState();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Omitted version fields keep their current values: a local edit changes the
|
|
630
|
+
// text only, never the server version it was based on.
|
|
438
631
|
function upsertConversationDraftState(conversationId, {
|
|
439
632
|
draftText = '',
|
|
440
633
|
draftAttachments = undefined,
|
|
441
|
-
draftUpdatedAt =
|
|
442
|
-
draftUpdatedByClientId =
|
|
634
|
+
draftUpdatedAt = undefined,
|
|
635
|
+
draftUpdatedByClientId = undefined,
|
|
443
636
|
} = {}) {
|
|
444
637
|
const id = String(conversationId || '').trim();
|
|
445
638
|
if (!id || !conversations[id]) return;
|
|
@@ -450,8 +643,10 @@ function upsertConversationDraftState(conversationId, {
|
|
|
450
643
|
draftAttachments: draftAttachments === undefined
|
|
451
644
|
? (existing.draftAttachments || [])
|
|
452
645
|
: (Array.isArray(draftAttachments) ? draftAttachments : []),
|
|
453
|
-
draftUpdatedAt: draftUpdatedAt || null,
|
|
454
|
-
draftUpdatedByClientId: draftUpdatedByClientId
|
|
646
|
+
draftUpdatedAt: draftUpdatedAt === undefined ? (existing.draftUpdatedAt || null) : (draftUpdatedAt || null),
|
|
647
|
+
draftUpdatedByClientId: draftUpdatedByClientId === undefined
|
|
648
|
+
? (existing.draftUpdatedByClientId || null)
|
|
649
|
+
: (draftUpdatedByClientId || null),
|
|
455
650
|
};
|
|
456
651
|
}
|
|
457
652
|
|
|
@@ -466,7 +661,8 @@ function upsertConversationDraftState(conversationId, {
|
|
|
466
661
|
*/
|
|
467
662
|
export function persistComposerAttachments() {
|
|
468
663
|
const id = String(currentConvId || '').trim();
|
|
469
|
-
|
|
664
|
+
// A send owns the draft: its post-send save picks the attachments up.
|
|
665
|
+
if (!id || isDraftOwnedByPendingSend(id)) return null;
|
|
470
666
|
return scheduleConversationDraftSave({
|
|
471
667
|
conversationId: id,
|
|
472
668
|
draftText: document.getElementById('msg-input')?.value || '',
|
|
@@ -475,37 +671,251 @@ export function persistComposerAttachments() {
|
|
|
475
671
|
});
|
|
476
672
|
}
|
|
477
673
|
|
|
478
|
-
|
|
674
|
+
// Saves the conversation's composer text (or, off screen, its cached text)
|
|
675
|
+
// when it differs from the synced draft.
|
|
676
|
+
function saveComposerIfUnsynced(conversationId) {
|
|
677
|
+
const synced = getSyncedDraft(conversationId);
|
|
678
|
+
const viewing = isCurrentConversation(conversationId);
|
|
679
|
+
const text = draftTextForSync(viewing
|
|
680
|
+
? (document.getElementById('msg-input')?.value || '')
|
|
681
|
+
: (conversations[conversationId]?.draftText || ''));
|
|
682
|
+
const attachments = viewing ? serializeDraftAttachments(selectedAttachments) : undefined;
|
|
683
|
+
const attachmentsKey = draftAttachmentsKey(attachments);
|
|
684
|
+
if (synced && synced.text === text && (attachmentsKey === null || synced.attachmentsKey === null || synced.attachmentsKey === attachmentsKey)) return;
|
|
685
|
+
void scheduleConversationDraftSave({ conversationId, draftText: text, draftAttachments: attachments });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// The server echoes a stored message to its sender (user_message with this
|
|
689
|
+
// client's id), which drops it from pendingUserMessageIds; status events for
|
|
690
|
+
// it do the same. Either means the send landed even if its response failed.
|
|
691
|
+
function hasSendEchoArrived(clientMessageId) {
|
|
692
|
+
const id = String(clientMessageId || '').trim();
|
|
693
|
+
return !!id && !pendingUserMessageIds.has(id);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// A send that failed hands its text back to the composer. Text typed while it
|
|
697
|
+
// was in flight is kept after it (blank line between) instead of being
|
|
698
|
+
// replaced, so neither is lost; if the send never emptied the composer, the
|
|
699
|
+
// composer still holds the message and is kept as it is. A send whose echo
|
|
700
|
+
// already arrived did land (the failure was only the response): putting its
|
|
701
|
+
// text back would invite a duplicate, so only the typing is kept.
|
|
702
|
+
function restoreComposerAfterFailedSend(conversationId, originalText, { viewing, composerCleared, sendLanded = false }) {
|
|
703
|
+
const input = document.getElementById('msg-input');
|
|
704
|
+
const current = viewing
|
|
705
|
+
? String(input?.value || '')
|
|
706
|
+
: String(conversations[conversationId]?.draftText || '');
|
|
707
|
+
let text = originalText;
|
|
708
|
+
if (sendLanded) text = current;
|
|
709
|
+
else if (!composerCleared) text = current || originalText;
|
|
710
|
+
else if (current.trim()) text = `${originalText}
|
|
711
|
+
|
|
712
|
+
${current}`;
|
|
713
|
+
if (viewing && input) {
|
|
714
|
+
input.value = text;
|
|
715
|
+
autoResize(input);
|
|
716
|
+
}
|
|
717
|
+
void scheduleConversationDraftSave({ conversationId, draftText: text, immediate: true });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function isDraftVersionConflict(response) {
|
|
721
|
+
return response?.conflict === true || response?.code === 'draft-version-conflict';
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function isCurrentConversation(conversationId) {
|
|
725
|
+
return String(currentConvId || '').trim() === String(conversationId || '').trim();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The version a save is based on. Always sent — even when null, which tells
|
|
729
|
+
// the server "I have seen no draft yet" and is conflict-checked like any other.
|
|
730
|
+
function draftSaveBaseVersion(conversationId) {
|
|
731
|
+
const synced = getSyncedDraft(conversationId);
|
|
732
|
+
if (synced) return synced.updatedAt || null;
|
|
733
|
+
return conversations[conversationId]?.draftUpdatedAt || null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function sendConversationDraft(conversationId, text, draftAttachments, baseDraftUpdatedAt) {
|
|
737
|
+
return updateConversationDraftApi(conversationId, {
|
|
738
|
+
draftText: text,
|
|
739
|
+
clientId: CLIENT_ID,
|
|
740
|
+
draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
|
|
741
|
+
baseDraftUpdatedAt,
|
|
742
|
+
...(draftAttachments === undefined ? {} : { draftAttachments }),
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function acknowledgeSavedDraft(conversationId, response, sentText) {
|
|
747
|
+
const serverText = String(response?.draftText || '');
|
|
748
|
+
const updatedAt = response?.draftUpdatedAt || response?.updatedAt || null;
|
|
749
|
+
// The server stores whitespace-only text as "no draft"; remembering what was
|
|
750
|
+
// sent keeps that composer from looking modified against the empty echo.
|
|
751
|
+
const text = !serverText && !String(sentText || '').trim() ? String(sentText || '') : serverText;
|
|
752
|
+
recordSyncedDraft(conversationId, { text, attachments: response?.draftAttachments, updatedAt });
|
|
753
|
+
upsertConversationDraftState(conversationId, {
|
|
754
|
+
draftText: serverText,
|
|
755
|
+
draftAttachments: response?.draftAttachments,
|
|
756
|
+
draftUpdatedAt: updatedAt,
|
|
757
|
+
draftUpdatedByClientId: response?.draftUpdatedByClientId || response?.senderClientId || null,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function applyDraftToComposer(conversationId, text, draftAttachments = undefined) {
|
|
762
|
+
if (!isCurrentConversation(conversationId)) return;
|
|
763
|
+
if (Array.isArray(draftAttachments)) {
|
|
764
|
+
const hydrated = hydrateDraftAttachments(draftAttachments);
|
|
765
|
+
if (!draftAttachmentsEqual(selectedAttachments, hydrated)) setComposerAttachments(hydrated);
|
|
766
|
+
}
|
|
767
|
+
const input = document.getElementById('msg-input');
|
|
768
|
+
if (input && draftTextForSync(input.value) !== text) {
|
|
769
|
+
input.value = text;
|
|
770
|
+
autoResize(input);
|
|
771
|
+
}
|
|
772
|
+
syncSendButtonState();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function adoptServerDraft(conversationId, {
|
|
776
|
+
draftText = '',
|
|
777
|
+
draftAttachments = undefined,
|
|
778
|
+
draftUpdatedAt = null,
|
|
779
|
+
draftUpdatedByClientId = null,
|
|
780
|
+
} = {}) {
|
|
781
|
+
const text = String(draftText || '');
|
|
782
|
+
clearDraftTimerForConversation(conversationId);
|
|
783
|
+
recordSyncedDraft(conversationId, { text, attachments: draftAttachments, updatedAt: draftUpdatedAt });
|
|
784
|
+
upsertConversationDraftState(conversationId, { draftText: text, draftAttachments, draftUpdatedAt, draftUpdatedByClientId });
|
|
785
|
+
applyDraftToComposer(conversationId, text, draftAttachments);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// The draft that lost the last conflict, per conversation, so it can be put
|
|
789
|
+
// back. It lives outside the toast: a later notice may cover the toast, and
|
|
790
|
+
// the action reads the slot when tapped. Cleared when the draft is sent.
|
|
791
|
+
const restorableDraftByConversation = new Map();
|
|
792
|
+
|
|
793
|
+
function draftHasContent({ text = '', attachments = undefined } = {}) {
|
|
794
|
+
return !!String(text || '').trim() || (Array.isArray(attachments) && attachments.length > 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function currentComposerDraft(conversationId) {
|
|
798
|
+
if (isCurrentConversation(conversationId)) {
|
|
799
|
+
return {
|
|
800
|
+
text: String(document.getElementById('msg-input')?.value || ''),
|
|
801
|
+
attachments: serializeDraftAttachments(selectedAttachments),
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const conversation = conversations[conversationId] || {};
|
|
805
|
+
return {
|
|
806
|
+
text: String(conversation.draftText || ''),
|
|
807
|
+
attachments: Array.isArray(conversation.draftAttachments) ? conversation.draftAttachments : [],
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function offerRestorableDraft(conversationId, message, actionLabel) {
|
|
812
|
+
showTransientRelayNotice(message, 20000, {
|
|
813
|
+
actionLabel,
|
|
814
|
+
onAction: () => swapRestorableDraft(conversationId),
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function keepLosingDraft(conversationId, losingDraft) {
|
|
819
|
+
if (!draftHasContent(losingDraft)) return;
|
|
820
|
+
restorableDraftByConversation.set(String(conversationId || '').trim(), losingDraft);
|
|
821
|
+
offerRestorableDraft(conversationId, 'Your edit replaced a newer draft from another device.', 'Restore');
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Restore is a swap: the draft it replaces takes the slot, so the offer can
|
|
825
|
+
// always be reversed.
|
|
826
|
+
function swapRestorableDraft(conversationId) {
|
|
827
|
+
const id = String(conversationId || '').trim();
|
|
828
|
+
const restored = restorableDraftByConversation.get(id);
|
|
829
|
+
if (!restored) return;
|
|
830
|
+
restorableDraftByConversation.set(id, currentComposerDraft(id));
|
|
831
|
+
const text = String(restored.text || '');
|
|
832
|
+
applyDraftToComposer(id, text, restored.attachments);
|
|
833
|
+
void scheduleConversationDraftSave({
|
|
834
|
+
conversationId: id,
|
|
835
|
+
draftText: text,
|
|
836
|
+
draftAttachments: Array.isArray(restored.attachments) ? restored.attachments : undefined,
|
|
837
|
+
immediate: true,
|
|
838
|
+
});
|
|
839
|
+
offerRestorableDraft(id, 'Draft restored.', 'Undo');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// A 409 means another client saved since this one's base version. The local
|
|
843
|
+
// composer is re-read here (not the text the save carried): it may have moved
|
|
844
|
+
// on while the request was in flight, and the newest keystroke is what wins.
|
|
845
|
+
async function resolveDraftSaveConflict(id, { text, draftAttachments, synced, conflict }) {
|
|
846
|
+
const serverText = String(conflict.draftText || '');
|
|
847
|
+
const serverAttachments = Array.isArray(conflict.draftAttachments) ? conflict.draftAttachments : undefined;
|
|
848
|
+
const viewing = isCurrentConversation(id);
|
|
849
|
+
const localText = viewing ? draftTextForSync(document.getElementById('msg-input')?.value ?? text) : text;
|
|
850
|
+
const localAttachments = draftAttachments === undefined
|
|
851
|
+
? undefined
|
|
852
|
+
: (viewing ? serializeDraftAttachments(selectedAttachments) : draftAttachments);
|
|
853
|
+
let outcome = resolveDraftConflict({
|
|
854
|
+
localText,
|
|
855
|
+
localAttachmentsKey: draftAttachmentsKey(localAttachments),
|
|
856
|
+
baseText: synced ? synced.text : null,
|
|
857
|
+
baseAttachmentsKey: synced ? synced.attachmentsKey : null,
|
|
858
|
+
serverText,
|
|
859
|
+
serverAttachmentsKey: draftAttachmentsKey(serverAttachments),
|
|
860
|
+
});
|
|
861
|
+
// Uploads in flight are not in the fingerprint; adopting would replace the
|
|
862
|
+
// attachment list and drop them, so they count as a local edit.
|
|
863
|
+
if (outcome === 'adopt' && viewing && hasUploadingAttachments(selectedAttachments)) outcome = 'keep-local';
|
|
864
|
+
if (outcome === 'adopt') {
|
|
865
|
+
adoptServerDraft(id, conflict);
|
|
866
|
+
return conflict;
|
|
867
|
+
}
|
|
868
|
+
recordSyncedDraft(id, { text: serverText, attachments: serverAttachments, updatedAt: conflict.draftUpdatedAt || null });
|
|
869
|
+
if (outcome === 'converged') {
|
|
870
|
+
upsertConversationDraftState(id, {
|
|
871
|
+
draftText: serverText,
|
|
872
|
+
draftAttachments: serverAttachments,
|
|
873
|
+
draftUpdatedAt: conflict.draftUpdatedAt || null,
|
|
874
|
+
draftUpdatedByClientId: conflict.draftUpdatedByClientId || null,
|
|
875
|
+
});
|
|
876
|
+
return conflict;
|
|
877
|
+
}
|
|
878
|
+
const retry = await sendConversationDraft(id, localText, localAttachments, conflict.draftUpdatedAt || null);
|
|
879
|
+
if (retry?.ok) {
|
|
880
|
+
acknowledgeSavedDraft(id, retry, localText);
|
|
881
|
+
const localKey = draftAttachmentsKey(localAttachments);
|
|
882
|
+
const lostSomething = serverText !== localText
|
|
883
|
+
|| (localKey !== null && localKey !== draftAttachmentsKey(serverAttachments));
|
|
884
|
+
if (lostSomething) keepLosingDraft(id, { text: serverText, attachments: serverAttachments });
|
|
885
|
+
return retry;
|
|
886
|
+
}
|
|
887
|
+
// Lost a second race. The base stays at the first conflict's version, so the
|
|
888
|
+
// retry scheduled here conflicts again and resolves (with its restore offer)
|
|
889
|
+
// through this same path instead of leaving the edit unsaved.
|
|
890
|
+
if (isDraftVersionConflict(retry)) {
|
|
891
|
+
void scheduleConversationDraftSave({ conversationId: id, draftText: localText, draftAttachments: localAttachments });
|
|
892
|
+
}
|
|
893
|
+
return retry || null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function persistConversationDraft(conversationId, draftText, draftAttachments = undefined, { force = false } = {}) {
|
|
479
897
|
const id = String(conversationId || '').trim();
|
|
480
898
|
if (!id) return null;
|
|
481
899
|
const text = String(draftText || '');
|
|
482
900
|
const runPersist = async () => {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
if (response?.conflict === true || response?.code === 'draft-version-conflict') {
|
|
492
|
-
applyIncomingConversationDraftUpdate({
|
|
493
|
-
conversationId: id,
|
|
494
|
-
draftText: response.draftText || '',
|
|
495
|
-
draftAttachments: response.draftAttachments,
|
|
496
|
-
draftUpdatedAt: response.draftUpdatedAt || null,
|
|
497
|
-
draftUpdatedByClientId: response.draftUpdatedByClientId || null,
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
return response || null;
|
|
901
|
+
const synced = getSyncedDraft(id);
|
|
902
|
+
if (!force && isDraftSaveNoop({
|
|
903
|
+
synced,
|
|
904
|
+
text,
|
|
905
|
+
attachmentsKey: draftAttachmentsKey(draftAttachments),
|
|
906
|
+
knownUpdatedAt: conversations[id]?.draftUpdatedAt || null,
|
|
907
|
+
})) {
|
|
908
|
+
return { ok: true, skipped: true, conversationId: id, draftText: synced.text, draftUpdatedAt: synced.updatedAt };
|
|
501
909
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
910
|
+
const response = await sendConversationDraft(id, text, draftAttachments, draftSaveBaseVersion(id));
|
|
911
|
+
if (response?.ok) {
|
|
912
|
+
acknowledgeSavedDraft(id, response, text);
|
|
913
|
+
return response;
|
|
914
|
+
}
|
|
915
|
+
if (isDraftVersionConflict(response)) {
|
|
916
|
+
return resolveDraftSaveConflict(id, { text, draftAttachments, synced, conflict: response });
|
|
917
|
+
}
|
|
918
|
+
return response || null;
|
|
509
919
|
};
|
|
510
920
|
const previous = draftSavePromiseByConversation.get(id) || Promise.resolve();
|
|
511
921
|
const next = previous
|
|
@@ -525,19 +935,20 @@ async function scheduleConversationDraftSave({
|
|
|
525
935
|
draftText,
|
|
526
936
|
draftAttachments = undefined,
|
|
527
937
|
immediate = false,
|
|
938
|
+
force = false,
|
|
528
939
|
} = {}) {
|
|
529
940
|
const id = String(conversationId || '').trim();
|
|
530
941
|
if (!id) return null;
|
|
531
|
-
const text =
|
|
942
|
+
const text = draftTextForSync(draftText);
|
|
532
943
|
upsertConversationDraftState(id, { draftText: text, draftAttachments });
|
|
533
944
|
clearDraftTimerForConversation(id);
|
|
534
945
|
if (immediate) {
|
|
535
|
-
return persistConversationDraft(id, text, draftAttachments);
|
|
946
|
+
return persistConversationDraft(id, text, draftAttachments, { force });
|
|
536
947
|
}
|
|
537
948
|
return new Promise((resolve) => {
|
|
538
949
|
const timer = setTimeout(() => {
|
|
539
950
|
draftSaveTimerByConversation.delete(id);
|
|
540
|
-
persistConversationDraft(id, text, draftAttachments).then(resolve).catch(() => resolve(null));
|
|
951
|
+
persistConversationDraft(id, text, draftAttachments, { force }).then(resolve).catch(() => resolve(null));
|
|
541
952
|
}, COMPOSER_DRAFT_DEBOUNCE_MS);
|
|
542
953
|
draftSaveTimerByConversation.set(id, timer);
|
|
543
954
|
});
|
|
@@ -548,6 +959,11 @@ export async function flushConversationDraft(conversationId = currentConvId) {
|
|
|
548
959
|
if (!id) return null;
|
|
549
960
|
const input = document.getElementById('msg-input');
|
|
550
961
|
const draftText = String((id === String(currentConvId || '').trim() && input) ? input.value : (conversations[id]?.draftText || ''));
|
|
962
|
+
if (isDraftOwnedByPendingSend(id)) {
|
|
963
|
+
// Held: the send saves the cached text once it settles.
|
|
964
|
+
upsertConversationDraftState(id, { draftText: draftTextForSync(draftText) });
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
551
967
|
return scheduleConversationDraftSave({
|
|
552
968
|
conversationId: id,
|
|
553
969
|
draftText,
|
|
@@ -555,6 +971,46 @@ export async function flushConversationDraft(conversationId = currentConvId) {
|
|
|
555
971
|
});
|
|
556
972
|
}
|
|
557
973
|
|
|
974
|
+
// Shared tail of both incoming-draft paths (conversation load/poll and the
|
|
975
|
+
// cross-client broadcast) for the conversation on screen. `incoming.attachments`
|
|
976
|
+
// is undefined when the update did not carry attachments.
|
|
977
|
+
function reconcileIncomingComposerDraft(id, incoming) {
|
|
978
|
+
const input = document.getElementById('msg-input');
|
|
979
|
+
if (!input) return;
|
|
980
|
+
const synced = getSyncedDraft(id);
|
|
981
|
+
const composerAttachments = serializeDraftAttachments(selectedAttachments);
|
|
982
|
+
if (!shouldApplyIncomingDraftToComposer({
|
|
983
|
+
inputText: input.value,
|
|
984
|
+
incomingText: incoming.text,
|
|
985
|
+
syncedText: synced ? synced.text : null,
|
|
986
|
+
inputAttachmentsKey: draftAttachmentsKey(composerAttachments),
|
|
987
|
+
incomingAttachmentsKey: draftAttachmentsKey(incoming.attachments),
|
|
988
|
+
syncedAttachmentsKey: synced ? synced.attachmentsKey : null,
|
|
989
|
+
attachmentsUploading: hasUploadingAttachments(selectedAttachments),
|
|
990
|
+
})) {
|
|
991
|
+
// An unsaved local edit (typing, an attachment change, or a flush that
|
|
992
|
+
// failed). Re-save it, attachments included, so the version check
|
|
993
|
+
// decides: the same version saves, a newer one conflicts.
|
|
994
|
+
if (!isDraftSavePending(id)) {
|
|
995
|
+
void scheduleConversationDraftSave({ conversationId: id, draftText: input.value, draftAttachments: composerAttachments });
|
|
996
|
+
}
|
|
997
|
+
syncSendButtonState();
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
recordSyncedDraft(id, incoming);
|
|
1001
|
+
if (Array.isArray(incoming.attachments)) {
|
|
1002
|
+
const hydrated = hydrateDraftAttachments(incoming.attachments);
|
|
1003
|
+
if (!draftAttachmentsEqual(selectedAttachments, hydrated)) setComposerAttachments(hydrated);
|
|
1004
|
+
}
|
|
1005
|
+
// A composer longer than the draft limit matches its truncated echo; it must
|
|
1006
|
+
// not be cut down to it.
|
|
1007
|
+
if (draftTextForSync(input.value) !== incoming.text) {
|
|
1008
|
+
input.value = incoming.text;
|
|
1009
|
+
autoResize(input);
|
|
1010
|
+
}
|
|
1011
|
+
syncSendButtonState();
|
|
1012
|
+
}
|
|
1013
|
+
|
|
558
1014
|
export function hydrateConversationDraft(conversationId, {
|
|
559
1015
|
draftText = '',
|
|
560
1016
|
draftAttachments = [],
|
|
@@ -563,6 +1019,8 @@ export function hydrateConversationDraft(conversationId, {
|
|
|
563
1019
|
} = {}) {
|
|
564
1020
|
const id = String(conversationId || '').trim();
|
|
565
1021
|
if (!id) return;
|
|
1022
|
+
// A refresh in flight across a send predates the send's own clear.
|
|
1023
|
+
if (isDraftOwnedByPendingSend(id)) return;
|
|
566
1024
|
const normalizedDraftText = String(draftText || '');
|
|
567
1025
|
const normalizedAttachments = Array.isArray(draftAttachments) ? draftAttachments : [];
|
|
568
1026
|
const existingMs = normalizeDraftTimestampMs(conversations[id]?.draftUpdatedAt);
|
|
@@ -574,23 +1032,12 @@ export function hydrateConversationDraft(conversationId, {
|
|
|
574
1032
|
draftUpdatedAt,
|
|
575
1033
|
draftUpdatedByClientId,
|
|
576
1034
|
});
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
setComposerAttachments(hydrateDraftAttachments(normalizedAttachments));
|
|
581
|
-
}
|
|
582
|
-
const input = document.getElementById('msg-input');
|
|
583
|
-
if (!input) return;
|
|
584
|
-
const isFocused = document.activeElement === input;
|
|
585
|
-
if (isFocused && input.value !== normalizedDraftText) {
|
|
586
|
-
syncSendButtonState();
|
|
1035
|
+
const syncedDraft = { text: normalizedDraftText, attachments: normalizedAttachments, updatedAt: draftUpdatedAt };
|
|
1036
|
+
if (String(currentConvId || '').trim() !== id) {
|
|
1037
|
+
if (!isDraftSavePending(id)) recordSyncedDraft(id, syncedDraft);
|
|
587
1038
|
return;
|
|
588
1039
|
}
|
|
589
|
-
|
|
590
|
-
input.value = normalizedDraftText;
|
|
591
|
-
autoResize(input);
|
|
592
|
-
}
|
|
593
|
-
syncSendButtonState();
|
|
1040
|
+
reconcileIncomingComposerDraft(id, syncedDraft);
|
|
594
1041
|
}
|
|
595
1042
|
|
|
596
1043
|
export function applyIncomingConversationDraftUpdate({
|
|
@@ -604,6 +1051,9 @@ export function applyIncomingConversationDraftUpdate({
|
|
|
604
1051
|
const id = String(conversationId || '').trim();
|
|
605
1052
|
if (!id || !conversations[id]) return;
|
|
606
1053
|
if (senderClientId && senderClientId === CLIENT_ID) return;
|
|
1054
|
+
// Same window as above: a broadcast of the pre-send draft must not undo the
|
|
1055
|
+
// clear the send is still persisting.
|
|
1056
|
+
if (isDraftOwnedByPendingSend(id)) return;
|
|
607
1057
|
const incomingDraftText = String(draftText || '');
|
|
608
1058
|
const existingMs = normalizeDraftTimestampMs(conversations[id]?.draftUpdatedAt);
|
|
609
1059
|
const incomingMs = normalizeDraftTimestampMs(draftUpdatedAt);
|
|
@@ -614,26 +1064,12 @@ export function applyIncomingConversationDraftUpdate({
|
|
|
614
1064
|
draftUpdatedAt,
|
|
615
1065
|
draftUpdatedByClientId: draftUpdatedByClientId || senderClientId || null,
|
|
616
1066
|
});
|
|
617
|
-
|
|
618
|
-
if (
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
incoming: hydrateDraftAttachments(Array.isArray(draftAttachments) ? draftAttachments : []),
|
|
622
|
-
existingUpdatedAt: conversations[id]?.draftUpdatedAt || null,
|
|
623
|
-
incomingUpdatedAt: draftUpdatedAt,
|
|
624
|
-
isLocalEcho: senderClientId === CLIENT_ID,
|
|
625
|
-
});
|
|
626
|
-
if (merged.changed) setComposerAttachments(merged.attachments);
|
|
627
|
-
}
|
|
628
|
-
const input = document.getElementById('msg-input');
|
|
629
|
-
if (!input) return;
|
|
630
|
-
const isFocused = document.activeElement === input;
|
|
631
|
-
if (isFocused && input.value !== incomingDraftText) return;
|
|
632
|
-
if (input.value !== incomingDraftText) {
|
|
633
|
-
input.value = incomingDraftText;
|
|
634
|
-
autoResize(input);
|
|
1067
|
+
const syncedDraft = { text: incomingDraftText, attachments: draftAttachments, updatedAt: draftUpdatedAt };
|
|
1068
|
+
if (String(currentConvId || '').trim() !== id) {
|
|
1069
|
+
if (!isDraftSavePending(id)) recordSyncedDraft(id, syncedDraft);
|
|
1070
|
+
return;
|
|
635
1071
|
}
|
|
636
|
-
|
|
1072
|
+
reconcileIncomingComposerDraft(id, syncedDraft);
|
|
637
1073
|
}
|
|
638
1074
|
|
|
639
1075
|
function setConversationTurnState(conversationId, state = null) {
|
|
@@ -806,6 +1242,19 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
806
1242
|
const continuationTag = (msg.role === 'assistant' && String(msg?.kind || '').trim() === 'continuation')
|
|
807
1243
|
? ' <span class="msg-continuation" title="The agent continued on its own after a background task finished.">background continuation</span>'
|
|
808
1244
|
: '';
|
|
1245
|
+
// A steering absorption: this reply continues through the next (steered)
|
|
1246
|
+
// user message. The class feeds syncSteeredTurnMerge, which joins the trio
|
|
1247
|
+
// into one visual flow.
|
|
1248
|
+
const messageKind = String(msg?.kind || '').trim();
|
|
1249
|
+
if (msg.role === 'assistant' && messageKind === 'absorbed') {
|
|
1250
|
+
div.classList.add(ABSORBED_MSG_CLASS);
|
|
1251
|
+
}
|
|
1252
|
+
// The settle marker of a steer the CLI consumed without a turn of its own:
|
|
1253
|
+
// folded into (and answered by) the turn above, or cut off unanswered by a
|
|
1254
|
+
// Stop. Compact, attached to its own user message, never merging forward.
|
|
1255
|
+
const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
|
|
1256
|
+
if (steerMarkerClass) div.classList.add(steerMarkerClass);
|
|
1257
|
+
const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
|
|
809
1258
|
// A turn answered by a different provider than the conversation is bound to
|
|
810
1259
|
// (e.g. the Copilot relay answering a Cursor conversation) must be visible,
|
|
811
1260
|
// not silent: it ran on another plan than the header indicates.
|
|
@@ -883,7 +1332,15 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
883
1332
|
: 'msg-bubble';
|
|
884
1333
|
|
|
885
1334
|
const isQueuedUserMessage = msg.role === 'user' && msgId && pendingUserMessageIds.has(msgId);
|
|
886
|
-
|
|
1335
|
+
// A row the worker pushed into its runtime but reports as still unconsumed
|
|
1336
|
+
// (Copilot's queued lane, heartbeat `cancellableIds`) keeps Cancel too: the
|
|
1337
|
+
// worker pulls it back out and the row ends cancelled, never answered.
|
|
1338
|
+
const isCancellableSteer = !isQueuedUserMessage
|
|
1339
|
+
&& msg.role === 'user'
|
|
1340
|
+
&& !!msgId
|
|
1341
|
+
&& cancellableSteerIdsForConversation(currentConvId).has(msgId);
|
|
1342
|
+
const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
|
|
1343
|
+
const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
|
|
887
1344
|
const hiddenFromShares = msg?.hiddenFromShares === true;
|
|
888
1345
|
const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
|
|
889
1346
|
const sourceMessageId = String(msg?.sourceMessageId || '').trim();
|
|
@@ -907,12 +1364,20 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
907
1364
|
.map((item) => `<button type="button" class="bubble-action-btn" data-action="relay-error-cta" data-cta="${escHtml(item.action)}">${escHtml(item.label)}</button>`)
|
|
908
1365
|
.join('')}</div>`
|
|
909
1366
|
: '';
|
|
910
|
-
const userBubbleActionsHtml = (!IS_SHARED_VIEW &&
|
|
911
|
-
?
|
|
1367
|
+
const userBubbleActionsHtml = (!IS_SHARED_VIEW && showsCancelButton)
|
|
1368
|
+
? buildUserBubbleCancelActionsMarkup(msgId, isCancelInFlight)
|
|
1369
|
+
: '';
|
|
1370
|
+
// Resent per the relay (any device, survives reloads), or by this page.
|
|
1371
|
+
const resendState = isStoppedSteer && msgId
|
|
1372
|
+
? (stoppedSteerResends.get(msgId) || (String(msg?.resentAs || '').trim() ? { status: 'sent' } : null))
|
|
1373
|
+
: null;
|
|
1374
|
+
const resendLabel = resendState?.status === 'sent' ? 'Resent' : (resendState ? 'Resending…' : 'Resend');
|
|
1375
|
+
const stoppedSteerActionsHtml = (!IS_SHARED_VIEW && isStoppedSteer && msgId)
|
|
1376
|
+
? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="resend-stopped-steer" data-message-id="${escHtml(msgId)}" title="Send this message again as a new turn"${resendState ? ' disabled' : ''}>${resendLabel}</button></div>`
|
|
912
1377
|
: '';
|
|
913
1378
|
|
|
914
1379
|
div.innerHTML = `
|
|
915
|
-
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}</div>
|
|
1380
|
+
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
|
|
916
1381
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
917
1382
|
|
|
918
1383
|
const bubble = div.querySelector('.msg-bubble');
|
|
@@ -947,6 +1412,16 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
947
1412
|
}
|
|
948
1413
|
div.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
|
|
949
1414
|
attachCodeCopyButtons(div);
|
|
1415
|
+
// What a Resend of this message would send, kept on the node itself so it
|
|
1416
|
+
// lives exactly as long as the row is on screen.
|
|
1417
|
+
if (msg.role === 'user') {
|
|
1418
|
+
const { references, unavailable } = attachmentReferencesForResend(attachments);
|
|
1419
|
+
div[RESEND_PAYLOAD_KEY] = {
|
|
1420
|
+
text: String(msg.text || ''),
|
|
1421
|
+
attachments: references,
|
|
1422
|
+
unavailableAttachments: unavailable,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
950
1425
|
return div;
|
|
951
1426
|
}
|
|
952
1427
|
|
|
@@ -1008,34 +1483,7 @@ export function decorateActivityText(text) {
|
|
|
1008
1483
|
return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
|
|
1009
1484
|
};
|
|
1010
1485
|
const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
|
|
1011
|
-
|
|
1012
|
-
if (sharedSafeValue.startsWith('● ')) return `🔄 ${sharedSafeValue.slice(2).trim()}`;
|
|
1013
|
-
if (/^Model selected:/i.test(sharedSafeValue)) return `🧠 ${sharedSafeValue}`;
|
|
1014
|
-
if (/^Search \((glob|grep)\)/i.test(sharedSafeValue)) return `🔍 ${sharedSafeValue}`;
|
|
1015
|
-
if (/^Tool \(ask_user\)/i.test(sharedSafeValue)) return `❓ ${sharedSafeValue}`;
|
|
1016
|
-
if (/^Tool \(view\)/i.test(sharedSafeValue)) return `👀 ${sharedSafeValue}`;
|
|
1017
|
-
if (/^Tool \(apply_patch\)/i.test(sharedSafeValue)) return `🪡 ${sharedSafeValue}`;
|
|
1018
|
-
if (/^Tool \(powershell\)/i.test(sharedSafeValue)) return `🪓 ${sharedSafeValue}`;
|
|
1019
|
-
if (/^Tool \(edit\)/i.test(sharedSafeValue)) return `📝 ${sharedSafeValue}`;
|
|
1020
|
-
if (/^Tool \(read_file\)/i.test(sharedSafeValue)) return `📄 ${sharedSafeValue}`;
|
|
1021
|
-
if (/^Tool \((grep_search|file_search)\)/i.test(sharedSafeValue)) return `🔎 ${sharedSafeValue}`;
|
|
1022
|
-
if (/^Tool \(semantic_search\)/i.test(sharedSafeValue)) return `🧭 ${sharedSafeValue}`;
|
|
1023
|
-
if (/^Tool \(vscode_listCodeUsages\)/i.test(sharedSafeValue)) return `🔗 ${sharedSafeValue}`;
|
|
1024
|
-
if (/^Tool \(vscode_renameSymbol\)/i.test(sharedSafeValue)) return `✏️ ${sharedSafeValue}`;
|
|
1025
|
-
if (/^Tool \(list_dir\)/i.test(sharedSafeValue)) return `📂 ${sharedSafeValue}`;
|
|
1026
|
-
if (/^Tool \(create_directory\)/i.test(sharedSafeValue)) return `📁 ${sharedSafeValue}`;
|
|
1027
|
-
if (/^Tool \((delete|remove)\)/i.test(sharedSafeValue)) return `🗑️ ${sharedSafeValue}`;
|
|
1028
|
-
if (/^Tool \(execution_subagent\)/i.test(sharedSafeValue)) return `🚀 ${sharedSafeValue}`;
|
|
1029
|
-
if (/^Tool \(get_errors\)/i.test(sharedSafeValue)) return `🚨 ${sharedSafeValue}`;
|
|
1030
|
-
if (/^Tool \(debug_[^)]+\)/i.test(sharedSafeValue)) return `🐞 ${sharedSafeValue}`;
|
|
1031
|
-
if (/^Tool \(fetch_webpage\)/i.test(sharedSafeValue)) return `🌐 ${sharedSafeValue}`;
|
|
1032
|
-
if (/^Tool \(github_[^)]+\)/i.test(sharedSafeValue)) return `🐙 ${sharedSafeValue}`;
|
|
1033
|
-
if (/^Tool \(run_in_terminal\)/i.test(sharedSafeValue)) return `🖥️ ${sharedSafeValue}`;
|
|
1034
|
-
if (/^Tool \((create_file|write)\)/i.test(sharedSafeValue)) return `🆕 ${sharedSafeValue}`;
|
|
1035
|
-
if (/^Tool \((bash|shell|terminal)\)/i.test(sharedSafeValue)) return `🔧 ${sharedSafeValue}`;
|
|
1036
|
-
if (/^Tool \((sql|sqlite)\)/i.test(sharedSafeValue)) return `🗄️ ${sharedSafeValue}`;
|
|
1037
|
-
if (/^Tool \(/i.test(sharedSafeValue)) return `🛠️ ${sharedSafeValue}`;
|
|
1038
|
-
return `ℹ️ ${sharedSafeValue}`;
|
|
1486
|
+
return prefixToolActivityEmoji(sharedSafeValue);
|
|
1039
1487
|
}
|
|
1040
1488
|
|
|
1041
1489
|
export function renderThoughtsMarkup(thoughts) {
|
|
@@ -1209,11 +1657,38 @@ export function renderSubagentRunsMarkup(subagentRuns, activities, thoughts) {
|
|
|
1209
1657
|
return roots.map(renderRun).join('');
|
|
1210
1658
|
}
|
|
1211
1659
|
|
|
1660
|
+
// Places the live bubble directly under its owning user message, or leaves a
|
|
1661
|
+
// bubble already in the transcript where it is when that row is not rendered.
|
|
1662
|
+
// Idempotent so the reuse path can call it every tick: a bubble built before
|
|
1663
|
+
// its owning row existed gets re-anchored once the row appears.
|
|
1664
|
+
function anchorThinkingBubble(div, messageId) {
|
|
1665
|
+
const el = document.getElementById('messages');
|
|
1666
|
+
if (!el || !div) return;
|
|
1667
|
+
const id = String(messageId || '').trim();
|
|
1668
|
+
const target = id ? el.querySelector(`[data-message-id="${id}"]`) : null;
|
|
1669
|
+
if (target && target.parentNode === el) {
|
|
1670
|
+
if (target.nextSibling === div) return;
|
|
1671
|
+
const next = target.nextSibling;
|
|
1672
|
+
if (next) el.insertBefore(div, next);
|
|
1673
|
+
else el.appendChild(div);
|
|
1674
|
+
} else if (div.parentNode !== el) {
|
|
1675
|
+
el.appendChild(div);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Which conversation a live bubble belongs to. #messages is shared by every
|
|
1680
|
+
// conversation, so a bubble is only ever reused or preserved for its own.
|
|
1681
|
+
function thinkingConversationKey() {
|
|
1682
|
+
return String(currentConvId || '').trim() || '__new__';
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1212
1685
|
export function showThinking(messageId = null, autoScroll = true) {
|
|
1213
1686
|
const nextMessageId = String(messageId || '').trim();
|
|
1214
1687
|
if (nextMessageId) thinkingMessageId = nextMessageId;
|
|
1215
1688
|
const existing = document.getElementById('thinking-indicator');
|
|
1216
|
-
if (existing
|
|
1689
|
+
if (existing
|
|
1690
|
+
&& existing.dataset.conversationId === thinkingConversationKey()
|
|
1691
|
+
&& (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
|
|
1217
1692
|
// Reuse the live bubble: rebuilding it every poll tick destroys any text
|
|
1218
1693
|
// selection anchored inside and drops in-progress subagent bubbles.
|
|
1219
1694
|
const stopBtn = existing.querySelector('[data-action="stop-turn"]');
|
|
@@ -1223,14 +1698,15 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1223
1698
|
stopBtn.textContent = stopping ? 'Stopping…' : 'Stop';
|
|
1224
1699
|
stopBtn.classList.toggle('stopping', stopping);
|
|
1225
1700
|
}
|
|
1701
|
+
anchorThinkingBubble(existing, nextMessageId || existing.dataset.messageId);
|
|
1226
1702
|
if (autoScroll) scrollBottom();
|
|
1227
1703
|
return;
|
|
1228
1704
|
}
|
|
1229
1705
|
existing?.remove();
|
|
1230
|
-
const el = document.getElementById('messages');
|
|
1231
1706
|
const div = document.createElement('div');
|
|
1232
1707
|
div.className = 'msg assistant';
|
|
1233
1708
|
div.id = 'thinking-indicator';
|
|
1709
|
+
div.dataset.conversationId = thinkingConversationKey();
|
|
1234
1710
|
if (nextMessageId) div.dataset.messageId = nextMessageId;
|
|
1235
1711
|
const isCancelInFlight = nextMessageId && bubbleCancelInFlight.has(nextMessageId);
|
|
1236
1712
|
const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
|
|
@@ -1248,14 +1724,7 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1248
1724
|
<div id="thinking-activity" class="thinking-activity"></div>
|
|
1249
1725
|
<div class="subagent-bubbles-container" data-subagent-bubbles-root="1"></div>
|
|
1250
1726
|
</div>`;
|
|
1251
|
-
|
|
1252
|
-
if (target && target.parentNode === el) {
|
|
1253
|
-
const next = target.nextSibling;
|
|
1254
|
-
if (next) el.insertBefore(div, next);
|
|
1255
|
-
else el.appendChild(div);
|
|
1256
|
-
} else {
|
|
1257
|
-
el.appendChild(div);
|
|
1258
|
-
}
|
|
1727
|
+
anchorThinkingBubble(div, nextMessageId);
|
|
1259
1728
|
renderThinkingThoughts();
|
|
1260
1729
|
renderThinkingStream();
|
|
1261
1730
|
if (autoScroll) scrollBottom();
|
|
@@ -1276,7 +1745,7 @@ export function collapseThinkingThoughts() {
|
|
|
1276
1745
|
});
|
|
1277
1746
|
}
|
|
1278
1747
|
|
|
1279
|
-
function clearRelayStreamState(messageId = null) {
|
|
1748
|
+
export function clearRelayStreamState(messageId = null) {
|
|
1280
1749
|
const id = String(messageId || '').trim();
|
|
1281
1750
|
if (!id) {
|
|
1282
1751
|
relayStreamStateByMessageId.clear();
|
|
@@ -1406,9 +1875,13 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
|
|
|
1406
1875
|
}
|
|
1407
1876
|
// Skip the rebuild when the payload matches the previous tick: the 900ms
|
|
1408
1877
|
// live poll would otherwise churn the DOM (and the user's selection) with
|
|
1409
|
-
// identical content.
|
|
1878
|
+
// identical content. The indicator must belong to this turn's message —
|
|
1879
|
+
// skipping on a foreign bubble would let a misplaced one stick around.
|
|
1410
1880
|
const snapshotKey = buildInFlightSnapshotKey(inFlight);
|
|
1411
|
-
|
|
1881
|
+
const liveIndicator = document.getElementById('thinking-indicator');
|
|
1882
|
+
if (snapshotKey === lastInFlightSnapshotKey
|
|
1883
|
+
&& liveIndicator
|
|
1884
|
+
&& String(liveIndicator.dataset.messageId || '') === messageId) {
|
|
1412
1885
|
return;
|
|
1413
1886
|
}
|
|
1414
1887
|
lastInFlightSnapshotKey = snapshotKey;
|
|
@@ -1886,14 +2359,20 @@ export function flushDeferredMessageRender() {
|
|
|
1886
2359
|
renderMessages(msgs, scroll, meta);
|
|
1887
2360
|
}
|
|
1888
2361
|
|
|
1889
|
-
|
|
2362
|
+
// Blacklists the id from further live relay_stream frames. Kept separate from
|
|
2363
|
+
// the state clearing so mid-flow statuses ('pending'/'parked') can reset the
|
|
2364
|
+
// bookkeeping without muting the rest of the turn.
|
|
2365
|
+
export function markRelayStreamComplete(messageId) {
|
|
1890
2366
|
const id = String(messageId || '').trim();
|
|
1891
|
-
if (id)
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
}
|
|
2367
|
+
if (!id) return;
|
|
2368
|
+
completedMessageIds.add(id);
|
|
2369
|
+
if (completedMessageIds.size > 100) {
|
|
2370
|
+
completedMessageIds.delete(completedMessageIds.values().next().value);
|
|
1896
2371
|
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
export function clearRelayStreamStateForMessage(messageId) {
|
|
2375
|
+
markRelayStreamComplete(messageId);
|
|
1897
2376
|
clearRelayStreamState(messageId);
|
|
1898
2377
|
}
|
|
1899
2378
|
|
|
@@ -1901,6 +2380,7 @@ export function applyConversationTurnStatus({ conversationId, messageId, status
|
|
|
1901
2380
|
const conversationKey = String(conversationId || '').trim();
|
|
1902
2381
|
const messageKey = String(messageId || '').trim();
|
|
1903
2382
|
const normalizedStatus = String(status || '').trim().toLowerCase();
|
|
2383
|
+
if (['cancelled', 'failed'].includes(normalizedStatus)) releaseStoppedSteerResendsOf(messageKey);
|
|
1904
2384
|
if (!conversationKey) {
|
|
1905
2385
|
syncSendButtonState();
|
|
1906
2386
|
return;
|
|
@@ -1924,58 +2404,9 @@ export function applyConversationTurnStatus({ conversationId, messageId, status
|
|
|
1924
2404
|
syncSendButtonState();
|
|
1925
2405
|
}
|
|
1926
2406
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
if (!conversationKey || !activeTurn?.messageId) return;
|
|
1931
|
-
if (activeTurn.cancelRequested) return;
|
|
1932
|
-
if (isMobileComposerViewport() && !confirm('Stop the current turn?')) return;
|
|
1933
|
-
setConversationTurnState(conversationKey, {
|
|
1934
|
-
messageId: activeTurn.messageId,
|
|
1935
|
-
status: activeTurn.status || 'processing',
|
|
1936
|
-
cancelRequested: true,
|
|
1937
|
-
});
|
|
1938
|
-
const expectedMessageId = activeTurn.messageId;
|
|
1939
|
-
const result = await cancelConversationTurn(conversationKey, {
|
|
1940
|
-
clientId: CLIENT_ID,
|
|
1941
|
-
messageId: expectedMessageId,
|
|
1942
|
-
});
|
|
1943
|
-
if (!result?.ok) {
|
|
1944
|
-
setConversationTurnState(conversationKey, {
|
|
1945
|
-
messageId: expectedMessageId,
|
|
1946
|
-
status: activeTurn.status || 'processing',
|
|
1947
|
-
cancelRequested: false,
|
|
1948
|
-
});
|
|
1949
|
-
showTransientRelayNotice('Could not stop the current turn.');
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
if (
|
|
1953
|
-
result.acknowledgement === 'already-finished'
|
|
1954
|
-
|| result.acknowledgement === 'no-active-turn'
|
|
1955
|
-
|| result.acknowledgement === 'message-mismatch'
|
|
1956
|
-
) {
|
|
1957
|
-
const latestTurn = getActiveTurnForConversation(conversationKey);
|
|
1958
|
-
if (latestTurn?.messageId === expectedMessageId) {
|
|
1959
|
-
setConversationTurnState(conversationKey, null);
|
|
1960
|
-
}
|
|
1961
|
-
showTransientRelayNotice('That turn already finished.');
|
|
1962
|
-
return;
|
|
1963
|
-
}
|
|
1964
|
-
if (result.acknowledgement === 'active-turn-unbound') {
|
|
1965
|
-
const latestTurn = getActiveTurnForConversation(conversationKey);
|
|
1966
|
-
if (latestTurn?.messageId === expectedMessageId) {
|
|
1967
|
-
setConversationTurnState(conversationKey, {
|
|
1968
|
-
messageId: expectedMessageId,
|
|
1969
|
-
status: activeTurn.status || 'processing',
|
|
1970
|
-
cancelRequested: false,
|
|
1971
|
-
});
|
|
1972
|
-
}
|
|
1973
|
-
showTransientRelayNotice('The active turn is not bound to a live SDK session.');
|
|
1974
|
-
return;
|
|
1975
|
-
}
|
|
1976
|
-
showTransientRelayNotice('Stopping the current turn…');
|
|
1977
|
-
}
|
|
1978
|
-
|
|
2407
|
+
// Stopping a turn lives on the message bubbles only (the live turn's Stop and
|
|
2408
|
+
// pending rows' Cancel). The composer's send button never doubles as Stop —
|
|
2409
|
+
// the old stopCurrentConversationTurn composer path is gone with it.
|
|
1979
2410
|
async function stopTurnByMessageId(conversationId, messageId) {
|
|
1980
2411
|
const conversationKey = String(conversationId || '').trim();
|
|
1981
2412
|
const targetMessageId = String(messageId || '').trim();
|
|
@@ -2053,6 +2484,12 @@ function updateBubbleStopButton(messageId, isStopping) {
|
|
|
2053
2484
|
btn.classList.toggle('stopping', isStopping);
|
|
2054
2485
|
}
|
|
2055
2486
|
|
|
2487
|
+
// The Cancel control of a user bubble: a pending row's, or a pushed row's the
|
|
2488
|
+
// worker can still pull back (both post cancel-queued-turn).
|
|
2489
|
+
function buildUserBubbleCancelActionsMarkup(messageId, isCancelling) {
|
|
2490
|
+
return `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn${isCancelling ? ' stopping' : ''}" data-action="cancel-queued" data-message-id="${escHtml(messageId)}"${isCancelling ? ' disabled' : ''}>${isCancelling ? 'Cancelling…' : 'Cancel'}</button></div>`;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2056
2493
|
function updateUserBubbleCancelButton(messageId, isCancelling) {
|
|
2057
2494
|
const btn = document.querySelector(`.bubble-action-btn[data-message-id="${messageId}"][data-action="cancel-queued"]`);
|
|
2058
2495
|
if (!btn) return;
|
|
@@ -2068,10 +2505,239 @@ export function removeUserBubbleCancelButton(messageId) {
|
|
|
2068
2505
|
if (actionsContainer) actionsContainer.remove();
|
|
2069
2506
|
}
|
|
2070
2507
|
|
|
2508
|
+
function clearUnsteerFallbackTimer(messageId) {
|
|
2509
|
+
const timer = unsteerFallbackTimers.get(messageId);
|
|
2510
|
+
if (timer === undefined) return;
|
|
2511
|
+
clearTimeout(timer);
|
|
2512
|
+
unsteerFallbackTimers.delete(messageId);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2071
2515
|
export function clearBubbleCancelState(messageId) {
|
|
2072
2516
|
const id = String(messageId || '').trim();
|
|
2073
2517
|
if (!id) return;
|
|
2074
2518
|
bubbleCancelInFlight.delete(id);
|
|
2519
|
+
clearUnsteerFallbackTimer(id);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Keeps the Cancel controls of the viewed conversation's pushed rows in step
|
|
2523
|
+
// with what its worker reports as still un-steerable. Bubbles are built once
|
|
2524
|
+
// (renderMessages skips unchanged payloads), and the worker's set changes on
|
|
2525
|
+
// its own clock (the heartbeat), so the status refresh calls this next to the
|
|
2526
|
+
// composer sync. Pending rows own their Cancel and are left alone; a row whose
|
|
2527
|
+
// un-steer is still being acknowledged keeps its "Cancelling…" until the
|
|
2528
|
+
// answer or the fallback arrives.
|
|
2529
|
+
export function syncCancellableSteerButtons() {
|
|
2530
|
+
const el = getMessagesElement();
|
|
2531
|
+
if (!el || IS_SHARED_VIEW) return;
|
|
2532
|
+
const cancellableIds = cancellableSteerIdsForConversation(currentConvId);
|
|
2533
|
+
for (const rowNode of el.querySelectorAll('.msg.user[data-message-id]')) {
|
|
2534
|
+
const messageId = String(rowNode.dataset.messageId || '').trim();
|
|
2535
|
+
if (!messageId || pendingUserMessageIds.has(messageId)) continue;
|
|
2536
|
+
const button = rowNode.querySelector('.msg-bubble-actions [data-action="cancel-queued"]');
|
|
2537
|
+
if (cancellableIds.has(messageId)) {
|
|
2538
|
+
if (button) continue;
|
|
2539
|
+
const bubble = rowNode.querySelector('.msg-bubble');
|
|
2540
|
+
if (!bubble) continue;
|
|
2541
|
+
bubble.insertAdjacentHTML('beforeend', buildUserBubbleCancelActionsMarkup(messageId, bubbleCancelInFlight.has(messageId)));
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
if (button && !bubbleCancelInFlight.has(messageId)) button.closest('.msg-bubble-actions')?.remove();
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// The relay forwarded an un-steer to the worker ('cancel-requested'): the row
|
|
2549
|
+
// settles through the socket (message_status 'cancelled' removes the control;
|
|
2550
|
+
// an answer after all re-renders it away). If neither lands, hand the control
|
|
2551
|
+
// back — as "Cancel" when the worker still lists the row, gone otherwise.
|
|
2552
|
+
function armUnsteerFallback(messageId) {
|
|
2553
|
+
clearUnsteerFallbackTimer(messageId);
|
|
2554
|
+
const timer = setTimeout(() => {
|
|
2555
|
+
unsteerFallbackTimers.delete(messageId);
|
|
2556
|
+
bubbleCancelInFlight.delete(messageId);
|
|
2557
|
+
if (cancellableSteerIdsForConversation(currentConvId).has(messageId)) {
|
|
2558
|
+
updateUserBubbleCancelButton(messageId, false);
|
|
2559
|
+
} else {
|
|
2560
|
+
removeUserBubbleCancelButton(messageId);
|
|
2561
|
+
}
|
|
2562
|
+
}, UNSTEER_ACK_FALLBACK_MS);
|
|
2563
|
+
timer?.unref?.();
|
|
2564
|
+
unsteerFallbackTimers.set(messageId, timer);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// Resend of a steer the user's Stop cut off unanswered (kind='stopped').
|
|
2568
|
+
// Keyed by the marker row: one Resend per marker per page life — a second tap
|
|
2569
|
+
// while the first is in flight, or after it landed, does nothing. The relay's
|
|
2570
|
+
// duplicate guard backs this across reloads (a second Resend of the same text
|
|
2571
|
+
// collides with the first).
|
|
2572
|
+
const stoppedSteerResends = new Map();
|
|
2573
|
+
const RESEND_PAYLOAD_KEY = '__relayResendPayload';
|
|
2574
|
+
|
|
2575
|
+
// Only uploads (by sha256) can be re-referenced; anything else — a legacy
|
|
2576
|
+
// inline image — is counted so the user is told it was left out.
|
|
2577
|
+
function attachmentReferencesForResend(attachments) {
|
|
2578
|
+
const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
|
|
2579
|
+
const references = list
|
|
2580
|
+
.map((attachment) => {
|
|
2581
|
+
const sha256 = String(attachment?.sha256 || '').trim().toLowerCase();
|
|
2582
|
+
if (!sha256) return null;
|
|
2583
|
+
return {
|
|
2584
|
+
sha256,
|
|
2585
|
+
name: String(attachment?.name || '').trim() || undefined,
|
|
2586
|
+
type: String(attachment?.type || '').trim() || undefined,
|
|
2587
|
+
};
|
|
2588
|
+
})
|
|
2589
|
+
.filter(Boolean);
|
|
2590
|
+
return { references, unavailable: list.length - references.length };
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// The user message a stopped marker settles. By its source id whenever the
|
|
2594
|
+
// marker names one — if that row is not loaded (a page boundary) there is no
|
|
2595
|
+
// guessing, since the nearest user row above may be another stopped steer.
|
|
2596
|
+
// Only a marker without a source falls back to the user row right above it.
|
|
2597
|
+
function findStoppedSteerSource(markerNode) {
|
|
2598
|
+
const el = getMessagesElement();
|
|
2599
|
+
if (!el || !markerNode) return null;
|
|
2600
|
+
const sourceId = String(markerNode.dataset.sourceMessageId || '').trim();
|
|
2601
|
+
if (sourceId) {
|
|
2602
|
+
return el.querySelector(`.msg.user[data-message-id="${CSS.escape(sourceId)}"]`) || null;
|
|
2603
|
+
}
|
|
2604
|
+
for (let prev = markerNode.previousElementSibling; prev; prev = prev.previousElementSibling) {
|
|
2605
|
+
if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
|
|
2606
|
+
if (prev.classList?.contains('msg') && prev.classList.contains('user')) return prev;
|
|
2607
|
+
}
|
|
2608
|
+
return null;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function syncStoppedSteerResendButton(markerId) {
|
|
2612
|
+
const btn = document.querySelector(`.bubble-action-btn[data-message-id="${CSS.escape(markerId)}"][data-action="resend-stopped-steer"]`);
|
|
2613
|
+
if (!btn) return;
|
|
2614
|
+
const state = stoppedSteerResends.get(markerId);
|
|
2615
|
+
btn.disabled = !!state;
|
|
2616
|
+
btn.textContent = state?.status === 'sent' ? 'Resent' : (state ? 'Resending…' : 'Resend');
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// The Resend a marker's "Resent" rests on was cancelled or failed: the
|
|
2620
|
+
// original may be resent again, as the relay agrees.
|
|
2621
|
+
function releaseStoppedSteerResendsOf(messageId) {
|
|
2622
|
+
const id = String(messageId || '').trim();
|
|
2623
|
+
if (!id) return;
|
|
2624
|
+
for (const [markerId, entry] of stoppedSteerResends) {
|
|
2625
|
+
if (entry?.status !== 'sent' || entry.resentId !== id) continue;
|
|
2626
|
+
stoppedSteerResends.delete(markerId);
|
|
2627
|
+
syncStoppedSteerResendButton(markerId);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
async function resendStoppedSteer(conversationId, markerId) {
|
|
2632
|
+
const conversationKey = String(conversationId || '').trim();
|
|
2633
|
+
const id = String(markerId || '').trim();
|
|
2634
|
+
if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
|
|
2635
|
+
const markerNode = getMessagesElement()?.querySelector(`.msg.assistant[data-message-id="${CSS.escape(id)}"]`);
|
|
2636
|
+
const sourceNode = findStoppedSteerSource(markerNode);
|
|
2637
|
+
const payload = sourceNode?.[RESEND_PAYLOAD_KEY] || null;
|
|
2638
|
+
const text = String(payload?.text || '').trim();
|
|
2639
|
+
const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
|
|
2640
|
+
if (!payload) {
|
|
2641
|
+
showTransientRelayNotice('The original message is not loaded — scroll up to it, then try Resend again.');
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
if (!text && !attachments.length) {
|
|
2645
|
+
showTransientRelayNotice('Nothing left to resend: the original message\'s attachments are no longer available.');
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
// Claimed before the first await: this is what makes a double tap a no-op.
|
|
2649
|
+
stoppedSteerResends.set(id, { status: 'sending' });
|
|
2650
|
+
syncStoppedSteerResendButton(id);
|
|
2651
|
+
const clientMessageId = generateId();
|
|
2652
|
+
let sent = false;
|
|
2653
|
+
// The message that now stands as this marker's Resend: a cancel or failure
|
|
2654
|
+
// of it releases the page's "Resent" (releaseStoppedSteerResendsOf).
|
|
2655
|
+
let resentId = clientMessageId;
|
|
2656
|
+
let bubbleAppended = false;
|
|
2657
|
+
const dropOptimisticBubble = () => {
|
|
2658
|
+
clearPendingUserMessage(clientMessageId);
|
|
2659
|
+
pendingUserMessageIds.delete(clientMessageId);
|
|
2660
|
+
seenMessageIds.delete(clientMessageId);
|
|
2661
|
+
if (bubbleAppended) document.querySelector(`.msg[data-message-id="${CSS.escape(clientMessageId)}"]`)?.remove();
|
|
2662
|
+
};
|
|
2663
|
+
try {
|
|
2664
|
+
if (!(await validateSelectedConversationBeforeSend(conversationKey))) return;
|
|
2665
|
+
const selectedModel = document.getElementById('model-select')?.value || '';
|
|
2666
|
+
const selectedReasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
|
|
2667
|
+
const selectedContextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim();
|
|
2668
|
+
const selectedMode = document.getElementById('mode-select')?.value || 'agent';
|
|
2669
|
+
if (!selectedReasoningEffort) {
|
|
2670
|
+
showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
|
|
2674
|
+
pendingUserMessageIds.add(clientMessageId);
|
|
2675
|
+
if (isCurrentConversation(conversationKey)) {
|
|
2676
|
+
appendMessage({
|
|
2677
|
+
role: 'user',
|
|
2678
|
+
text,
|
|
2679
|
+
model: selectedModel,
|
|
2680
|
+
mode: selectedMode,
|
|
2681
|
+
timestamp: new Date().toISOString(),
|
|
2682
|
+
attachments,
|
|
2683
|
+
}, true, clientMessageId, true);
|
|
2684
|
+
bubbleAppended = true;
|
|
2685
|
+
}
|
|
2686
|
+
const r = await sendMessageApi({
|
|
2687
|
+
messageId: clientMessageId,
|
|
2688
|
+
clientId: CLIENT_ID,
|
|
2689
|
+
text,
|
|
2690
|
+
model: selectedModel,
|
|
2691
|
+
reasoningEffort: selectedReasoningEffort,
|
|
2692
|
+
contextTier: selectedContextTier,
|
|
2693
|
+
relayMode: selectedMode,
|
|
2694
|
+
conversationId: conversationKey,
|
|
2695
|
+
attachments,
|
|
2696
|
+
resendOfMessageId: String(sourceNode?.dataset?.messageId || '').trim() || undefined,
|
|
2697
|
+
});
|
|
2698
|
+
if (!r) {
|
|
2699
|
+
if (!hasSendEchoArrived(clientMessageId)) {
|
|
2700
|
+
dropOptimisticBubble();
|
|
2701
|
+
showTransientRelayNotice('Could not resend the message. Please try again.');
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
sent = true;
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
if (r.duplicate) {
|
|
2708
|
+
dropOptimisticBubble();
|
|
2709
|
+
if (r.alreadyResent) {
|
|
2710
|
+
// Resent before (another tab or device): nothing to retry.
|
|
2711
|
+
sent = true;
|
|
2712
|
+
resentId = String(r.duplicateOfMessageId || '').trim() || null;
|
|
2713
|
+
showTransientRelayNotice('That message was already resent.');
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
// Another recent message has the same text — not a Resend of this one,
|
|
2717
|
+
// so the button stays available.
|
|
2718
|
+
showTransientRelayNotice('A message with the same text was sent in the last few minutes, so this one was not resent. Try again later.');
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
sent = true;
|
|
2722
|
+
const dropped = Number(payload.unavailableAttachments || 0) + Math.max(0, Number(r.droppedAttachmentCount) || 0);
|
|
2723
|
+
if (dropped > 0) {
|
|
2724
|
+
showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
|
|
2725
|
+
}
|
|
2726
|
+
if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
|
|
2727
|
+
showThinking(r.messageId || null);
|
|
2728
|
+
}
|
|
2729
|
+
} catch (error) {
|
|
2730
|
+
if (!hasSendEchoArrived(clientMessageId)) {
|
|
2731
|
+
dropOptimisticBubble();
|
|
2732
|
+
showTransientRelayNotice(error?.message || 'Could not resend the message.');
|
|
2733
|
+
} else {
|
|
2734
|
+
sent = true;
|
|
2735
|
+
}
|
|
2736
|
+
} finally {
|
|
2737
|
+
if (sent) stoppedSteerResends.set(id, { status: 'sent', resentId });
|
|
2738
|
+
else stoppedSteerResends.delete(id);
|
|
2739
|
+
syncStoppedSteerResendButton(id);
|
|
2740
|
+
}
|
|
2075
2741
|
}
|
|
2076
2742
|
|
|
2077
2743
|
async function cancelQueuedTurnByMessageId(conversationId, messageId) {
|
|
@@ -2102,6 +2768,14 @@ async function cancelQueuedTurnByMessageId(conversationId, messageId) {
|
|
|
2102
2768
|
return;
|
|
2103
2769
|
}
|
|
2104
2770
|
|
|
2771
|
+
if (result.acknowledgement === 'cancel-requested') {
|
|
2772
|
+
// The worker pulls the row out of its runtime and settles it through
|
|
2773
|
+
// POST /api/queue-cancelled → message_status 'cancelled'. Keep the
|
|
2774
|
+
// bubble in "Cancelling…" until then (or until the fallback hands it back).
|
|
2775
|
+
armUnsteerFallback(targetMessageId);
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2105
2779
|
if (result.acknowledgement === 'already-processing') {
|
|
2106
2780
|
bubbleCancelInFlight.delete(targetMessageId);
|
|
2107
2781
|
removeUserBubbleCancelButton(targetMessageId);
|
|
@@ -2279,6 +2953,12 @@ function handleBubbleActionClick(event) {
|
|
|
2279
2953
|
void cancelQueuedTurnByMessageId(currentConvId, messageId);
|
|
2280
2954
|
}
|
|
2281
2955
|
|
|
2956
|
+
if (action === 'resend-stopped-steer' && messageId) {
|
|
2957
|
+
event.preventDefault();
|
|
2958
|
+
event.stopPropagation();
|
|
2959
|
+
void resendStoppedSteer(currentConvId, messageId);
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2282
2962
|
if (action === 'stop-subagent' && subagentRunId) {
|
|
2283
2963
|
event.preventDefault();
|
|
2284
2964
|
event.stopPropagation();
|
|
@@ -2286,10 +2966,37 @@ function handleBubbleActionClick(event) {
|
|
|
2286
2966
|
}
|
|
2287
2967
|
}
|
|
2288
2968
|
|
|
2969
|
+
// Clicking an inline-embedded image in a message bubble opens it in the file
|
|
2970
|
+
// viewer modal (zoom + Download + Copy), the same viewer the repo browser
|
|
2971
|
+
// uses. Attachment thumbnails keep their own handler; video/audio keep their
|
|
2972
|
+
// native controls untouched.
|
|
2973
|
+
function handleEmbeddedImageClick(event) {
|
|
2974
|
+
const img = event.target.closest?.('.msg-bubble img');
|
|
2975
|
+
if (!img || img.closest('.msg-attachment')) return;
|
|
2976
|
+
if (IS_SHARED_VIEW) return; // the file routes are cookie-authed; shared viewers would only see a failed load
|
|
2977
|
+
const src = String(img.getAttribute('src') || '').trim();
|
|
2978
|
+
if (!src) return;
|
|
2979
|
+
event.preventDefault();
|
|
2980
|
+
event.stopPropagation();
|
|
2981
|
+
const target = parseAppFileHref(src);
|
|
2982
|
+
if (target?.kind === 'drive') {
|
|
2983
|
+
void openDriveFilePreview(target.path);
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
if (target?.kind === 'workspace') {
|
|
2987
|
+
void openWorkspaceFilePreview(target.path);
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
// data: thumbnails and external http(s) images: show them directly.
|
|
2991
|
+
const dataMime = /^data:(image\/[a-z0-9.+-]+)/i.exec(src)?.[1] || '';
|
|
2992
|
+
openUploadedAttachmentViewer(img.getAttribute('alt') || 'image', src, dataMime || 'image/*');
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2289
2995
|
export function initBubbleActionHandlers() {
|
|
2290
2996
|
const messagesEl = document.getElementById('messages');
|
|
2291
2997
|
if (!messagesEl) return;
|
|
2292
2998
|
messagesEl.addEventListener('click', handleBubbleActionClick);
|
|
2999
|
+
messagesEl.addEventListener('click', handleEmbeddedImageClick);
|
|
2293
3000
|
}
|
|
2294
3001
|
|
|
2295
3002
|
export function appendMessage(msg, scroll = true, msgId = null, force = false, insertAfterId = null, trackHistory = true) {
|
|
@@ -2367,6 +3074,10 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
|
|
|
2367
3074
|
mode: String(item?.mode || '').trim(),
|
|
2368
3075
|
attachments: Array.isArray(item?.attachments) ? item.attachments.length : 0,
|
|
2369
3076
|
hiddenFromShares: item?.hiddenFromShares === true,
|
|
3077
|
+
// The continuation badge and the absorbed (steering) merge both hang
|
|
3078
|
+
// off kind; a payload differing only in it must not short-circuit.
|
|
3079
|
+
kind: String(item?.kind || '').trim(),
|
|
3080
|
+
resentAs: String(item?.resentAs || '').trim(),
|
|
2370
3081
|
// Without this, a payload that differs from the last render only by a
|
|
2371
3082
|
// newly linked compaction activity short-circuits below and the break
|
|
2372
3083
|
// row never appears until some unrelated field changes.
|
|
@@ -2418,7 +3129,62 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
2418
3129
|
.map((item) => [String(item?.id || '').trim(), item])
|
|
2419
3130
|
.filter(([id]) => !!id),
|
|
2420
3131
|
);
|
|
2421
|
-
|
|
3132
|
+
// A payload that already carries this page's Resend but no resentAs on its
|
|
3133
|
+
// marker is newer than the page's provisional "Resent": the Resend was
|
|
3134
|
+
// cancelled or failed (possibly on another device), so the data wins.
|
|
3135
|
+
for (const item of ordered) {
|
|
3136
|
+
if (item?.role !== 'assistant' || String(item?.kind || '').trim() !== 'stopped') continue;
|
|
3137
|
+
const markerId = String(item?.id || '').trim();
|
|
3138
|
+
const entry = stoppedSteerResends.get(markerId);
|
|
3139
|
+
if (entry?.status === 'sent' && entry.resentId && messageById.has(entry.resentId) && !String(item?.resentAs || '').trim()) {
|
|
3140
|
+
stoppedSteerResends.delete(markerId);
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
// Preserve optimistic pending user bubbles the payload does not yet carry.
|
|
3144
|
+
// A message is persisted the instant it is sent, but a poll whose response
|
|
3145
|
+
// predates the send returns a payload without it; rebuilding from that stale
|
|
3146
|
+
// payload would drop the just-sent bubble until the next poll catches up
|
|
3147
|
+
// (it "vanishes then reappears"). Steering makes this visible because the
|
|
3148
|
+
// absorbed-reply broadcast forces extra rebuilds during the send window.
|
|
3149
|
+
// Capturing the live DOM nodes keeps their exact content and handlers; the
|
|
3150
|
+
// wipe below orphans them but the references keep them alive to re-append.
|
|
3151
|
+
// Matches store.js pendingConversationKey (trimmed id, or '__new__').
|
|
3152
|
+
const renderConversationKey = String(meta.conversationId || currentConvId || '').trim() || '__new__';
|
|
3153
|
+
const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
|
|
3154
|
+
const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
|
|
3155
|
+
? meta.hasMoreNewer
|
|
3156
|
+
: !!pageInfo?.hasMoreNewer;
|
|
3157
|
+
const preservedPendingNodes = [];
|
|
3158
|
+
// A kept pending bubble re-joins at the end of the loaded window, which is
|
|
3159
|
+
// only the newest end when nothing newer is left unloaded (a search jump
|
|
3160
|
+
// loads a window in the middle of the history).
|
|
3161
|
+
for (const id of (hasMoreNewer ? [] : pendingUserMessageIds)) {
|
|
3162
|
+
const pendingId = String(id || '').trim();
|
|
3163
|
+
if (!pendingId || messageById.has(pendingId)) continue;
|
|
3164
|
+
// Only this conversation's pending bubbles: a bubble still waiting in
|
|
3165
|
+
// another conversation must never be carried into the one being rendered
|
|
3166
|
+
// (a switch would otherwise drag it along).
|
|
3167
|
+
const entry = pendingUserMessageEntries.get(pendingId);
|
|
3168
|
+
if (String(entry?.conversationKey || '').trim() !== renderConversationKey) continue;
|
|
3169
|
+
const node = el.querySelector(`[data-message-id="${CSS.escape(pendingId)}"]`);
|
|
3170
|
+
if (node) preservedPendingNodes.push(node);
|
|
3171
|
+
}
|
|
3172
|
+
// Preserve the live thinking bubble across the wipe. It holds in-progress
|
|
3173
|
+
// thoughts, streamed text and subagent bubbles that a full re-derive would
|
|
3174
|
+
// lose (or briefly blank while restoreInFlightThinking rebuilds from the
|
|
3175
|
+
// server's inFlight snapshot). Re-appending the same node lets that restore
|
|
3176
|
+
// update it in place instead of rebuilding — the same reuse showThinking
|
|
3177
|
+
// already relies on. removeThinking() still drops it when the turn ends.
|
|
3178
|
+
const thinkingNode = document.getElementById('thinking-indicator');
|
|
3179
|
+
// Only when it genuinely lives in the transcript being rebuilt and belongs
|
|
3180
|
+
// to the conversation being rendered: a detached node must not be spliced
|
|
3181
|
+
// in as an extra row, and another conversation's bubble must not leak into
|
|
3182
|
+
// this one (it is dropped instead, so the next restore builds afresh).
|
|
3183
|
+
const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
|
|
3184
|
+
const preservedThinkingNode = (thinkingNodeInTranscript
|
|
3185
|
+
&& thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
|
|
3186
|
+
if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
|
|
3187
|
+
if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
|
|
2422
3188
|
el.innerHTML = `<div class="empty-state">
|
|
2423
3189
|
<div class="icon">${currentConvId ? '💬' : '🚀'}</div>
|
|
2424
3190
|
<h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
|
|
@@ -2431,13 +3197,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
2431
3197
|
return true;
|
|
2432
3198
|
}
|
|
2433
3199
|
const conversationId = String(meta.conversationId || currentConvId || '').trim();
|
|
2434
|
-
const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
|
|
2435
3200
|
const hasMoreOlder = typeof meta.hasMoreOlder === 'boolean'
|
|
2436
3201
|
? meta.hasMoreOlder
|
|
2437
3202
|
: (typeof meta.hasMoreHistory === 'boolean' ? meta.hasMoreHistory : !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore);
|
|
2438
|
-
const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
|
|
2439
|
-
? meta.hasMoreNewer
|
|
2440
|
-
: !!pageInfo?.hasMoreNewer;
|
|
2441
3203
|
const oldestMessageId = String(
|
|
2442
3204
|
meta.historyCursor
|
|
2443
3205
|
|| pageInfo?.olderCursor?.beforeMessageId
|
|
@@ -2486,6 +3248,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
|
|
|
2486
3248
|
});
|
|
2487
3249
|
withSuspendedSeparatorSync(() => {
|
|
2488
3250
|
for (const m of ordered) appendMessage(m, false, m.id || null, true, getMessageThreadAnchor(m, messageById), false);
|
|
3251
|
+
// A pending bubble the payload lacks is the newest message, so it re-joins
|
|
3252
|
+
// at the end. Re-appending the original node (not a fresh one) keeps its
|
|
3253
|
+
// cancel button and click handlers intact.
|
|
3254
|
+
for (const node of preservedPendingNodes) el.appendChild(node);
|
|
3255
|
+
// The live thinking bubble re-joins under its owning message right here:
|
|
3256
|
+
// restoreInFlightThinking skips an unchanged snapshot, so waiting for it
|
|
3257
|
+
// to re-anchor left the bubble at the end, below later (steered) rows.
|
|
3258
|
+
if (preservedThinkingNode) {
|
|
3259
|
+
el.appendChild(preservedThinkingNode);
|
|
3260
|
+
anchorThinkingBubble(preservedThinkingNode, preservedThinkingNode.dataset.messageId);
|
|
3261
|
+
}
|
|
2489
3262
|
});
|
|
2490
3263
|
syncSeparatorsNow();
|
|
2491
3264
|
renderRelayQuestions();
|
|
@@ -2560,7 +3333,8 @@ export async function sendMessage() {
|
|
|
2560
3333
|
const originalComposerText = String(input?.value || '');
|
|
2561
3334
|
const text = originalComposerText.trim();
|
|
2562
3335
|
const mobileSend = isMobileComposerViewport();
|
|
2563
|
-
|
|
3336
|
+
// A send while steering is held is allowed: the relay holds the message
|
|
3337
|
+
// and it steers in once the hold clears (the button reads "Queue").
|
|
2564
3338
|
const hasDraft = hasComposerDraft({ text, attachmentCount: selectedAttachments.length });
|
|
2565
3339
|
if (sendInFlight) {
|
|
2566
3340
|
showTransientRelayNotice('Please wait for the current message to finish sending.');
|
|
@@ -2585,6 +3359,7 @@ export async function sendMessage() {
|
|
|
2585
3359
|
input.value = '';
|
|
2586
3360
|
autoResize(input);
|
|
2587
3361
|
releaseComposerFocusAfterSend(input);
|
|
3362
|
+
void flushConversationDraft(currentConvId);
|
|
2588
3363
|
compactCurrentConversation();
|
|
2589
3364
|
scrollBottomAfterSend();
|
|
2590
3365
|
return;
|
|
@@ -2595,6 +3370,7 @@ export async function sendMessage() {
|
|
|
2595
3370
|
input.value = '';
|
|
2596
3371
|
autoResize(input);
|
|
2597
3372
|
releaseComposerFocusAfterSend(input);
|
|
3373
|
+
void flushConversationDraft(currentConvId);
|
|
2598
3374
|
const result = await runPreviewCommand(previewCommand, { conversationId: currentConvId });
|
|
2599
3375
|
showTransientRelayNotice(result.notice);
|
|
2600
3376
|
return;
|
|
@@ -2631,10 +3407,14 @@ export async function sendMessage() {
|
|
|
2631
3407
|
return;
|
|
2632
3408
|
}
|
|
2633
3409
|
if (targetConversationId) {
|
|
3410
|
+
// The send takes the draft over from here: the debounced save is dropped,
|
|
3411
|
+
// and no refresh may re-apply the draft this send is about to clear.
|
|
2634
3412
|
clearDraftTimerForConversation(targetConversationId);
|
|
3413
|
+
sendOwnedDraftConversations.add(targetConversationId);
|
|
2635
3414
|
}
|
|
2636
3415
|
|
|
2637
3416
|
setSendInFlight(true);
|
|
3417
|
+
let composerClearedBySend = false;
|
|
2638
3418
|
let attachments = [];
|
|
2639
3419
|
let clientMessageId = null;
|
|
2640
3420
|
try {
|
|
@@ -2648,8 +3428,12 @@ export async function sendMessage() {
|
|
|
2648
3428
|
}
|
|
2649
3429
|
const titleSeed = text || (attachments[0]?.name || 'Attachment');
|
|
2650
3430
|
clientMessageId = generateId();
|
|
2651
|
-
trackPendingUserMessage(clientMessageId, targetConversationId, text);
|
|
3431
|
+
trackPendingUserMessage(clientMessageId, targetConversationId, text, { attachments });
|
|
2652
3432
|
pendingUserMessageIds.add(clientMessageId);
|
|
3433
|
+
// The message leaves the draft: from here the cached draft is only what is
|
|
3434
|
+
// typed next, which the post-send save (or a failure restore) builds on.
|
|
3435
|
+
if (targetConversationId) upsertConversationDraftState(targetConversationId, { draftText: '' });
|
|
3436
|
+
composerClearedBySend = true;
|
|
2653
3437
|
if (viewingSendConversation()) {
|
|
2654
3438
|
input.value = '';
|
|
2655
3439
|
autoResize(input);
|
|
@@ -2694,23 +3478,22 @@ export async function sendMessage() {
|
|
|
2694
3478
|
if (queued) {
|
|
2695
3479
|
void registerOutboxSync();
|
|
2696
3480
|
showTransientRelayNotice('You are offline. Message queued — it will send when the connection returns.', 7000);
|
|
2697
|
-
if (viewingSendConversation())
|
|
3481
|
+
if (viewingSendConversation()) removeComposerAttachments(draftAttachments);
|
|
2698
3482
|
return;
|
|
2699
3483
|
}
|
|
2700
3484
|
}
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
autoResize(input);
|
|
3485
|
+
const sendLanded = hasSendEchoArrived(clientMessageId);
|
|
3486
|
+
if (!sendLanded) {
|
|
3487
|
+
clearPendingUserMessage(clientMessageId);
|
|
3488
|
+
const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
|
|
3489
|
+
pendingNode?.remove();
|
|
3490
|
+
pendingUserMessageIds.delete(clientMessageId);
|
|
3491
|
+
seenMessageIds.delete(clientMessageId);
|
|
2709
3492
|
}
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
3493
|
+
restoreComposerAfterFailedSend(targetConversationId, originalComposerText, {
|
|
3494
|
+
viewing: viewingSendConversation(),
|
|
3495
|
+
composerCleared: composerClearedBySend,
|
|
3496
|
+
sendLanded,
|
|
2714
3497
|
});
|
|
2715
3498
|
if (!mobileSend && viewingSendConversation()) input.focus();
|
|
2716
3499
|
setModelBanner('⚠️ Message could not be sent. Please try again.');
|
|
@@ -2742,7 +3525,7 @@ export async function sendMessage() {
|
|
|
2742
3525
|
// conversation that was compacted.
|
|
2743
3526
|
if (viewingSendConversation()) {
|
|
2744
3527
|
await window.openConversation?.(r.compactedConversationId);
|
|
2745
|
-
|
|
3528
|
+
removeComposerAttachments(draftAttachments);
|
|
2746
3529
|
if (!mobileSend) input.focus();
|
|
2747
3530
|
scrollBottomAfterSend();
|
|
2748
3531
|
}
|
|
@@ -2771,6 +3554,12 @@ export async function sendMessage() {
|
|
|
2771
3554
|
title: titleSeed.slice(0, 60),
|
|
2772
3555
|
updatedAt: new Date().toISOString(),
|
|
2773
3556
|
messageCount: 1,
|
|
3557
|
+
// The session the relay routed this first message to: the id its
|
|
3558
|
+
// worker is launched, registered and heartbeats under, so the
|
|
3559
|
+
// composer reads that worker's steering snapshot before the list
|
|
3560
|
+
// catches up. A binding that names another id (the placeholder
|
|
3561
|
+
// rekey) replaces it via conversation_session_bound.
|
|
3562
|
+
sdkSessionId: r.ownerSessionId || null,
|
|
2774
3563
|
runtimeSessionId: r.runtimeSessionId || null,
|
|
2775
3564
|
runtimeProviderType: r.runtimeProviderType || 'github',
|
|
2776
3565
|
runtimeProviderModel: r.runtimeProviderModel || null,
|
|
@@ -2802,44 +3591,69 @@ export async function sendMessage() {
|
|
|
2802
3591
|
}
|
|
2803
3592
|
const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
|
|
2804
3593
|
if (persistedConversationId) {
|
|
2805
|
-
|
|
3594
|
+
restorableDraftByConversation.delete(persistedConversationId);
|
|
3595
|
+
// Forced: the send transaction already moved the server's version, and
|
|
3596
|
+
// this round trip is how the client learns it (a 409 carries it). What
|
|
3597
|
+
// is saved is the composer as it is now — the user may already have
|
|
3598
|
+
// typed the next message while this one was sending.
|
|
3599
|
+
const postSendText = viewingSendConversation()
|
|
3600
|
+
? String(input.value || '')
|
|
3601
|
+
: String(conversations[persistedConversationId]?.draftText || '');
|
|
3602
|
+
const clearedDraft = await scheduleConversationDraftSave({
|
|
2806
3603
|
conversationId: persistedConversationId,
|
|
2807
|
-
draftText:
|
|
3604
|
+
draftText: postSendText,
|
|
2808
3605
|
immediate: true,
|
|
3606
|
+
force: true,
|
|
2809
3607
|
});
|
|
2810
3608
|
upsertConversationDraftState(persistedConversationId, {
|
|
2811
|
-
draftText:
|
|
2812
|
-
|
|
3609
|
+
draftText: postSendText,
|
|
3610
|
+
// Prefer the server's version pointer (a 409 carries the one the send
|
|
3611
|
+
// transaction wrote when it cleared the draft) over this client's
|
|
3612
|
+
// clock: once the send releases the draft, every later refresh is
|
|
3613
|
+
// judged stale against this value, and a skewed clock would either
|
|
3614
|
+
// resurrect the sent text or freeze out a genuine remote draft.
|
|
3615
|
+
draftUpdatedAt: clearedDraft?.draftUpdatedAt || new Date().toISOString(),
|
|
2813
3616
|
draftUpdatedByClientId: CLIENT_ID,
|
|
2814
3617
|
});
|
|
2815
3618
|
}
|
|
2816
|
-
if (cliOnline && viewingSendConversation())
|
|
3619
|
+
if (cliOnline && viewingSendConversation()) {
|
|
3620
|
+
// A turn already running here owns the live bubble; adopting the
|
|
3621
|
+
// just-queued id would re-anchor it under the newest user message.
|
|
3622
|
+
const activeTurn = getActiveTurnForConversation(composerConversationId);
|
|
3623
|
+
if (!activeTurn?.messageId) showThinking(r.messageId || null);
|
|
3624
|
+
}
|
|
2817
3625
|
|
|
3626
|
+
// Only what this message took leaves the composer: attachments added (or
|
|
3627
|
+
// still uploading) since the send began stay, and the settle-time save
|
|
3628
|
+
// below persists them.
|
|
2818
3629
|
if (viewingSendConversation()) {
|
|
2819
|
-
|
|
3630
|
+
removeComposerAttachments(draftAttachments);
|
|
2820
3631
|
if (!mobileSend) input.focus();
|
|
2821
3632
|
scrollBottomAfterSend();
|
|
2822
3633
|
}
|
|
2823
3634
|
} catch (e) {
|
|
2824
|
-
|
|
3635
|
+
const sendLanded = hasSendEchoArrived(clientMessageId);
|
|
3636
|
+
if (clientMessageId && !sendLanded) {
|
|
2825
3637
|
clearPendingUserMessage(clientMessageId);
|
|
2826
3638
|
const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
|
|
2827
3639
|
pendingNode?.remove();
|
|
2828
3640
|
pendingUserMessageIds.delete(clientMessageId);
|
|
2829
3641
|
seenMessageIds.delete(clientMessageId);
|
|
2830
3642
|
}
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
void scheduleConversationDraftSave({
|
|
2836
|
-
conversationId: targetConversationId,
|
|
2837
|
-
draftText: originalComposerText,
|
|
2838
|
-
immediate: true,
|
|
3643
|
+
restoreComposerAfterFailedSend(targetConversationId, originalComposerText, {
|
|
3644
|
+
viewing: viewingSendConversation(),
|
|
3645
|
+
composerCleared: composerClearedBySend,
|
|
3646
|
+
sendLanded,
|
|
2839
3647
|
});
|
|
2840
3648
|
alert(e.message || 'Failed to send message');
|
|
2841
3649
|
} finally {
|
|
2842
3650
|
setSendInFlight(false);
|
|
3651
|
+
// Every exit path has settled the draft by now: cleared it (success),
|
|
3652
|
+
// restored the composer text (offline/failure), or never touched it.
|
|
3653
|
+
if (targetConversationId) sendOwnedDraftConversations.delete(targetConversationId);
|
|
3654
|
+
// Edits made after the post-send save were held back while the send owned
|
|
3655
|
+
// the draft; save them now, on screen or not.
|
|
3656
|
+
if (targetConversationId) saveComposerIfUnsynced(targetConversationId);
|
|
2843
3657
|
}
|
|
2844
3658
|
}
|
|
2845
3659
|
|