@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
@@ -43,6 +43,8 @@ import {
43
43
  loadConversationScrollTop,
44
44
  loadConversationLoadedMessageCount,
45
45
  saveConversationScrollTop,
46
+ loadConversationReadingAnchor,
47
+ saveConversationReadingAnchor,
46
48
  getSessionWorkerState,
47
49
  resolveConversationUiState,
48
50
  summaryModalState,
@@ -61,6 +63,8 @@ import {
61
63
  } from './transcript-viewport-anchor.mjs';
62
64
  import { initTranscriptJumpToBottom, onTranscriptEndLeft } from './transcript-jump-bottom-view.mjs';
63
65
  import { syncScrollOnSendToggle, updateScrollOnSendSetting } from './transcript-jump-bottom.mjs';
66
+ import { previewWindowRetention, syncWindowRetentionSlider, updateWindowRetention } from './conversation-window-retention.mjs';
67
+ import { conversationPayloadCache, mergeReloadIntoWindow } from './conversation-payload-cache.mjs';
64
68
  import {
65
69
  verifyExistingSession,
66
70
  verifyToken,
@@ -101,6 +105,7 @@ import {
101
105
  openOriginConversation,
102
106
  openConversationGroupPicker,
103
107
  toggleConversationGroupFold,
108
+ isConversationWorking,
104
109
  } from './journal-view.js';
105
110
  import {
106
111
  loadRelayQuestions,
@@ -139,8 +144,12 @@ import {
139
144
  isConversationWindowAtTail,
140
145
  getTranscriptJumpEpoch,
141
146
  getTranscriptLiveRowEpoch,
147
+ getHeldBackLiveRowIds,
148
+ captureConversationReadingAnchor,
149
+ canMergeIntoTranscript,
142
150
  } from './conversation-view.js';
143
151
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
152
+ import { newerPageReachesEnd } from './transcript-island.mjs';
144
153
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
145
154
  import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
146
155
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
@@ -189,6 +198,7 @@ import {
189
198
  closeMessageSearchModal,
190
199
  } from './message-search-view.js';
191
200
  import { initSubagentViewer } from './subagent-viewer.mjs';
201
+ import { initTaskConsole } from './task-console.mjs';
192
202
  import { watchPreviewMarks } from './share-preview-marks.mjs';
193
203
  import {
194
204
  initGitChangesView,
@@ -327,6 +337,8 @@ import {
327
337
  updateTurnCeilingSetting,
328
338
  previewBackgroundTaskTimeoutSetting,
329
339
  updateBackgroundTaskTimeoutSetting,
340
+ previewTaskStallMinutesSetting,
341
+ updateTaskStallMinutesSetting,
330
342
  updateSubagentRecordRetentionSetting,
331
343
  } from './settings-modal.js';
332
344
  import {
@@ -3703,6 +3715,23 @@ function transcriptIsInHistory(conversationId) {
3703
3715
  return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
3704
3716
  }
3705
3717
 
3718
+ // A reload of the end of the conversation on screen, when the page holds
3719
+ // more rows than the answer can (the relay caps a page at 100): the answer
3720
+ // is merged into the window (mergeReloadIntoWindow) rather than rendered,
3721
+ // which would cut the page and its kept window down to the answer. Null
3722
+ // means the plain render, as before: nothing to merge, or a transcript that
3723
+ // cannot take a merge (a bubble waiting for its send, the status view, a
3724
+ // held selection, a row missing from the page). Both callers merge only an
3725
+ // answer no live row overtook (loadKeepingLiveRows not stale), so the window
3726
+ // predates it: a newer row the answer lacks was deleted (a cancelled
3727
+ // follow-up) and leaves the page.
3728
+ function reloadMergeFor(conversationId, response) {
3729
+ if (IS_SHARED_VIEW) return null;
3730
+ const merge = mergeReloadIntoWindow(conversationPayloadCache.peekWindow(conversationId), response, { windowPredatesPage: true });
3731
+ if (!merge || !canMergeIntoTranscript(merge.mergeFrom, conversationId)) return null;
3732
+ return merge;
3733
+ }
3734
+
3706
3735
  async function pollAuthenticatedCurrentConversationLive() {
3707
3736
  if (liveConversationPollInFlight) return;
3708
3737
  if (isSharedReaderMode()) return;
@@ -3748,11 +3777,16 @@ async function pollAuthenticatedCurrentConversationLive() {
3748
3777
  // The rows are rebuilt: a reader who is not at the end keeps the text
3749
3778
  // they read where it is, whatever the rows above it measure now.
3750
3779
  const reading = preserveBottom ? null : captureReadingBlock(messagesEl);
3751
- applyLoadedConversationState(currentId, response, {
3780
+ const merge = reloadMergeFor(currentId, response);
3781
+ applyLoadedConversationState(currentId, merge?.payload || response, {
3752
3782
  restoreScroll: !preserveBottom,
3753
3783
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3754
3784
  followLiveUpdates: preserveBottom,
3785
+ mergeFrom: merge?.mergeFrom || null,
3755
3786
  });
3787
+ // A merge does not scroll as the full render does: a reader at the end
3788
+ // is taken to the new end here.
3789
+ if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
3756
3790
  if (reading) restoreReadingBlock(messagesEl, reading);
3757
3791
  } finally {
3758
3792
  liveConversationPollInFlight = false;
@@ -3888,6 +3922,21 @@ function initComposerAttachmentInput() {
3888
3922
  window.addEventListener('dragend', clearHighlight);
3889
3923
  }
3890
3924
 
3925
+ // The place read is noted once per frame: it measures rows, a scroll event
3926
+ // can come many times a frame. It is noted for the conversation whose rows
3927
+ // are on the page, and only while that is the one open: during a switch the
3928
+ // page still shows the conversation left.
3929
+ let readingAnchorSaveFrame = 0;
3930
+ function scheduleReadingAnchorSave() {
3931
+ if (readingAnchorSaveFrame || IS_SHARED_VIEW) return;
3932
+ readingAnchorSaveFrame = requestAnimationFrame(() => {
3933
+ readingAnchorSaveFrame = 0;
3934
+ const captured = captureConversationReadingAnchor();
3935
+ if (!captured || captured.conversationId !== String(currentConvId || '').trim()) return;
3936
+ saveConversationReadingAnchor(captured.conversationId, captured.anchor);
3937
+ });
3938
+ }
3939
+
3891
3940
  function initMessageScrollPersistence() {
3892
3941
  const el = document.getElementById('messages');
3893
3942
  if (!el || el.dataset.scrollPersistenceBound === '1') return;
@@ -3897,19 +3946,43 @@ function initMessageScrollPersistence() {
3897
3946
  const convId = String(currentConvId || '').trim();
3898
3947
  if (!convId) return;
3899
3948
  saveConversationScrollTop(convId, el.scrollTop);
3949
+ scheduleReadingAnchorSave();
3900
3950
  }, { passive: true });
3901
3951
  }
3902
3952
 
3903
- // The jump-to-bottom button on a window in the middle of the history: the
3904
- // end of the conversation replaces it, scrolled to the bottom.
3953
+ // The end's answer can predate a row held back below the window while it
3954
+ // was on its way (stored after the request read the conversation), and its
3955
+ // render lets the held rows go. The newest row held is the one to look for:
3956
+ // older ones can lie before a page of the end and are not missing.
3957
+ function endAnswerLacksHeldBackRow(response) {
3958
+ const newestHeld = getHeldBackLiveRowIds().slice(-1);
3959
+ if (!newestHeld.length) return false;
3960
+ return !newerPageReachesEnd({
3961
+ pageHasMore: false,
3962
+ pageMessageIds: (Array.isArray(response?.messages) ? response.messages : []).map((message) => message?.id),
3963
+ heldBackIds: newestHeld,
3964
+ });
3965
+ }
3966
+
3967
+ // The jump-to-bottom button on a window in the middle of the history (and an
3968
+ // own send from one): the end of the conversation replaces it, scrolled to
3969
+ // the bottom.
3905
3970
  async function loadCurrentConversationEnd() {
3906
3971
  const currentId = String(currentConvId || '').trim();
3907
3972
  if (!currentId) return;
3908
3973
  const jumpEpoch = getTranscriptJumpEpoch();
3909
- const response = await loadConversation(currentId, { limit: 20 });
3974
+ const load = () => loadConversation(currentId, { limit: 20 });
3975
+ let response = await load();
3910
3976
  if (!response || String(currentConvId || '').trim() !== currentId) return;
3911
3977
  // A jump into the history while it was on its way wins.
3912
3978
  if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3979
+ // Asked once more, not until it has them: a held row deleted since would
3980
+ // never come.
3981
+ if (endAnswerLacksHeldBackRow(response)) {
3982
+ const again = await load();
3983
+ if (String(currentConvId || '').trim() !== currentId || getTranscriptJumpEpoch() !== jumpEpoch) return;
3984
+ if (again) response = again;
3985
+ }
3913
3986
  applyLoadedConversationState(currentId, response, { restoreScroll: false });
3914
3987
  }
3915
3988
 
@@ -4005,12 +4078,16 @@ async function refreshCurrentView() {
4005
4078
  const reading = preserveBottom || keepTranscript ? null : captureReadingBlock(messagesEl);
4006
4079
  let restoredAt = null;
4007
4080
  if (r) {
4008
- applyLoadedConversationState(currentId, r, {
4081
+ const merge = keepTranscript ? null : reloadMergeFor(currentId, r);
4082
+ applyLoadedConversationState(currentId, merge?.payload || r, {
4009
4083
  restoreScroll: !preserveBottom,
4010
4084
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
4011
4085
  followLiveUpdates: preserveBottom && !keepTranscript,
4012
4086
  keepTranscript,
4087
+ mergeFrom: merge?.mergeFrom || null,
4013
4088
  });
4089
+ // As in the live poll: a merge leaves the scrolling to the caller.
4090
+ if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
4014
4091
  // At once, and again below once the cards are back on the page.
4015
4092
  restoreReadingBlock(messagesEl, reading);
4016
4093
  restoredAt = { scrollTop: messagesEl?.scrollTop };
@@ -4254,6 +4331,7 @@ function syncChatTitleControls() {
4254
4331
  if (title) {
4255
4332
  if (!convId || !conversation) {
4256
4333
  delete title.dataset.uiState;
4334
+ delete title.dataset.working;
4257
4335
  } else {
4258
4336
  const pendingByConversation = getPendingQuestionCountsByConversation();
4259
4337
  const pendingCount = Number(pendingByConversation[convId] || 0);
@@ -4264,6 +4342,8 @@ function syncChatTitleControls() {
4264
4342
  hasPendingQuestion: pendingCount > 0,
4265
4343
  });
4266
4344
  title.dataset.uiState = uiState;
4345
+ // Bright green while it works, the darker green while idle, as its row.
4346
+ title.toggleAttribute('data-working', isConversationWorking(conversation));
4267
4347
  }
4268
4348
  }
4269
4349
  if (chatTitleEditingConversationId && chatTitleEditingConversationId !== convId) {
@@ -4519,6 +4599,7 @@ async function initApp() {
4519
4599
  if (!sharedMode) {
4520
4600
  syncSuspendHostVisibility();
4521
4601
  syncScrollOnSendToggle();
4602
+ syncWindowRetentionSlider();
4522
4603
  initHostSuspendUi();
4523
4604
  initUsageLimitUi();
4524
4605
  }
@@ -4848,6 +4929,7 @@ async function initApp() {
4848
4929
  initComposerAttachmentInput();
4849
4930
  initMessageSearchView({ openConversation });
4850
4931
  initSubagentViewer();
4932
+ initTaskConsole();
4851
4933
  initPinnedMessagesView({
4852
4934
  openConversation,
4853
4935
  focusMessage: focusConversationMessageById,
@@ -5007,11 +5089,19 @@ window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
5007
5089
  window.toggleCursorProvider = toggleCursorProvider;
5008
5090
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5009
5091
  window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
5092
+ window.previewConversationWindowRetention = (position) => previewWindowRetention(position);
5093
+ // A lower cap cuts the kept windows down now; the open conversation's is cut
5094
+ // when it is left.
5095
+ window.updateConversationWindowRetention = (position) => updateWindowRetention(position, {
5096
+ onChange: (cap) => conversationPayloadCache.trimAll(cap, loadConversationReadingAnchor, { except: currentConvId }),
5097
+ });
5010
5098
  window.updateWindowsAutostartMode = updateWindowsAutostartMode;
5011
5099
  window.previewTurnCeilingSetting = previewTurnCeilingSetting;
5012
5100
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
5013
5101
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
5014
5102
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
5103
+ window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
5104
+ window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
5015
5105
  window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
5016
5106
  window.togglePushOnThisDevice = togglePushOnThisDevice;
5017
5107
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
@@ -0,0 +1,21 @@
1
+ // Mirrors shared/claude-background-command-limit.mjs, which the browser cannot
2
+ // import (only server/public is served).
3
+ // shared/claude-background-command-limit.test.mjs asserts the two stay
4
+ // identical.
5
+ //
6
+ // The Claude settings slider shows the default until the relay's own value
7
+ // loads; the authoritative bounds come from GET /api/settings/claude.
8
+
9
+ export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
10
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
11
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
12
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
13
+
14
+ export function formatClaudeBackgroundCommandLimitLabel(minutes) {
15
+ const numeric = Math.round(Number(minutes));
16
+ const value = Number.isFinite(numeric) && numeric > 0 ? numeric : DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES;
17
+ if (value < 60) return `${value} min`;
18
+ const hours = Math.floor(value / 60);
19
+ const remainder = value % 60;
20
+ return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
21
+ }
@@ -0,0 +1,357 @@
1
+ // The window of messages the page showed for each recently viewed
2
+ // conversation, with the payload it came in, in memory for this page load
3
+ // only. Switching back to one of them draws it at once from here, where the
4
+ // reader left it, older pages included; the fresh answer the switch asks for
5
+ // is merged into it when it arrives. The window follows the page: pages of
6
+ // older or newer history and the socket's live rows are added to it, and it
7
+ // is cut down to the retention cap when the reader leaves the conversation
8
+ // (conversation-window-retention.mjs).
9
+ // The live parts of a payload (a turn in flight, its state, tasks, draft,
10
+ // usage pause) are left out of the copy a read hands out: they are out of
11
+ // date the moment the payload is stored, and the fresh answer and the socket
12
+ // set them.
13
+
14
+ import { mergeTailPage, trimMessageWindow } from './transcript-reading-anchor.mjs';
15
+
16
+ /** Conversations kept; the least recently used goes first. */
17
+ export const CONVERSATION_PAYLOAD_CACHE_LIMIT = 8;
18
+
19
+ // Fields of a conversation payload that describe the moment it was read, not
20
+ // the transcript. A cached copy without them cannot end a turn, start the
21
+ // thinking bubble, or bring back a draft or a task list that has changed.
22
+ const LIVE_PAYLOAD_FIELDS = [
23
+ 'inFlight',
24
+ 'activeTurn',
25
+ 'sessionState',
26
+ 'backgroundTasks',
27
+ 'endedBackgroundTasks',
28
+ 'usageLimitPause',
29
+ 'draftText',
30
+ 'draftAttachments',
31
+ 'draftUpdatedAt',
32
+ 'draftUpdatedByClientId',
33
+ 'previews',
34
+ ];
35
+
36
+ // What the payload said about its own page; a read describes the kept
37
+ // window instead.
38
+ const PAGE_FIELDS = ['pageInfo', 'hasMoreHistory', 'historyCursor', 'historyNewestMessageId'];
39
+
40
+ function normalizeId(conversationId) {
41
+ return String(conversationId || '').trim();
42
+ }
43
+
44
+ function messageIdOf(message) {
45
+ return String(message?.id || '').trim();
46
+ }
47
+
48
+ function clonePayload(value) {
49
+ try {
50
+ return structuredClone(value);
51
+ } catch {
52
+ // A payload is plain JSON; this is only reached by an odd value in it.
53
+ return JSON.parse(JSON.stringify(value));
54
+ }
55
+ }
56
+
57
+ /** True when the payload is a window with newer messages left unloaded. */
58
+ export function payloadHasMoreNewer(response) {
59
+ if (!response || typeof response !== 'object') return false;
60
+ if (typeof response.hasMoreNewer === 'boolean') return response.hasMoreNewer;
61
+ const pageInfo = response.pageInfo && typeof response.pageInfo === 'object' ? response.pageInfo : null;
62
+ return !!pageInfo?.hasMoreNewer;
63
+ }
64
+
65
+ /** True when the payload has older messages left unloaded; read as renderMessages reads it. */
66
+ export function payloadHasMoreOlder(response) {
67
+ if (!response || typeof response !== 'object') return false;
68
+ if (typeof response.hasMoreOlder === 'boolean') return response.hasMoreOlder;
69
+ if (typeof response.hasMoreHistory === 'boolean') return response.hasMoreHistory;
70
+ const pageInfo = response.pageInfo && typeof response.pageInfo === 'object' ? response.pageInfo : null;
71
+ return !!pageInfo?.hasMoreOlder || !!pageInfo?.hasMore;
72
+ }
73
+
74
+ function isCacheable(response) {
75
+ return !!response && typeof response === 'object' && Array.isArray(response.messages);
76
+ }
77
+
78
+ function cursorOf(message, direction) {
79
+ if (!message) return null;
80
+ const id = messageIdOf(message);
81
+ const timestamp = String(message.timestamp || '').trim() || null;
82
+ return direction === 'older'
83
+ ? { beforeMessageId: id, beforeTimestamp: timestamp }
84
+ : { afterMessageId: id, afterTimestamp: timestamp };
85
+ }
86
+
87
+ /** The pageInfo of a kept window, in the relay's shape, so the loaders page on from its edges. */
88
+ export function windowPageInfo(messages, { hasMoreOlder = false, hasMoreNewer = false } = {}) {
89
+ const list = Array.isArray(messages) ? messages : [];
90
+ const first = list[0] || null;
91
+ const last = list[list.length - 1] || null;
92
+ return {
93
+ hasMore: !!hasMoreOlder,
94
+ hasMoreOlder: !!hasMoreOlder,
95
+ hasMoreNewer: !!hasMoreNewer,
96
+ nextCursor: cursorOf(first, 'older'),
97
+ olderCursor: cursorOf(first, 'older'),
98
+ newerCursor: cursorOf(last, 'newer'),
99
+ };
100
+ }
101
+
102
+ function windowPayload(entry) {
103
+ const transcriptOnly = { ...entry.payload };
104
+ for (const field of LIVE_PAYLOAD_FIELDS) delete transcriptOnly[field];
105
+ for (const field of PAGE_FIELDS) delete transcriptOnly[field];
106
+ const flags = { hasMoreOlder: entry.hasMoreOlder, hasMoreNewer: entry.hasMoreNewer };
107
+ return {
108
+ ...transcriptOnly,
109
+ messages: entry.messages,
110
+ ...flags,
111
+ pageInfo: windowPageInfo(entry.messages, flags),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The copy of a payload the cache hands out, without its live fields, or
117
+ * null when it must not be kept. The live fields are left out before the
118
+ * clone: a turn in flight can carry thousands of thought rows nobody reads.
119
+ */
120
+ export function cacheablePayload(response) {
121
+ if (!isCacheable(response)) return null;
122
+ return clonePayload(windowPayload(entryFor(response)));
123
+ }
124
+
125
+ function entryFor(response) {
126
+ return {
127
+ payload: response,
128
+ messages: response.messages,
129
+ hasMoreOlder: payloadHasMoreOlder(response),
130
+ hasMoreNewer: payloadHasMoreNewer(response),
131
+ };
132
+ }
133
+
134
+ // The messages of `incoming` whose ids the window lacks, in their order.
135
+ function unseen(entry, incoming) {
136
+ const known = new Set(entry.messages.map(messageIdOf).filter(Boolean));
137
+ return (Array.isArray(incoming) ? incoming : []).filter((message) => {
138
+ const id = messageIdOf(message);
139
+ if (!message || !id || known.has(id)) return false;
140
+ known.add(id);
141
+ return true;
142
+ });
143
+ }
144
+
145
+ /**
146
+ * A reload of the end of the conversation on screen (the live poll, a view
147
+ * refresh) merged into the window the page holds, or null when the plain
148
+ * render of the answer is right. The relay caps a page at 100 messages: with
149
+ * more loaded, a render of the answer would cut the page and the kept window
150
+ * down to it. Merged only when both the window and the answer reach the end,
151
+ * the window holds more than the answer, and the two meet (mergeTailPage).
152
+ * `mergeFrom` is the window as it is on the page, `payload` the answer with
153
+ * the merged window as its messages and the window's edges.
154
+ * `windowPredatesPage`: no live row reached the window while the answer was
155
+ * on its way (see mergeTailPage), so a newer window row the answer lacks was
156
+ * deleted, not brought by the socket after the read.
157
+ */
158
+ export function mergeReloadIntoWindow(kept, response, { windowPredatesPage = false } = {}) {
159
+ if (!kept || !Array.isArray(kept.messages) || !isCacheable(response)) return null;
160
+ if (kept.hasMoreNewer || payloadHasMoreNewer(response)) return null;
161
+ if (kept.messages.length <= response.messages.length) return null;
162
+ const merged = mergeTailPage(kept.messages, response.messages, {
163
+ pageHasMoreOlder: payloadHasMoreOlder(response),
164
+ windowPredatesPage,
165
+ });
166
+ if (!merged) return null;
167
+ const flags = { hasMoreOlder: !!kept.hasMoreOlder, hasMoreNewer: false };
168
+ return {
169
+ mergeFrom: kept.messages,
170
+ payload: {
171
+ ...response,
172
+ messages: merged.messages,
173
+ ...flags,
174
+ pageInfo: windowPageInfo(merged.messages, flags),
175
+ },
176
+ };
177
+ }
178
+
179
+ export function createConversationPayloadCache({ limit = CONVERSATION_PAYLOAD_CACHE_LIMIT } = {}) {
180
+ const maxEntries = Math.max(1, Math.trunc(Number(limit) || CONVERSATION_PAYLOAD_CACHE_LIMIT));
181
+ // A Map keeps insertion order: the first key is the least recently used.
182
+ const entries = new Map();
183
+
184
+ function forget(conversationId) {
185
+ const id = normalizeId(conversationId);
186
+ if (!id) return false;
187
+ return entries.delete(id);
188
+ }
189
+
190
+ /**
191
+ * Keeps the payload just applied for the conversation: its messages are the
192
+ * window the page shows. A payload that may not be kept drops the older one
193
+ * too: the page no longer shows it.
194
+ * Only a reference is kept: this runs on every apply, the live poll during
195
+ * a turn included, and a fresh payload from the relay is not changed once
196
+ * it is on the page. The copy is made on read, when a switch needs it; the
197
+ * window's own changes below make new arrays and never touch the payload.
198
+ */
199
+ function remember(conversationId, response) {
200
+ const id = normalizeId(conversationId);
201
+ if (!id) return false;
202
+ entries.delete(id);
203
+ if (!isCacheable(response)) return false;
204
+ entries.set(id, entryFor(response));
205
+ while (entries.size > maxEntries) {
206
+ entries.delete(entries.keys().next().value);
207
+ }
208
+ return true;
209
+ }
210
+
211
+ // Changes to a window do not count as use: a conversation still open is
212
+ // the most recently used anyway, and one read last keeps its place.
213
+ function entryOf(conversationId) {
214
+ return entries.get(normalizeId(conversationId)) || null;
215
+ }
216
+
217
+ /** An older page was put above the window. */
218
+ function addOlder(conversationId, messages, { hasMoreOlder = false } = {}) {
219
+ const entry = entryOf(conversationId);
220
+ if (!entry) return false;
221
+ entry.messages = [...unseen(entry, messages), ...entry.messages];
222
+ entry.hasMoreOlder = !!hasMoreOlder;
223
+ return true;
224
+ }
225
+
226
+ /** A newer page was put below the window. */
227
+ function addNewer(conversationId, messages, { hasMoreNewer = false } = {}) {
228
+ const entry = entryOf(conversationId);
229
+ if (!entry) return false;
230
+ entry.messages = [...entry.messages, ...unseen(entry, messages)];
231
+ entry.hasMoreNewer = !!hasMoreNewer;
232
+ return true;
233
+ }
234
+
235
+ /**
236
+ * A live row from the socket: it replaces the message with its id, or joins
237
+ * the end of a window that is the end of the conversation. A window in the
238
+ * middle of the history leaves it to the newer pages.
239
+ */
240
+ function upsertMessage(conversationId, message) {
241
+ const entry = entryOf(conversationId);
242
+ const messageId = messageIdOf(message);
243
+ if (!entry || !messageId) return false;
244
+ const index = entry.messages.findIndex((item) => messageIdOf(item) === messageId);
245
+ if (index >= 0) {
246
+ entry.messages = entry.messages.map((item, at) => (at === index ? message : item));
247
+ return true;
248
+ }
249
+ if (entry.hasMoreNewer) return false;
250
+ entry.messages = [...entry.messages, message];
251
+ return true;
252
+ }
253
+
254
+ /** The window no longer meets the end of the conversation (more was written than one page holds). */
255
+ function detachFromTail(conversationId) {
256
+ const entry = entryOf(conversationId);
257
+ if (!entry) return false;
258
+ entry.hasMoreNewer = true;
259
+ return true;
260
+ }
261
+
262
+ /**
263
+ * Cuts the window down to `cap` messages around the reader's place
264
+ * (trimMessageWindow). True when rows were dropped.
265
+ */
266
+ function trim(conversationId, { cap = Infinity, anchorMessageId = '', atTail = false } = {}) {
267
+ const entry = entryOf(conversationId);
268
+ if (!entry) return false;
269
+ const next = trimMessageWindow(entry.messages, {
270
+ cap,
271
+ anchorMessageId,
272
+ atTail,
273
+ hasMoreOlder: entry.hasMoreOlder,
274
+ hasMoreNewer: entry.hasMoreNewer,
275
+ });
276
+ if (!next.trimmed) return false;
277
+ entry.messages = next.messages;
278
+ entry.hasMoreOlder = next.hasMoreOlder;
279
+ entry.hasMoreNewer = next.hasMoreNewer;
280
+ return true;
281
+ }
282
+
283
+ /** `anchorOf(id)` gives the reader's place in each kept conversation. */
284
+ function trimAll(cap, anchorOf = () => null, { except = '' } = {}) {
285
+ const skip = normalizeId(except);
286
+ let trimmed = 0;
287
+ for (const id of [...entries.keys()]) {
288
+ if (id === skip) continue;
289
+ const anchor = anchorOf(id) || null;
290
+ if (trim(id, { cap, anchorMessageId: anchor?.messageId || '', atTail: anchor?.atTail === true })) trimmed += 1;
291
+ }
292
+ return trimmed;
293
+ }
294
+
295
+ /**
296
+ * A copy of the kept window without its live fields (the caller may change
297
+ * it), or null. Its messages are the window, its pageInfo and flags
298
+ * describe the window's edges.
299
+ */
300
+ function read(conversationId) {
301
+ const id = normalizeId(conversationId);
302
+ if (!id || !entries.has(id)) return null;
303
+ const entry = entries.get(id);
304
+ entries.delete(id);
305
+ entries.set(id, entry);
306
+ return clonePayload(windowPayload(entry));
307
+ }
308
+
309
+ function has(conversationId) {
310
+ return entries.has(normalizeId(conversationId));
311
+ }
312
+
313
+ /**
314
+ * The window as it is, uncopied, without counting as use: for a reload of
315
+ * the conversation on screen, which reads it on every poll. The caller must
316
+ * not change it (the window's own changes make new arrays, so a held
317
+ * reference stays as it was read).
318
+ */
319
+ function peekWindow(conversationId) {
320
+ const entry = entryOf(conversationId);
321
+ if (!entry) return null;
322
+ return { messages: entry.messages, hasMoreOlder: entry.hasMoreOlder, hasMoreNewer: entry.hasMoreNewer };
323
+ }
324
+
325
+ /** How many messages the window holds (0 when none is kept). */
326
+ function windowSize(conversationId) {
327
+ return entryOf(conversationId)?.messages.length || 0;
328
+ }
329
+
330
+ function clear() {
331
+ entries.clear();
332
+ }
333
+
334
+ return {
335
+ remember,
336
+ addOlder,
337
+ addNewer,
338
+ upsertMessage,
339
+ detachFromTail,
340
+ trim,
341
+ trimAll,
342
+ read,
343
+ peekWindow,
344
+ forget,
345
+ has,
346
+ windowSize,
347
+ clear,
348
+ keys: () => [...entries.keys()],
349
+ get size() {
350
+ return entries.size;
351
+ },
352
+ };
353
+ }
354
+
355
+ // The page's one cache: the conversation views write and read it, the socket
356
+ // handlers drop entries the relay has changed.
357
+ export const conversationPayloadCache = createConversationPayloadCache();
@@ -2,7 +2,7 @@
2
2
  // `backgroundTaskCount`) kept in step with the live events, for every row
3
3
  // and not only the open one. The list fetch sets them; between fetches the
4
4
  // `message_status` and `background_tasks` events are the only word on what
5
- // a session is doing, and the sidebar's dots, a group head's pill and its
5
+ // a session is doing, and the sidebar's title colour, a group head's pill and its
6
6
  // fold read these fields. DOM-free.
7
7
 
8
8
  const TERMINAL_STATUSES = new Set(['done', 'failed', 'dropped', 'cancelled']);