@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.
- package/CHANGELOG.md +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- 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
|
-
|
|
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
|
|
3904
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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']);
|