@oar-sh/oar 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. 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,11 +44,13 @@ import {
43
44
  SHARED_CONVERSATION_TOKEN,
44
45
  imageEditTarget,
45
46
  setImageEditTarget as setStoredImageEditTarget,
47
+ getSessionWorkerState,
46
48
  } from './store.js';
49
+ import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
47
50
  import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
48
51
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
49
52
  import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
50
- import { renderAttachmentMarkup, clearAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
53
+ import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
51
54
  import { buildWorkflowRunCard } from './background-tasks-view.mjs';
52
55
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
53
56
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
@@ -61,7 +64,6 @@ import { openSettingsModal } from './settings-modal.js';
61
64
  import {
62
65
  serializeDraftAttachments,
63
66
  hydrateDraftAttachments,
64
- mergeDraftAttachmentUpdate,
65
67
  draftAttachmentsEqual,
66
68
  } from './composer-attachment-cache.mjs';
67
69
  import { renderRelayQuestions } from './ask-user-view.js';
@@ -83,13 +85,26 @@ import {
83
85
  relayActivityEntryText,
84
86
  } from './activity-replay-state.mjs';
85
87
  import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
88
+ import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
86
89
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
87
90
  import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
88
91
  import { createInfiniteLoader } from './infinite-loader.js';
89
92
  import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
93
+ import {
94
+ DRAFT_SYNC_PROTOCOL_VERSION,
95
+ MAX_DRAFT_TEXT_LENGTH,
96
+ draftTextForSync,
97
+ draftAttachmentsKey,
98
+ getSyncedDraft,
99
+ isDraftSaveNoop,
100
+ recordSyncedDraft,
101
+ resolveDraftConflict,
102
+ shouldApplyIncomingDraftToComposer,
103
+ } from './conversation-draft-sync.mjs';
90
104
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
91
105
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
92
106
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
107
+ import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
93
108
 
94
109
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
95
110
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -110,6 +125,7 @@ function syncSeparatorsNow() {
110
125
  const el = getMessagesElement();
111
126
  if (!el) return;
112
127
  syncTranscriptSeparators(el);
128
+ syncSteeredTurnMerge(el);
113
129
  observeTranscriptResize(el);
114
130
  }
115
131
 
@@ -145,6 +161,12 @@ const relayStreamStateByMessageId = new Map();
145
161
  const relayStreamTextByMessageId = new Map();
146
162
  const completedMessageIds = new Set();
147
163
  const bubbleCancelInFlight = new Set();
164
+ // Un-steer requests the relay acknowledged with 'cancel-requested': the worker
165
+ // answers asynchronously (message_status 'cancelled', or the row is answered
166
+ // after all). Until then the bubble reads "Cancelling…"; the timer restores
167
+ // the control if neither arrives.
168
+ const UNSTEER_ACK_FALLBACK_MS = 20_000;
169
+ const unsteerFallbackTimers = new Map();
148
170
  const shareVisibilityInFlight = new Set();
149
171
  const SUBAGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'dropped', 'done']);
150
172
  let lastRenderedMessageSnapshotKey = '';
@@ -306,6 +328,9 @@ const conversationHistoryLoader = createInfiniteLoader({
306
328
  // separator rows, and the height delta the restore measures has to include
307
329
  // them or the viewport jumps.
308
330
  syncTranscriptSeparators(el);
331
+ // A steering pair split across the page boundary heals here (the absorbed
332
+ // reply just arrived above its already-rendered steered user row).
333
+ syncSteeredTurnMerge(el);
309
334
  requestAnimationFrame(() => {
310
335
  if (!el || String(currentConvId || '').trim() !== currentId) return;
311
336
  const nextScrollHeight = el.scrollHeight;
@@ -408,13 +433,93 @@ function getActiveTurnForConversation(conversationId) {
408
433
  return activeTurnsByConversation.get(conversationKey) || null;
409
434
  }
410
435
 
436
+ // The session worker id a conversation is bound to. Same hydration shapes
437
+ // validateSelectedConversationBeforeSend handles: camelCase, snake_case, or
438
+ // only the runtime session binding.
439
+ function sdkSessionIdForConversation(conversationId) {
440
+ const conversation = conversations[String(conversationId || '').trim()] || null;
441
+ return String(
442
+ conversation?.sdkSessionId
443
+ || conversation?.sdk_session_id
444
+ || conversation?.runtimeSession?.sdkSessionId
445
+ || conversation?.runtimeSession?.sdk_session_id
446
+ || '',
447
+ ).trim();
448
+ }
449
+
450
+ // The worker-reported steering snapshot for a conversation's session worker
451
+ // (rides the status payload from the worker's heartbeat), or null.
452
+ function workerSteeringForConversation(conversationId) {
453
+ const sdkSessionId = sdkSessionIdForConversation(conversationId);
454
+ return sdkSessionId ? (getSessionWorkerState(sdkSessionId)?.steering || null) : null;
455
+ }
456
+
457
+ // Which conversations deliver a message typed mid-turn INTO the running turn
458
+ // (steering) rather than queueing it behind: exactly those whose session
459
+ // worker advertises it on its heartbeat steering snapshot (`supported` — the
460
+ // Claude and Copilot SDK workers both do). One rule for every provider; the
461
+ // provider name is not consulted (the name-based Claude rule was retired
462
+ // 2026-09-26 once every live Claude worker advertised the flag). Workers that
463
+ // do not advertise (the extension path, Cursor, Grok) stay strictly serial,
464
+ // so their composer keeps saying "Queue".
465
+ function conversationSupportsSteering(conversationId) {
466
+ const conversationKey = String(conversationId || '').trim();
467
+ if (!conversationKey) return false;
468
+ return workerSteeringForConversation(conversationKey)?.supported === true;
469
+ }
470
+
471
+ // Pushed rows the conversation's worker reports it can still pull back out of
472
+ // its runtime (Copilot's queued lane): their bubbles keep Cancel.
473
+ function cancellableSteerIdsForConversation(conversationId) {
474
+ const ids = workerSteeringForConversation(conversationId)?.cancellableIds;
475
+ return new Set(Array.isArray(ids) ? ids : []);
476
+ }
477
+
478
+ // Whether steering into this conversation's live turn is momentarily held,
479
+ // and why. Two sources, freshest first: the client's own knowledge of an open
480
+ // question card (instant — the worker's heartbeat snapshot lags up to ~10s),
481
+ // then the worker-reported steering snapshot for the slower holds only it can
482
+ // see (compaction, post-compaction adoption).
483
+ function steeringHoldForConversation(conversationId) {
484
+ const conversationKey = String(conversationId || '').trim();
485
+ if (!conversationKey || !conversationSupportsSteering(conversationKey)) {
486
+ return { held: false, reason: null };
487
+ }
488
+ try {
489
+ const pendingQuestions = Number(getPendingQuestionCountsByConversation()?.[conversationKey] || 0);
490
+ if (pendingQuestions > 0) return { held: true, reason: 'question' };
491
+ } catch {}
492
+ const steering = workerSteeringForConversation(conversationKey);
493
+ if (steering && steering.turnActive && !steering.canSteer) {
494
+ // The client's own question knowledge is authoritative both ways: it saw
495
+ // the card answered instantly, while the worker snapshot lags ~10s.
496
+ if (steering.holdReason === 'question') return { held: false, reason: null };
497
+ // The 'delivery' hold covers the seconds while a turn-opening delivery is
498
+ // still in flight — but a heartbeat-captured copy outlives that window by
499
+ // up to a poll cycle and names the SAME turn, so the client cannot tell
500
+ // stale from live. Ignore it: a steer sent into that window just queues
501
+ // and rides in moments later, which was always safe.
502
+ if (steering.holdReason === 'delivery') return { held: false, reason: null };
503
+ // A snapshot naming a different turn than the one the client is watching
504
+ // is a leftover from the previous turn — ignore it.
505
+ const clientTurnMessageId = String(getActiveTurnForConversation(conversationKey)?.messageId || '').trim();
506
+ if (steering.messageId && clientTurnMessageId && steering.messageId !== clientTurnMessageId) {
507
+ return { held: false, reason: null };
508
+ }
509
+ return { held: true, reason: steering.holdReason || null };
510
+ }
511
+ return { held: false, reason: null };
512
+ }
513
+
411
514
  function syncSendButtonState() {
412
515
  const btn = document.getElementById('send-btn');
413
516
  if (!btn) return;
414
517
  const currentTurn = getActiveTurnForConversation(currentConvId);
518
+ const steeringHold = currentTurn
519
+ ? steeringHoldForConversation(currentConvId)
520
+ : { held: false, reason: null };
415
521
  const state = deriveComposerControlState({
416
522
  hasActiveTurn: !!currentTurn,
417
- cancelRequested: currentTurn?.cancelRequested === true,
418
523
  hasDraft: hasComposerDraft({
419
524
  text: document.getElementById('msg-input')?.value || '',
420
525
  attachmentCount: selectedAttachments.length,
@@ -422,6 +527,9 @@ function syncSendButtonState() {
422
527
  sendInFlight,
423
528
  modelMetadataBlocked: window.isModelMetadataBlocked?.() === true,
424
529
  attachmentsUploading: hasUploadingAttachments(selectedAttachments),
530
+ steeringSupported: conversationSupportsSteering(currentConvId),
531
+ steeringHeld: steeringHold.held,
532
+ steeringHoldReason: steeringHold.reason,
425
533
  });
426
534
  btn.disabled = state.disabled;
427
535
  btn.dataset.action = state.action;
@@ -429,12 +537,45 @@ function syncSendButtonState() {
429
537
  btn.title = state.title;
430
538
  }
431
539
 
432
- export function syncComposerControlState() {
540
+ // Button-only refresh for status, poll and question paths. It must never save
541
+ // the draft: an idle client that saves on every poll tick writes its stale
542
+ // composer text over another device's draft.
543
+ export function syncComposerButtonState() {
433
544
  syncSendButtonState();
434
- void scheduleConversationDraftSave({
435
- conversationId: currentConvId,
436
- draftText: document.getElementById('msg-input')?.value || '',
437
- });
545
+ }
546
+
547
+ // The composer's input handler: the user edited the text, so the draft is
548
+ // saved (debounced) as well as the button refreshed. While a send owns the
549
+ // draft nothing is saved — the send transaction clears the draft
550
+ // unconditionally after any such save — but the text is still cached, and
551
+ // the send saves it when it settles, wherever the user is by then.
552
+ export function syncComposerAfterUserEdit() {
553
+ syncSendButtonState();
554
+ const text = document.getElementById('msg-input')?.value || '';
555
+ warnOnceIfDraftTooLong(text);
556
+ if (isDraftOwnedByPendingSend(currentConvId)) {
557
+ upsertConversationDraftState(currentConvId, { draftText: draftTextForSync(text) });
558
+ return;
559
+ }
560
+ void scheduleConversationDraftSave({ conversationId: currentConvId, draftText: text });
561
+ }
562
+
563
+ /** Whether a send in flight holds this conversation's draft saves back. */
564
+ export function isConversationDraftHeldBySend(conversationId) {
565
+ return isDraftOwnedByPendingSend(conversationId);
566
+ }
567
+
568
+ let draftLengthWarningShown = false;
569
+
570
+ function warnOnceIfDraftTooLong(text) {
571
+ const tooLong = String(text || '').length > MAX_DRAFT_TEXT_LENGTH;
572
+ if (tooLong && !draftLengthWarningShown) {
573
+ showTransientRelayNotice(
574
+ `Drafts are saved up to ${MAX_DRAFT_TEXT_LENGTH.toLocaleString('en-US')} characters; the rest stays only in this tab until you send.`,
575
+ 7000,
576
+ );
577
+ }
578
+ draftLengthWarningShown = tooLong;
438
579
  }
439
580
 
440
581
  export function isSendInFlight() {
@@ -454,11 +595,44 @@ function isDraftOwnedByPendingSend(conversationId) {
454
595
  return sendOwnedDraftConversations.has(String(conversationId || '').trim());
455
596
  }
456
597
 
598
+ function isDraftSavePending(conversationId) {
599
+ const id = String(conversationId || '').trim();
600
+ return draftSaveTimerByConversation.has(id) || draftSavePromiseByConversation.has(id);
601
+ }
602
+
603
+ /**
604
+ * Called by openConversation once the composer belongs to the next
605
+ * conversation (after the previous draft was flushed): replaces the previous
606
+ * conversation's text with the next one's cached draft, so no blur, keystroke
607
+ * or flush can save the old text under the new conversation — and so a load
608
+ * that fails still leaves that conversation's known draft on screen rather
609
+ * than an empty composer a keystroke would save over it. The load's hydrate
610
+ * then reconciles against it like any other incoming draft.
611
+ */
612
+ export function beginConversationDraftSwitch(nextConversationId) {
613
+ const id = String(nextConversationId || '').trim();
614
+ const cached = id ? conversations[id] : null;
615
+ const text = String(cached?.draftText || '');
616
+ const attachments = Array.isArray(cached?.draftAttachments) ? cached.draftAttachments : [];
617
+ if (cached && !getSyncedDraft(id)) {
618
+ recordSyncedDraft(id, { text: draftTextForSync(text), attachments, updatedAt: cached.draftUpdatedAt || null });
619
+ }
620
+ if (attachments.length) setComposerAttachments(hydrateDraftAttachments(attachments));
621
+ const input = document.getElementById('msg-input');
622
+ if (input && input.value !== text) {
623
+ input.value = text;
624
+ autoResize(input);
625
+ }
626
+ syncSendButtonState();
627
+ }
628
+
629
+ // Omitted version fields keep their current values: a local edit changes the
630
+ // text only, never the server version it was based on.
457
631
  function upsertConversationDraftState(conversationId, {
458
632
  draftText = '',
459
633
  draftAttachments = undefined,
460
- draftUpdatedAt = null,
461
- draftUpdatedByClientId = null,
634
+ draftUpdatedAt = undefined,
635
+ draftUpdatedByClientId = undefined,
462
636
  } = {}) {
463
637
  const id = String(conversationId || '').trim();
464
638
  if (!id || !conversations[id]) return;
@@ -469,8 +643,10 @@ function upsertConversationDraftState(conversationId, {
469
643
  draftAttachments: draftAttachments === undefined
470
644
  ? (existing.draftAttachments || [])
471
645
  : (Array.isArray(draftAttachments) ? draftAttachments : []),
472
- draftUpdatedAt: draftUpdatedAt || null,
473
- draftUpdatedByClientId: draftUpdatedByClientId || null,
646
+ draftUpdatedAt: draftUpdatedAt === undefined ? (existing.draftUpdatedAt || null) : (draftUpdatedAt || null),
647
+ draftUpdatedByClientId: draftUpdatedByClientId === undefined
648
+ ? (existing.draftUpdatedByClientId || null)
649
+ : (draftUpdatedByClientId || null),
474
650
  };
475
651
  }
476
652
 
@@ -485,7 +661,8 @@ function upsertConversationDraftState(conversationId, {
485
661
  */
486
662
  export function persistComposerAttachments() {
487
663
  const id = String(currentConvId || '').trim();
488
- if (!id) return null;
664
+ // A send owns the draft: its post-send save picks the attachments up.
665
+ if (!id || isDraftOwnedByPendingSend(id)) return null;
489
666
  return scheduleConversationDraftSave({
490
667
  conversationId: id,
491
668
  draftText: document.getElementById('msg-input')?.value || '',
@@ -494,37 +671,251 @@ export function persistComposerAttachments() {
494
671
  });
495
672
  }
496
673
 
497
- async function persistConversationDraft(conversationId, draftText, draftAttachments = undefined) {
674
+ // Saves the conversation's composer text (or, off screen, its cached text)
675
+ // when it differs from the synced draft.
676
+ function saveComposerIfUnsynced(conversationId) {
677
+ const synced = getSyncedDraft(conversationId);
678
+ const viewing = isCurrentConversation(conversationId);
679
+ const text = draftTextForSync(viewing
680
+ ? (document.getElementById('msg-input')?.value || '')
681
+ : (conversations[conversationId]?.draftText || ''));
682
+ const attachments = viewing ? serializeDraftAttachments(selectedAttachments) : undefined;
683
+ const attachmentsKey = draftAttachmentsKey(attachments);
684
+ if (synced && synced.text === text && (attachmentsKey === null || synced.attachmentsKey === null || synced.attachmentsKey === attachmentsKey)) return;
685
+ void scheduleConversationDraftSave({ conversationId, draftText: text, draftAttachments: attachments });
686
+ }
687
+
688
+ // The server echoes a stored message to its sender (user_message with this
689
+ // client's id), which drops it from pendingUserMessageIds; status events for
690
+ // it do the same. Either means the send landed even if its response failed.
691
+ function hasSendEchoArrived(clientMessageId) {
692
+ const id = String(clientMessageId || '').trim();
693
+ return !!id && !pendingUserMessageIds.has(id);
694
+ }
695
+
696
+ // A send that failed hands its text back to the composer. Text typed while it
697
+ // was in flight is kept after it (blank line between) instead of being
698
+ // replaced, so neither is lost; if the send never emptied the composer, the
699
+ // composer still holds the message and is kept as it is. A send whose echo
700
+ // already arrived did land (the failure was only the response): putting its
701
+ // text back would invite a duplicate, so only the typing is kept.
702
+ function restoreComposerAfterFailedSend(conversationId, originalText, { viewing, composerCleared, sendLanded = false }) {
703
+ const input = document.getElementById('msg-input');
704
+ const current = viewing
705
+ ? String(input?.value || '')
706
+ : String(conversations[conversationId]?.draftText || '');
707
+ let text = originalText;
708
+ if (sendLanded) text = current;
709
+ else if (!composerCleared) text = current || originalText;
710
+ else if (current.trim()) text = `${originalText}
711
+
712
+ ${current}`;
713
+ if (viewing && input) {
714
+ input.value = text;
715
+ autoResize(input);
716
+ }
717
+ void scheduleConversationDraftSave({ conversationId, draftText: text, immediate: true });
718
+ }
719
+
720
+ function isDraftVersionConflict(response) {
721
+ return response?.conflict === true || response?.code === 'draft-version-conflict';
722
+ }
723
+
724
+ function isCurrentConversation(conversationId) {
725
+ return String(currentConvId || '').trim() === String(conversationId || '').trim();
726
+ }
727
+
728
+ // The version a save is based on. Always sent — even when null, which tells
729
+ // the server "I have seen no draft yet" and is conflict-checked like any other.
730
+ function draftSaveBaseVersion(conversationId) {
731
+ const synced = getSyncedDraft(conversationId);
732
+ if (synced) return synced.updatedAt || null;
733
+ return conversations[conversationId]?.draftUpdatedAt || null;
734
+ }
735
+
736
+ function sendConversationDraft(conversationId, text, draftAttachments, baseDraftUpdatedAt) {
737
+ return updateConversationDraftApi(conversationId, {
738
+ draftText: text,
739
+ clientId: CLIENT_ID,
740
+ draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
741
+ baseDraftUpdatedAt,
742
+ ...(draftAttachments === undefined ? {} : { draftAttachments }),
743
+ });
744
+ }
745
+
746
+ function acknowledgeSavedDraft(conversationId, response, sentText) {
747
+ const serverText = String(response?.draftText || '');
748
+ const updatedAt = response?.draftUpdatedAt || response?.updatedAt || null;
749
+ // The server stores whitespace-only text as "no draft"; remembering what was
750
+ // sent keeps that composer from looking modified against the empty echo.
751
+ const text = !serverText && !String(sentText || '').trim() ? String(sentText || '') : serverText;
752
+ recordSyncedDraft(conversationId, { text, attachments: response?.draftAttachments, updatedAt });
753
+ upsertConversationDraftState(conversationId, {
754
+ draftText: serverText,
755
+ draftAttachments: response?.draftAttachments,
756
+ draftUpdatedAt: updatedAt,
757
+ draftUpdatedByClientId: response?.draftUpdatedByClientId || response?.senderClientId || null,
758
+ });
759
+ }
760
+
761
+ function applyDraftToComposer(conversationId, text, draftAttachments = undefined) {
762
+ if (!isCurrentConversation(conversationId)) return;
763
+ if (Array.isArray(draftAttachments)) {
764
+ const hydrated = hydrateDraftAttachments(draftAttachments);
765
+ if (!draftAttachmentsEqual(selectedAttachments, hydrated)) setComposerAttachments(hydrated);
766
+ }
767
+ const input = document.getElementById('msg-input');
768
+ if (input && draftTextForSync(input.value) !== text) {
769
+ input.value = text;
770
+ autoResize(input);
771
+ }
772
+ syncSendButtonState();
773
+ }
774
+
775
+ function adoptServerDraft(conversationId, {
776
+ draftText = '',
777
+ draftAttachments = undefined,
778
+ draftUpdatedAt = null,
779
+ draftUpdatedByClientId = null,
780
+ } = {}) {
781
+ const text = String(draftText || '');
782
+ clearDraftTimerForConversation(conversationId);
783
+ recordSyncedDraft(conversationId, { text, attachments: draftAttachments, updatedAt: draftUpdatedAt });
784
+ upsertConversationDraftState(conversationId, { draftText: text, draftAttachments, draftUpdatedAt, draftUpdatedByClientId });
785
+ applyDraftToComposer(conversationId, text, draftAttachments);
786
+ }
787
+
788
+ // The draft that lost the last conflict, per conversation, so it can be put
789
+ // back. It lives outside the toast: a later notice may cover the toast, and
790
+ // the action reads the slot when tapped. Cleared when the draft is sent.
791
+ const restorableDraftByConversation = new Map();
792
+
793
+ function draftHasContent({ text = '', attachments = undefined } = {}) {
794
+ return !!String(text || '').trim() || (Array.isArray(attachments) && attachments.length > 0);
795
+ }
796
+
797
+ function currentComposerDraft(conversationId) {
798
+ if (isCurrentConversation(conversationId)) {
799
+ return {
800
+ text: String(document.getElementById('msg-input')?.value || ''),
801
+ attachments: serializeDraftAttachments(selectedAttachments),
802
+ };
803
+ }
804
+ const conversation = conversations[conversationId] || {};
805
+ return {
806
+ text: String(conversation.draftText || ''),
807
+ attachments: Array.isArray(conversation.draftAttachments) ? conversation.draftAttachments : [],
808
+ };
809
+ }
810
+
811
+ function offerRestorableDraft(conversationId, message, actionLabel) {
812
+ showTransientRelayNotice(message, 20000, {
813
+ actionLabel,
814
+ onAction: () => swapRestorableDraft(conversationId),
815
+ });
816
+ }
817
+
818
+ function keepLosingDraft(conversationId, losingDraft) {
819
+ if (!draftHasContent(losingDraft)) return;
820
+ restorableDraftByConversation.set(String(conversationId || '').trim(), losingDraft);
821
+ offerRestorableDraft(conversationId, 'Your edit replaced a newer draft from another device.', 'Restore');
822
+ }
823
+
824
+ // Restore is a swap: the draft it replaces takes the slot, so the offer can
825
+ // always be reversed.
826
+ function swapRestorableDraft(conversationId) {
827
+ const id = String(conversationId || '').trim();
828
+ const restored = restorableDraftByConversation.get(id);
829
+ if (!restored) return;
830
+ restorableDraftByConversation.set(id, currentComposerDraft(id));
831
+ const text = String(restored.text || '');
832
+ applyDraftToComposer(id, text, restored.attachments);
833
+ void scheduleConversationDraftSave({
834
+ conversationId: id,
835
+ draftText: text,
836
+ draftAttachments: Array.isArray(restored.attachments) ? restored.attachments : undefined,
837
+ immediate: true,
838
+ });
839
+ offerRestorableDraft(id, 'Draft restored.', 'Undo');
840
+ }
841
+
842
+ // A 409 means another client saved since this one's base version. The local
843
+ // composer is re-read here (not the text the save carried): it may have moved
844
+ // on while the request was in flight, and the newest keystroke is what wins.
845
+ async function resolveDraftSaveConflict(id, { text, draftAttachments, synced, conflict }) {
846
+ const serverText = String(conflict.draftText || '');
847
+ const serverAttachments = Array.isArray(conflict.draftAttachments) ? conflict.draftAttachments : undefined;
848
+ const viewing = isCurrentConversation(id);
849
+ const localText = viewing ? draftTextForSync(document.getElementById('msg-input')?.value ?? text) : text;
850
+ const localAttachments = draftAttachments === undefined
851
+ ? undefined
852
+ : (viewing ? serializeDraftAttachments(selectedAttachments) : draftAttachments);
853
+ let outcome = resolveDraftConflict({
854
+ localText,
855
+ localAttachmentsKey: draftAttachmentsKey(localAttachments),
856
+ baseText: synced ? synced.text : null,
857
+ baseAttachmentsKey: synced ? synced.attachmentsKey : null,
858
+ serverText,
859
+ serverAttachmentsKey: draftAttachmentsKey(serverAttachments),
860
+ });
861
+ // Uploads in flight are not in the fingerprint; adopting would replace the
862
+ // attachment list and drop them, so they count as a local edit.
863
+ if (outcome === 'adopt' && viewing && hasUploadingAttachments(selectedAttachments)) outcome = 'keep-local';
864
+ if (outcome === 'adopt') {
865
+ adoptServerDraft(id, conflict);
866
+ return conflict;
867
+ }
868
+ recordSyncedDraft(id, { text: serverText, attachments: serverAttachments, updatedAt: conflict.draftUpdatedAt || null });
869
+ if (outcome === 'converged') {
870
+ upsertConversationDraftState(id, {
871
+ draftText: serverText,
872
+ draftAttachments: serverAttachments,
873
+ draftUpdatedAt: conflict.draftUpdatedAt || null,
874
+ draftUpdatedByClientId: conflict.draftUpdatedByClientId || null,
875
+ });
876
+ return conflict;
877
+ }
878
+ const retry = await sendConversationDraft(id, localText, localAttachments, conflict.draftUpdatedAt || null);
879
+ if (retry?.ok) {
880
+ acknowledgeSavedDraft(id, retry, localText);
881
+ const localKey = draftAttachmentsKey(localAttachments);
882
+ const lostSomething = serverText !== localText
883
+ || (localKey !== null && localKey !== draftAttachmentsKey(serverAttachments));
884
+ if (lostSomething) keepLosingDraft(id, { text: serverText, attachments: serverAttachments });
885
+ return retry;
886
+ }
887
+ // Lost a second race. The base stays at the first conflict's version, so the
888
+ // retry scheduled here conflicts again and resolves (with its restore offer)
889
+ // through this same path instead of leaving the edit unsaved.
890
+ if (isDraftVersionConflict(retry)) {
891
+ void scheduleConversationDraftSave({ conversationId: id, draftText: localText, draftAttachments: localAttachments });
892
+ }
893
+ return retry || null;
894
+ }
895
+
896
+ async function persistConversationDraft(conversationId, draftText, draftAttachments = undefined, { force = false } = {}) {
498
897
  const id = String(conversationId || '').trim();
499
898
  if (!id) return null;
500
899
  const text = String(draftText || '');
501
900
  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;
901
+ const synced = getSyncedDraft(id);
902
+ if (!force && isDraftSaveNoop({
903
+ synced,
904
+ text,
905
+ attachmentsKey: draftAttachmentsKey(draftAttachments),
906
+ knownUpdatedAt: conversations[id]?.draftUpdatedAt || null,
907
+ })) {
908
+ return { ok: true, skipped: true, conversationId: id, draftText: synced.text, draftUpdatedAt: synced.updatedAt };
520
909
  }
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;
910
+ const response = await sendConversationDraft(id, text, draftAttachments, draftSaveBaseVersion(id));
911
+ if (response?.ok) {
912
+ acknowledgeSavedDraft(id, response, text);
913
+ return response;
914
+ }
915
+ if (isDraftVersionConflict(response)) {
916
+ return resolveDraftSaveConflict(id, { text, draftAttachments, synced, conflict: response });
917
+ }
918
+ return response || null;
528
919
  };
529
920
  const previous = draftSavePromiseByConversation.get(id) || Promise.resolve();
530
921
  const next = previous
@@ -544,19 +935,20 @@ async function scheduleConversationDraftSave({
544
935
  draftText,
545
936
  draftAttachments = undefined,
546
937
  immediate = false,
938
+ force = false,
547
939
  } = {}) {
548
940
  const id = String(conversationId || '').trim();
549
941
  if (!id) return null;
550
- const text = String(draftText || '');
942
+ const text = draftTextForSync(draftText);
551
943
  upsertConversationDraftState(id, { draftText: text, draftAttachments });
552
944
  clearDraftTimerForConversation(id);
553
945
  if (immediate) {
554
- return persistConversationDraft(id, text, draftAttachments);
946
+ return persistConversationDraft(id, text, draftAttachments, { force });
555
947
  }
556
948
  return new Promise((resolve) => {
557
949
  const timer = setTimeout(() => {
558
950
  draftSaveTimerByConversation.delete(id);
559
- persistConversationDraft(id, text, draftAttachments).then(resolve).catch(() => resolve(null));
951
+ persistConversationDraft(id, text, draftAttachments, { force }).then(resolve).catch(() => resolve(null));
560
952
  }, COMPOSER_DRAFT_DEBOUNCE_MS);
561
953
  draftSaveTimerByConversation.set(id, timer);
562
954
  });
@@ -567,6 +959,11 @@ export async function flushConversationDraft(conversationId = currentConvId) {
567
959
  if (!id) return null;
568
960
  const input = document.getElementById('msg-input');
569
961
  const draftText = String((id === String(currentConvId || '').trim() && input) ? input.value : (conversations[id]?.draftText || ''));
962
+ if (isDraftOwnedByPendingSend(id)) {
963
+ // Held: the send saves the cached text once it settles.
964
+ upsertConversationDraftState(id, { draftText: draftTextForSync(draftText) });
965
+ return null;
966
+ }
570
967
  return scheduleConversationDraftSave({
571
968
  conversationId: id,
572
969
  draftText,
@@ -574,6 +971,46 @@ export async function flushConversationDraft(conversationId = currentConvId) {
574
971
  });
575
972
  }
576
973
 
974
+ // Shared tail of both incoming-draft paths (conversation load/poll and the
975
+ // cross-client broadcast) for the conversation on screen. `incoming.attachments`
976
+ // is undefined when the update did not carry attachments.
977
+ function reconcileIncomingComposerDraft(id, incoming) {
978
+ const input = document.getElementById('msg-input');
979
+ if (!input) return;
980
+ const synced = getSyncedDraft(id);
981
+ const composerAttachments = serializeDraftAttachments(selectedAttachments);
982
+ if (!shouldApplyIncomingDraftToComposer({
983
+ inputText: input.value,
984
+ incomingText: incoming.text,
985
+ syncedText: synced ? synced.text : null,
986
+ inputAttachmentsKey: draftAttachmentsKey(composerAttachments),
987
+ incomingAttachmentsKey: draftAttachmentsKey(incoming.attachments),
988
+ syncedAttachmentsKey: synced ? synced.attachmentsKey : null,
989
+ attachmentsUploading: hasUploadingAttachments(selectedAttachments),
990
+ })) {
991
+ // An unsaved local edit (typing, an attachment change, or a flush that
992
+ // failed). Re-save it, attachments included, so the version check
993
+ // decides: the same version saves, a newer one conflicts.
994
+ if (!isDraftSavePending(id)) {
995
+ void scheduleConversationDraftSave({ conversationId: id, draftText: input.value, draftAttachments: composerAttachments });
996
+ }
997
+ syncSendButtonState();
998
+ return;
999
+ }
1000
+ recordSyncedDraft(id, incoming);
1001
+ if (Array.isArray(incoming.attachments)) {
1002
+ const hydrated = hydrateDraftAttachments(incoming.attachments);
1003
+ if (!draftAttachmentsEqual(selectedAttachments, hydrated)) setComposerAttachments(hydrated);
1004
+ }
1005
+ // A composer longer than the draft limit matches its truncated echo; it must
1006
+ // not be cut down to it.
1007
+ if (draftTextForSync(input.value) !== incoming.text) {
1008
+ input.value = incoming.text;
1009
+ autoResize(input);
1010
+ }
1011
+ syncSendButtonState();
1012
+ }
1013
+
577
1014
  export function hydrateConversationDraft(conversationId, {
578
1015
  draftText = '',
579
1016
  draftAttachments = [],
@@ -595,23 +1032,12 @@ export function hydrateConversationDraft(conversationId, {
595
1032
  draftUpdatedAt,
596
1033
  draftUpdatedByClientId,
597
1034
  });
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();
1035
+ const syncedDraft = { text: normalizedDraftText, attachments: normalizedAttachments, updatedAt: draftUpdatedAt };
1036
+ if (String(currentConvId || '').trim() !== id) {
1037
+ if (!isDraftSavePending(id)) recordSyncedDraft(id, syncedDraft);
608
1038
  return;
609
1039
  }
610
- if (input.value !== normalizedDraftText) {
611
- input.value = normalizedDraftText;
612
- autoResize(input);
613
- }
614
- syncSendButtonState();
1040
+ reconcileIncomingComposerDraft(id, syncedDraft);
615
1041
  }
616
1042
 
617
1043
  export function applyIncomingConversationDraftUpdate({
@@ -638,26 +1064,12 @@ export function applyIncomingConversationDraftUpdate({
638
1064
  draftUpdatedAt,
639
1065
  draftUpdatedByClientId: draftUpdatedByClientId || senderClientId || null,
640
1066
  });
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);
1067
+ const syncedDraft = { text: incomingDraftText, attachments: draftAttachments, updatedAt: draftUpdatedAt };
1068
+ if (String(currentConvId || '').trim() !== id) {
1069
+ if (!isDraftSavePending(id)) recordSyncedDraft(id, syncedDraft);
1070
+ return;
659
1071
  }
660
- syncSendButtonState();
1072
+ reconcileIncomingComposerDraft(id, syncedDraft);
661
1073
  }
662
1074
 
663
1075
  function setConversationTurnState(conversationId, state = null) {
@@ -830,6 +1242,19 @@ function createMessageNode(msg, msgId = null, force = false) {
830
1242
  const continuationTag = (msg.role === 'assistant' && String(msg?.kind || '').trim() === 'continuation')
831
1243
  ? ' <span class="msg-continuation" title="The agent continued on its own after a background task finished.">background continuation</span>'
832
1244
  : '';
1245
+ // A steering absorption: this reply continues through the next (steered)
1246
+ // user message. The class feeds syncSteeredTurnMerge, which joins the trio
1247
+ // into one visual flow.
1248
+ const messageKind = String(msg?.kind || '').trim();
1249
+ if (msg.role === 'assistant' && messageKind === 'absorbed') {
1250
+ div.classList.add(ABSORBED_MSG_CLASS);
1251
+ }
1252
+ // The settle marker of a steer the CLI consumed without a turn of its own:
1253
+ // folded into (and answered by) the turn above, or cut off unanswered by a
1254
+ // Stop. Compact, attached to its own user message, never merging forward.
1255
+ const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
1256
+ if (steerMarkerClass) div.classList.add(steerMarkerClass);
1257
+ const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
833
1258
  // A turn answered by a different provider than the conversation is bound to
834
1259
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
835
1260
  // not silent: it ran on another plan than the header indicates.
@@ -907,7 +1332,15 @@ function createMessageNode(msg, msgId = null, force = false) {
907
1332
  : 'msg-bubble';
908
1333
 
909
1334
  const isQueuedUserMessage = msg.role === 'user' && msgId && pendingUserMessageIds.has(msgId);
910
- const isCancelInFlight = isQueuedUserMessage && bubbleCancelInFlight.has(msgId);
1335
+ // A row the worker pushed into its runtime but reports as still unconsumed
1336
+ // (Copilot's queued lane, heartbeat `cancellableIds`) keeps Cancel too: the
1337
+ // worker pulls it back out and the row ends cancelled, never answered.
1338
+ const isCancellableSteer = !isQueuedUserMessage
1339
+ && msg.role === 'user'
1340
+ && !!msgId
1341
+ && cancellableSteerIdsForConversation(currentConvId).has(msgId);
1342
+ const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
1343
+ const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
911
1344
  const hiddenFromShares = msg?.hiddenFromShares === true;
912
1345
  const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
913
1346
  const sourceMessageId = String(msg?.sourceMessageId || '').trim();
@@ -931,12 +1364,20 @@ function createMessageNode(msg, msgId = null, force = false) {
931
1364
  .map((item) => `<button type="button" class="bubble-action-btn" data-action="relay-error-cta" data-cta="${escHtml(item.action)}">${escHtml(item.label)}</button>`)
932
1365
  .join('')}</div>`
933
1366
  : '';
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>`
1367
+ const userBubbleActionsHtml = (!IS_SHARED_VIEW && showsCancelButton)
1368
+ ? buildUserBubbleCancelActionsMarkup(msgId, isCancelInFlight)
1369
+ : '';
1370
+ // Resent per the relay (any device, survives reloads), or by this page.
1371
+ const resendState = isStoppedSteer && msgId
1372
+ ? (stoppedSteerResends.get(msgId) || (String(msg?.resentAs || '').trim() ? { status: 'sent' } : null))
1373
+ : null;
1374
+ const resendLabel = resendState?.status === 'sent' ? 'Resent' : (resendState ? 'Resending…' : 'Resend');
1375
+ const stoppedSteerActionsHtml = (!IS_SHARED_VIEW && isStoppedSteer && msgId)
1376
+ ? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="resend-stopped-steer" data-message-id="${escHtml(msgId)}" title="Send this message again as a new turn"${resendState ? ' disabled' : ''}>${resendLabel}</button></div>`
936
1377
  : '';
937
1378
 
938
1379
  div.innerHTML = `
939
- <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}</div>
1380
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
940
1381
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
941
1382
 
942
1383
  const bubble = div.querySelector('.msg-bubble');
@@ -971,6 +1412,16 @@ function createMessageNode(msg, msgId = null, force = false) {
971
1412
  }
972
1413
  div.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
973
1414
  attachCodeCopyButtons(div);
1415
+ // What a Resend of this message would send, kept on the node itself so it
1416
+ // lives exactly as long as the row is on screen.
1417
+ if (msg.role === 'user') {
1418
+ const { references, unavailable } = attachmentReferencesForResend(attachments);
1419
+ div[RESEND_PAYLOAD_KEY] = {
1420
+ text: String(msg.text || ''),
1421
+ attachments: references,
1422
+ unavailableAttachments: unavailable,
1423
+ };
1424
+ }
974
1425
  return div;
975
1426
  }
976
1427
 
@@ -1032,34 +1483,7 @@ export function decorateActivityText(text) {
1032
1483
  return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
1033
1484
  };
1034
1485
  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}`;
1486
+ return prefixToolActivityEmoji(sharedSafeValue);
1063
1487
  }
1064
1488
 
1065
1489
  export function renderThoughtsMarkup(thoughts) {
@@ -1252,11 +1676,19 @@ function anchorThinkingBubble(div, messageId) {
1252
1676
  }
1253
1677
  }
1254
1678
 
1679
+ // Which conversation a live bubble belongs to. #messages is shared by every
1680
+ // conversation, so a bubble is only ever reused or preserved for its own.
1681
+ function thinkingConversationKey() {
1682
+ return String(currentConvId || '').trim() || '__new__';
1683
+ }
1684
+
1255
1685
  export function showThinking(messageId = null, autoScroll = true) {
1256
1686
  const nextMessageId = String(messageId || '').trim();
1257
1687
  if (nextMessageId) thinkingMessageId = nextMessageId;
1258
1688
  const existing = document.getElementById('thinking-indicator');
1259
- if (existing && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
1689
+ if (existing
1690
+ && existing.dataset.conversationId === thinkingConversationKey()
1691
+ && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
1260
1692
  // Reuse the live bubble: rebuilding it every poll tick destroys any text
1261
1693
  // selection anchored inside and drops in-progress subagent bubbles.
1262
1694
  const stopBtn = existing.querySelector('[data-action="stop-turn"]');
@@ -1274,6 +1706,7 @@ export function showThinking(messageId = null, autoScroll = true) {
1274
1706
  const div = document.createElement('div');
1275
1707
  div.className = 'msg assistant';
1276
1708
  div.id = 'thinking-indicator';
1709
+ div.dataset.conversationId = thinkingConversationKey();
1277
1710
  if (nextMessageId) div.dataset.messageId = nextMessageId;
1278
1711
  const isCancelInFlight = nextMessageId && bubbleCancelInFlight.has(nextMessageId);
1279
1712
  const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
@@ -1947,6 +2380,7 @@ export function applyConversationTurnStatus({ conversationId, messageId, status
1947
2380
  const conversationKey = String(conversationId || '').trim();
1948
2381
  const messageKey = String(messageId || '').trim();
1949
2382
  const normalizedStatus = String(status || '').trim().toLowerCase();
2383
+ if (['cancelled', 'failed'].includes(normalizedStatus)) releaseStoppedSteerResendsOf(messageKey);
1950
2384
  if (!conversationKey) {
1951
2385
  syncSendButtonState();
1952
2386
  return;
@@ -1970,58 +2404,9 @@ export function applyConversationTurnStatus({ conversationId, messageId, status
1970
2404
  syncSendButtonState();
1971
2405
  }
1972
2406
 
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
-
2407
+ // Stopping a turn lives on the message bubbles only (the live turn's Stop and
2408
+ // pending rows' Cancel). The composer's send button never doubles as Stop —
2409
+ // the old stopCurrentConversationTurn composer path is gone with it.
2025
2410
  async function stopTurnByMessageId(conversationId, messageId) {
2026
2411
  const conversationKey = String(conversationId || '').trim();
2027
2412
  const targetMessageId = String(messageId || '').trim();
@@ -2099,6 +2484,12 @@ function updateBubbleStopButton(messageId, isStopping) {
2099
2484
  btn.classList.toggle('stopping', isStopping);
2100
2485
  }
2101
2486
 
2487
+ // The Cancel control of a user bubble: a pending row's, or a pushed row's the
2488
+ // worker can still pull back (both post cancel-queued-turn).
2489
+ function buildUserBubbleCancelActionsMarkup(messageId, isCancelling) {
2490
+ return `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn${isCancelling ? ' stopping' : ''}" data-action="cancel-queued" data-message-id="${escHtml(messageId)}"${isCancelling ? ' disabled' : ''}>${isCancelling ? 'Cancelling…' : 'Cancel'}</button></div>`;
2491
+ }
2492
+
2102
2493
  function updateUserBubbleCancelButton(messageId, isCancelling) {
2103
2494
  const btn = document.querySelector(`.bubble-action-btn[data-message-id="${messageId}"][data-action="cancel-queued"]`);
2104
2495
  if (!btn) return;
@@ -2114,10 +2505,239 @@ export function removeUserBubbleCancelButton(messageId) {
2114
2505
  if (actionsContainer) actionsContainer.remove();
2115
2506
  }
2116
2507
 
2508
+ function clearUnsteerFallbackTimer(messageId) {
2509
+ const timer = unsteerFallbackTimers.get(messageId);
2510
+ if (timer === undefined) return;
2511
+ clearTimeout(timer);
2512
+ unsteerFallbackTimers.delete(messageId);
2513
+ }
2514
+
2117
2515
  export function clearBubbleCancelState(messageId) {
2118
2516
  const id = String(messageId || '').trim();
2119
2517
  if (!id) return;
2120
2518
  bubbleCancelInFlight.delete(id);
2519
+ clearUnsteerFallbackTimer(id);
2520
+ }
2521
+
2522
+ // Keeps the Cancel controls of the viewed conversation's pushed rows in step
2523
+ // with what its worker reports as still un-steerable. Bubbles are built once
2524
+ // (renderMessages skips unchanged payloads), and the worker's set changes on
2525
+ // its own clock (the heartbeat), so the status refresh calls this next to the
2526
+ // composer sync. Pending rows own their Cancel and are left alone; a row whose
2527
+ // un-steer is still being acknowledged keeps its "Cancelling…" until the
2528
+ // answer or the fallback arrives.
2529
+ export function syncCancellableSteerButtons() {
2530
+ const el = getMessagesElement();
2531
+ if (!el || IS_SHARED_VIEW) return;
2532
+ const cancellableIds = cancellableSteerIdsForConversation(currentConvId);
2533
+ for (const rowNode of el.querySelectorAll('.msg.user[data-message-id]')) {
2534
+ const messageId = String(rowNode.dataset.messageId || '').trim();
2535
+ if (!messageId || pendingUserMessageIds.has(messageId)) continue;
2536
+ const button = rowNode.querySelector('.msg-bubble-actions [data-action="cancel-queued"]');
2537
+ if (cancellableIds.has(messageId)) {
2538
+ if (button) continue;
2539
+ const bubble = rowNode.querySelector('.msg-bubble');
2540
+ if (!bubble) continue;
2541
+ bubble.insertAdjacentHTML('beforeend', buildUserBubbleCancelActionsMarkup(messageId, bubbleCancelInFlight.has(messageId)));
2542
+ continue;
2543
+ }
2544
+ if (button && !bubbleCancelInFlight.has(messageId)) button.closest('.msg-bubble-actions')?.remove();
2545
+ }
2546
+ }
2547
+
2548
+ // The relay forwarded an un-steer to the worker ('cancel-requested'): the row
2549
+ // settles through the socket (message_status 'cancelled' removes the control;
2550
+ // an answer after all re-renders it away). If neither lands, hand the control
2551
+ // back — as "Cancel" when the worker still lists the row, gone otherwise.
2552
+ function armUnsteerFallback(messageId) {
2553
+ clearUnsteerFallbackTimer(messageId);
2554
+ const timer = setTimeout(() => {
2555
+ unsteerFallbackTimers.delete(messageId);
2556
+ bubbleCancelInFlight.delete(messageId);
2557
+ if (cancellableSteerIdsForConversation(currentConvId).has(messageId)) {
2558
+ updateUserBubbleCancelButton(messageId, false);
2559
+ } else {
2560
+ removeUserBubbleCancelButton(messageId);
2561
+ }
2562
+ }, UNSTEER_ACK_FALLBACK_MS);
2563
+ timer?.unref?.();
2564
+ unsteerFallbackTimers.set(messageId, timer);
2565
+ }
2566
+
2567
+ // Resend of a steer the user's Stop cut off unanswered (kind='stopped').
2568
+ // Keyed by the marker row: one Resend per marker per page life — a second tap
2569
+ // while the first is in flight, or after it landed, does nothing. The relay's
2570
+ // duplicate guard backs this across reloads (a second Resend of the same text
2571
+ // collides with the first).
2572
+ const stoppedSteerResends = new Map();
2573
+ const RESEND_PAYLOAD_KEY = '__relayResendPayload';
2574
+
2575
+ // Only uploads (by sha256) can be re-referenced; anything else — a legacy
2576
+ // inline image — is counted so the user is told it was left out.
2577
+ function attachmentReferencesForResend(attachments) {
2578
+ const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
2579
+ const references = list
2580
+ .map((attachment) => {
2581
+ const sha256 = String(attachment?.sha256 || '').trim().toLowerCase();
2582
+ if (!sha256) return null;
2583
+ return {
2584
+ sha256,
2585
+ name: String(attachment?.name || '').trim() || undefined,
2586
+ type: String(attachment?.type || '').trim() || undefined,
2587
+ };
2588
+ })
2589
+ .filter(Boolean);
2590
+ return { references, unavailable: list.length - references.length };
2591
+ }
2592
+
2593
+ // The user message a stopped marker settles. By its source id whenever the
2594
+ // marker names one — if that row is not loaded (a page boundary) there is no
2595
+ // guessing, since the nearest user row above may be another stopped steer.
2596
+ // Only a marker without a source falls back to the user row right above it.
2597
+ function findStoppedSteerSource(markerNode) {
2598
+ const el = getMessagesElement();
2599
+ if (!el || !markerNode) return null;
2600
+ const sourceId = String(markerNode.dataset.sourceMessageId || '').trim();
2601
+ if (sourceId) {
2602
+ return el.querySelector(`.msg.user[data-message-id="${CSS.escape(sourceId)}"]`) || null;
2603
+ }
2604
+ for (let prev = markerNode.previousElementSibling; prev; prev = prev.previousElementSibling) {
2605
+ if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
2606
+ if (prev.classList?.contains('msg') && prev.classList.contains('user')) return prev;
2607
+ }
2608
+ return null;
2609
+ }
2610
+
2611
+ function syncStoppedSteerResendButton(markerId) {
2612
+ const btn = document.querySelector(`.bubble-action-btn[data-message-id="${CSS.escape(markerId)}"][data-action="resend-stopped-steer"]`);
2613
+ if (!btn) return;
2614
+ const state = stoppedSteerResends.get(markerId);
2615
+ btn.disabled = !!state;
2616
+ btn.textContent = state?.status === 'sent' ? 'Resent' : (state ? 'Resending…' : 'Resend');
2617
+ }
2618
+
2619
+ // The Resend a marker's "Resent" rests on was cancelled or failed: the
2620
+ // original may be resent again, as the relay agrees.
2621
+ function releaseStoppedSteerResendsOf(messageId) {
2622
+ const id = String(messageId || '').trim();
2623
+ if (!id) return;
2624
+ for (const [markerId, entry] of stoppedSteerResends) {
2625
+ if (entry?.status !== 'sent' || entry.resentId !== id) continue;
2626
+ stoppedSteerResends.delete(markerId);
2627
+ syncStoppedSteerResendButton(markerId);
2628
+ }
2629
+ }
2630
+
2631
+ async function resendStoppedSteer(conversationId, markerId) {
2632
+ const conversationKey = String(conversationId || '').trim();
2633
+ const id = String(markerId || '').trim();
2634
+ if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
2635
+ const markerNode = getMessagesElement()?.querySelector(`.msg.assistant[data-message-id="${CSS.escape(id)}"]`);
2636
+ const sourceNode = findStoppedSteerSource(markerNode);
2637
+ const payload = sourceNode?.[RESEND_PAYLOAD_KEY] || null;
2638
+ const text = String(payload?.text || '').trim();
2639
+ const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
2640
+ if (!payload) {
2641
+ showTransientRelayNotice('The original message is not loaded — scroll up to it, then try Resend again.');
2642
+ return;
2643
+ }
2644
+ if (!text && !attachments.length) {
2645
+ showTransientRelayNotice('Nothing left to resend: the original message\'s attachments are no longer available.');
2646
+ return;
2647
+ }
2648
+ // Claimed before the first await: this is what makes a double tap a no-op.
2649
+ stoppedSteerResends.set(id, { status: 'sending' });
2650
+ syncStoppedSteerResendButton(id);
2651
+ const clientMessageId = generateId();
2652
+ let sent = false;
2653
+ // The message that now stands as this marker's Resend: a cancel or failure
2654
+ // of it releases the page's "Resent" (releaseStoppedSteerResendsOf).
2655
+ let resentId = clientMessageId;
2656
+ let bubbleAppended = false;
2657
+ const dropOptimisticBubble = () => {
2658
+ clearPendingUserMessage(clientMessageId);
2659
+ pendingUserMessageIds.delete(clientMessageId);
2660
+ seenMessageIds.delete(clientMessageId);
2661
+ if (bubbleAppended) document.querySelector(`.msg[data-message-id="${CSS.escape(clientMessageId)}"]`)?.remove();
2662
+ };
2663
+ try {
2664
+ if (!(await validateSelectedConversationBeforeSend(conversationKey))) return;
2665
+ const selectedModel = document.getElementById('model-select')?.value || '';
2666
+ const selectedReasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
2667
+ const selectedContextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim();
2668
+ const selectedMode = document.getElementById('mode-select')?.value || 'agent';
2669
+ if (!selectedReasoningEffort) {
2670
+ showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
2671
+ return;
2672
+ }
2673
+ trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
2674
+ pendingUserMessageIds.add(clientMessageId);
2675
+ if (isCurrentConversation(conversationKey)) {
2676
+ appendMessage({
2677
+ role: 'user',
2678
+ text,
2679
+ model: selectedModel,
2680
+ mode: selectedMode,
2681
+ timestamp: new Date().toISOString(),
2682
+ attachments,
2683
+ }, true, clientMessageId, true);
2684
+ bubbleAppended = true;
2685
+ }
2686
+ const r = await sendMessageApi({
2687
+ messageId: clientMessageId,
2688
+ clientId: CLIENT_ID,
2689
+ text,
2690
+ model: selectedModel,
2691
+ reasoningEffort: selectedReasoningEffort,
2692
+ contextTier: selectedContextTier,
2693
+ relayMode: selectedMode,
2694
+ conversationId: conversationKey,
2695
+ attachments,
2696
+ resendOfMessageId: String(sourceNode?.dataset?.messageId || '').trim() || undefined,
2697
+ });
2698
+ if (!r) {
2699
+ if (!hasSendEchoArrived(clientMessageId)) {
2700
+ dropOptimisticBubble();
2701
+ showTransientRelayNotice('Could not resend the message. Please try again.');
2702
+ return;
2703
+ }
2704
+ sent = true;
2705
+ return;
2706
+ }
2707
+ if (r.duplicate) {
2708
+ dropOptimisticBubble();
2709
+ if (r.alreadyResent) {
2710
+ // Resent before (another tab or device): nothing to retry.
2711
+ sent = true;
2712
+ resentId = String(r.duplicateOfMessageId || '').trim() || null;
2713
+ showTransientRelayNotice('That message was already resent.');
2714
+ return;
2715
+ }
2716
+ // Another recent message has the same text — not a Resend of this one,
2717
+ // so the button stays available.
2718
+ showTransientRelayNotice('A message with the same text was sent in the last few minutes, so this one was not resent. Try again later.');
2719
+ return;
2720
+ }
2721
+ sent = true;
2722
+ const dropped = Number(payload.unavailableAttachments || 0) + Math.max(0, Number(r.droppedAttachmentCount) || 0);
2723
+ if (dropped > 0) {
2724
+ showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
2725
+ }
2726
+ if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
2727
+ showThinking(r.messageId || null);
2728
+ }
2729
+ } catch (error) {
2730
+ if (!hasSendEchoArrived(clientMessageId)) {
2731
+ dropOptimisticBubble();
2732
+ showTransientRelayNotice(error?.message || 'Could not resend the message.');
2733
+ } else {
2734
+ sent = true;
2735
+ }
2736
+ } finally {
2737
+ if (sent) stoppedSteerResends.set(id, { status: 'sent', resentId });
2738
+ else stoppedSteerResends.delete(id);
2739
+ syncStoppedSteerResendButton(id);
2740
+ }
2121
2741
  }
2122
2742
 
2123
2743
  async function cancelQueuedTurnByMessageId(conversationId, messageId) {
@@ -2148,6 +2768,14 @@ async function cancelQueuedTurnByMessageId(conversationId, messageId) {
2148
2768
  return;
2149
2769
  }
2150
2770
 
2771
+ if (result.acknowledgement === 'cancel-requested') {
2772
+ // The worker pulls the row out of its runtime and settles it through
2773
+ // POST /api/queue-cancelled → message_status 'cancelled'. Keep the
2774
+ // bubble in "Cancelling…" until then (or until the fallback hands it back).
2775
+ armUnsteerFallback(targetMessageId);
2776
+ return;
2777
+ }
2778
+
2151
2779
  if (result.acknowledgement === 'already-processing') {
2152
2780
  bubbleCancelInFlight.delete(targetMessageId);
2153
2781
  removeUserBubbleCancelButton(targetMessageId);
@@ -2325,6 +2953,12 @@ function handleBubbleActionClick(event) {
2325
2953
  void cancelQueuedTurnByMessageId(currentConvId, messageId);
2326
2954
  }
2327
2955
 
2956
+ if (action === 'resend-stopped-steer' && messageId) {
2957
+ event.preventDefault();
2958
+ event.stopPropagation();
2959
+ void resendStoppedSteer(currentConvId, messageId);
2960
+ }
2961
+
2328
2962
  if (action === 'stop-subagent' && subagentRunId) {
2329
2963
  event.preventDefault();
2330
2964
  event.stopPropagation();
@@ -2440,6 +3074,10 @@ function buildMessageSnapshotKey(messages = [], meta = {}) {
2440
3074
  mode: String(item?.mode || '').trim(),
2441
3075
  attachments: Array.isArray(item?.attachments) ? item.attachments.length : 0,
2442
3076
  hiddenFromShares: item?.hiddenFromShares === true,
3077
+ // The continuation badge and the absorbed (steering) merge both hang
3078
+ // off kind; a payload differing only in it must not short-circuit.
3079
+ kind: String(item?.kind || '').trim(),
3080
+ resentAs: String(item?.resentAs || '').trim(),
2443
3081
  // Without this, a payload that differs from the last render only by a
2444
3082
  // newly linked compaction activity short-circuits below and the break
2445
3083
  // row never appears until some unrelated field changes.
@@ -2491,7 +3129,62 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
2491
3129
  .map((item) => [String(item?.id || '').trim(), item])
2492
3130
  .filter(([id]) => !!id),
2493
3131
  );
2494
- if (!ordered.length) {
3132
+ // A payload that already carries this page's Resend but no resentAs on its
3133
+ // marker is newer than the page's provisional "Resent": the Resend was
3134
+ // cancelled or failed (possibly on another device), so the data wins.
3135
+ for (const item of ordered) {
3136
+ if (item?.role !== 'assistant' || String(item?.kind || '').trim() !== 'stopped') continue;
3137
+ const markerId = String(item?.id || '').trim();
3138
+ const entry = stoppedSteerResends.get(markerId);
3139
+ if (entry?.status === 'sent' && entry.resentId && messageById.has(entry.resentId) && !String(item?.resentAs || '').trim()) {
3140
+ stoppedSteerResends.delete(markerId);
3141
+ }
3142
+ }
3143
+ // Preserve optimistic pending user bubbles the payload does not yet carry.
3144
+ // A message is persisted the instant it is sent, but a poll whose response
3145
+ // predates the send returns a payload without it; rebuilding from that stale
3146
+ // payload would drop the just-sent bubble until the next poll catches up
3147
+ // (it "vanishes then reappears"). Steering makes this visible because the
3148
+ // absorbed-reply broadcast forces extra rebuilds during the send window.
3149
+ // Capturing the live DOM nodes keeps their exact content and handlers; the
3150
+ // wipe below orphans them but the references keep them alive to re-append.
3151
+ // Matches store.js pendingConversationKey (trimmed id, or '__new__').
3152
+ const renderConversationKey = String(meta.conversationId || currentConvId || '').trim() || '__new__';
3153
+ const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
3154
+ const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
3155
+ ? meta.hasMoreNewer
3156
+ : !!pageInfo?.hasMoreNewer;
3157
+ const preservedPendingNodes = [];
3158
+ // A kept pending bubble re-joins at the end of the loaded window, which is
3159
+ // only the newest end when nothing newer is left unloaded (a search jump
3160
+ // loads a window in the middle of the history).
3161
+ for (const id of (hasMoreNewer ? [] : pendingUserMessageIds)) {
3162
+ const pendingId = String(id || '').trim();
3163
+ if (!pendingId || messageById.has(pendingId)) continue;
3164
+ // Only this conversation's pending bubbles: a bubble still waiting in
3165
+ // another conversation must never be carried into the one being rendered
3166
+ // (a switch would otherwise drag it along).
3167
+ const entry = pendingUserMessageEntries.get(pendingId);
3168
+ if (String(entry?.conversationKey || '').trim() !== renderConversationKey) continue;
3169
+ const node = el.querySelector(`[data-message-id="${CSS.escape(pendingId)}"]`);
3170
+ if (node) preservedPendingNodes.push(node);
3171
+ }
3172
+ // Preserve the live thinking bubble across the wipe. It holds in-progress
3173
+ // thoughts, streamed text and subagent bubbles that a full re-derive would
3174
+ // lose (or briefly blank while restoreInFlightThinking rebuilds from the
3175
+ // server's inFlight snapshot). Re-appending the same node lets that restore
3176
+ // update it in place instead of rebuilding — the same reuse showThinking
3177
+ // already relies on. removeThinking() still drops it when the turn ends.
3178
+ const thinkingNode = document.getElementById('thinking-indicator');
3179
+ // Only when it genuinely lives in the transcript being rebuilt and belongs
3180
+ // to the conversation being rendered: a detached node must not be spliced
3181
+ // in as an extra row, and another conversation's bubble must not leak into
3182
+ // this one (it is dropped instead, so the next restore builds afresh).
3183
+ const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
3184
+ const preservedThinkingNode = (thinkingNodeInTranscript
3185
+ && thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
3186
+ if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
3187
+ if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
2495
3188
  el.innerHTML = `<div class="empty-state">
2496
3189
  <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
2497
3190
  <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
@@ -2504,13 +3197,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
2504
3197
  return true;
2505
3198
  }
2506
3199
  const conversationId = String(meta.conversationId || currentConvId || '').trim();
2507
- const pageInfo = meta.pageInfo && typeof meta.pageInfo === 'object' ? meta.pageInfo : null;
2508
3200
  const hasMoreOlder = typeof meta.hasMoreOlder === 'boolean'
2509
3201
  ? meta.hasMoreOlder
2510
3202
  : (typeof meta.hasMoreHistory === 'boolean' ? meta.hasMoreHistory : !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore);
2511
- const hasMoreNewer = typeof meta.hasMoreNewer === 'boolean'
2512
- ? meta.hasMoreNewer
2513
- : !!pageInfo?.hasMoreNewer;
2514
3203
  const oldestMessageId = String(
2515
3204
  meta.historyCursor
2516
3205
  || pageInfo?.olderCursor?.beforeMessageId
@@ -2559,6 +3248,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
2559
3248
  });
2560
3249
  withSuspendedSeparatorSync(() => {
2561
3250
  for (const m of ordered) appendMessage(m, false, m.id || null, true, getMessageThreadAnchor(m, messageById), false);
3251
+ // A pending bubble the payload lacks is the newest message, so it re-joins
3252
+ // at the end. Re-appending the original node (not a fresh one) keeps its
3253
+ // cancel button and click handlers intact.
3254
+ for (const node of preservedPendingNodes) el.appendChild(node);
3255
+ // The live thinking bubble re-joins under its owning message right here:
3256
+ // restoreInFlightThinking skips an unchanged snapshot, so waiting for it
3257
+ // to re-anchor left the bubble at the end, below later (steered) rows.
3258
+ if (preservedThinkingNode) {
3259
+ el.appendChild(preservedThinkingNode);
3260
+ anchorThinkingBubble(preservedThinkingNode, preservedThinkingNode.dataset.messageId);
3261
+ }
2562
3262
  });
2563
3263
  syncSeparatorsNow();
2564
3264
  renderRelayQuestions();
@@ -2633,7 +3333,8 @@ export async function sendMessage() {
2633
3333
  const originalComposerText = String(input?.value || '');
2634
3334
  const text = originalComposerText.trim();
2635
3335
  const mobileSend = isMobileComposerViewport();
2636
- const activeTurn = getActiveTurnForConversation(currentConvId);
3336
+ // A send while steering is held is allowed: the relay holds the message
3337
+ // and it steers in once the hold clears (the button reads "Queue").
2637
3338
  const hasDraft = hasComposerDraft({ text, attachmentCount: selectedAttachments.length });
2638
3339
  if (sendInFlight) {
2639
3340
  showTransientRelayNotice('Please wait for the current message to finish sending.');
@@ -2658,6 +3359,7 @@ export async function sendMessage() {
2658
3359
  input.value = '';
2659
3360
  autoResize(input);
2660
3361
  releaseComposerFocusAfterSend(input);
3362
+ void flushConversationDraft(currentConvId);
2661
3363
  compactCurrentConversation();
2662
3364
  scrollBottomAfterSend();
2663
3365
  return;
@@ -2668,6 +3370,7 @@ export async function sendMessage() {
2668
3370
  input.value = '';
2669
3371
  autoResize(input);
2670
3372
  releaseComposerFocusAfterSend(input);
3373
+ void flushConversationDraft(currentConvId);
2671
3374
  const result = await runPreviewCommand(previewCommand, { conversationId: currentConvId });
2672
3375
  showTransientRelayNotice(result.notice);
2673
3376
  return;
@@ -2711,6 +3414,7 @@ export async function sendMessage() {
2711
3414
  }
2712
3415
 
2713
3416
  setSendInFlight(true);
3417
+ let composerClearedBySend = false;
2714
3418
  let attachments = [];
2715
3419
  let clientMessageId = null;
2716
3420
  try {
@@ -2724,8 +3428,12 @@ export async function sendMessage() {
2724
3428
  }
2725
3429
  const titleSeed = text || (attachments[0]?.name || 'Attachment');
2726
3430
  clientMessageId = generateId();
2727
- trackPendingUserMessage(clientMessageId, targetConversationId, text);
3431
+ trackPendingUserMessage(clientMessageId, targetConversationId, text, { attachments });
2728
3432
  pendingUserMessageIds.add(clientMessageId);
3433
+ // The message leaves the draft: from here the cached draft is only what is
3434
+ // typed next, which the post-send save (or a failure restore) builds on.
3435
+ if (targetConversationId) upsertConversationDraftState(targetConversationId, { draftText: '' });
3436
+ composerClearedBySend = true;
2729
3437
  if (viewingSendConversation()) {
2730
3438
  input.value = '';
2731
3439
  autoResize(input);
@@ -2770,23 +3478,22 @@ export async function sendMessage() {
2770
3478
  if (queued) {
2771
3479
  void registerOutboxSync();
2772
3480
  showTransientRelayNotice('You are offline. Message queued — it will send when the connection returns.', 7000);
2773
- if (viewingSendConversation()) clearAttachments();
3481
+ if (viewingSendConversation()) removeComposerAttachments(draftAttachments);
2774
3482
  return;
2775
3483
  }
2776
3484
  }
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);
3485
+ const sendLanded = hasSendEchoArrived(clientMessageId);
3486
+ if (!sendLanded) {
3487
+ clearPendingUserMessage(clientMessageId);
3488
+ const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
3489
+ pendingNode?.remove();
3490
+ pendingUserMessageIds.delete(clientMessageId);
3491
+ seenMessageIds.delete(clientMessageId);
2785
3492
  }
2786
- void scheduleConversationDraftSave({
2787
- conversationId: targetConversationId,
2788
- draftText: originalComposerText,
2789
- immediate: true,
3493
+ restoreComposerAfterFailedSend(targetConversationId, originalComposerText, {
3494
+ viewing: viewingSendConversation(),
3495
+ composerCleared: composerClearedBySend,
3496
+ sendLanded,
2790
3497
  });
2791
3498
  if (!mobileSend && viewingSendConversation()) input.focus();
2792
3499
  setModelBanner('⚠️ Message could not be sent. Please try again.');
@@ -2818,7 +3525,7 @@ export async function sendMessage() {
2818
3525
  // conversation that was compacted.
2819
3526
  if (viewingSendConversation()) {
2820
3527
  await window.openConversation?.(r.compactedConversationId);
2821
- clearAttachments();
3528
+ removeComposerAttachments(draftAttachments);
2822
3529
  if (!mobileSend) input.focus();
2823
3530
  scrollBottomAfterSend();
2824
3531
  }
@@ -2847,6 +3554,12 @@ export async function sendMessage() {
2847
3554
  title: titleSeed.slice(0, 60),
2848
3555
  updatedAt: new Date().toISOString(),
2849
3556
  messageCount: 1,
3557
+ // The session the relay routed this first message to: the id its
3558
+ // worker is launched, registered and heartbeats under, so the
3559
+ // composer reads that worker's steering snapshot before the list
3560
+ // catches up. A binding that names another id (the placeholder
3561
+ // rekey) replaces it via conversation_session_bound.
3562
+ sdkSessionId: r.ownerSessionId || null,
2850
3563
  runtimeSessionId: r.runtimeSessionId || null,
2851
3564
  runtimeProviderType: r.runtimeProviderType || 'github',
2852
3565
  runtimeProviderModel: r.runtimeProviderModel || null,
@@ -2878,13 +3591,22 @@ export async function sendMessage() {
2878
3591
  }
2879
3592
  const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
2880
3593
  if (persistedConversationId) {
3594
+ restorableDraftByConversation.delete(persistedConversationId);
3595
+ // Forced: the send transaction already moved the server's version, and
3596
+ // this round trip is how the client learns it (a 409 carries it). What
3597
+ // is saved is the composer as it is now — the user may already have
3598
+ // typed the next message while this one was sending.
3599
+ const postSendText = viewingSendConversation()
3600
+ ? String(input.value || '')
3601
+ : String(conversations[persistedConversationId]?.draftText || '');
2881
3602
  const clearedDraft = await scheduleConversationDraftSave({
2882
3603
  conversationId: persistedConversationId,
2883
- draftText: '',
3604
+ draftText: postSendText,
2884
3605
  immediate: true,
3606
+ force: true,
2885
3607
  });
2886
3608
  upsertConversationDraftState(persistedConversationId, {
2887
- draftText: '',
3609
+ draftText: postSendText,
2888
3610
  // Prefer the server's version pointer (a 409 carries the one the send
2889
3611
  // transaction wrote when it cleared the draft) over this client's
2890
3612
  // clock: once the send releases the draft, every later refresh is
@@ -2901,27 +3623,27 @@ export async function sendMessage() {
2901
3623
  if (!activeTurn?.messageId) showThinking(r.messageId || null);
2902
3624
  }
2903
3625
 
3626
+ // Only what this message took leaves the composer: attachments added (or
3627
+ // still uploading) since the send began stay, and the settle-time save
3628
+ // below persists them.
2904
3629
  if (viewingSendConversation()) {
2905
- clearAttachments();
3630
+ removeComposerAttachments(draftAttachments);
2906
3631
  if (!mobileSend) input.focus();
2907
3632
  scrollBottomAfterSend();
2908
3633
  }
2909
3634
  } catch (e) {
2910
- if (clientMessageId) {
3635
+ const sendLanded = hasSendEchoArrived(clientMessageId);
3636
+ if (clientMessageId && !sendLanded) {
2911
3637
  clearPendingUserMessage(clientMessageId);
2912
3638
  const pendingNode = document.querySelector(`[data-message-id="${clientMessageId}"]`);
2913
3639
  pendingNode?.remove();
2914
3640
  pendingUserMessageIds.delete(clientMessageId);
2915
3641
  seenMessageIds.delete(clientMessageId);
2916
3642
  }
2917
- if (viewingSendConversation()) {
2918
- input.value = originalComposerText;
2919
- autoResize(input);
2920
- }
2921
- void scheduleConversationDraftSave({
2922
- conversationId: targetConversationId,
2923
- draftText: originalComposerText,
2924
- immediate: true,
3643
+ restoreComposerAfterFailedSend(targetConversationId, originalComposerText, {
3644
+ viewing: viewingSendConversation(),
3645
+ composerCleared: composerClearedBySend,
3646
+ sendLanded,
2925
3647
  });
2926
3648
  alert(e.message || 'Failed to send message');
2927
3649
  } finally {
@@ -2929,6 +3651,9 @@ export async function sendMessage() {
2929
3651
  // Every exit path has settled the draft by now: cleared it (success),
2930
3652
  // restored the composer text (offline/failure), or never touched it.
2931
3653
  if (targetConversationId) sendOwnedDraftConversations.delete(targetConversationId);
3654
+ // Edits made after the post-send save were held back while the send owned
3655
+ // the draft; save them now, on screen or not.
3656
+ if (targetConversationId) saveComposerIfUnsynced(targetConversationId);
2932
3657
  }
2933
3658
  }
2934
3659