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