@oar-sh/oar 0.9.11 → 0.9.12

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 (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -19,9 +19,10 @@ import {
19
19
  applyContextUsageBar,
20
20
  isMobileComposerViewport,
21
21
  releaseComposerFocusAfterSend,
22
- loadConversationScrollTop,
23
- loadConversationLoadedMessageCount,
24
22
  saveConversationScrollTop,
23
+ loadConversationReadingAnchor,
24
+ saveConversationReadingAnchor,
25
+ isMessagesNearBottom,
25
26
  IS_SHARED_VIEW,
26
27
  openSummaryModal,
27
28
  closeSummaryModal,
@@ -76,9 +77,16 @@ import { isSetupRequired, updateProviderReadinessInputs } from './provider-readi
76
77
  import { SETUP_TEXT } from './no-conversation-state.mjs';
77
78
  import { openSettingsModal } from './settings-modal.js';
78
79
  import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
79
- import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
80
+ import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, getTranscriptJumpEpoch, getTranscriptLiveRowEpoch, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn, captureConversationReadingAnchor, restoreConversationReadingAnchor, detachConversationWindowFromTail, mergeMessagesIntoTranscript, isConversationWindowAtTail } from './conversation-view.js';
81
+ import { mergeTailPage } from './transcript-reading-anchor.mjs';
82
+ import { readWindowRetention } from './conversation-window-retention.mjs';
80
83
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
81
- import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
84
+ import {
85
+ setBackgroundTasksConversation,
86
+ setConversationBackgroundTasks,
87
+ setConversationEndedBackgroundTasks,
88
+ setConversationTaskStallMinutes,
89
+ } from './background-tasks-view.mjs';
82
90
  import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
83
91
  import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
84
92
  import { setUsageLimitConversation } from './usage-limit-ui.js';
@@ -88,6 +96,8 @@ import { loadRelayBoards } from './relay-board-view.js';
88
96
  import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRepoBrowserLaunchCwdPath, openRepoBrowserForCwdPick } from './attachments-view.js';
89
97
  import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
90
98
  import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
99
+ import { conversationPayloadCache, payloadHasMoreOlder, windowPageInfo } from './conversation-payload-cache.mjs';
100
+ import { loadKeepingLiveRows, shouldKeepTranscriptOnReload } from './transcript-reload-freshness.mjs';
91
101
  import { createInfiniteLoader } from './infinite-loader.js';
92
102
  import {
93
103
  buildNewConversationModelChoices,
@@ -129,8 +139,6 @@ import {
129
139
  describeFilterMatchCount,
130
140
  } from './conversation-list-filter.mjs';
131
141
 
132
- const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
133
- const PROCESSING_DOT_INTERVAL_MS = 1000;
134
142
  const LOCAL_PROCESSING_STALE_MS = 5 * 60 * 1000;
135
143
  const CONVERSATION_LIST_PAGE_SIZE = 40;
136
144
  const REASONING_STORAGE_KEY = 'copilot_selected_reasoning_effort';
@@ -145,8 +153,6 @@ const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
145
153
  const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
146
154
  const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
147
155
  const CONV_FILTER_DEBOUNCE_MS = 220;
148
- let processingDotFrame = 0;
149
- let processingDotTimer = null;
150
156
  let lastConvListHtml = '';
151
157
  // The list as last rendered, as drag items (conversation-group-drag.mjs).
152
158
  let lastConvListDragItems = [];
@@ -224,7 +230,7 @@ function mergeConversationRecord(current, next) {
224
230
  // localTurnStatus is optimistic client state driven by one-shot message_status
225
231
  // socket events. If the socket drops between a turn finishing and the event
226
232
  // being delivered — a relay restart is the obvious case — the flag would stay
227
- // 'processing' until it aged out, leaving the list spinner running forever.
233
+ // 'processing' until it aged out, leaving the list row marked as working forever.
228
234
  // The server's activeTurn flag is authoritative, so let it clear the flag.
229
235
  if (next && typeof next === 'object' && next.activeTurn === false) {
230
236
  delete merged.localTurnStatus;
@@ -348,27 +354,6 @@ function isConversationProcessing(conversation, workerState) {
348
354
  return runtimeStatus === 'processing';
349
355
  }
350
356
 
351
- function ensureProcessingDotTimer(enabled) {
352
- if (enabled) {
353
- if (processingDotTimer) return;
354
- processingDotTimer = setInterval(() => {
355
- processingDotFrame = (processingDotFrame + 1) % PROCESSING_DOT_FRAMES.length;
356
- // Touch only the dot spans: rewriting the whole list every second kills
357
- // sidebar selections and burns battery for a spinner frame.
358
- const frame = PROCESSING_DOT_FRAMES[processingDotFrame];
359
- document.querySelectorAll('#conv-list .conv-processing-dots').forEach((el) => {
360
- el.textContent = ` ${frame}`;
361
- });
362
- }, PROCESSING_DOT_INTERVAL_MS);
363
- return;
364
- }
365
- if (processingDotTimer) {
366
- clearInterval(processingDotTimer);
367
- processingDotTimer = null;
368
- }
369
- processingDotFrame = 0;
370
- }
371
-
372
357
  export async function loadConversations() {
373
358
  await refreshConversations({ preservePagination: false });
374
359
  const lastId = localStorage.getItem('copilot_last_conv');
@@ -522,7 +507,10 @@ function applyConversationGroupPositions(positions) {
522
507
  }
523
508
  }
524
509
 
525
- /** A sidebar row's state: its colour (green is 'ready') and whether its dots run. */
510
+ /**
511
+ * A sidebar row's state: its colour (green is 'ready') and whether it works
512
+ * (a turn or background work), which keeps a ready title bright green.
513
+ */
526
514
  function conversationRowState(conversation, pendingByConversation = getPendingQuestionCountsByConversation()) {
527
515
  const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
528
516
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
@@ -542,6 +530,7 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
542
530
  return {
543
531
  visualState,
544
532
  processing,
533
+ working: processing || sessionState === 'background_only',
545
534
  pendingCount,
546
535
  // A turn or background work, whether or not a card waits in it: what
547
536
  // "Stop all running" acts on, and what keeps an archive from it.
@@ -557,6 +546,11 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
557
546
  };
558
547
  }
559
548
 
549
+ /** Whether the conversation works now: the chat header's title follows the row's. */
550
+ export function isConversationWorking(conversation) {
551
+ return !!conversation && conversationRowState(conversation).working;
552
+ }
553
+
560
554
  /** The group's head and its name for the picker and the menus. */
561
555
  function groupDisplayName(groupId) {
562
556
  const info = conversationGroups[groupId] || null;
@@ -634,7 +628,6 @@ function paintConvList() {
634
628
  },
635
629
  });
636
630
  lastConvListDragItems = describeDragItems(items);
637
- let hasProcessingConversation = false;
638
631
  const footerHtml = (() => {
639
632
  if (activeFilter) {
640
633
  // While a filter is active the drain loads older pages by itself; a
@@ -653,7 +646,6 @@ function paintConvList() {
653
646
  return '';
654
647
  })();
655
648
  if (items.length === 0) {
656
- ensureProcessingDotTimer(false);
657
649
  const emptyText = activeFilter && inView.length > 0
658
650
  ? 'No conversations match your filter'
659
651
  : (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
@@ -662,11 +654,6 @@ function paintConvList() {
662
654
  scheduleConversationListBoundaryCheck();
663
655
  return;
664
656
  }
665
- const conversationView = (conversation) => {
666
- const view = viewOf(conversation);
667
- if (view.processing) hasProcessingConversation = true;
668
- return view;
669
- };
670
657
  // The head row's pill: the member count and what the members are doing;
671
658
  // while folded, its "waiting" part opens the member the card sits in.
672
659
  const groupPillHtml = (item) => {
@@ -688,8 +675,7 @@ function paintConvList() {
688
675
  return `<button class="conv-group-fold" type="button" data-group-fold="${escHtml(item.group.id)}" aria-expanded="${item.group.open ? 'true' : 'false'}" aria-label="${label}" title="${label}"${forced ? ' disabled' : ''} onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">${item.group.open ? '▾' : '▸'}</button>`;
689
676
  };
690
677
  const rowHtml = (c, item) => {
691
- const view = conversationView(c);
692
- const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
678
+ const view = viewOf(c);
693
679
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
694
680
  const providerIndicatorKey = conversationProviderIndicatorKey(c);
695
681
  const providerIndicatorHtml = providerIndicatorLabel
@@ -711,8 +697,8 @@ function paintConvList() {
711
697
  const rowTitle = handName || c.title;
712
698
  const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
713
699
  return `
714
- <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
715
- <div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
700
+ <div class="conv-item worker-ui-${view.visualState}${view.working ? ' conv-working' : ''}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
701
+ <div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
716
702
  <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
717
703
  ${c.archived
718
704
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
@@ -738,7 +724,6 @@ function paintConvList() {
738
724
  }
739
725
  bindConversationContextMenu(list);
740
726
  bindConversationGroupControls(list);
741
- ensureProcessingDotTimer(hasProcessingConversation);
742
727
  window.syncChatTitleControls?.();
743
728
  scheduleConversationListBoundaryCheck();
744
729
  }
@@ -782,19 +767,48 @@ export function toggleConversationGroupFold(event, groupId) {
782
767
  // keepTranscript: the page shows a window this payload must not replace (the
783
768
  // user jumped into the history; the payload is the end of the conversation).
784
769
  // Everything but the messages still applies.
770
+ // fromCache: the payload is the window kept from an earlier visit
771
+ // (conversation-payload-cache.mjs), drawn while the fresh one is on its way.
772
+ // Only the transcript and what goes with it is drawn: the record, the
773
+ // composer, the draft, the live bubble and the tasks wait for the fresh answer.
774
+ // viewAnchor: the place read (transcript-reading-anchor.mjs) the restore
775
+ // puts back, in place of savedScrollTop.
776
+ // mergeFrom: the messages on the page; the payload's are merged into them
777
+ // rather than drawn anew (mergeMessagesIntoTranscript).
778
+ // windowPayload: with keepTranscript, the window the page keeps, kept for
779
+ // the next visit in place of nothing.
785
780
  export function applyLoadedConversationState(id, response, {
786
781
  restoreScroll = false,
787
782
  savedScrollTop = null,
783
+ viewAnchor = null,
788
784
  followLiveUpdates = !restoreScroll,
789
785
  keepTranscript = false,
786
+ fromCache = false,
787
+ mergeFrom = null,
788
+ windowPayload = null,
790
789
  } = {}) {
791
790
  if (!response) {
791
+ if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
792
792
  setRepoBrowserSessionInfo('', '');
793
793
  restoreInFlightThinking(null);
794
794
  renderMessages([]);
795
795
  window.syncChatTitleControls?.();
796
796
  return;
797
797
  }
798
+ if (fromCache) {
799
+ applyCachedConversationState(id, response, { viewAnchor });
800
+ return;
801
+ }
802
+ if (!IS_SHARED_VIEW) {
803
+ // The page keeps a window this payload does not show: what it shows is
804
+ // not this payload, so only a window the caller names is kept for the
805
+ // next visit. Without one the entry already there stays: it follows the
806
+ // page (its pages and live rows are added to it), so it still is the
807
+ // window on screen. The cache keeps this very object, uncopied: nothing
808
+ // below may change it.
809
+ if (windowPayload) conversationPayloadCache.remember(id, windowPayload);
810
+ else if (!keepTranscript) conversationPayloadCache.remember(id, response);
811
+ }
798
812
  const existingConversation = conversations[id] || {};
799
813
  upsertConversationRecord({
800
814
  ...existingConversation,
@@ -862,9 +876,12 @@ export function applyLoadedConversationState(id, response, {
862
876
  // Their Hide / Unhide too, from the hidden list (owner payloads only).
863
877
  if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
864
878
  setHiddenMessagesConversation(id);
865
- const didRenderMessages = keepTranscript
866
- ? false
867
- : renderMessages(response.messages, !restoreScroll, response);
879
+ let didRenderMessages = false;
880
+ if (!keepTranscript) {
881
+ didRenderMessages = mergeFrom
882
+ ? mergeMessagesIntoTranscript(mergeFrom, response.messages, response)
883
+ : renderMessages(response.messages, !restoreScroll, response);
884
+ }
868
885
  hydrateConversationDraft(id, {
869
886
  draftText: response.draftText,
870
887
  draftAttachments: response.draftAttachments,
@@ -873,7 +890,11 @@ export function applyLoadedConversationState(id, response, {
873
890
  });
874
891
  restoreInFlightThinking(response.inFlight || null, followLiveUpdates);
875
892
  setBackgroundTasksConversation(id);
893
+ // Absent from an older relay's payload: the threshold keeps its default.
894
+ if ('taskStallMinutes' in response) setConversationTaskStallMinutes(id, response.taskStallMinutes);
876
895
  setConversationBackgroundTasks(id, response.backgroundTasks || []);
896
+ // Absent from an older relay's payload: keep what the socket said.
897
+ if (Array.isArray(response.endedBackgroundTasks)) setConversationEndedBackgroundTasks(id, response.endedBackgroundTasks);
877
898
  setUsageLimitConversation(id, {
878
899
  // Absent from an older relay's payload: keep what the socket said.
879
900
  pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
@@ -884,8 +905,18 @@ export function applyLoadedConversationState(id, response, {
884
905
  updateSessionPill(conversations[id], response.runtimeSession || null);
885
906
  window.syncChatTitleControls?.();
886
907
  if (!restoreScroll || !didRenderMessages) return;
908
+ restoreConversationScroll(id, savedScrollTop, viewAnchor);
909
+ }
910
+
911
+ // The place read when it is on the page, else the pixel position given, else
912
+ // the end.
913
+ function restoreConversationScroll(id, savedScrollTop, viewAnchor = null) {
887
914
  const el = document.getElementById('messages');
888
915
  if (!el) return;
916
+ if (viewAnchor && restoreConversationReadingAnchor(viewAnchor)) {
917
+ saveConversationScrollTop(id, el.scrollTop);
918
+ return;
919
+ }
889
920
  if (Number.isFinite(savedScrollTop)) {
890
921
  el.scrollTop = savedScrollTop;
891
922
  saveConversationScrollTop(id, el.scrollTop);
@@ -895,6 +926,54 @@ export function applyLoadedConversationState(id, response, {
895
926
  saveConversationScrollTop(id, el.scrollTop);
896
927
  }
897
928
 
929
+ function payloadHasMessage(response, messageId) {
930
+ const id = String(messageId || '').trim();
931
+ return !!id && Array.isArray(response?.messages) && response.messages.some((message) => String(message?.id || '').trim() === id);
932
+ }
933
+
934
+ // The kept window of a conversation, when it holds the place read: a window
935
+ // without it (a row edited since dropped it, the kept rows were cut) would
936
+ // put the reader somewhere else.
937
+ function readCachedWindow(id, anchor) {
938
+ const cached = conversationPayloadCache.read(id);
939
+ if (!cached) return null;
940
+ if (anchor && !anchor.atTail && anchor.messageId && !payloadHasMessage(cached, anchor.messageId)) return null;
941
+ return cached;
942
+ }
943
+
944
+ // The conversation being left: its place is noted as it is on the page, and
945
+ // its kept window is cut down to the retention cap around that place.
946
+ function keepPlaceOfConversationLeft(conversationId) {
947
+ const captured = captureConversationReadingAnchor();
948
+ const shown = captured?.conversationId === conversationId;
949
+ // During a quick run through several conversations the page still shows
950
+ // an earlier one: the place saved for this one stands.
951
+ const anchor = shown ? captured.anchor : loadConversationReadingAnchor(conversationId);
952
+ if (shown) saveConversationReadingAnchor(conversationId, anchor);
953
+ conversationPayloadCache.trim(conversationId, {
954
+ cap: readWindowRetention(),
955
+ anchorMessageId: anchor?.messageId || '',
956
+ atTail: anchor?.atTail === true,
957
+ });
958
+ }
959
+
960
+ // The kept window of a conversation, drawn on a switch back to it, at the
961
+ // place read. Pins and the hidden list are revision-guarded, so an older copy
962
+ // cannot undo a newer change the socket brought.
963
+ function applyCachedConversationState(id, response, { viewAnchor = null } = {}) {
964
+ setRepoBrowserSessionInfo(response.sessionRootPath || '', response.sessionRootName || response.title || '');
965
+ if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
966
+ setPinsConversation(id);
967
+ if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
968
+ setHiddenMessagesConversation(id);
969
+ const didRenderMessages = renderMessages(response.messages, false, response);
970
+ // The panel shows this conversation's tasks as the socket keeps them.
971
+ setBackgroundTasksConversation(id);
972
+ window.syncChatTitleControls?.();
973
+ if (!didRenderMessages) return;
974
+ restoreConversationScroll(id, null, viewAnchor);
975
+ }
976
+
898
977
  // The "via agent" marker (sidebar row, header, message badge): opens the
899
978
  // conversation whose agent started this one. The marker sits inside a sidebar
900
979
  // row that opens on click itself, so the click stops here.
@@ -912,6 +991,67 @@ export function openOriginConversation(event, element) {
912
991
  return openConversation(id);
913
992
  }
914
993
 
994
+ // The fresh answer for a conversation whose kept window is on the page (drawn
995
+ // on the switch back, or still there when the conversation on screen is
996
+ // opened again). A window that reaches the end takes the answer's rows: those
997
+ // it has are replaced (a reply may have finished since), the new ones join
998
+ // it, nothing else is rebuilt. A reader at the end stays at the end, one who
999
+ // is not keeps the message they are at where it is, wherever they scrolled
1000
+ // to while the answer was on its way. A window in the middle of the history
1001
+ // keeps its rows: they do not change (an edit comes as message_updated, which
1002
+ // drops the window of a conversation not on screen and updates the one on
1003
+ // screen); the rest of the answer applies.
1004
+ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript = false } = {}) {
1005
+ const atEnd = isMessagesNearBottom() && isConversationWindowAtTail();
1006
+ if (keepTranscript) {
1007
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false });
1008
+ return;
1009
+ }
1010
+ const keptWindow = (hasMoreNewer) => ({
1011
+ ...response,
1012
+ messages: cached.messages,
1013
+ hasMoreOlder: cached.hasMoreOlder,
1014
+ hasMoreNewer,
1015
+ });
1016
+ if (cached.hasMoreNewer) {
1017
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false, windowPayload: keptWindow(true) });
1018
+ return;
1019
+ }
1020
+ // The kept copy was read before the request: every row of it predates the
1021
+ // answer, so a newer one the answer lacks was deleted while away.
1022
+ const merged = mergeTailPage(cached.messages, response.messages, {
1023
+ pageHasMoreOlder: payloadHasMoreOlder(response),
1024
+ windowPredatesPage: true,
1025
+ });
1026
+ if (!merged) {
1027
+ // More was written than the answer holds: a reader at the end gets the
1028
+ // end; anyone else keeps the window, which no longer reaches it.
1029
+ if (atEnd) {
1030
+ applyLoadedConversationState(id, response, { restoreScroll: false });
1031
+ return;
1032
+ }
1033
+ detachConversationWindowFromTail();
1034
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false, windowPayload: keptWindow(true) });
1035
+ return;
1036
+ }
1037
+ const place = atEnd ? null : captureConversationReadingAnchor()?.anchor;
1038
+ const flags = { hasMoreOlder: cached.hasMoreOlder, hasMoreNewer: false };
1039
+ applyLoadedConversationState(id, {
1040
+ ...response,
1041
+ messages: merged.messages,
1042
+ ...flags,
1043
+ pageInfo: windowPageInfo(merged.messages, flags),
1044
+ }, {
1045
+ mergeFrom: cached.messages,
1046
+ followLiveUpdates: atEnd,
1047
+ });
1048
+ const messagesEl = document.getElementById('messages');
1049
+ if (!messagesEl) return;
1050
+ if (atEnd) messagesEl.scrollTop = messagesEl.scrollHeight;
1051
+ else if (place) restoreConversationReadingAnchor(place);
1052
+ saveConversationScrollTop(id, messagesEl.scrollTop);
1053
+ }
1054
+
915
1055
  export async function openConversation(id, options = {}) {
916
1056
  const didLeaveStatusView = leaveStatusView();
917
1057
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -925,6 +1065,7 @@ export async function openConversation(id, options = {}) {
925
1065
  window.clearImageEditTarget?.();
926
1066
  }
927
1067
  const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
1068
+ if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
928
1069
  const capturedVersion = ++openConversationVersion;
929
1070
  setCurrentConv(id);
930
1071
  if (repoBrowserState.activeRoot === 'workspace') {
@@ -961,35 +1102,74 @@ export async function openConversation(id, options = {}) {
961
1102
  const focusMessageId = String(options.focusMessageId || '').trim();
962
1103
  const aroundMessageId = String(options.aroundMessageId || focusMessageId || '').trim();
963
1104
  const forceFreshWindow = !!aroundMessageId;
964
- const savedScrollTop = loadConversationScrollTop(id);
965
- const restoreScroll = !forceFreshWindow && Number.isFinite(savedScrollTop);
966
- const savedLoadedCount = loadConversationLoadedMessageCount(id);
967
- const requestLimit = forceFreshWindow
968
- ? 40
969
- : (Number.isFinite(savedLoadedCount)
970
- ? Math.max(20, savedLoadedCount)
971
- : 20);
972
- const r = await loadConversation(id, {
973
- limit: requestLimit,
974
- aroundMessageId: aroundMessageId || undefined,
1105
+ const keepsPlace = !forceFreshWindow && !IS_SHARED_VIEW;
1106
+ const savedAnchor = keepsPlace ? loadConversationReadingAnchor(id) : null;
1107
+ // Switching back to a conversation seen before on this page: its kept
1108
+ // window is drawn at once, at the place read, and the fresh answer below
1109
+ // is merged into it.
1110
+ const cached = keepsPlace ? readCachedWindow(id, savedAnchor) : null;
1111
+ if (cached && (switchingConversation || didLeaveStatusView)) {
1112
+ applyLoadedConversationState(id, cached, { fromCache: true, viewAnchor: savedAnchor });
1113
+ }
1114
+ // Nothing kept (a reload, a window dropped): a reader who was not at the
1115
+ // end gets the messages around the one they read, as after a search jump.
1116
+ // Only when the page does not show the conversation already: reopening the
1117
+ // one on screen (a session bound, a click on it, a push) rebuilds its end
1118
+ // under the reader, a jump would throw away the window they scrolled.
1119
+ const showsOtherTranscript = switchingConversation || didLeaveStatusView;
1120
+ const anchorMessageId = !cached && showsOtherTranscript && savedAnchor && !savedAnchor.atTail
1121
+ ? String(savedAnchor.messageId || '').trim()
1122
+ : '';
1123
+ const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
1124
+ const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
1125
+ limit: around ? 40 : 20,
1126
+ aroundMessageId: around || undefined,
975
1127
  });
976
- if (!shouldApplyConversationLoad({
1128
+ const stillWanted = () => shouldApplyConversationLoad({
977
1129
  requestedConversationId: id,
978
1130
  activeConversationId: currentConvId,
979
1131
  capturedVersion,
980
1132
  currentVersion: openConversationVersion,
981
- })) {
982
- return;
983
- }
984
- if (r) {
1133
+ });
1134
+ // The drawn copy takes live rows from the socket while the answer is on its
1135
+ // way; an answer that may lack them is asked again, as the live poll does.
1136
+ const { response: fetched, stale } = cached
1137
+ ? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
1138
+ : { response: await load(), stale: false };
1139
+ if (!stillWanted()) return;
1140
+ let r = fetched;
1141
+ const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
1142
+ if (r && anchorMessageId && !anchorFound) {
1143
+ // The message read is gone (deleted, compacted away): the end instead.
1144
+ r = await load('');
1145
+ if (!stillWanted()) return;
1146
+ }
1147
+ if (r && cached) {
1148
+ applyFreshOverCachedConversation(id, r, {
1149
+ cached,
1150
+ // A jump into the history from the drawn copy (a pin) wins. A stale
1151
+ // answer is still merged, unlike on the live poll: the drawn copy
1152
+ // predates the rows stored while the reader was away.
1153
+ keepTranscript: shouldKeepTranscriptOnReload({
1154
+ stale,
1155
+ jumped: getTranscriptJumpEpoch() !== cachedJumpEpoch,
1156
+ showsLiveTranscript: false,
1157
+ }),
1158
+ });
1159
+ } else if (r) {
985
1160
  // The conversation on screen opened again (a worker bound its session):
986
1161
  // its rows are rebuilt under the reader, who keeps the text they are at.
987
1162
  const messagesEl = document.getElementById('messages');
988
- const reading = !switchingConversation && restoreScroll ? captureReadingBlock(messagesEl) : null;
1163
+ const rereading = !switchingConversation && !anchorFound && !isMessagesNearBottom();
1164
+ const reading = rereading ? captureReadingBlock(messagesEl) : null;
989
1165
  // Counted when the window is put on the page, not when it is asked for:
990
1166
  // a reload of the end that is still on its way must see the change.
991
- if (forceFreshWindow) noteTranscriptJump();
992
- applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
1167
+ if (forceFreshWindow || anchorFound) noteTranscriptJump();
1168
+ applyLoadedConversationState(id, r, {
1169
+ restoreScroll: rereading || anchorFound,
1170
+ savedScrollTop: rereading ? (messagesEl?.scrollTop ?? null) : null,
1171
+ viewAnchor: anchorFound ? savedAnchor : null,
1172
+ });
993
1173
  if (reading) restoreReadingBlock(messagesEl, reading);
994
1174
  if (focusMessageId) {
995
1175
  requestAnimationFrame(() => {
@@ -997,6 +1177,7 @@ export async function openConversation(id, options = {}) {
997
1177
  });
998
1178
  }
999
1179
  } else {
1180
+ conversationPayloadCache.forget(id);
1000
1181
  setRepoBrowserSessionInfo('', '');
1001
1182
  restoreInFlightThinking(null);
1002
1183
  renderMessages([]);
@@ -2319,6 +2500,7 @@ async function setConversationArchived(id, archived) {
2319
2500
  return false;
2320
2501
  }
2321
2502
  if (conversations[id]) conversations[id].archived = archived;
2503
+ conversationPayloadCache.forget(id);
2322
2504
  renderConvList();
2323
2505
  window.syncChatTitleControls?.();
2324
2506
  window.syncComposerButtonState?.();
@@ -2531,7 +2713,10 @@ async function archiveConversationGroup(groupId) {
2531
2713
  return;
2532
2714
  }
2533
2715
  const skipped = Array.isArray(result.skipped) ? result.skipped : [];
2534
- for (const id of result.archived || []) if (conversations[id]) conversations[id].archived = true;
2716
+ for (const id of result.archived || []) {
2717
+ if (conversations[id]) conversations[id].archived = true;
2718
+ conversationPayloadCache.forget(id);
2719
+ }
2535
2720
  renderConvList();
2536
2721
  if (skipped.length) {
2537
2722
  const names = skipped.map((entry) => `“${String(entry.title || entry.id).trim()}”`).join(', ');
@@ -2861,6 +3046,7 @@ export async function deleteConv(e, id) {
2861
3046
  return;
2862
3047
  }
2863
3048
  delete conversations[id];
3049
+ conversationPayloadCache.forget(id);
2864
3050
  renderConvList();
2865
3051
  if (currentConvId === id) clearOpenConversation();
2866
3052
  }
@@ -77,12 +77,17 @@ function whitespaceFlexiblePattern(value) {
77
77
  .join('\\s+');
78
78
  }
79
79
 
80
+ // The patterns depend only on the mode, so they are built once per mode and
81
+ // reused: a long conversation sanitises every message on each history load.
82
+ const PROMPT_PREFIX_PATTERN_CACHE = new Map();
83
+
80
84
  function buildPromptPrefixPatterns(mode) {
81
85
  const requestedMode = String(mode || '').trim().toLowerCase();
82
- const modes = requestedMode && MODE_MARKERS[requestedMode]
83
- ? [requestedMode]
84
- : Object.keys(MODE_MARKERS);
85
- return modes.flatMap((key) => {
86
+ const cacheKey = requestedMode && MODE_MARKERS[requestedMode] ? requestedMode : '';
87
+ const cached = PROMPT_PREFIX_PATTERN_CACHE.get(cacheKey);
88
+ if (cached) return cached;
89
+ const modes = cacheKey ? [cacheKey] : Object.keys(MODE_MARKERS);
90
+ const patterns = modes.flatMap((key) => {
86
91
  const marker = MODE_MARKERS[key];
87
92
  const currentFullPrefix = `${marker} ${MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
88
93
  const legacyFullPrefix = `${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
@@ -92,6 +97,8 @@ function buildPromptPrefixPatterns(mode) {
92
97
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
93
98
  ];
94
99
  });
100
+ PROMPT_PREFIX_PATTERN_CACHE.set(cacheKey, patterns);
101
+ return patterns;
95
102
  }
96
103
 
97
104
  function stripAttachmentPromptArtifacts(text) {