@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  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 +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -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,17 @@ 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
+ isBackgroundTasksSnapshotCurrent,
86
+ setBackgroundTasksConversation,
87
+ setConversationBackgroundTasks,
88
+ setConversationEndedBackgroundTasks,
89
+ setConversationTaskStallMinutes,
90
+ } from './background-tasks-view.mjs';
82
91
  import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
83
92
  import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
84
93
  import { setUsageLimitConversation } from './usage-limit-ui.js';
@@ -88,6 +97,8 @@ import { loadRelayBoards } from './relay-board-view.js';
88
97
  import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRepoBrowserLaunchCwdPath, openRepoBrowserForCwdPick } from './attachments-view.js';
89
98
  import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
90
99
  import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
100
+ import { conversationPayloadCache, payloadHasMoreOlder, windowPageInfo } from './conversation-payload-cache.mjs';
101
+ import { loadKeepingLiveRows, shouldKeepTranscriptOnReload } from './transcript-reload-freshness.mjs';
91
102
  import { createInfiniteLoader } from './infinite-loader.js';
92
103
  import {
93
104
  buildNewConversationModelChoices,
@@ -129,8 +140,6 @@ import {
129
140
  describeFilterMatchCount,
130
141
  } from './conversation-list-filter.mjs';
131
142
 
132
- const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
133
- const PROCESSING_DOT_INTERVAL_MS = 1000;
134
143
  const LOCAL_PROCESSING_STALE_MS = 5 * 60 * 1000;
135
144
  const CONVERSATION_LIST_PAGE_SIZE = 40;
136
145
  const REASONING_STORAGE_KEY = 'copilot_selected_reasoning_effort';
@@ -145,8 +154,6 @@ const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
145
154
  const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
146
155
  const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
147
156
  const CONV_FILTER_DEBOUNCE_MS = 220;
148
- let processingDotFrame = 0;
149
- let processingDotTimer = null;
150
157
  let lastConvListHtml = '';
151
158
  // The list as last rendered, as drag items (conversation-group-drag.mjs).
152
159
  let lastConvListDragItems = [];
@@ -224,7 +231,7 @@ function mergeConversationRecord(current, next) {
224
231
  // localTurnStatus is optimistic client state driven by one-shot message_status
225
232
  // socket events. If the socket drops between a turn finishing and the event
226
233
  // 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.
234
+ // 'processing' until it aged out, leaving the list row marked as working forever.
228
235
  // The server's activeTurn flag is authoritative, so let it clear the flag.
229
236
  if (next && typeof next === 'object' && next.activeTurn === false) {
230
237
  delete merged.localTurnStatus;
@@ -348,27 +355,6 @@ function isConversationProcessing(conversation, workerState) {
348
355
  return runtimeStatus === 'processing';
349
356
  }
350
357
 
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
358
  export async function loadConversations() {
373
359
  await refreshConversations({ preservePagination: false });
374
360
  const lastId = localStorage.getItem('copilot_last_conv');
@@ -522,7 +508,10 @@ function applyConversationGroupPositions(positions) {
522
508
  }
523
509
  }
524
510
 
525
- /** A sidebar row's state: its colour (green is 'ready') and whether its dots run. */
511
+ /**
512
+ * A sidebar row's state: its colour (green is 'ready') and whether it works
513
+ * (a turn or background work), which keeps a ready title bright green.
514
+ */
526
515
  function conversationRowState(conversation, pendingByConversation = getPendingQuestionCountsByConversation()) {
527
516
  const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
528
517
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
@@ -542,6 +531,7 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
542
531
  return {
543
532
  visualState,
544
533
  processing,
534
+ working: processing || sessionState === 'background_only',
545
535
  pendingCount,
546
536
  // A turn or background work, whether or not a card waits in it: what
547
537
  // "Stop all running" acts on, and what keeps an archive from it.
@@ -557,6 +547,11 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
557
547
  };
558
548
  }
559
549
 
550
+ /** Whether the conversation works now: the chat header's title follows the row's. */
551
+ export function isConversationWorking(conversation) {
552
+ return !!conversation && conversationRowState(conversation).working;
553
+ }
554
+
560
555
  /** The group's head and its name for the picker and the menus. */
561
556
  function groupDisplayName(groupId) {
562
557
  const info = conversationGroups[groupId] || null;
@@ -634,7 +629,6 @@ function paintConvList() {
634
629
  },
635
630
  });
636
631
  lastConvListDragItems = describeDragItems(items);
637
- let hasProcessingConversation = false;
638
632
  const footerHtml = (() => {
639
633
  if (activeFilter) {
640
634
  // While a filter is active the drain loads older pages by itself; a
@@ -653,7 +647,6 @@ function paintConvList() {
653
647
  return '';
654
648
  })();
655
649
  if (items.length === 0) {
656
- ensureProcessingDotTimer(false);
657
650
  const emptyText = activeFilter && inView.length > 0
658
651
  ? 'No conversations match your filter'
659
652
  : (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
@@ -662,11 +655,6 @@ function paintConvList() {
662
655
  scheduleConversationListBoundaryCheck();
663
656
  return;
664
657
  }
665
- const conversationView = (conversation) => {
666
- const view = viewOf(conversation);
667
- if (view.processing) hasProcessingConversation = true;
668
- return view;
669
- };
670
658
  // The head row's pill: the member count and what the members are doing;
671
659
  // while folded, its "waiting" part opens the member the card sits in.
672
660
  const groupPillHtml = (item) => {
@@ -688,8 +676,7 @@ function paintConvList() {
688
676
  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
677
  };
690
678
  const rowHtml = (c, item) => {
691
- const view = conversationView(c);
692
- const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
679
+ const view = viewOf(c);
693
680
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
694
681
  const providerIndicatorKey = conversationProviderIndicatorKey(c);
695
682
  const providerIndicatorHtml = providerIndicatorLabel
@@ -711,8 +698,8 @@ function paintConvList() {
711
698
  const rowTitle = handName || c.title;
712
699
  const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
713
700
  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>
701
+ <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}')">
702
+ <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
703
  <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
704
  ${c.archived
718
705
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
@@ -738,7 +725,6 @@ function paintConvList() {
738
725
  }
739
726
  bindConversationContextMenu(list);
740
727
  bindConversationGroupControls(list);
741
- ensureProcessingDotTimer(hasProcessingConversation);
742
728
  window.syncChatTitleControls?.();
743
729
  scheduleConversationListBoundaryCheck();
744
730
  }
@@ -782,19 +768,51 @@ export function toggleConversationGroupFold(event, groupId) {
782
768
  // keepTranscript: the page shows a window this payload must not replace (the
783
769
  // user jumped into the history; the payload is the end of the conversation).
784
770
  // Everything but the messages still applies.
771
+ // fromCache: the payload is the window kept from an earlier visit
772
+ // (conversation-payload-cache.mjs), drawn while the fresh one is on its way.
773
+ // Only the transcript and what goes with it is drawn: the record, the
774
+ // composer, the draft, the live bubble and the tasks wait for the fresh answer.
775
+ // viewAnchor: the place read (transcript-reading-anchor.mjs) the restore
776
+ // puts back, in place of savedScrollTop.
777
+ // mergeFrom: the messages on the page; the payload's are merged into them
778
+ // rather than drawn anew (mergeMessagesIntoTranscript).
779
+ // windowPayload: with keepTranscript, the window the page keeps, kept for
780
+ // the next visit in place of nothing.
781
+ // backgroundTasksAsOf: backgroundTasksSnapshotMark(id) as the request left;
782
+ // the payload's task sets are left out when an event brought newer ones since.
785
783
  export function applyLoadedConversationState(id, response, {
786
784
  restoreScroll = false,
787
785
  savedScrollTop = null,
786
+ viewAnchor = null,
788
787
  followLiveUpdates = !restoreScroll,
789
788
  keepTranscript = false,
789
+ fromCache = false,
790
+ mergeFrom = null,
791
+ windowPayload = null,
792
+ backgroundTasksAsOf = null,
790
793
  } = {}) {
791
794
  if (!response) {
795
+ if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
792
796
  setRepoBrowserSessionInfo('', '');
793
797
  restoreInFlightThinking(null);
794
798
  renderMessages([]);
795
799
  window.syncChatTitleControls?.();
796
800
  return;
797
801
  }
802
+ if (fromCache) {
803
+ applyCachedConversationState(id, response, { viewAnchor });
804
+ return;
805
+ }
806
+ if (!IS_SHARED_VIEW) {
807
+ // The page keeps a window this payload does not show: what it shows is
808
+ // not this payload, so only a window the caller names is kept for the
809
+ // next visit. Without one the entry already there stays: it follows the
810
+ // page (its pages and live rows are added to it), so it still is the
811
+ // window on screen. The cache keeps this very object, uncopied: nothing
812
+ // below may change it.
813
+ if (windowPayload) conversationPayloadCache.remember(id, windowPayload);
814
+ else if (!keepTranscript) conversationPayloadCache.remember(id, response);
815
+ }
798
816
  const existingConversation = conversations[id] || {};
799
817
  upsertConversationRecord({
800
818
  ...existingConversation,
@@ -862,9 +880,12 @@ export function applyLoadedConversationState(id, response, {
862
880
  // Their Hide / Unhide too, from the hidden list (owner payloads only).
863
881
  if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
864
882
  setHiddenMessagesConversation(id);
865
- const didRenderMessages = keepTranscript
866
- ? false
867
- : renderMessages(response.messages, !restoreScroll, response);
883
+ let didRenderMessages = false;
884
+ if (!keepTranscript) {
885
+ didRenderMessages = mergeFrom
886
+ ? mergeMessagesIntoTranscript(mergeFrom, response.messages, response)
887
+ : renderMessages(response.messages, !restoreScroll, response);
888
+ }
868
889
  hydrateConversationDraft(id, {
869
890
  draftText: response.draftText,
870
891
  draftAttachments: response.draftAttachments,
@@ -873,7 +894,13 @@ export function applyLoadedConversationState(id, response, {
873
894
  });
874
895
  restoreInFlightThinking(response.inFlight || null, followLiveUpdates);
875
896
  setBackgroundTasksConversation(id);
876
- setConversationBackgroundTasks(id, response.backgroundTasks || []);
897
+ // Absent from an older relay's payload: the threshold keeps its default.
898
+ if ('taskStallMinutes' in response) setConversationTaskStallMinutes(id, response.taskStallMinutes);
899
+ if (isBackgroundTasksSnapshotCurrent(id, backgroundTasksAsOf)) {
900
+ setConversationBackgroundTasks(id, response.backgroundTasks || []);
901
+ // Absent from an older relay's payload: keep what the socket said.
902
+ if (Array.isArray(response.endedBackgroundTasks)) setConversationEndedBackgroundTasks(id, response.endedBackgroundTasks);
903
+ }
877
904
  setUsageLimitConversation(id, {
878
905
  // Absent from an older relay's payload: keep what the socket said.
879
906
  pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
@@ -884,8 +911,18 @@ export function applyLoadedConversationState(id, response, {
884
911
  updateSessionPill(conversations[id], response.runtimeSession || null);
885
912
  window.syncChatTitleControls?.();
886
913
  if (!restoreScroll || !didRenderMessages) return;
914
+ restoreConversationScroll(id, savedScrollTop, viewAnchor);
915
+ }
916
+
917
+ // The place read when it is on the page, else the pixel position given, else
918
+ // the end.
919
+ function restoreConversationScroll(id, savedScrollTop, viewAnchor = null) {
887
920
  const el = document.getElementById('messages');
888
921
  if (!el) return;
922
+ if (viewAnchor && restoreConversationReadingAnchor(viewAnchor)) {
923
+ saveConversationScrollTop(id, el.scrollTop);
924
+ return;
925
+ }
889
926
  if (Number.isFinite(savedScrollTop)) {
890
927
  el.scrollTop = savedScrollTop;
891
928
  saveConversationScrollTop(id, el.scrollTop);
@@ -895,6 +932,54 @@ export function applyLoadedConversationState(id, response, {
895
932
  saveConversationScrollTop(id, el.scrollTop);
896
933
  }
897
934
 
935
+ function payloadHasMessage(response, messageId) {
936
+ const id = String(messageId || '').trim();
937
+ return !!id && Array.isArray(response?.messages) && response.messages.some((message) => String(message?.id || '').trim() === id);
938
+ }
939
+
940
+ // The kept window of a conversation, when it holds the place read: a window
941
+ // without it (a row edited since dropped it, the kept rows were cut) would
942
+ // put the reader somewhere else.
943
+ function readCachedWindow(id, anchor) {
944
+ const cached = conversationPayloadCache.read(id);
945
+ if (!cached) return null;
946
+ if (anchor && !anchor.atTail && anchor.messageId && !payloadHasMessage(cached, anchor.messageId)) return null;
947
+ return cached;
948
+ }
949
+
950
+ // The conversation being left: its place is noted as it is on the page, and
951
+ // its kept window is cut down to the retention cap around that place.
952
+ function keepPlaceOfConversationLeft(conversationId) {
953
+ const captured = captureConversationReadingAnchor();
954
+ const shown = captured?.conversationId === conversationId;
955
+ // During a quick run through several conversations the page still shows
956
+ // an earlier one: the place saved for this one stands.
957
+ const anchor = shown ? captured.anchor : loadConversationReadingAnchor(conversationId);
958
+ if (shown) saveConversationReadingAnchor(conversationId, anchor);
959
+ conversationPayloadCache.trim(conversationId, {
960
+ cap: readWindowRetention(),
961
+ anchorMessageId: anchor?.messageId || '',
962
+ atTail: anchor?.atTail === true,
963
+ });
964
+ }
965
+
966
+ // The kept window of a conversation, drawn on a switch back to it, at the
967
+ // place read. Pins and the hidden list are revision-guarded, so an older copy
968
+ // cannot undo a newer change the socket brought.
969
+ function applyCachedConversationState(id, response, { viewAnchor = null } = {}) {
970
+ setRepoBrowserSessionInfo(response.sessionRootPath || '', response.sessionRootName || response.title || '');
971
+ if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
972
+ setPinsConversation(id);
973
+ if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
974
+ setHiddenMessagesConversation(id);
975
+ const didRenderMessages = renderMessages(response.messages, false, response);
976
+ // The panel shows this conversation's tasks as the socket keeps them.
977
+ setBackgroundTasksConversation(id);
978
+ window.syncChatTitleControls?.();
979
+ if (!didRenderMessages) return;
980
+ restoreConversationScroll(id, null, viewAnchor);
981
+ }
982
+
898
983
  // The "via agent" marker (sidebar row, header, message badge): opens the
899
984
  // conversation whose agent started this one. The marker sits inside a sidebar
900
985
  // row that opens on click itself, so the click stops here.
@@ -912,6 +997,67 @@ export function openOriginConversation(event, element) {
912
997
  return openConversation(id);
913
998
  }
914
999
 
1000
+ // The fresh answer for a conversation whose kept window is on the page (drawn
1001
+ // on the switch back, or still there when the conversation on screen is
1002
+ // opened again). A window that reaches the end takes the answer's rows: those
1003
+ // it has are replaced (a reply may have finished since), the new ones join
1004
+ // it, nothing else is rebuilt. A reader at the end stays at the end, one who
1005
+ // is not keeps the message they are at where it is, wherever they scrolled
1006
+ // to while the answer was on its way. A window in the middle of the history
1007
+ // keeps its rows: they do not change (an edit comes as message_updated, which
1008
+ // drops the window of a conversation not on screen and updates the one on
1009
+ // screen); the rest of the answer applies.
1010
+ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript = false } = {}) {
1011
+ const atEnd = isMessagesNearBottom() && isConversationWindowAtTail();
1012
+ if (keepTranscript) {
1013
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false });
1014
+ return;
1015
+ }
1016
+ const keptWindow = (hasMoreNewer) => ({
1017
+ ...response,
1018
+ messages: cached.messages,
1019
+ hasMoreOlder: cached.hasMoreOlder,
1020
+ hasMoreNewer,
1021
+ });
1022
+ if (cached.hasMoreNewer) {
1023
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false, windowPayload: keptWindow(true) });
1024
+ return;
1025
+ }
1026
+ // The kept copy was read before the request: every row of it predates the
1027
+ // answer, so a newer one the answer lacks was deleted while away.
1028
+ const merged = mergeTailPage(cached.messages, response.messages, {
1029
+ pageHasMoreOlder: payloadHasMoreOlder(response),
1030
+ windowPredatesPage: true,
1031
+ });
1032
+ if (!merged) {
1033
+ // More was written than the answer holds: a reader at the end gets the
1034
+ // end; anyone else keeps the window, which no longer reaches it.
1035
+ if (atEnd) {
1036
+ applyLoadedConversationState(id, response, { restoreScroll: false });
1037
+ return;
1038
+ }
1039
+ detachConversationWindowFromTail();
1040
+ applyLoadedConversationState(id, response, { keepTranscript: true, followLiveUpdates: false, windowPayload: keptWindow(true) });
1041
+ return;
1042
+ }
1043
+ const place = atEnd ? null : captureConversationReadingAnchor()?.anchor;
1044
+ const flags = { hasMoreOlder: cached.hasMoreOlder, hasMoreNewer: false };
1045
+ applyLoadedConversationState(id, {
1046
+ ...response,
1047
+ messages: merged.messages,
1048
+ ...flags,
1049
+ pageInfo: windowPageInfo(merged.messages, flags),
1050
+ }, {
1051
+ mergeFrom: cached.messages,
1052
+ followLiveUpdates: atEnd,
1053
+ });
1054
+ const messagesEl = document.getElementById('messages');
1055
+ if (!messagesEl) return;
1056
+ if (atEnd) messagesEl.scrollTop = messagesEl.scrollHeight;
1057
+ else if (place) restoreConversationReadingAnchor(place);
1058
+ saveConversationScrollTop(id, messagesEl.scrollTop);
1059
+ }
1060
+
915
1061
  export async function openConversation(id, options = {}) {
916
1062
  const didLeaveStatusView = leaveStatusView();
917
1063
  document.getElementById('input-area')?.removeAttribute('hidden');
@@ -925,6 +1071,7 @@ export async function openConversation(id, options = {}) {
925
1071
  window.clearImageEditTarget?.();
926
1072
  }
927
1073
  const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
1074
+ if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
928
1075
  const capturedVersion = ++openConversationVersion;
929
1076
  setCurrentConv(id);
930
1077
  if (repoBrowserState.activeRoot === 'workspace') {
@@ -961,35 +1108,74 @@ export async function openConversation(id, options = {}) {
961
1108
  const focusMessageId = String(options.focusMessageId || '').trim();
962
1109
  const aroundMessageId = String(options.aroundMessageId || focusMessageId || '').trim();
963
1110
  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,
1111
+ const keepsPlace = !forceFreshWindow && !IS_SHARED_VIEW;
1112
+ const savedAnchor = keepsPlace ? loadConversationReadingAnchor(id) : null;
1113
+ // Switching back to a conversation seen before on this page: its kept
1114
+ // window is drawn at once, at the place read, and the fresh answer below
1115
+ // is merged into it.
1116
+ const cached = keepsPlace ? readCachedWindow(id, savedAnchor) : null;
1117
+ if (cached && (switchingConversation || didLeaveStatusView)) {
1118
+ applyLoadedConversationState(id, cached, { fromCache: true, viewAnchor: savedAnchor });
1119
+ }
1120
+ // Nothing kept (a reload, a window dropped): a reader who was not at the
1121
+ // end gets the messages around the one they read, as after a search jump.
1122
+ // Only when the page does not show the conversation already: reopening the
1123
+ // one on screen (a session bound, a click on it, a push) rebuilds its end
1124
+ // under the reader, a jump would throw away the window they scrolled.
1125
+ const showsOtherTranscript = switchingConversation || didLeaveStatusView;
1126
+ const anchorMessageId = !cached && showsOtherTranscript && savedAnchor && !savedAnchor.atTail
1127
+ ? String(savedAnchor.messageId || '').trim()
1128
+ : '';
1129
+ const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
1130
+ const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
1131
+ limit: around ? 40 : 20,
1132
+ aroundMessageId: around || undefined,
975
1133
  });
976
- if (!shouldApplyConversationLoad({
1134
+ const stillWanted = () => shouldApplyConversationLoad({
977
1135
  requestedConversationId: id,
978
1136
  activeConversationId: currentConvId,
979
1137
  capturedVersion,
980
1138
  currentVersion: openConversationVersion,
981
- })) {
982
- return;
983
- }
984
- if (r) {
1139
+ });
1140
+ // The drawn copy takes live rows from the socket while the answer is on its
1141
+ // way; an answer that may lack them is asked again, as the live poll does.
1142
+ const { response: fetched, stale } = cached
1143
+ ? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
1144
+ : { response: await load(), stale: false };
1145
+ if (!stillWanted()) return;
1146
+ let r = fetched;
1147
+ const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
1148
+ if (r && anchorMessageId && !anchorFound) {
1149
+ // The message read is gone (deleted, compacted away): the end instead.
1150
+ r = await load('');
1151
+ if (!stillWanted()) return;
1152
+ }
1153
+ if (r && cached) {
1154
+ applyFreshOverCachedConversation(id, r, {
1155
+ cached,
1156
+ // A jump into the history from the drawn copy (a pin) wins. A stale
1157
+ // answer is still merged, unlike on the live poll: the drawn copy
1158
+ // predates the rows stored while the reader was away.
1159
+ keepTranscript: shouldKeepTranscriptOnReload({
1160
+ stale,
1161
+ jumped: getTranscriptJumpEpoch() !== cachedJumpEpoch,
1162
+ showsLiveTranscript: false,
1163
+ }),
1164
+ });
1165
+ } else if (r) {
985
1166
  // The conversation on screen opened again (a worker bound its session):
986
1167
  // its rows are rebuilt under the reader, who keeps the text they are at.
987
1168
  const messagesEl = document.getElementById('messages');
988
- const reading = !switchingConversation && restoreScroll ? captureReadingBlock(messagesEl) : null;
1169
+ const rereading = !switchingConversation && !anchorFound && !isMessagesNearBottom();
1170
+ const reading = rereading ? captureReadingBlock(messagesEl) : null;
989
1171
  // Counted when the window is put on the page, not when it is asked for:
990
1172
  // 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 });
1173
+ if (forceFreshWindow || anchorFound) noteTranscriptJump();
1174
+ applyLoadedConversationState(id, r, {
1175
+ restoreScroll: rereading || anchorFound,
1176
+ savedScrollTop: rereading ? (messagesEl?.scrollTop ?? null) : null,
1177
+ viewAnchor: anchorFound ? savedAnchor : null,
1178
+ });
993
1179
  if (reading) restoreReadingBlock(messagesEl, reading);
994
1180
  if (focusMessageId) {
995
1181
  requestAnimationFrame(() => {
@@ -997,6 +1183,7 @@ export async function openConversation(id, options = {}) {
997
1183
  });
998
1184
  }
999
1185
  } else {
1186
+ conversationPayloadCache.forget(id);
1000
1187
  setRepoBrowserSessionInfo('', '');
1001
1188
  restoreInFlightThinking(null);
1002
1189
  renderMessages([]);
@@ -2319,6 +2506,7 @@ async function setConversationArchived(id, archived) {
2319
2506
  return false;
2320
2507
  }
2321
2508
  if (conversations[id]) conversations[id].archived = archived;
2509
+ conversationPayloadCache.forget(id);
2322
2510
  renderConvList();
2323
2511
  window.syncChatTitleControls?.();
2324
2512
  window.syncComposerButtonState?.();
@@ -2531,7 +2719,10 @@ async function archiveConversationGroup(groupId) {
2531
2719
  return;
2532
2720
  }
2533
2721
  const skipped = Array.isArray(result.skipped) ? result.skipped : [];
2534
- for (const id of result.archived || []) if (conversations[id]) conversations[id].archived = true;
2722
+ for (const id of result.archived || []) {
2723
+ if (conversations[id]) conversations[id].archived = true;
2724
+ conversationPayloadCache.forget(id);
2725
+ }
2535
2726
  renderConvList();
2536
2727
  if (skipped.length) {
2537
2728
  const names = skipped.map((entry) => `“${String(entry.title || entry.id).trim()}”`).join(', ');
@@ -2861,6 +3052,7 @@ export async function deleteConv(e, id) {
2861
3052
  return;
2862
3053
  }
2863
3054
  delete conversations[id];
3055
+ conversationPayloadCache.forget(id);
2864
3056
  renderConvList();
2865
3057
  if (currentConvId === id) clearOpenConversation();
2866
3058
  }
@@ -11,6 +11,7 @@ const PUSH_EVENT_CONTROL_IDS = {
11
11
  board: 'push-event-board-toggle',
12
12
  cliOffline: 'push-event-cli-offline-toggle',
13
13
  hostSuspend: 'push-event-host-suspend-toggle',
14
+ relayRestart: 'push-event-relay-restart-toggle',
14
15
  };
15
16
 
16
17
  let pushUpdateInFlight = false;
@@ -1,6 +1,6 @@
1
1
  import { stripRemotePromptHeader } from './remote-relay-shared.mjs';
2
2
 
3
- const RELAY_TOOL_GUIDANCE = [
3
+ const RELAY_TOOL_GUIDANCE_LINES = [
4
4
  '# Relay Tool Guidance',
5
5
  'For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.',
6
6
  'When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so the web relay can render clickable buttons. Only omit choices when the question genuinely requires freeform text input.',
@@ -8,7 +8,12 @@ const RELAY_TOOL_GUIDANCE = [
8
8
  'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
9
9
  'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
10
10
  'Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.',
11
- ].join(' ');
11
+ 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.',
12
+ ];
13
+ const RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE_LINES.join(' ');
14
+ // Prompts stored before the `immediate` line was added carry the guidance
15
+ // without it.
16
+ const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE_LINES.slice(0, -1).join(' ');
12
17
 
13
18
  const MODE_MARKERS = {
14
19
  plan: '[Relay mode: plan]',
@@ -77,21 +82,30 @@ function whitespaceFlexiblePattern(value) {
77
82
  .join('\\s+');
78
83
  }
79
84
 
85
+ // The patterns depend only on the mode, so they are built once per mode and
86
+ // reused: a long conversation sanitises every message on each history load.
87
+ const PROMPT_PREFIX_PATTERN_CACHE = new Map();
88
+
80
89
  function buildPromptPrefixPatterns(mode) {
81
90
  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) => {
91
+ const cacheKey = requestedMode && MODE_MARKERS[requestedMode] ? requestedMode : '';
92
+ const cached = PROMPT_PREFIX_PATTERN_CACHE.get(cacheKey);
93
+ if (cached) return cached;
94
+ const modes = cacheKey ? [cacheKey] : Object.keys(MODE_MARKERS);
95
+ const patterns = modes.flatMap((key) => {
86
96
  const marker = MODE_MARKERS[key];
87
97
  const currentFullPrefix = `${marker} ${MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
88
98
  const legacyFullPrefix = `${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
89
99
  return [
90
100
  new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
91
101
  new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
102
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
103
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
92
104
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
93
105
  ];
94
106
  });
107
+ PROMPT_PREFIX_PATTERN_CACHE.set(cacheKey, patterns);
108
+ return patterns;
95
109
  }
96
110
 
97
111
  function stripAttachmentPromptArtifacts(text) {
@@ -0,0 +1,36 @@
1
+ // The note the relay writes into a conversation whose turn a relay restart
2
+ // cut off ("After a relay restart, tell interrupted sessions to carry on",
3
+ // server/services/restart-resume-service.mjs): a compact row in the look of
4
+ // the relay event rows, never a bubble of the user's.
5
+ //
6
+ // DOM-free, like relay-event-view.mjs: the caller owns the element.
7
+
8
+ export const RESTART_RESUME_KIND = 'relay-restart-resume';
9
+ export const RESTART_RESUME_LEAD = 'Automatic message from OAR, not from the user:';
10
+
11
+ function defaultEscape(value) {
12
+ return String(value ?? '')
13
+ .replace(/&/g, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#39;');
18
+ }
19
+
20
+ export function isRestartResumeNote(message) {
21
+ return message?.role === 'user' && String(message?.kind || '').trim() === RESTART_RESUME_KIND;
22
+ }
23
+
24
+ /** The row's markup: folded to one line, opened to the note as the agent got it. */
25
+ export function renderRestartResumeNoteHtml(message, { escHtml = defaultEscape, timeText = '' } = {}) {
26
+ const text = String(message?.text ?? '').trim();
27
+ const body = text.startsWith(RESTART_RESUME_LEAD) ? text.slice(RESTART_RESUME_LEAD.length).trim() : text;
28
+ return '<details class="relay-event-row relay-restart-note">'
29
+ + '<summary class="relay-event-summary">'
30
+ + '<span class="relay-event-tag" title="An automated message of the relay, not from you.">relay</span>'
31
+ + '<span class="relay-event-text">Relay restarted · the interrupted turn was asked to carry on</span>'
32
+ + (timeText ? `<span class="relay-event-time">${escHtml(timeText)}</span>` : '')
33
+ + '</summary>'
34
+ + `<div class="relay-event-lines relay-restart-note-text">${escHtml(body)}</div>`
35
+ + '</details>';
36
+ }