@oar-sh/oar 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -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,15 +44,22 @@ import {
43
44
  SHARED_CONVERSATION_TOKEN,
44
45
  imageEditTarget,
45
46
  setImageEditTarget as setStoredImageEditTarget,
47
+ getSessionWorkerState,
48
+ openSummaryModal,
49
+ closeSummaryModal,
46
50
  } from './store.js';
51
+ import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
47
52
  import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
48
53
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
49
54
  import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
50
- import { renderAttachmentMarkup, clearAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
55
+ import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
51
56
  import { buildWorkflowRunCard } from './background-tasks-view.mjs';
52
57
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
53
58
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
54
59
  import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
60
+ import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
61
+ import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
62
+ import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
55
63
  import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
56
64
  import { attachCodeCopyButtons } from './code-copy.mjs';
57
65
  import { relayErrorCtaActions } from './relay-error-ctas.mjs';
@@ -61,7 +69,6 @@ import { openSettingsModal } from './settings-modal.js';
61
69
  import {
62
70
  serializeDraftAttachments,
63
71
  hydrateDraftAttachments,
64
- mergeDraftAttachmentUpdate,
65
72
  draftAttachmentsEqual,
66
73
  } from './composer-attachment-cache.mjs';
67
74
  import { renderRelayQuestions } from './ask-user-view.js';
@@ -83,13 +90,36 @@ import {
83
90
  relayActivityEntryText,
84
91
  } from './activity-replay-state.mjs';
85
92
  import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
93
+ import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
86
94
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
87
95
  import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
88
96
  import { createInfiniteLoader } from './infinite-loader.js';
89
97
  import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
98
+ import {
99
+ DRAFT_SYNC_PROTOCOL_VERSION,
100
+ MAX_DRAFT_TEXT_LENGTH,
101
+ draftTextForSync,
102
+ draftAttachmentsKey,
103
+ getSyncedDraft,
104
+ isDraftSaveNoop,
105
+ recordSyncedDraft,
106
+ resolveDraftConflict,
107
+ shouldApplyIncomingDraftToComposer,
108
+ } from './conversation-draft-sync.mjs';
90
109
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
110
+ import {
111
+ applyThinkingFollow,
112
+ getThinkingFollowMode,
113
+ initThinkingFollow,
114
+ observeThinkingBubble,
115
+ renderThinkingFollowButtonsHtml,
116
+ syncThinkingFollowButtons,
117
+ toggleThinkingFollowMode,
118
+ unobserveThinkingBubble,
119
+ } from './thinking-follow.js';
91
120
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
92
121
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
122
+ import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
93
123
 
94
124
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
95
125
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -110,6 +140,7 @@ function syncSeparatorsNow() {
110
140
  const el = getMessagesElement();
111
141
  if (!el) return;
112
142
  syncTranscriptSeparators(el);
143
+ syncSteeredTurnMerge(el);
113
144
  observeTranscriptResize(el);
114
145
  }
115
146
 
@@ -145,6 +176,12 @@ const relayStreamStateByMessageId = new Map();
145
176
  const relayStreamTextByMessageId = new Map();
146
177
  const completedMessageIds = new Set();
147
178
  const bubbleCancelInFlight = new Set();
179
+ // Un-steer requests the relay acknowledged with 'cancel-requested': the worker
180
+ // answers asynchronously (message_status 'cancelled', or the row is answered
181
+ // after all). Until then the bubble reads "Cancelling…"; the timer restores
182
+ // the control if neither arrives.
183
+ const UNSTEER_ACK_FALLBACK_MS = 20_000;
184
+ const unsteerFallbackTimers = new Map();
148
185
  const shareVisibilityInFlight = new Set();
149
186
  const SUBAGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'dropped', 'done']);
150
187
  let lastRenderedMessageSnapshotKey = '';
@@ -306,6 +343,9 @@ const conversationHistoryLoader = createInfiniteLoader({
306
343
  // separator rows, and the height delta the restore measures has to include
307
344
  // them or the viewport jumps.
308
345
  syncTranscriptSeparators(el);
346
+ // A steering pair split across the page boundary heals here (the absorbed
347
+ // reply just arrived above its already-rendered steered user row).
348
+ syncSteeredTurnMerge(el);
309
349
  requestAnimationFrame(() => {
310
350
  if (!el || String(currentConvId || '').trim() !== currentId) return;
311
351
  const nextScrollHeight = el.scrollHeight;
@@ -408,13 +448,93 @@ function getActiveTurnForConversation(conversationId) {
408
448
  return activeTurnsByConversation.get(conversationKey) || null;
409
449
  }
410
450
 
451
+ // The session worker id a conversation is bound to. Same hydration shapes
452
+ // validateSelectedConversationBeforeSend handles: camelCase, snake_case, or
453
+ // only the runtime session binding.
454
+ function sdkSessionIdForConversation(conversationId) {
455
+ const conversation = conversations[String(conversationId || '').trim()] || null;
456
+ return String(
457
+ conversation?.sdkSessionId
458
+ || conversation?.sdk_session_id
459
+ || conversation?.runtimeSession?.sdkSessionId
460
+ || conversation?.runtimeSession?.sdk_session_id
461
+ || '',
462
+ ).trim();
463
+ }
464
+
465
+ // The worker-reported steering snapshot for a conversation's session worker
466
+ // (rides the status payload from the worker's heartbeat), or null.
467
+ function workerSteeringForConversation(conversationId) {
468
+ const sdkSessionId = sdkSessionIdForConversation(conversationId);
469
+ return sdkSessionId ? (getSessionWorkerState(sdkSessionId)?.steering || null) : null;
470
+ }
471
+
472
+ // Which conversations deliver a message typed mid-turn INTO the running turn
473
+ // (steering) rather than queueing it behind: exactly those whose session
474
+ // worker advertises it on its heartbeat steering snapshot (`supported` — the
475
+ // Claude and Copilot SDK workers both do). One rule for every provider; the
476
+ // provider name is not consulted (the name-based Claude rule was retired
477
+ // 2026-09-26 once every live Claude worker advertised the flag). Workers that
478
+ // do not advertise (the extension path, Cursor, Grok) stay strictly serial,
479
+ // so their composer keeps saying "Queue".
480
+ function conversationSupportsSteering(conversationId) {
481
+ const conversationKey = String(conversationId || '').trim();
482
+ if (!conversationKey) return false;
483
+ return workerSteeringForConversation(conversationKey)?.supported === true;
484
+ }
485
+
486
+ // Pushed rows the conversation's worker reports it can still pull back out of
487
+ // its runtime (Copilot's queued lane): their bubbles keep Cancel.
488
+ function cancellableSteerIdsForConversation(conversationId) {
489
+ const ids = workerSteeringForConversation(conversationId)?.cancellableIds;
490
+ return new Set(Array.isArray(ids) ? ids : []);
491
+ }
492
+
493
+ // Whether steering into this conversation's live turn is momentarily held,
494
+ // and why. Two sources, freshest first: the client's own knowledge of an open
495
+ // question card (instant — the worker's heartbeat snapshot lags up to ~10s),
496
+ // then the worker-reported steering snapshot for the slower holds only it can
497
+ // see (compaction, post-compaction adoption).
498
+ function steeringHoldForConversation(conversationId) {
499
+ const conversationKey = String(conversationId || '').trim();
500
+ if (!conversationKey || !conversationSupportsSteering(conversationKey)) {
501
+ return { held: false, reason: null };
502
+ }
503
+ try {
504
+ const pendingQuestions = Number(getPendingQuestionCountsByConversation()?.[conversationKey] || 0);
505
+ if (pendingQuestions > 0) return { held: true, reason: 'question' };
506
+ } catch {}
507
+ const steering = workerSteeringForConversation(conversationKey);
508
+ if (steering && steering.turnActive && !steering.canSteer) {
509
+ // The client's own question knowledge is authoritative both ways: it saw
510
+ // the card answered instantly, while the worker snapshot lags ~10s.
511
+ if (steering.holdReason === 'question') return { held: false, reason: null };
512
+ // The 'delivery' hold covers the seconds while a turn-opening delivery is
513
+ // still in flight — but a heartbeat-captured copy outlives that window by
514
+ // up to a poll cycle and names the SAME turn, so the client cannot tell
515
+ // stale from live. Ignore it: a steer sent into that window just queues
516
+ // and rides in moments later, which was always safe.
517
+ if (steering.holdReason === 'delivery') return { held: false, reason: null };
518
+ // A snapshot naming a different turn than the one the client is watching
519
+ // is a leftover from the previous turn — ignore it.
520
+ const clientTurnMessageId = String(getActiveTurnForConversation(conversationKey)?.messageId || '').trim();
521
+ if (steering.messageId && clientTurnMessageId && steering.messageId !== clientTurnMessageId) {
522
+ return { held: false, reason: null };
523
+ }
524
+ return { held: true, reason: steering.holdReason || null };
525
+ }
526
+ return { held: false, reason: null };
527
+ }
528
+
411
529
  function syncSendButtonState() {
412
530
  const btn = document.getElementById('send-btn');
413
531
  if (!btn) return;
414
532
  const currentTurn = getActiveTurnForConversation(currentConvId);
533
+ const steeringHold = currentTurn
534
+ ? steeringHoldForConversation(currentConvId)
535
+ : { held: false, reason: null };
415
536
  const state = deriveComposerControlState({
416
537
  hasActiveTurn: !!currentTurn,
417
- cancelRequested: currentTurn?.cancelRequested === true,
418
538
  hasDraft: hasComposerDraft({
419
539
  text: document.getElementById('msg-input')?.value || '',
420
540
  attachmentCount: selectedAttachments.length,
@@ -422,6 +542,9 @@ function syncSendButtonState() {
422
542
  sendInFlight,
423
543
  modelMetadataBlocked: window.isModelMetadataBlocked?.() === true,
424
544
  attachmentsUploading: hasUploadingAttachments(selectedAttachments),
545
+ steeringSupported: conversationSupportsSteering(currentConvId),
546
+ steeringHeld: steeringHold.held,
547
+ steeringHoldReason: steeringHold.reason,
425
548
  });
426
549
  btn.disabled = state.disabled;
427
550
  btn.dataset.action = state.action;
@@ -429,12 +552,45 @@ function syncSendButtonState() {
429
552
  btn.title = state.title;
430
553
  }
431
554
 
432
- export function syncComposerControlState() {
555
+ // Button-only refresh for status, poll and question paths. It must never save
556
+ // the draft: an idle client that saves on every poll tick writes its stale
557
+ // composer text over another device's draft.
558
+ export function syncComposerButtonState() {
433
559
  syncSendButtonState();
434
- void scheduleConversationDraftSave({
435
- conversationId: currentConvId,
436
- draftText: document.getElementById('msg-input')?.value || '',
437
- });
560
+ }
561
+
562
+ // The composer's input handler: the user edited the text, so the draft is
563
+ // saved (debounced) as well as the button refreshed. While a send owns the
564
+ // draft nothing is saved — the send transaction clears the draft
565
+ // unconditionally after any such save — but the text is still cached, and
566
+ // the send saves it when it settles, wherever the user is by then.
567
+ export function syncComposerAfterUserEdit() {
568
+ syncSendButtonState();
569
+ const text = document.getElementById('msg-input')?.value || '';
570
+ warnOnceIfDraftTooLong(text);
571
+ if (isDraftOwnedByPendingSend(currentConvId)) {
572
+ upsertConversationDraftState(currentConvId, { draftText: draftTextForSync(text) });
573
+ return;
574
+ }
575
+ void scheduleConversationDraftSave({ conversationId: currentConvId, draftText: text });
576
+ }
577
+
578
+ /** Whether a send in flight holds this conversation's draft saves back. */
579
+ export function isConversationDraftHeldBySend(conversationId) {
580
+ return isDraftOwnedByPendingSend(conversationId);
581
+ }
582
+
583
+ let draftLengthWarningShown = false;
584
+
585
+ function warnOnceIfDraftTooLong(text) {
586
+ const tooLong = String(text || '').length > MAX_DRAFT_TEXT_LENGTH;
587
+ if (tooLong && !draftLengthWarningShown) {
588
+ showTransientRelayNotice(
589
+ `Drafts are saved up to ${MAX_DRAFT_TEXT_LENGTH.toLocaleString('en-US')} characters; the rest stays only in this tab until you send.`,
590
+ 7000,
591
+ );
592
+ }
593
+ draftLengthWarningShown = tooLong;
438
594
  }
439
595
 
440
596
  export function isSendInFlight() {
@@ -454,11 +610,44 @@ function isDraftOwnedByPendingSend(conversationId) {
454
610
  return sendOwnedDraftConversations.has(String(conversationId || '').trim());
455
611
  }
456
612
 
613
+ function isDraftSavePending(conversationId) {
614
+ const id = String(conversationId || '').trim();
615
+ return draftSaveTimerByConversation.has(id) || draftSavePromiseByConversation.has(id);
616
+ }
617
+
618
+ /**
619
+ * Called by openConversation once the composer belongs to the next
620
+ * conversation (after the previous draft was flushed): replaces the previous
621
+ * conversation's text with the next one's cached draft, so no blur, keystroke
622
+ * or flush can save the old text under the new conversation — and so a load
623
+ * that fails still leaves that conversation's known draft on screen rather
624
+ * than an empty composer a keystroke would save over it. The load's hydrate
625
+ * then reconciles against it like any other incoming draft.
626
+ */
627
+ export function beginConversationDraftSwitch(nextConversationId) {
628
+ const id = String(nextConversationId || '').trim();
629
+ const cached = id ? conversations[id] : null;
630
+ const text = String(cached?.draftText || '');
631
+ const attachments = Array.isArray(cached?.draftAttachments) ? cached.draftAttachments : [];
632
+ if (cached && !getSyncedDraft(id)) {
633
+ recordSyncedDraft(id, { text: draftTextForSync(text), attachments, updatedAt: cached.draftUpdatedAt || null });
634
+ }
635
+ if (attachments.length) setComposerAttachments(hydrateDraftAttachments(attachments));
636
+ const input = document.getElementById('msg-input');
637
+ if (input && input.value !== text) {
638
+ input.value = text;
639
+ autoResize(input);
640
+ }
641
+ syncSendButtonState();
642
+ }
643
+
644
+ // Omitted version fields keep their current values: a local edit changes the
645
+ // text only, never the server version it was based on.
457
646
  function upsertConversationDraftState(conversationId, {
458
647
  draftText = '',
459
648
  draftAttachments = undefined,
460
- draftUpdatedAt = null,
461
- draftUpdatedByClientId = null,
649
+ draftUpdatedAt = undefined,
650
+ draftUpdatedByClientId = undefined,
462
651
  } = {}) {
463
652
  const id = String(conversationId || '').trim();
464
653
  if (!id || !conversations[id]) return;
@@ -469,8 +658,10 @@ function upsertConversationDraftState(conversationId, {
469
658
  draftAttachments: draftAttachments === undefined
470
659
  ? (existing.draftAttachments || [])
471
660
  : (Array.isArray(draftAttachments) ? draftAttachments : []),
472
- draftUpdatedAt: draftUpdatedAt || null,
473
- draftUpdatedByClientId: draftUpdatedByClientId || null,
661
+ draftUpdatedAt: draftUpdatedAt === undefined ? (existing.draftUpdatedAt || null) : (draftUpdatedAt || null),
662
+ draftUpdatedByClientId: draftUpdatedByClientId === undefined
663
+ ? (existing.draftUpdatedByClientId || null)
664
+ : (draftUpdatedByClientId || null),
474
665
  };
475
666
  }
476
667
 
@@ -485,7 +676,8 @@ function upsertConversationDraftState(conversationId, {
485
676
  */
486
677
  export function persistComposerAttachments() {
487
678
  const id = String(currentConvId || '').trim();
488
- if (!id) return null;
679
+ // A send owns the draft: its post-send save picks the attachments up.
680
+ if (!id || isDraftOwnedByPendingSend(id)) return null;
489
681
  return scheduleConversationDraftSave({
490
682
  conversationId: id,
491
683
  draftText: document.getElementById('msg-input')?.value || '',
@@ -494,37 +686,251 @@ export function persistComposerAttachments() {
494
686
  });
495
687
  }
496
688
 
497
- async function persistConversationDraft(conversationId, draftText, draftAttachments = undefined) {
689
+ // Saves the conversation's composer text (or, off screen, its cached text)
690
+ // when it differs from the synced draft.
691
+ function saveComposerIfUnsynced(conversationId) {
692
+ const synced = getSyncedDraft(conversationId);
693
+ const viewing = isCurrentConversation(conversationId);
694
+ const text = draftTextForSync(viewing
695
+ ? (document.getElementById('msg-input')?.value || '')
696
+ : (conversations[conversationId]?.draftText || ''));
697
+ const attachments = viewing ? serializeDraftAttachments(selectedAttachments) : undefined;
698
+ const attachmentsKey = draftAttachmentsKey(attachments);
699
+ if (synced && synced.text === text && (attachmentsKey === null || synced.attachmentsKey === null || synced.attachmentsKey === attachmentsKey)) return;
700
+ void scheduleConversationDraftSave({ conversationId, draftText: text, draftAttachments: attachments });
701
+ }
702
+
703
+ // The server echoes a stored message to its sender (user_message with this
704
+ // client's id), which drops it from pendingUserMessageIds; status events for
705
+ // it do the same. Either means the send landed even if its response failed.
706
+ function hasSendEchoArrived(clientMessageId) {
707
+ const id = String(clientMessageId || '').trim();
708
+ return !!id && !pendingUserMessageIds.has(id);
709
+ }
710
+
711
+ // A send that failed hands its text back to the composer. Text typed while it
712
+ // was in flight is kept after it (blank line between) instead of being
713
+ // replaced, so neither is lost; if the send never emptied the composer, the
714
+ // composer still holds the message and is kept as it is. A send whose echo
715
+ // already arrived did land (the failure was only the response): putting its
716
+ // text back would invite a duplicate, so only the typing is kept.
717
+ function restoreComposerAfterFailedSend(conversationId, originalText, { viewing, composerCleared, sendLanded = false }) {
718
+ const input = document.getElementById('msg-input');
719
+ const current = viewing
720
+ ? String(input?.value || '')
721
+ : String(conversations[conversationId]?.draftText || '');
722
+ let text = originalText;
723
+ if (sendLanded) text = current;
724
+ else if (!composerCleared) text = current || originalText;
725
+ else if (current.trim()) text = `${originalText}
726
+
727
+ ${current}`;
728
+ if (viewing && input) {
729
+ input.value = text;
730
+ autoResize(input);
731
+ }
732
+ void scheduleConversationDraftSave({ conversationId, draftText: text, immediate: true });
733
+ }
734
+
735
+ function isDraftVersionConflict(response) {
736
+ return response?.conflict === true || response?.code === 'draft-version-conflict';
737
+ }
738
+
739
+ function isCurrentConversation(conversationId) {
740
+ return String(currentConvId || '').trim() === String(conversationId || '').trim();
741
+ }
742
+
743
+ // The version a save is based on. Always sent — even when null, which tells
744
+ // the server "I have seen no draft yet" and is conflict-checked like any other.
745
+ function draftSaveBaseVersion(conversationId) {
746
+ const synced = getSyncedDraft(conversationId);
747
+ if (synced) return synced.updatedAt || null;
748
+ return conversations[conversationId]?.draftUpdatedAt || null;
749
+ }
750
+
751
+ function sendConversationDraft(conversationId, text, draftAttachments, baseDraftUpdatedAt) {
752
+ return updateConversationDraftApi(conversationId, {
753
+ draftText: text,
754
+ clientId: CLIENT_ID,
755
+ draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
756
+ baseDraftUpdatedAt,
757
+ ...(draftAttachments === undefined ? {} : { draftAttachments }),
758
+ });
759
+ }
760
+
761
+ function acknowledgeSavedDraft(conversationId, response, sentText) {
762
+ const serverText = String(response?.draftText || '');
763
+ const updatedAt = response?.draftUpdatedAt || response?.updatedAt || null;
764
+ // The server stores whitespace-only text as "no draft"; remembering what was
765
+ // sent keeps that composer from looking modified against the empty echo.
766
+ const text = !serverText && !String(sentText || '').trim() ? String(sentText || '') : serverText;
767
+ recordSyncedDraft(conversationId, { text, attachments: response?.draftAttachments, updatedAt });
768
+ upsertConversationDraftState(conversationId, {
769
+ draftText: serverText,
770
+ draftAttachments: response?.draftAttachments,
771
+ draftUpdatedAt: updatedAt,
772
+ draftUpdatedByClientId: response?.draftUpdatedByClientId || response?.senderClientId || null,
773
+ });
774
+ }
775
+
776
+ function applyDraftToComposer(conversationId, text, draftAttachments = undefined) {
777
+ if (!isCurrentConversation(conversationId)) return;
778
+ if (Array.isArray(draftAttachments)) {
779
+ const hydrated = hydrateDraftAttachments(draftAttachments);
780
+ if (!draftAttachmentsEqual(selectedAttachments, hydrated)) setComposerAttachments(hydrated);
781
+ }
782
+ const input = document.getElementById('msg-input');
783
+ if (input && draftTextForSync(input.value) !== text) {
784
+ input.value = text;
785
+ autoResize(input);
786
+ }
787
+ syncSendButtonState();
788
+ }
789
+
790
+ function adoptServerDraft(conversationId, {
791
+ draftText = '',
792
+ draftAttachments = undefined,
793
+ draftUpdatedAt = null,
794
+ draftUpdatedByClientId = null,
795
+ } = {}) {
796
+ const text = String(draftText || '');
797
+ clearDraftTimerForConversation(conversationId);
798
+ recordSyncedDraft(conversationId, { text, attachments: draftAttachments, updatedAt: draftUpdatedAt });
799
+ upsertConversationDraftState(conversationId, { draftText: text, draftAttachments, draftUpdatedAt, draftUpdatedByClientId });
800
+ applyDraftToComposer(conversationId, text, draftAttachments);
801
+ }
802
+
803
+ // The draft that lost the last conflict, per conversation, so it can be put
804
+ // back. It lives outside the toast: a later notice may cover the toast, and
805
+ // the action reads the slot when tapped. Cleared when the draft is sent.
806
+ const restorableDraftByConversation = new Map();
807
+
808
+ function draftHasContent({ text = '', attachments = undefined } = {}) {
809
+ return !!String(text || '').trim() || (Array.isArray(attachments) && attachments.length > 0);
810
+ }
811
+
812
+ function currentComposerDraft(conversationId) {
813
+ if (isCurrentConversation(conversationId)) {
814
+ return {
815
+ text: String(document.getElementById('msg-input')?.value || ''),
816
+ attachments: serializeDraftAttachments(selectedAttachments),
817
+ };
818
+ }
819
+ const conversation = conversations[conversationId] || {};
820
+ return {
821
+ text: String(conversation.draftText || ''),
822
+ attachments: Array.isArray(conversation.draftAttachments) ? conversation.draftAttachments : [],
823
+ };
824
+ }
825
+
826
+ function offerRestorableDraft(conversationId, message, actionLabel) {
827
+ showTransientRelayNotice(message, 20000, {
828
+ actionLabel,
829
+ onAction: () => swapRestorableDraft(conversationId),
830
+ });
831
+ }
832
+
833
+ function keepLosingDraft(conversationId, losingDraft) {
834
+ if (!draftHasContent(losingDraft)) return;
835
+ restorableDraftByConversation.set(String(conversationId || '').trim(), losingDraft);
836
+ offerRestorableDraft(conversationId, 'Your edit replaced a newer draft from another device.', 'Restore');
837
+ }
838
+
839
+ // Restore is a swap: the draft it replaces takes the slot, so the offer can
840
+ // always be reversed.
841
+ function swapRestorableDraft(conversationId) {
842
+ const id = String(conversationId || '').trim();
843
+ const restored = restorableDraftByConversation.get(id);
844
+ if (!restored) return;
845
+ restorableDraftByConversation.set(id, currentComposerDraft(id));
846
+ const text = String(restored.text || '');
847
+ applyDraftToComposer(id, text, restored.attachments);
848
+ void scheduleConversationDraftSave({
849
+ conversationId: id,
850
+ draftText: text,
851
+ draftAttachments: Array.isArray(restored.attachments) ? restored.attachments : undefined,
852
+ immediate: true,
853
+ });
854
+ offerRestorableDraft(id, 'Draft restored.', 'Undo');
855
+ }
856
+
857
+ // A 409 means another client saved since this one's base version. The local
858
+ // composer is re-read here (not the text the save carried): it may have moved
859
+ // on while the request was in flight, and the newest keystroke is what wins.
860
+ async function resolveDraftSaveConflict(id, { text, draftAttachments, synced, conflict }) {
861
+ const serverText = String(conflict.draftText || '');
862
+ const serverAttachments = Array.isArray(conflict.draftAttachments) ? conflict.draftAttachments : undefined;
863
+ const viewing = isCurrentConversation(id);
864
+ const localText = viewing ? draftTextForSync(document.getElementById('msg-input')?.value ?? text) : text;
865
+ const localAttachments = draftAttachments === undefined
866
+ ? undefined
867
+ : (viewing ? serializeDraftAttachments(selectedAttachments) : draftAttachments);
868
+ let outcome = resolveDraftConflict({
869
+ localText,
870
+ localAttachmentsKey: draftAttachmentsKey(localAttachments),
871
+ baseText: synced ? synced.text : null,
872
+ baseAttachmentsKey: synced ? synced.attachmentsKey : null,
873
+ serverText,
874
+ serverAttachmentsKey: draftAttachmentsKey(serverAttachments),
875
+ });
876
+ // Uploads in flight are not in the fingerprint; adopting would replace the
877
+ // attachment list and drop them, so they count as a local edit.
878
+ if (outcome === 'adopt' && viewing && hasUploadingAttachments(selectedAttachments)) outcome = 'keep-local';
879
+ if (outcome === 'adopt') {
880
+ adoptServerDraft(id, conflict);
881
+ return conflict;
882
+ }
883
+ recordSyncedDraft(id, { text: serverText, attachments: serverAttachments, updatedAt: conflict.draftUpdatedAt || null });
884
+ if (outcome === 'converged') {
885
+ upsertConversationDraftState(id, {
886
+ draftText: serverText,
887
+ draftAttachments: serverAttachments,
888
+ draftUpdatedAt: conflict.draftUpdatedAt || null,
889
+ draftUpdatedByClientId: conflict.draftUpdatedByClientId || null,
890
+ });
891
+ return conflict;
892
+ }
893
+ const retry = await sendConversationDraft(id, localText, localAttachments, conflict.draftUpdatedAt || null);
894
+ if (retry?.ok) {
895
+ acknowledgeSavedDraft(id, retry, localText);
896
+ const localKey = draftAttachmentsKey(localAttachments);
897
+ const lostSomething = serverText !== localText
898
+ || (localKey !== null && localKey !== draftAttachmentsKey(serverAttachments));
899
+ if (lostSomething) keepLosingDraft(id, { text: serverText, attachments: serverAttachments });
900
+ return retry;
901
+ }
902
+ // Lost a second race. The base stays at the first conflict's version, so the
903
+ // retry scheduled here conflicts again and resolves (with its restore offer)
904
+ // through this same path instead of leaving the edit unsaved.
905
+ if (isDraftVersionConflict(retry)) {
906
+ void scheduleConversationDraftSave({ conversationId: id, draftText: localText, draftAttachments: localAttachments });
907
+ }
908
+ return retry || null;
909
+ }
910
+
911
+ async function persistConversationDraft(conversationId, draftText, draftAttachments = undefined, { force = false } = {}) {
498
912
  const id = String(conversationId || '').trim();
499
913
  if (!id) return null;
500
914
  const text = String(draftText || '');
501
915
  const runPersist = async () => {
502
- const baseDraftUpdatedAt = conversations[id]?.draftUpdatedAt || null;
503
- const response = await updateConversationDraftApi(id, {
504
- draftText: text,
505
- clientId: CLIENT_ID,
506
- baseDraftUpdatedAt,
507
- ...(draftAttachments === undefined ? {} : { draftAttachments }),
508
- });
509
- if (!response?.ok) {
510
- if (response?.conflict === true || response?.code === 'draft-version-conflict') {
511
- applyIncomingConversationDraftUpdate({
512
- conversationId: id,
513
- draftText: response.draftText || '',
514
- draftAttachments: response.draftAttachments,
515
- draftUpdatedAt: response.draftUpdatedAt || null,
516
- draftUpdatedByClientId: response.draftUpdatedByClientId || null,
517
- });
518
- }
519
- return response || null;
916
+ const synced = getSyncedDraft(id);
917
+ if (!force && isDraftSaveNoop({
918
+ synced,
919
+ text,
920
+ attachmentsKey: draftAttachmentsKey(draftAttachments),
921
+ knownUpdatedAt: conversations[id]?.draftUpdatedAt || null,
922
+ })) {
923
+ return { ok: true, skipped: true, conversationId: id, draftText: synced.text, draftUpdatedAt: synced.updatedAt };
520
924
  }
521
- upsertConversationDraftState(id, {
522
- draftText: response.draftText,
523
- draftAttachments: response.draftAttachments,
524
- draftUpdatedAt: response.draftUpdatedAt || response.updatedAt || null,
525
- draftUpdatedByClientId: response.draftUpdatedByClientId || response.senderClientId || null,
526
- });
527
- return response;
925
+ const response = await sendConversationDraft(id, text, draftAttachments, draftSaveBaseVersion(id));
926
+ if (response?.ok) {
927
+ acknowledgeSavedDraft(id, response, text);
928
+ return response;
929
+ }
930
+ if (isDraftVersionConflict(response)) {
931
+ return resolveDraftSaveConflict(id, { text, draftAttachments, synced, conflict: response });
932
+ }
933
+ return response || null;
528
934
  };
529
935
  const previous = draftSavePromiseByConversation.get(id) || Promise.resolve();
530
936
  const next = previous
@@ -544,19 +950,20 @@ async function scheduleConversationDraftSave({
544
950
  draftText,
545
951
  draftAttachments = undefined,
546
952
  immediate = false,
953
+ force = false,
547
954
  } = {}) {
548
955
  const id = String(conversationId || '').trim();
549
956
  if (!id) return null;
550
- const text = String(draftText || '');
957
+ const text = draftTextForSync(draftText);
551
958
  upsertConversationDraftState(id, { draftText: text, draftAttachments });
552
959
  clearDraftTimerForConversation(id);
553
960
  if (immediate) {
554
- return persistConversationDraft(id, text, draftAttachments);
961
+ return persistConversationDraft(id, text, draftAttachments, { force });
555
962
  }
556
963
  return new Promise((resolve) => {
557
964
  const timer = setTimeout(() => {
558
965
  draftSaveTimerByConversation.delete(id);
559
- persistConversationDraft(id, text, draftAttachments).then(resolve).catch(() => resolve(null));
966
+ persistConversationDraft(id, text, draftAttachments, { force }).then(resolve).catch(() => resolve(null));
560
967
  }, COMPOSER_DRAFT_DEBOUNCE_MS);
561
968
  draftSaveTimerByConversation.set(id, timer);
562
969
  });
@@ -567,6 +974,11 @@ export async function flushConversationDraft(conversationId = currentConvId) {
567
974
  if (!id) return null;
568
975
  const input = document.getElementById('msg-input');
569
976
  const draftText = String((id === String(currentConvId || '').trim() && input) ? input.value : (conversations[id]?.draftText || ''));
977
+ if (isDraftOwnedByPendingSend(id)) {
978
+ // Held: the send saves the cached text once it settles.
979
+ upsertConversationDraftState(id, { draftText: draftTextForSync(draftText) });
980
+ return null;
981
+ }
570
982
  return scheduleConversationDraftSave({
571
983
  conversationId: id,
572
984
  draftText,
@@ -574,6 +986,46 @@ export async function flushConversationDraft(conversationId = currentConvId) {
574
986
  });
575
987
  }
576
988
 
989
+ // Shared tail of both incoming-draft paths (conversation load/poll and the
990
+ // cross-client broadcast) for the conversation on screen. `incoming.attachments`
991
+ // is undefined when the update did not carry attachments.
992
+ function reconcileIncomingComposerDraft(id, incoming) {
993
+ const input = document.getElementById('msg-input');
994
+ if (!input) return;
995
+ const synced = getSyncedDraft(id);
996
+ const composerAttachments = serializeDraftAttachments(selectedAttachments);
997
+ if (!shouldApplyIncomingDraftToComposer({
998
+ inputText: input.value,
999
+ incomingText: incoming.text,
1000
+ syncedText: synced ? synced.text : null,
1001
+ inputAttachmentsKey: draftAttachmentsKey(composerAttachments),
1002
+ incomingAttachmentsKey: draftAttachmentsKey(incoming.attachments),
1003
+ syncedAttachmentsKey: synced ? synced.attachmentsKey : null,
1004
+ attachmentsUploading: hasUploadingAttachments(selectedAttachments),
1005
+ })) {
1006
+ // An unsaved local edit (typing, an attachment change, or a flush that
1007
+ // failed). Re-save it, attachments included, so the version check
1008
+ // decides: the same version saves, a newer one conflicts.
1009
+ if (!isDraftSavePending(id)) {
1010
+ void scheduleConversationDraftSave({ conversationId: id, draftText: input.value, draftAttachments: composerAttachments });
1011
+ }
1012
+ syncSendButtonState();
1013
+ return;
1014
+ }
1015
+ recordSyncedDraft(id, incoming);
1016
+ if (Array.isArray(incoming.attachments)) {
1017
+ const hydrated = hydrateDraftAttachments(incoming.attachments);
1018
+ if (!draftAttachmentsEqual(selectedAttachments, hydrated)) setComposerAttachments(hydrated);
1019
+ }
1020
+ // A composer longer than the draft limit matches its truncated echo; it must
1021
+ // not be cut down to it.
1022
+ if (draftTextForSync(input.value) !== incoming.text) {
1023
+ input.value = incoming.text;
1024
+ autoResize(input);
1025
+ }
1026
+ syncSendButtonState();
1027
+ }
1028
+
577
1029
  export function hydrateConversationDraft(conversationId, {
578
1030
  draftText = '',
579
1031
  draftAttachments = [],
@@ -595,23 +1047,12 @@ export function hydrateConversationDraft(conversationId, {
595
1047
  draftUpdatedAt,
596
1048
  draftUpdatedByClientId,
597
1049
  });
598
- if (String(currentConvId || '').trim() !== id) return;
599
- // Restore the composer's pending attachments for the conversation being opened.
600
- if (!draftAttachmentsEqual(selectedAttachments, hydrateDraftAttachments(normalizedAttachments))) {
601
- setComposerAttachments(hydrateDraftAttachments(normalizedAttachments));
602
- }
603
- const input = document.getElementById('msg-input');
604
- if (!input) return;
605
- const isFocused = document.activeElement === input;
606
- if (isFocused && input.value !== normalizedDraftText) {
607
- syncSendButtonState();
1050
+ const syncedDraft = { text: normalizedDraftText, attachments: normalizedAttachments, updatedAt: draftUpdatedAt };
1051
+ if (String(currentConvId || '').trim() !== id) {
1052
+ if (!isDraftSavePending(id)) recordSyncedDraft(id, syncedDraft);
608
1053
  return;
609
1054
  }
610
- if (input.value !== normalizedDraftText) {
611
- input.value = normalizedDraftText;
612
- autoResize(input);
613
- }
614
- syncSendButtonState();
1055
+ reconcileIncomingComposerDraft(id, syncedDraft);
615
1056
  }
616
1057
 
617
1058
  export function applyIncomingConversationDraftUpdate({
@@ -638,26 +1079,12 @@ export function applyIncomingConversationDraftUpdate({
638
1079
  draftUpdatedAt,
639
1080
  draftUpdatedByClientId: draftUpdatedByClientId || senderClientId || null,
640
1081
  });
641
- if (String(currentConvId || '').trim() !== id) return;
642
- if (draftAttachments !== undefined) {
643
- const merged = mergeDraftAttachmentUpdate({
644
- existing: selectedAttachments,
645
- incoming: hydrateDraftAttachments(Array.isArray(draftAttachments) ? draftAttachments : []),
646
- existingUpdatedAt: conversations[id]?.draftUpdatedAt || null,
647
- incomingUpdatedAt: draftUpdatedAt,
648
- isLocalEcho: senderClientId === CLIENT_ID,
649
- });
650
- if (merged.changed) setComposerAttachments(merged.attachments);
651
- }
652
- const input = document.getElementById('msg-input');
653
- if (!input) return;
654
- const isFocused = document.activeElement === input;
655
- if (isFocused && input.value !== incomingDraftText) return;
656
- if (input.value !== incomingDraftText) {
657
- input.value = incomingDraftText;
658
- autoResize(input);
1082
+ const syncedDraft = { text: incomingDraftText, attachments: draftAttachments, updatedAt: draftUpdatedAt };
1083
+ if (String(currentConvId || '').trim() !== id) {
1084
+ if (!isDraftSavePending(id)) recordSyncedDraft(id, syncedDraft);
1085
+ return;
659
1086
  }
660
- syncSendButtonState();
1087
+ reconcileIncomingComposerDraft(id, syncedDraft);
661
1088
  }
662
1089
 
663
1090
  function setConversationTurnState(conversationId, state = null) {
@@ -800,8 +1227,18 @@ function createMessageNode(msg, msgId = null, force = false) {
800
1227
  seenMessageIds.add(msgId);
801
1228
  }
802
1229
 
1230
+ // A prompt another relay's agent sent carries `origin`: its header line is
1231
+ // hidden (the badge below says the same), and a stray remote-relay mention
1232
+ // hint never shows on any user bubble.
1233
+ const remoteOrigin = (msg?.role === 'user' && msg?.origin && typeof msg.origin === 'object') ? msg.origin : null;
1234
+ if (msg?.role === 'user') {
1235
+ const displayText = stripRemoteRelayDisplayArtifacts(msg.text, { origin: remoteOrigin });
1236
+ if (displayText !== String(msg.text ?? '')) msg = { ...msg, text: displayText };
1237
+ }
1238
+
803
1239
  const div = document.createElement('div');
804
1240
  div.className = `msg ${msg.role}`;
1241
+ if (remoteOrigin) div.classList.add('msg-from-remote-relay');
805
1242
  if (msgId) div.dataset.messageId = msgId;
806
1243
  const fingerprint = buildLiveMessageFingerprint({
807
1244
  ...(msg && typeof msg === 'object' ? msg : {}),
@@ -830,6 +1267,19 @@ function createMessageNode(msg, msgId = null, force = false) {
830
1267
  const continuationTag = (msg.role === 'assistant' && String(msg?.kind || '').trim() === 'continuation')
831
1268
  ? ' <span class="msg-continuation" title="The agent continued on its own after a background task finished.">background continuation</span>'
832
1269
  : '';
1270
+ // A steering absorption: this reply continues through the next (steered)
1271
+ // user message. The class feeds syncSteeredTurnMerge, which joins the trio
1272
+ // into one visual flow.
1273
+ const messageKind = String(msg?.kind || '').trim();
1274
+ if (msg.role === 'assistant' && messageKind === 'absorbed') {
1275
+ div.classList.add(ABSORBED_MSG_CLASS);
1276
+ }
1277
+ // The settle marker of a steer the CLI consumed without a turn of its own:
1278
+ // folded into (and answered by) the turn above, or cut off unanswered by a
1279
+ // Stop. Compact, attached to its own user message, never merging forward.
1280
+ const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
1281
+ if (steerMarkerClass) div.classList.add(steerMarkerClass);
1282
+ const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
833
1283
  // A turn answered by a different provider than the conversation is bound to
834
1284
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
835
1285
  // not silent: it ran on another plan than the header indicates.
@@ -907,7 +1357,15 @@ function createMessageNode(msg, msgId = null, force = false) {
907
1357
  : 'msg-bubble';
908
1358
 
909
1359
  const isQueuedUserMessage = msg.role === 'user' && msgId && pendingUserMessageIds.has(msgId);
910
- const isCancelInFlight = isQueuedUserMessage && bubbleCancelInFlight.has(msgId);
1360
+ // A row the worker pushed into its runtime but reports as still unconsumed
1361
+ // (Copilot's queued lane, heartbeat `cancellableIds`) keeps Cancel too: the
1362
+ // worker pulls it back out and the row ends cancelled, never answered.
1363
+ const isCancellableSteer = !isQueuedUserMessage
1364
+ && msg.role === 'user'
1365
+ && !!msgId
1366
+ && cancellableSteerIdsForConversation(currentConvId).has(msgId);
1367
+ const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
1368
+ const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
911
1369
  const hiddenFromShares = msg?.hiddenFromShares === true;
912
1370
  const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
913
1371
  const sourceMessageId = String(msg?.sourceMessageId || '').trim();
@@ -931,12 +1389,25 @@ function createMessageNode(msg, msgId = null, force = false) {
931
1389
  .map((item) => `<button type="button" class="bubble-action-btn" data-action="relay-error-cta" data-cta="${escHtml(item.action)}">${escHtml(item.label)}</button>`)
932
1390
  .join('')}</div>`
933
1391
  : '';
934
- const userBubbleActionsHtml = (!IS_SHARED_VIEW && isQueuedUserMessage)
935
- ? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn${isCancelInFlight ? ' stopping' : ''}" data-action="cancel-queued" data-message-id="${escHtml(msgId)}"${isCancelInFlight ? ' disabled' : ''}>${isCancelInFlight ? 'Cancelling…' : 'Cancel'}</button></div>`
1392
+ const userBubbleActionsHtml = (!IS_SHARED_VIEW && showsCancelButton)
1393
+ ? buildUserBubbleCancelActionsMarkup(msgId, isCancelInFlight)
1394
+ : '';
1395
+ // Resent per the relay (any device, survives reloads), or by this page.
1396
+ const resendState = isStoppedSteer && msgId
1397
+ ? (stoppedSteerResends.get(msgId) || (String(msg?.resentAs || '').trim() ? { status: 'sent' } : null))
1398
+ : null;
1399
+ const resendLabel = resendState?.status === 'sent' ? 'Resent' : (resendState ? 'Resending…' : 'Resend');
1400
+ const stoppedSteerActionsHtml = (!IS_SHARED_VIEW && isStoppedSteer && msgId)
1401
+ ? `<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>`
1402
+ : '';
1403
+
1404
+ // Shared viewers get the badge without the link to the other relay.
1405
+ const originBadgeHtml = remoteOrigin
1406
+ ? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
936
1407
  : '';
937
1408
 
938
1409
  div.innerHTML = `
939
- <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}</div>
1410
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
940
1411
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
941
1412
 
942
1413
  const bubble = div.querySelector('.msg-bubble');
@@ -971,6 +1442,16 @@ function createMessageNode(msg, msgId = null, force = false) {
971
1442
  }
972
1443
  div.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
973
1444
  attachCodeCopyButtons(div);
1445
+ // What a Resend of this message would send, kept on the node itself so it
1446
+ // lives exactly as long as the row is on screen.
1447
+ if (msg.role === 'user') {
1448
+ const { references, unavailable } = attachmentReferencesForResend(attachments);
1449
+ div[RESEND_PAYLOAD_KEY] = {
1450
+ text: String(msg.text || ''),
1451
+ attachments: references,
1452
+ unavailableAttachments: unavailable,
1453
+ };
1454
+ }
974
1455
  return div;
975
1456
  }
976
1457
 
@@ -1032,34 +1513,7 @@ export function decorateActivityText(text) {
1032
1513
  return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
1033
1514
  };
1034
1515
  const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
1035
- if (/^[\u{1F300}-\u{1FAFF}\u2600-\u27BF]/u.test(sharedSafeValue)) return sharedSafeValue;
1036
- if (sharedSafeValue.startsWith('● ')) return `🔄 ${sharedSafeValue.slice(2).trim()}`;
1037
- if (/^Model selected:/i.test(sharedSafeValue)) return `🧠 ${sharedSafeValue}`;
1038
- if (/^Search \((glob|grep)\)/i.test(sharedSafeValue)) return `🔍 ${sharedSafeValue}`;
1039
- if (/^Tool \(ask_user\)/i.test(sharedSafeValue)) return `❓ ${sharedSafeValue}`;
1040
- if (/^Tool \(view\)/i.test(sharedSafeValue)) return `👀 ${sharedSafeValue}`;
1041
- if (/^Tool \(apply_patch\)/i.test(sharedSafeValue)) return `🪡 ${sharedSafeValue}`;
1042
- if (/^Tool \(powershell\)/i.test(sharedSafeValue)) return `🪓 ${sharedSafeValue}`;
1043
- if (/^Tool \(edit\)/i.test(sharedSafeValue)) return `📝 ${sharedSafeValue}`;
1044
- if (/^Tool \(read_file\)/i.test(sharedSafeValue)) return `📄 ${sharedSafeValue}`;
1045
- if (/^Tool \((grep_search|file_search)\)/i.test(sharedSafeValue)) return `🔎 ${sharedSafeValue}`;
1046
- if (/^Tool \(semantic_search\)/i.test(sharedSafeValue)) return `🧭 ${sharedSafeValue}`;
1047
- if (/^Tool \(vscode_listCodeUsages\)/i.test(sharedSafeValue)) return `🔗 ${sharedSafeValue}`;
1048
- if (/^Tool \(vscode_renameSymbol\)/i.test(sharedSafeValue)) return `✏️ ${sharedSafeValue}`;
1049
- if (/^Tool \(list_dir\)/i.test(sharedSafeValue)) return `📂 ${sharedSafeValue}`;
1050
- if (/^Tool \(create_directory\)/i.test(sharedSafeValue)) return `📁 ${sharedSafeValue}`;
1051
- if (/^Tool \((delete|remove)\)/i.test(sharedSafeValue)) return `🗑️ ${sharedSafeValue}`;
1052
- if (/^Tool \(execution_subagent\)/i.test(sharedSafeValue)) return `🚀 ${sharedSafeValue}`;
1053
- if (/^Tool \(get_errors\)/i.test(sharedSafeValue)) return `🚨 ${sharedSafeValue}`;
1054
- if (/^Tool \(debug_[^)]+\)/i.test(sharedSafeValue)) return `🐞 ${sharedSafeValue}`;
1055
- if (/^Tool \(fetch_webpage\)/i.test(sharedSafeValue)) return `🌐 ${sharedSafeValue}`;
1056
- if (/^Tool \(github_[^)]+\)/i.test(sharedSafeValue)) return `🐙 ${sharedSafeValue}`;
1057
- if (/^Tool \(run_in_terminal\)/i.test(sharedSafeValue)) return `🖥️ ${sharedSafeValue}`;
1058
- if (/^Tool \((create_file|write)\)/i.test(sharedSafeValue)) return `🆕 ${sharedSafeValue}`;
1059
- if (/^Tool \((bash|shell|terminal)\)/i.test(sharedSafeValue)) return `🔧 ${sharedSafeValue}`;
1060
- if (/^Tool \((sql|sqlite)\)/i.test(sharedSafeValue)) return `🗄️ ${sharedSafeValue}`;
1061
- if (/^Tool \(/i.test(sharedSafeValue)) return `🛠️ ${sharedSafeValue}`;
1062
- return `ℹ️ ${sharedSafeValue}`;
1516
+ return prefixToolActivityEmoji(sharedSafeValue);
1063
1517
  }
1064
1518
 
1065
1519
  export function renderThoughtsMarkup(thoughts) {
@@ -1252,11 +1706,19 @@ function anchorThinkingBubble(div, messageId) {
1252
1706
  }
1253
1707
  }
1254
1708
 
1709
+ // Which conversation a live bubble belongs to. #messages is shared by every
1710
+ // conversation, so a bubble is only ever reused or preserved for its own.
1711
+ function thinkingConversationKey() {
1712
+ return String(currentConvId || '').trim() || '__new__';
1713
+ }
1714
+
1255
1715
  export function showThinking(messageId = null, autoScroll = true) {
1256
1716
  const nextMessageId = String(messageId || '').trim();
1257
1717
  if (nextMessageId) thinkingMessageId = nextMessageId;
1258
1718
  const existing = document.getElementById('thinking-indicator');
1259
- if (existing && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
1719
+ if (existing
1720
+ && existing.dataset.conversationId === thinkingConversationKey()
1721
+ && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
1260
1722
  // Reuse the live bubble: rebuilding it every poll tick destroys any text
1261
1723
  // selection anchored inside and drops in-progress subagent bubbles.
1262
1724
  const stopBtn = existing.querySelector('[data-action="stop-turn"]');
@@ -1267,21 +1729,29 @@ export function showThinking(messageId = null, autoScroll = true) {
1267
1729
  stopBtn.classList.toggle('stopping', stopping);
1268
1730
  }
1269
1731
  anchorThinkingBubble(existing, nextMessageId || existing.dataset.messageId);
1270
- if (autoScroll) scrollBottom();
1732
+ syncThinkingFollowButtons(existing, getThinkingFollowMode());
1733
+ observeThinkingBubble(existing);
1734
+ if (getThinkingFollowMode()) applyThinkingFollow();
1735
+ else if (autoScroll) scrollBottom();
1271
1736
  return;
1272
1737
  }
1273
1738
  existing?.remove();
1739
+ unobserveThinkingBubble();
1274
1740
  const div = document.createElement('div');
1275
1741
  div.className = 'msg assistant';
1276
1742
  div.id = 'thinking-indicator';
1743
+ div.dataset.conversationId = thinkingConversationKey();
1277
1744
  if (nextMessageId) div.dataset.messageId = nextMessageId;
1278
1745
  const isCancelInFlight = nextMessageId && bubbleCancelInFlight.has(nextMessageId);
1279
1746
  const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
1280
1747
  ? `<button type="button" class="bubble-action-btn${isCancelInFlight ? ' stopping' : ''}" data-action="stop-turn" data-message-id="${escHtml(nextMessageId)}"${isCancelInFlight ? ' disabled' : ''}>${isCancelInFlight ? 'Stopping…' : 'Stop'}</button>`
1281
1748
  : '';
1749
+ // Stop stays left; the follow buttons (💭 thoughts, 📃 answer, 🛠️ tools)
1750
+ // sit on the right. See thinking-follow.js.
1751
+ const followBtnsHtml = renderThinkingFollowButtonsHtml(getThinkingFollowMode());
1282
1752
  div.innerHTML = `
1283
1753
  <div class="thinking-bubble">
1284
- <div class="thinking-bubble-header">${stopBtnHtml}</div>
1754
+ <div class="thinking-bubble-header">${stopBtnHtml}${followBtnsHtml}</div>
1285
1755
  <details id="thinking-thoughts" class="thinking-thoughts-panel" open>
1286
1756
  <summary>💭 Thoughts</summary>
1287
1757
  <div class="thinking-thoughts-list"></div>
@@ -1294,15 +1764,49 @@ export function showThinking(messageId = null, autoScroll = true) {
1294
1764
  anchorThinkingBubble(div, nextMessageId);
1295
1765
  renderThinkingThoughts();
1296
1766
  renderThinkingStream();
1297
- if (autoScroll) scrollBottom();
1767
+ observeThinkingBubble(div);
1768
+ if (getThinkingFollowMode()) applyThinkingFollow();
1769
+ else if (autoScroll) scrollBottom();
1298
1770
  }
1299
1771
 
1300
1772
  export function removeThinking() {
1301
1773
  thinkingMessageId = null;
1302
1774
  lastInFlightSnapshotKey = '';
1775
+ unobserveThinkingBubble();
1303
1776
  document.getElementById('thinking-indicator')?.remove();
1304
1777
  }
1305
1778
 
1779
+ // Stop is one tap away on a sticky header, so a stray touch must not kill a
1780
+ // long turn: confirm first, then run the existing stop path.
1781
+ function openStopTurnConfirmation(conversationId, messageId) {
1782
+ const convId = String(conversationId || '').trim();
1783
+ const id = String(messageId || '').trim();
1784
+ if (!convId || !id) return;
1785
+ if (bubbleCancelInFlight.has(id)) return;
1786
+ openSummaryModal({
1787
+ title: 'Stop this turn?',
1788
+ subtitle: 'The agent stops after its current step',
1789
+ kind: 'stop-turn',
1790
+ bodyHtml: `
1791
+ <p>Stop the running turn? Work already done stays; anything the agent was about to do is cut short.</p>
1792
+ <div class="summary-modal-actions">
1793
+ <button class="chat-title-action-btn danger-btn" type="button" data-stop-turn-confirm="1">⏹ Stop turn</button>
1794
+ <button class="chat-title-action-btn" type="button" data-stop-turn-cancel="1">Keep running</button>
1795
+ </div>
1796
+ `,
1797
+ });
1798
+ const body = document.getElementById('summary-modal-body');
1799
+ body?.querySelector('[data-stop-turn-confirm]')?.addEventListener('click', (event) => {
1800
+ event.preventDefault();
1801
+ closeSummaryModal();
1802
+ void stopTurnByMessageId(convId, id);
1803
+ }, { once: true });
1804
+ body?.querySelector('[data-stop-turn-cancel]')?.addEventListener('click', (event) => {
1805
+ event.preventDefault();
1806
+ closeSummaryModal();
1807
+ }, { once: true });
1808
+ }
1809
+
1306
1810
  export function collapseThinkingThoughts() {
1307
1811
  const panel = document.getElementById('thinking-thoughts');
1308
1812
  if (!(panel instanceof HTMLDetailsElement)) return;
@@ -1947,6 +2451,7 @@ export function applyConversationTurnStatus({ conversationId, messageId, status
1947
2451
  const conversationKey = String(conversationId || '').trim();
1948
2452
  const messageKey = String(messageId || '').trim();
1949
2453
  const normalizedStatus = String(status || '').trim().toLowerCase();
2454
+ if (['cancelled', 'failed'].includes(normalizedStatus)) releaseStoppedSteerResendsOf(messageKey);
1950
2455
  if (!conversationKey) {
1951
2456
  syncSendButtonState();
1952
2457
  return;
@@ -1970,58 +2475,9 @@ export function applyConversationTurnStatus({ conversationId, messageId, status
1970
2475
  syncSendButtonState();
1971
2476
  }
1972
2477
 
1973
- async function stopCurrentConversationTurn(conversationId) {
1974
- const conversationKey = String(conversationId || '').trim();
1975
- const activeTurn = getActiveTurnForConversation(conversationKey);
1976
- if (!conversationKey || !activeTurn?.messageId) return;
1977
- if (activeTurn.cancelRequested) return;
1978
- if (isMobileComposerViewport() && !confirm('Stop the current turn?')) return;
1979
- setConversationTurnState(conversationKey, {
1980
- messageId: activeTurn.messageId,
1981
- status: activeTurn.status || 'processing',
1982
- cancelRequested: true,
1983
- });
1984
- const expectedMessageId = activeTurn.messageId;
1985
- const result = await cancelConversationTurn(conversationKey, {
1986
- clientId: CLIENT_ID,
1987
- messageId: expectedMessageId,
1988
- });
1989
- if (!result?.ok) {
1990
- setConversationTurnState(conversationKey, {
1991
- messageId: expectedMessageId,
1992
- status: activeTurn.status || 'processing',
1993
- cancelRequested: false,
1994
- });
1995
- showTransientRelayNotice('Could not stop the current turn.');
1996
- return;
1997
- }
1998
- if (
1999
- result.acknowledgement === 'already-finished'
2000
- || result.acknowledgement === 'no-active-turn'
2001
- || result.acknowledgement === 'message-mismatch'
2002
- ) {
2003
- const latestTurn = getActiveTurnForConversation(conversationKey);
2004
- if (latestTurn?.messageId === expectedMessageId) {
2005
- setConversationTurnState(conversationKey, null);
2006
- }
2007
- showTransientRelayNotice('That turn already finished.');
2008
- return;
2009
- }
2010
- if (result.acknowledgement === 'active-turn-unbound') {
2011
- const latestTurn = getActiveTurnForConversation(conversationKey);
2012
- if (latestTurn?.messageId === expectedMessageId) {
2013
- setConversationTurnState(conversationKey, {
2014
- messageId: expectedMessageId,
2015
- status: activeTurn.status || 'processing',
2016
- cancelRequested: false,
2017
- });
2018
- }
2019
- showTransientRelayNotice('The active turn is not bound to a live SDK session.');
2020
- return;
2021
- }
2022
- showTransientRelayNotice('Stopping the current turn…');
2023
- }
2024
-
2478
+ // Stopping a turn lives on the message bubbles only (the live turn's Stop and
2479
+ // pending rows' Cancel). The composer's send button never doubles as Stop —
2480
+ // the old stopCurrentConversationTurn composer path is gone with it.
2025
2481
  async function stopTurnByMessageId(conversationId, messageId) {
2026
2482
  const conversationKey = String(conversationId || '').trim();
2027
2483
  const targetMessageId = String(messageId || '').trim();
@@ -2099,6 +2555,12 @@ function updateBubbleStopButton(messageId, isStopping) {
2099
2555
  btn.classList.toggle('stopping', isStopping);
2100
2556
  }
2101
2557
 
2558
+ // The Cancel control of a user bubble: a pending row's, or a pushed row's the
2559
+ // worker can still pull back (both post cancel-queued-turn).
2560
+ function buildUserBubbleCancelActionsMarkup(messageId, isCancelling) {
2561
+ 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>`;
2562
+ }
2563
+
2102
2564
  function updateUserBubbleCancelButton(messageId, isCancelling) {
2103
2565
  const btn = document.querySelector(`.bubble-action-btn[data-message-id="${messageId}"][data-action="cancel-queued"]`);
2104
2566
  if (!btn) return;
@@ -2114,10 +2576,239 @@ export function removeUserBubbleCancelButton(messageId) {
2114
2576
  if (actionsContainer) actionsContainer.remove();
2115
2577
  }
2116
2578
 
2579
+ function clearUnsteerFallbackTimer(messageId) {
2580
+ const timer = unsteerFallbackTimers.get(messageId);
2581
+ if (timer === undefined) return;
2582
+ clearTimeout(timer);
2583
+ unsteerFallbackTimers.delete(messageId);
2584
+ }
2585
+
2117
2586
  export function clearBubbleCancelState(messageId) {
2118
2587
  const id = String(messageId || '').trim();
2119
2588
  if (!id) return;
2120
2589
  bubbleCancelInFlight.delete(id);
2590
+ clearUnsteerFallbackTimer(id);
2591
+ }
2592
+
2593
+ // Keeps the Cancel controls of the viewed conversation's pushed rows in step
2594
+ // with what its worker reports as still un-steerable. Bubbles are built once
2595
+ // (renderMessages skips unchanged payloads), and the worker's set changes on
2596
+ // its own clock (the heartbeat), so the status refresh calls this next to the
2597
+ // composer sync. Pending rows own their Cancel and are left alone; a row whose
2598
+ // un-steer is still being acknowledged keeps its "Cancelling…" until the
2599
+ // answer or the fallback arrives.
2600
+ export function syncCancellableSteerButtons() {
2601
+ const el = getMessagesElement();
2602
+ if (!el || IS_SHARED_VIEW) return;
2603
+ const cancellableIds = cancellableSteerIdsForConversation(currentConvId);
2604
+ for (const rowNode of el.querySelectorAll('.msg.user[data-message-id]')) {
2605
+ const messageId = String(rowNode.dataset.messageId || '').trim();
2606
+ if (!messageId || pendingUserMessageIds.has(messageId)) continue;
2607
+ const button = rowNode.querySelector('.msg-bubble-actions [data-action="cancel-queued"]');
2608
+ if (cancellableIds.has(messageId)) {
2609
+ if (button) continue;
2610
+ const bubble = rowNode.querySelector('.msg-bubble');
2611
+ if (!bubble) continue;
2612
+ bubble.insertAdjacentHTML('beforeend', buildUserBubbleCancelActionsMarkup(messageId, bubbleCancelInFlight.has(messageId)));
2613
+ continue;
2614
+ }
2615
+ if (button && !bubbleCancelInFlight.has(messageId)) button.closest('.msg-bubble-actions')?.remove();
2616
+ }
2617
+ }
2618
+
2619
+ // The relay forwarded an un-steer to the worker ('cancel-requested'): the row
2620
+ // settles through the socket (message_status 'cancelled' removes the control;
2621
+ // an answer after all re-renders it away). If neither lands, hand the control
2622
+ // back — as "Cancel" when the worker still lists the row, gone otherwise.
2623
+ function armUnsteerFallback(messageId) {
2624
+ clearUnsteerFallbackTimer(messageId);
2625
+ const timer = setTimeout(() => {
2626
+ unsteerFallbackTimers.delete(messageId);
2627
+ bubbleCancelInFlight.delete(messageId);
2628
+ if (cancellableSteerIdsForConversation(currentConvId).has(messageId)) {
2629
+ updateUserBubbleCancelButton(messageId, false);
2630
+ } else {
2631
+ removeUserBubbleCancelButton(messageId);
2632
+ }
2633
+ }, UNSTEER_ACK_FALLBACK_MS);
2634
+ timer?.unref?.();
2635
+ unsteerFallbackTimers.set(messageId, timer);
2636
+ }
2637
+
2638
+ // Resend of a steer the user's Stop cut off unanswered (kind='stopped').
2639
+ // Keyed by the marker row: one Resend per marker per page life — a second tap
2640
+ // while the first is in flight, or after it landed, does nothing. The relay's
2641
+ // duplicate guard backs this across reloads (a second Resend of the same text
2642
+ // collides with the first).
2643
+ const stoppedSteerResends = new Map();
2644
+ const RESEND_PAYLOAD_KEY = '__relayResendPayload';
2645
+
2646
+ // Only uploads (by sha256) can be re-referenced; anything else — a legacy
2647
+ // inline image — is counted so the user is told it was left out.
2648
+ function attachmentReferencesForResend(attachments) {
2649
+ const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
2650
+ const references = list
2651
+ .map((attachment) => {
2652
+ const sha256 = String(attachment?.sha256 || '').trim().toLowerCase();
2653
+ if (!sha256) return null;
2654
+ return {
2655
+ sha256,
2656
+ name: String(attachment?.name || '').trim() || undefined,
2657
+ type: String(attachment?.type || '').trim() || undefined,
2658
+ };
2659
+ })
2660
+ .filter(Boolean);
2661
+ return { references, unavailable: list.length - references.length };
2662
+ }
2663
+
2664
+ // The user message a stopped marker settles. By its source id whenever the
2665
+ // marker names one — if that row is not loaded (a page boundary) there is no
2666
+ // guessing, since the nearest user row above may be another stopped steer.
2667
+ // Only a marker without a source falls back to the user row right above it.
2668
+ function findStoppedSteerSource(markerNode) {
2669
+ const el = getMessagesElement();
2670
+ if (!el || !markerNode) return null;
2671
+ const sourceId = String(markerNode.dataset.sourceMessageId || '').trim();
2672
+ if (sourceId) {
2673
+ return el.querySelector(`.msg.user[data-message-id="${CSS.escape(sourceId)}"]`) || null;
2674
+ }
2675
+ for (let prev = markerNode.previousElementSibling; prev; prev = prev.previousElementSibling) {
2676
+ if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
2677
+ if (prev.classList?.contains('msg') && prev.classList.contains('user')) return prev;
2678
+ }
2679
+ return null;
2680
+ }
2681
+
2682
+ function syncStoppedSteerResendButton(markerId) {
2683
+ const btn = document.querySelector(`.bubble-action-btn[data-message-id="${CSS.escape(markerId)}"][data-action="resend-stopped-steer"]`);
2684
+ if (!btn) return;
2685
+ const state = stoppedSteerResends.get(markerId);
2686
+ btn.disabled = !!state;
2687
+ btn.textContent = state?.status === 'sent' ? 'Resent' : (state ? 'Resending…' : 'Resend');
2688
+ }
2689
+
2690
+ // The Resend a marker's "Resent" rests on was cancelled or failed: the
2691
+ // original may be resent again, as the relay agrees.
2692
+ function releaseStoppedSteerResendsOf(messageId) {
2693
+ const id = String(messageId || '').trim();
2694
+ if (!id) return;
2695
+ for (const [markerId, entry] of stoppedSteerResends) {
2696
+ if (entry?.status !== 'sent' || entry.resentId !== id) continue;
2697
+ stoppedSteerResends.delete(markerId);
2698
+ syncStoppedSteerResendButton(markerId);
2699
+ }
2700
+ }
2701
+
2702
+ async function resendStoppedSteer(conversationId, markerId) {
2703
+ const conversationKey = String(conversationId || '').trim();
2704
+ const id = String(markerId || '').trim();
2705
+ if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
2706
+ const markerNode = getMessagesElement()?.querySelector(`.msg.assistant[data-message-id="${CSS.escape(id)}"]`);
2707
+ const sourceNode = findStoppedSteerSource(markerNode);
2708
+ const payload = sourceNode?.[RESEND_PAYLOAD_KEY] || null;
2709
+ const text = String(payload?.text || '').trim();
2710
+ const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
2711
+ if (!payload) {
2712
+ showTransientRelayNotice('The original message is not loaded — scroll up to it, then try Resend again.');
2713
+ return;
2714
+ }
2715
+ if (!text && !attachments.length) {
2716
+ showTransientRelayNotice('Nothing left to resend: the original message\'s attachments are no longer available.');
2717
+ return;
2718
+ }
2719
+ // Claimed before the first await: this is what makes a double tap a no-op.
2720
+ stoppedSteerResends.set(id, { status: 'sending' });
2721
+ syncStoppedSteerResendButton(id);
2722
+ const clientMessageId = generateId();
2723
+ let sent = false;
2724
+ // The message that now stands as this marker's Resend: a cancel or failure
2725
+ // of it releases the page's "Resent" (releaseStoppedSteerResendsOf).
2726
+ let resentId = clientMessageId;
2727
+ let bubbleAppended = false;
2728
+ const dropOptimisticBubble = () => {
2729
+ clearPendingUserMessage(clientMessageId);
2730
+ pendingUserMessageIds.delete(clientMessageId);
2731
+ seenMessageIds.delete(clientMessageId);
2732
+ if (bubbleAppended) document.querySelector(`.msg[data-message-id="${CSS.escape(clientMessageId)}"]`)?.remove();
2733
+ };
2734
+ try {
2735
+ if (!(await validateSelectedConversationBeforeSend(conversationKey))) return;
2736
+ const selectedModel = document.getElementById('model-select')?.value || '';
2737
+ const selectedReasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
2738
+ const selectedContextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim();
2739
+ const selectedMode = document.getElementById('mode-select')?.value || 'agent';
2740
+ if (!selectedReasoningEffort) {
2741
+ showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
2742
+ return;
2743
+ }
2744
+ trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
2745
+ pendingUserMessageIds.add(clientMessageId);
2746
+ if (isCurrentConversation(conversationKey)) {
2747
+ appendMessage({
2748
+ role: 'user',
2749
+ text,
2750
+ model: selectedModel,
2751
+ mode: selectedMode,
2752
+ timestamp: new Date().toISOString(),
2753
+ attachments,
2754
+ }, true, clientMessageId, true);
2755
+ bubbleAppended = true;
2756
+ }
2757
+ const r = await sendMessageApi({
2758
+ messageId: clientMessageId,
2759
+ clientId: CLIENT_ID,
2760
+ text,
2761
+ model: selectedModel,
2762
+ reasoningEffort: selectedReasoningEffort,
2763
+ contextTier: selectedContextTier,
2764
+ relayMode: selectedMode,
2765
+ conversationId: conversationKey,
2766
+ attachments,
2767
+ resendOfMessageId: String(sourceNode?.dataset?.messageId || '').trim() || undefined,
2768
+ });
2769
+ if (!r) {
2770
+ if (!hasSendEchoArrived(clientMessageId)) {
2771
+ dropOptimisticBubble();
2772
+ showTransientRelayNotice('Could not resend the message. Please try again.');
2773
+ return;
2774
+ }
2775
+ sent = true;
2776
+ return;
2777
+ }
2778
+ if (r.duplicate) {
2779
+ dropOptimisticBubble();
2780
+ if (r.alreadyResent) {
2781
+ // Resent before (another tab or device): nothing to retry.
2782
+ sent = true;
2783
+ resentId = String(r.duplicateOfMessageId || '').trim() || null;
2784
+ showTransientRelayNotice('That message was already resent.');
2785
+ return;
2786
+ }
2787
+ // Another recent message has the same text — not a Resend of this one,
2788
+ // so the button stays available.
2789
+ showTransientRelayNotice('A message with the same text was sent in the last few minutes, so this one was not resent. Try again later.');
2790
+ return;
2791
+ }
2792
+ sent = true;
2793
+ const dropped = Number(payload.unavailableAttachments || 0) + Math.max(0, Number(r.droppedAttachmentCount) || 0);
2794
+ if (dropped > 0) {
2795
+ showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
2796
+ }
2797
+ if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
2798
+ showThinking(r.messageId || null);
2799
+ }
2800
+ } catch (error) {
2801
+ if (!hasSendEchoArrived(clientMessageId)) {
2802
+ dropOptimisticBubble();
2803
+ showTransientRelayNotice(error?.message || 'Could not resend the message.');
2804
+ } else {
2805
+ sent = true;
2806
+ }
2807
+ } finally {
2808
+ if (sent) stoppedSteerResends.set(id, { status: 'sent', resentId });
2809
+ else stoppedSteerResends.delete(id);
2810
+ syncStoppedSteerResendButton(id);
2811
+ }
2121
2812
  }
2122
2813
 
2123
2814
  async function cancelQueuedTurnByMessageId(conversationId, messageId) {
@@ -2148,6 +2839,14 @@ async function cancelQueuedTurnByMessageId(conversationId, messageId) {
2148
2839
  return;
2149
2840
  }
2150
2841
 
2842
+ if (result.acknowledgement === 'cancel-requested') {
2843
+ // The worker pulls the row out of its runtime and settles it through
2844
+ // POST /api/queue-cancelled → message_status 'cancelled'. Keep the
2845
+ // bubble in "Cancelling…" until then (or until the fallback hands it back).
2846
+ armUnsteerFallback(targetMessageId);
2847
+ return;
2848
+ }
2849
+
2151
2850
  if (result.acknowledgement === 'already-processing') {
2152
2851
  bubbleCancelInFlight.delete(targetMessageId);
2153
2852
  removeUserBubbleCancelButton(targetMessageId);
@@ -2291,7 +2990,7 @@ function runRelayErrorCta(cta) {
2291
2990
  }
2292
2991
 
2293
2992
  function handleBubbleActionClick(event) {
2294
- const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn');
2993
+ const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
2295
2994
  if (!btn) return;
2296
2995
  const action = btn.dataset.action;
2297
2996
  const messageId = btn.dataset.messageId;
@@ -2316,7 +3015,15 @@ function handleBubbleActionClick(event) {
2316
3015
  if (action === 'stop-turn' && messageId) {
2317
3016
  event.preventDefault();
2318
3017
  event.stopPropagation();
2319
- void stopTurnByMessageId(currentConvId, messageId);
3018
+ openStopTurnConfirmation(currentConvId, messageId);
3019
+ }
3020
+
3021
+ if (action === 'follow-thinking') {
3022
+ event.preventDefault();
3023
+ event.stopPropagation();
3024
+ const mode = toggleThinkingFollowMode(currentConvId, btn.dataset.follow);
3025
+ syncThinkingFollowButtons(document, mode);
3026
+ if (mode) applyThinkingFollow({ force: true });
2320
3027
  }
2321
3028
 
2322
3029
  if (action === 'cancel-queued' && messageId) {
@@ -2325,6 +3032,12 @@ function handleBubbleActionClick(event) {
2325
3032
  void cancelQueuedTurnByMessageId(currentConvId, messageId);
2326
3033
  }
2327
3034
 
3035
+ if (action === 'resend-stopped-steer' && messageId) {
3036
+ event.preventDefault();
3037
+ event.stopPropagation();
3038
+ void resendStoppedSteer(currentConvId, messageId);
3039
+ }
3040
+
2328
3041
  if (action === 'stop-subagent' && subagentRunId) {
2329
3042
  event.preventDefault();
2330
3043
  event.stopPropagation();
@@ -2363,6 +3076,7 @@ export function initBubbleActionHandlers() {
2363
3076
  if (!messagesEl) return;
2364
3077
  messagesEl.addEventListener('click', handleBubbleActionClick);
2365
3078
  messagesEl.addEventListener('click', handleEmbeddedImageClick);
3079
+ initThinkingFollow({ getCurrentConversationId: () => currentConvId });
2366
3080
  }
2367
3081
 
2368
3082
  export function appendMessage(msg, scroll = true, msgId = null, force = false, insertAfterId = null, trackHistory = true) {
@@ -2440,6 +3154,10 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
2440
3154
  mode: String(item?.mode || '').trim(),
2441
3155
  attachments: Array.isArray(item?.attachments) ? item.attachments.length : 0,
2442
3156
  hiddenFromShares: item?.hiddenFromShares === true,
3157
+ // The continuation badge and the absorbed (steering) merge both hang
3158
+ // off kind; a payload differing only in it must not short-circuit.
3159
+ kind: String(item?.kind || '').trim(),
3160
+ resentAs: String(item?.resentAs || '').trim(),
2443
3161
  // Without this, a payload that differs from the last render only by a
2444
3162
  // newly linked compaction activity short-circuits below and the break
2445
3163
  // row never appears until some unrelated field changes.
@@ -2491,7 +3209,62 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
2491
3209
  .map((item) => [String(item?.id || '').trim(), item])
2492
3210
  .filter(([id]) => !!id),
2493
3211
  );
2494
- if (!ordered.length) {
3212
+ // A payload that already carries this page's Resend but no resentAs on its
3213
+ // marker is newer than the page's provisional "Resent": the Resend was
3214
+ // cancelled or failed (possibly on another device), so the data wins.
3215
+ for (const item of ordered) {
3216
+ if (item?.role !== 'assistant' || String(item?.kind || '').trim() !== 'stopped') continue;
3217
+ const markerId = String(item?.id || '').trim();
3218
+ const entry = stoppedSteerResends.get(markerId);
3219
+ if (entry?.status === 'sent' && entry.resentId && messageById.has(entry.resentId) && !String(item?.resentAs || '').trim()) {
3220
+ stoppedSteerResends.delete(markerId);
3221
+ }
3222
+ }
3223
+ // Preserve optimistic pending user bubbles the payload does not yet carry.
3224
+ // A message is persisted the instant it is sent, but a poll whose response
3225
+ // predates the send returns a payload without it; rebuilding from that stale
3226
+ // payload would drop the just-sent bubble until the next poll catches up
3227
+ // (it "vanishes then reappears"). Steering makes this visible because the
3228
+ // absorbed-reply broadcast forces extra rebuilds during the send window.
3229
+ // Capturing the live DOM nodes keeps their exact content and handlers; the
3230
+ // wipe below orphans them but the references keep them alive to re-append.
3231
+ // Matches store.js pendingConversationKey (trimmed id, or '__new__').
3232
+ const renderConversationKey = String(meta.conversationId || currentConvId || '').trim() || '__new__';
3233
+ const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
3234
+ const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
3235
+ ? meta.hasMoreNewer
3236
+ : !!pageInfo?.hasMoreNewer;
3237
+ const preservedPendingNodes = [];
3238
+ // A kept pending bubble re-joins at the end of the loaded window, which is
3239
+ // only the newest end when nothing newer is left unloaded (a search jump
3240
+ // loads a window in the middle of the history).
3241
+ for (const id of (hasMoreNewer ? [] : pendingUserMessageIds)) {
3242
+ const pendingId = String(id || '').trim();
3243
+ if (!pendingId || messageById.has(pendingId)) continue;
3244
+ // Only this conversation's pending bubbles: a bubble still waiting in
3245
+ // another conversation must never be carried into the one being rendered
3246
+ // (a switch would otherwise drag it along).
3247
+ const entry = pendingUserMessageEntries.get(pendingId);
3248
+ if (String(entry?.conversationKey || '').trim() !== renderConversationKey) continue;
3249
+ const node = el.querySelector(`[data-message-id="${CSS.escape(pendingId)}"]`);
3250
+ if (node) preservedPendingNodes.push(node);
3251
+ }
3252
+ // Preserve the live thinking bubble across the wipe. It holds in-progress
3253
+ // thoughts, streamed text and subagent bubbles that a full re-derive would
3254
+ // lose (or briefly blank while restoreInFlightThinking rebuilds from the
3255
+ // server's inFlight snapshot). Re-appending the same node lets that restore
3256
+ // update it in place instead of rebuilding — the same reuse showThinking
3257
+ // already relies on. removeThinking() still drops it when the turn ends.
3258
+ const thinkingNode = document.getElementById('thinking-indicator');
3259
+ // Only when it genuinely lives in the transcript being rebuilt and belongs
3260
+ // to the conversation being rendered: a detached node must not be spliced
3261
+ // in as an extra row, and another conversation's bubble must not leak into
3262
+ // this one (it is dropped instead, so the next restore builds afresh).
3263
+ const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
3264
+ const preservedThinkingNode = (thinkingNodeInTranscript
3265
+ && thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
3266
+ if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
3267
+ if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
2495
3268
  el.innerHTML = `<div class="empty-state">
2496
3269
  <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
2497
3270
  <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
@@ -2504,13 +3277,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
2504
3277
  return true;
2505
3278
  }
2506
3279
  const conversationId = String(meta.conversationId || currentConvId || '').trim();
2507
- const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
2508
3280
  const hasMoreOlder = typeof meta.hasMoreOlder === 'boolean'
2509
3281
  ? meta.hasMoreOlder
2510
3282
  : (typeof meta.hasMoreHistory === 'boolean' ? meta.hasMoreHistory : !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore);
2511
- const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
2512
- ? meta.hasMoreNewer
2513
- : !!pageInfo?.hasMoreNewer;
2514
3283
  const oldestMessageId = String(
2515
3284
  meta.historyCursor
2516
3285
  || pageInfo?.olderCursor?.beforeMessageId
@@ -2559,6 +3328,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
2559
3328
  });
2560
3329
  withSuspendedSeparatorSync(() => {
2561
3330
  for (const m of ordered) appendMessage(m, false, m.id || null, true, getMessageThreadAnchor(m, messageById), false);
3331
+ // A pending bubble the payload lacks is the newest message, so it re-joins
3332
+ // at the end. Re-appending the original node (not a fresh one) keeps its
3333
+ // cancel button and click handlers intact.
3334
+ for (const node of preservedPendingNodes) el.appendChild(node);
3335
+ // The live thinking bubble re-joins under its owning message right here:
3336
+ // restoreInFlightThinking skips an unchanged snapshot, so waiting for it
3337
+ // to re-anchor left the bubble at the end, below later (steered) rows.
3338
+ if (preservedThinkingNode) {
3339
+ el.appendChild(preservedThinkingNode);
3340
+ anchorThinkingBubble(preservedThinkingNode, preservedThinkingNode.dataset.messageId);
3341
+ }
2562
3342
  });
2563
3343
  syncSeparatorsNow();
2564
3344
  renderRelayQuestions();
@@ -2633,7 +3413,8 @@ export async function sendMessage() {
2633
3413
  const originalComposerText = String(input?.value || '');
2634
3414
  const text = originalComposerText.trim();
2635
3415
  const mobileSend = isMobileComposerViewport();
2636
- const activeTurn = getActiveTurnForConversation(currentConvId);
3416
+ // A send while steering is held is allowed: the relay holds the message
3417
+ // and it steers in once the hold clears (the button reads "Queue").
2637
3418
  const hasDraft = hasComposerDraft({ text, attachmentCount: selectedAttachments.length });
2638
3419
  if (sendInFlight) {
2639
3420
  showTransientRelayNotice('Please wait for the current message to finish sending.');
@@ -2641,6 +3422,7 @@ export async function sendMessage() {
2641
3422
  }
2642
3423
  if (!hasDraft) return;
2643
3424
  closeSlashAutocomplete();
3425
+ closeMentionAutocomplete();
2644
3426
 
2645
3427
  // Warn-once typo guard: a message that looks like a command but matches none
2646
3428
  // would otherwise burn an agent turn as plain text.
@@ -2658,6 +3440,7 @@ export async function sendMessage() {
2658
3440
  input.value = '';
2659
3441
  autoResize(input);
2660
3442
  releaseComposerFocusAfterSend(input);
3443
+ void flushConversationDraft(currentConvId);
2661
3444
  compactCurrentConversation();
2662
3445
  scrollBottomAfterSend();
2663
3446
  return;
@@ -2668,6 +3451,7 @@ export async function sendMessage() {
2668
3451
  input.value = '';
2669
3452
  autoResize(input);
2670
3453
  releaseComposerFocusAfterSend(input);
3454
+ void flushConversationDraft(currentConvId);
2671
3455
  const result = await runPreviewCommand(previewCommand, { conversationId: currentConvId });
2672
3456
  showTransientRelayNotice(result.notice);
2673
3457
  return;
@@ -2711,6 +3495,7 @@ export async function sendMessage() {
2711
3495
  }
2712
3496
 
2713
3497
  setSendInFlight(true);
3498
+ let composerClearedBySend = false;
2714
3499
  let attachments = [];
2715
3500
  let clientMessageId = null;
2716
3501
  try {
@@ -2724,8 +3509,12 @@ export async function sendMessage() {
2724
3509
  }
2725
3510
  const titleSeed = text || (attachments[0]?.name || 'Attachment');
2726
3511
  clientMessageId = generateId();
2727
- trackPendingUserMessage(clientMessageId, targetConversationId, text);
3512
+ trackPendingUserMessage(clientMessageId, targetConversationId, text, { attachments });
2728
3513
  pendingUserMessageIds.add(clientMessageId);
3514
+ // The message leaves the draft: from here the cached draft is only what is
3515
+ // typed next, which the post-send save (or a failure restore) builds on.
3516
+ if (targetConversationId) upsertConversationDraftState(targetConversationId, { draftText: '' });
3517
+ composerClearedBySend = true;
2729
3518
  if (viewingSendConversation()) {
2730
3519
  input.value = '';
2731
3520
  autoResize(input);
@@ -2770,23 +3559,22 @@ export async function sendMessage() {
2770
3559
  if (queued) {
2771
3560
  void registerOutboxSync();
2772
3561
  showTransientRelayNotice('You are offline. Message queued — it will send when the connection returns.', 7000);
2773
- if (viewingSendConversation()) clearAttachments();
3562
+ if (viewingSendConversation()) removeComposerAttachments(draftAttachments);
2774
3563
  return;
2775
3564
  }
2776
3565
  }
2777
- clearPendingUserMessage(clientMessageId);
2778
- const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
2779
- pendingNode?.remove();
2780
- pendingUserMessageIds.delete(clientMessageId);
2781
- seenMessageIds.delete(clientMessageId);
2782
- if (viewingSendConversation()) {
2783
- input.value = originalComposerText;
2784
- autoResize(input);
3566
+ const sendLanded = hasSendEchoArrived(clientMessageId);
3567
+ if (!sendLanded) {
3568
+ clearPendingUserMessage(clientMessageId);
3569
+ const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
3570
+ pendingNode?.remove();
3571
+ pendingUserMessageIds.delete(clientMessageId);
3572
+ seenMessageIds.delete(clientMessageId);
2785
3573
  }
2786
- void scheduleConversationDraftSave({
2787
- conversationId: targetConversationId,
2788
- draftText: originalComposerText,
2789
- immediate: true,
3574
+ restoreComposerAfterFailedSend(targetConversationId, originalComposerText, {
3575
+ viewing: viewingSendConversation(),
3576
+ composerCleared: composerClearedBySend,
3577
+ sendLanded,
2790
3578
  });
2791
3579
  if (!mobileSend && viewingSendConversation()) input.focus();
2792
3580
  setModelBanner('⚠️ Message could not be sent. Please try again.');
@@ -2818,7 +3606,7 @@ export async function sendMessage() {
2818
3606
  // conversation that was compacted.
2819
3607
  if (viewingSendConversation()) {
2820
3608
  await window.openConversation?.(r.compactedConversationId);
2821
- clearAttachments();
3609
+ removeComposerAttachments(draftAttachments);
2822
3610
  if (!mobileSend) input.focus();
2823
3611
  scrollBottomAfterSend();
2824
3612
  }
@@ -2847,6 +3635,12 @@ export async function sendMessage() {
2847
3635
  title: titleSeed.slice(0, 60),
2848
3636
  updatedAt: new Date().toISOString(),
2849
3637
  messageCount: 1,
3638
+ // The session the relay routed this first message to: the id its
3639
+ // worker is launched, registered and heartbeats under, so the
3640
+ // composer reads that worker's steering snapshot before the list
3641
+ // catches up. A binding that names another id (the placeholder
3642
+ // rekey) replaces it via conversation_session_bound.
3643
+ sdkSessionId: r.ownerSessionId || null,
2850
3644
  runtimeSessionId: r.runtimeSessionId || null,
2851
3645
  runtimeProviderType: r.runtimeProviderType || 'github',
2852
3646
  runtimeProviderModel: r.runtimeProviderModel || null,
@@ -2876,15 +3670,30 @@ export async function sendMessage() {
2876
3670
  };
2877
3671
  window.syncAutoModelAvailability?.();
2878
3672
  }
3673
+ // Adopting the conversation can grow the composer (session-lock note,
3674
+ // warning banner), which shrinks the transcript under a pane that was
3675
+ // pinned to the newest message. Re-pin now rather than after the draft
3676
+ // save's round trip below, or the new bubble sits under the composer
3677
+ // for as long as that request takes.
3678
+ if (viewingSendConversation()) scrollBottom();
2879
3679
  const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
2880
3680
  if (persistedConversationId) {
3681
+ restorableDraftByConversation.delete(persistedConversationId);
3682
+ // Forced: the send transaction already moved the server's version, and
3683
+ // this round trip is how the client learns it (a 409 carries it). What
3684
+ // is saved is the composer as it is now — the user may already have
3685
+ // typed the next message while this one was sending.
3686
+ const postSendText = viewingSendConversation()
3687
+ ? String(input.value || '')
3688
+ : String(conversations[persistedConversationId]?.draftText || '');
2881
3689
  const clearedDraft = await scheduleConversationDraftSave({
2882
3690
  conversationId: persistedConversationId,
2883
- draftText: '',
3691
+ draftText: postSendText,
2884
3692
  immediate: true,
3693
+ force: true,
2885
3694
  });
2886
3695
  upsertConversationDraftState(persistedConversationId, {
2887
- draftText: '',
3696
+ draftText: postSendText,
2888
3697
  // Prefer the server's version pointer (a 409 carries the one the send
2889
3698
  // transaction wrote when it cleared the draft) over this client's
2890
3699
  // clock: once the send releases the draft, every later refresh is
@@ -2901,27 +3710,27 @@ export async function sendMessage() {
2901
3710
  if (!activeTurn?.messageId) showThinking(r.messageId || null);
2902
3711
  }
2903
3712
 
3713
+ // Only what this message took leaves the composer: attachments added (or
3714
+ // still uploading) since the send began stay, and the settle-time save
3715
+ // below persists them.
2904
3716
  if (viewingSendConversation()) {
2905
- clearAttachments();
3717
+ removeComposerAttachments(draftAttachments);
2906
3718
  if (!mobileSend) input.focus();
2907
3719
  scrollBottomAfterSend();
2908
3720
  }
2909
3721
  } catch (e) {
2910
- if (clientMessageId) {
3722
+ const sendLanded = hasSendEchoArrived(clientMessageId);
3723
+ if (clientMessageId && !sendLanded) {
2911
3724
  clearPendingUserMessage(clientMessageId);
2912
3725
  const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
2913
3726
  pendingNode?.remove();
2914
3727
  pendingUserMessageIds.delete(clientMessageId);
2915
3728
  seenMessageIds.delete(clientMessageId);
2916
3729
  }
2917
- if (viewingSendConversation()) {
2918
- input.value = originalComposerText;
2919
- autoResize(input);
2920
- }
2921
- void scheduleConversationDraftSave({
2922
- conversationId: targetConversationId,
2923
- draftText: originalComposerText,
2924
- immediate: true,
3730
+ restoreComposerAfterFailedSend(targetConversationId, originalComposerText, {
3731
+ viewing: viewingSendConversation(),
3732
+ composerCleared: composerClearedBySend,
3733
+ sendLanded,
2925
3734
  });
2926
3735
  alert(e.message || 'Failed to send message');
2927
3736
  } finally {
@@ -2929,6 +3738,9 @@ export async function sendMessage() {
2929
3738
  // Every exit path has settled the draft by now: cleared it (success),
2930
3739
  // restored the composer text (offline/failure), or never touched it.
2931
3740
  if (targetConversationId) sendOwnedDraftConversations.delete(targetConversationId);
3741
+ // Edits made after the post-send save were held back while the send owned
3742
+ // the draft; save them now, on screen or not.
3743
+ if (targetConversationId) saveComposerIfUnsynced(targetConversationId);
2932
3744
  }
2933
3745
  }
2934
3746
 
@@ -2936,12 +3748,16 @@ export async function sendMessage() {
2936
3748
  // menu in sync with the caret. Cheap no-op for non-slash text.
2937
3749
  export function updateComposerSlashMenu(input) {
2938
3750
  updateSlashAutocomplete(input, { conversationId: currentConvId });
3751
+ // Second, so it sees the slash menu's verdict: the @relay popup never opens
3752
+ // over an open slash menu, and at most one of them ever handles keys.
3753
+ updateMentionAutocomplete(input);
2939
3754
  }
2940
3755
 
2941
3756
  export function handleKey(e) {
2942
3757
  // The open menu owns navigation keys; Ctrl/Cmd+Enter still falls through to
2943
- // send because the menu never consumes it.
3758
+ // send because neither menu consumes it.
2944
3759
  if (handleSlashAutocompleteKey(e, e.target)) return;
3760
+ if (handleMentionAutocompleteKey(e, e.target)) return;
2945
3761
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
2946
3762
  e.preventDefault();
2947
3763
  sendMessage();