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