@oar-sh/oar 0.9.20 → 0.9.22
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 +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +309 -49
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -16,8 +16,9 @@ import {
|
|
|
16
16
|
updateSessionPill,
|
|
17
17
|
updateCompactButton,
|
|
18
18
|
closeSidebar,
|
|
19
|
+
collapseSidebarAfterListPick,
|
|
19
20
|
applyContextUsageBar,
|
|
20
|
-
|
|
21
|
+
isPhoneComposerViewport,
|
|
21
22
|
releaseComposerFocusAfterSend,
|
|
22
23
|
saveConversationScrollTop,
|
|
23
24
|
loadConversationReadingAnchor,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
pruneConversationGroups,
|
|
35
36
|
hasSessionWorkerStatus,
|
|
36
37
|
setConversationGroupFoldChoice,
|
|
38
|
+
setConversationGroupFoldChoices,
|
|
37
39
|
} from './store.js';
|
|
38
40
|
import { archiveConfirmationFor } from './archive-confirm.mjs';
|
|
39
41
|
import { arrangeConversationList, conversationGroupPosition } from './conversation-group-order.mjs';
|
|
@@ -41,9 +43,13 @@ import { describeDragItems, dropAnnouncement, groupMoveStep } from './conversati
|
|
|
41
43
|
import { bindConversationListDrag } from './conversation-list-drag.mjs';
|
|
42
44
|
import { applyFetchedTurnStateToConversation } from './conversation-turn-state.mjs';
|
|
43
45
|
import {
|
|
46
|
+
groupActivityText,
|
|
44
47
|
groupIsActive,
|
|
48
|
+
groupSessionCountText,
|
|
45
49
|
groupSummaryText,
|
|
50
|
+
groupTitleTone,
|
|
46
51
|
memberStateClass,
|
|
52
|
+
memberTone,
|
|
47
53
|
resolveGroupFold,
|
|
48
54
|
summarizeGroupMembers,
|
|
49
55
|
toggledGroupFoldChoice,
|
|
@@ -85,6 +91,7 @@ import { runRelayErrorCta, renderMessages, renderNoConversationState, restoreInF
|
|
|
85
91
|
import { mergeTailPage } from './transcript-reading-anchor.mjs';
|
|
86
92
|
import { readWindowRetention } from './conversation-window-retention.mjs';
|
|
87
93
|
import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
|
|
94
|
+
import { getUiScale } from './font-scaling.js';
|
|
88
95
|
import {
|
|
89
96
|
isBackgroundTasksSnapshotCurrent,
|
|
90
97
|
setBackgroundTasksConversation,
|
|
@@ -93,6 +100,7 @@ import {
|
|
|
93
100
|
setConversationTaskStallMinutes,
|
|
94
101
|
} from './background-tasks-view.mjs';
|
|
95
102
|
import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
|
|
103
|
+
import { setQuestionsConversation } from './questions-panel-ui.js';
|
|
96
104
|
import { setConversationHiddenMessages, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
|
|
97
105
|
import { setUsageLimitConversation } from './usage-limit-ui.js';
|
|
98
106
|
import { mergeConversationPreviews } from './preview-cards.mjs';
|
|
@@ -102,6 +110,11 @@ import { clearAttachments, setRepoBrowserSessionInfo, loadRepoBrowserTree, getRe
|
|
|
102
110
|
import { buildKnownCwdOptions, normalizeKnownCwdPath } from './known-cwd-options.mjs';
|
|
103
111
|
import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
|
|
104
112
|
import { conversationPayloadCache, payloadHasMoreOlder, windowPageInfo } from './conversation-payload-cache.mjs';
|
|
113
|
+
import { createHeldDetailsStore } from './held-details.mjs';
|
|
114
|
+
|
|
115
|
+
// The finished replies' details this page holds of the open conversation
|
|
116
|
+
// (the live poll's `heldDetails`, held-details.mjs).
|
|
117
|
+
export const heldDetailsStore = createHeldDetailsStore();
|
|
105
118
|
import { loadKeepingLiveRows, shouldKeepTranscriptOnReload } from './transcript-reload-freshness.mjs';
|
|
106
119
|
import { createInfiniteLoader } from './infinite-loader.js';
|
|
107
120
|
import {
|
|
@@ -136,9 +149,16 @@ import {
|
|
|
136
149
|
resolveCloudSourceAutoFill,
|
|
137
150
|
validateCloudSourceInputs,
|
|
138
151
|
} from './claude-cloud-ui.mjs';
|
|
139
|
-
import {
|
|
140
|
-
import {
|
|
141
|
-
import {
|
|
152
|
+
import { conversationOriginInfo } from './remote-relay-origin-view.mjs';
|
|
153
|
+
import { relayEventWatchedInfoText } from './relay-event-subscriptions-ui.js';
|
|
154
|
+
import { relayScheduleInfoText } from './relay-schedules-ui.js';
|
|
155
|
+
import {
|
|
156
|
+
noteConversationCleared,
|
|
157
|
+
noteConversationGone,
|
|
158
|
+
noteConversationMissing,
|
|
159
|
+
noteConversationShown,
|
|
160
|
+
syncSessionDocumentTitle,
|
|
161
|
+
} from './session-history-ui.js';
|
|
142
162
|
import {
|
|
143
163
|
normalizeConversationFilter,
|
|
144
164
|
conversationMatchesFilter,
|
|
@@ -364,10 +384,11 @@ function isConversationProcessing(conversation, workerState) {
|
|
|
364
384
|
return runtimeStatus === 'processing';
|
|
365
385
|
}
|
|
366
386
|
|
|
367
|
-
|
|
387
|
+
// `openLast: false` when the address names the conversation to open instead.
|
|
388
|
+
export async function loadConversations({ openLast = true } = {}) {
|
|
368
389
|
await refreshConversations({ preservePagination: false });
|
|
369
|
-
const lastId = localStorage.getItem('copilot_last_conv');
|
|
370
|
-
if (lastId) await openConversation(lastId, { restoreScroll: true });
|
|
390
|
+
const lastId = openLast ? localStorage.getItem('copilot_last_conv') : '';
|
|
391
|
+
if (lastId) await openConversation(lastId, { restoreScroll: true, history: 'replace' });
|
|
371
392
|
}
|
|
372
393
|
|
|
373
394
|
export async function refreshConversations(options = {}) {
|
|
@@ -540,6 +561,13 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
|
|
|
540
561
|
// event or fetch said (the worker was killed, or the relay restarted).
|
|
541
562
|
let sessionState = String(conversation?.sessionState || '').trim();
|
|
542
563
|
if (sessionState === 'background_only' && hasSessionWorkerStatus() && !isWorkerStateLive(workerState)) sessionState = 'idle';
|
|
564
|
+
const memberState = memberStateClass({
|
|
565
|
+
pendingCount,
|
|
566
|
+
visualState,
|
|
567
|
+
processing,
|
|
568
|
+
activeTurn: conversation?.activeTurn === true,
|
|
569
|
+
sessionState,
|
|
570
|
+
});
|
|
543
571
|
return {
|
|
544
572
|
visualState,
|
|
545
573
|
processing,
|
|
@@ -549,13 +577,9 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
|
|
|
549
577
|
// "Stop all running" acts on, and what keeps an archive from it.
|
|
550
578
|
live: processing || pendingCount > 0 || conversation?.activeTurn === true || sessionState === 'running_turn' || sessionState === 'background_only',
|
|
551
579
|
// The member class the group's head row sums up (conversation-group-state.mjs).
|
|
552
|
-
memberState
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
processing,
|
|
556
|
-
activeTurn: conversation?.activeTurn === true,
|
|
557
|
-
sessionState,
|
|
558
|
-
}),
|
|
580
|
+
memberState,
|
|
581
|
+
// Its colour in the group's name.
|
|
582
|
+
tone: memberTone({ memberState, visualState }),
|
|
559
583
|
};
|
|
560
584
|
}
|
|
561
585
|
|
|
@@ -571,6 +595,14 @@ function groupDisplayName(groupId) {
|
|
|
571
595
|
return String(info?.name || head?.title || info?.displayName || 'Group').trim() || 'Group';
|
|
572
596
|
}
|
|
573
597
|
|
|
598
|
+
/** A synthetic head's meta line: when its newest row was active, and how many rows it has. */
|
|
599
|
+
function groupMetaText(group, summary) {
|
|
600
|
+
const ms = Number(group?.activityMs) || 0;
|
|
601
|
+
const when = ms > 0 ? fmtDate(new Date(ms).toISOString()) : '';
|
|
602
|
+
const count = groupSessionCountText(summary?.count || 0);
|
|
603
|
+
return when ? `${when} · ${count}` : count;
|
|
604
|
+
}
|
|
605
|
+
|
|
574
606
|
/** The loaded rows of a group, in the current view and across it. */
|
|
575
607
|
function groupMemberRows(groupId, { inViewOnly = true } = {}) {
|
|
576
608
|
return Object.values(conversations).filter((conversation) => String(conversation?.groupId || '') === groupId
|
|
@@ -580,15 +612,16 @@ function groupMemberRows(groupId, { inViewOnly = true } = {}) {
|
|
|
580
612
|
/**
|
|
581
613
|
* What a head row shows for its group: the classes of every row in it, the
|
|
582
614
|
* head included (the menu's "Archive group (N)" and "Stop all running (N)"
|
|
583
|
-
* count the same rows), their summary,
|
|
584
|
-
* sits in.
|
|
615
|
+
* count the same rows), their summary, the colour of the group's name, and
|
|
616
|
+
* the first row a waiting card sits in.
|
|
585
617
|
*/
|
|
586
618
|
function describeGroupForHead(group, viewOf) {
|
|
587
619
|
const rows = group.head ? [group.head, ...group.members] : [...group.members];
|
|
588
620
|
const states = rows.map((row) => viewOf(row).memberState);
|
|
589
621
|
const summary = summarizeGroupMembers(states);
|
|
622
|
+
const titleTone = groupTitleTone(rows.map((row) => viewOf(row).tone));
|
|
590
623
|
const firstWaiting = rows.find((row) => viewOf(row).memberState === 'waiting') || null;
|
|
591
|
-
return { summary, firstWaiting, forcedOpen: false };
|
|
624
|
+
return { summary, titleTone, firstWaiting, forcedOpen: false };
|
|
592
625
|
}
|
|
593
626
|
|
|
594
627
|
export function renderConvList() {
|
|
@@ -618,31 +651,32 @@ function paintConvList() {
|
|
|
618
651
|
if (!rowViews.has(id)) rowViews.set(id, conversationRowState(conversation, pendingByConversation));
|
|
619
652
|
return rowViews.get(id);
|
|
620
653
|
};
|
|
621
|
-
const foldChoices = readConversationGroupFoldChoices();
|
|
654
|
+
const foldChoices = readConversationGroupFoldChoices({ archived: conversationListArchivedView });
|
|
622
655
|
const groupDescriptions = new Map();
|
|
623
656
|
// Groups order with the same rules the list route pages by; a group is
|
|
624
657
|
// folded once nothing in it runs or waits, unless the user chose otherwise.
|
|
658
|
+
// A folded group still shows the open conversation under its head.
|
|
625
659
|
const items = arrangeConversationList(inView, conversationGroups, {
|
|
626
660
|
matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
|
|
627
661
|
groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
|
|
628
662
|
// An owner's group an agent emptied shows in the live list, empty.
|
|
629
663
|
emptyGroups: !conversationListArchivedView,
|
|
664
|
+
keepVisibleId: currentConvId || null,
|
|
630
665
|
isOpen: (groupId, group) => {
|
|
631
666
|
const described = describeGroupForHead(group, viewOf);
|
|
632
|
-
const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
|
|
633
667
|
// Open whatever the choice: the fold button then says so and does nothing.
|
|
634
|
-
described.forcedOpen =
|
|
668
|
+
described.forcedOpen = !!activeFilter;
|
|
635
669
|
groupDescriptions.set(groupId, described);
|
|
636
670
|
if (convListTransientOpenGroups.has(groupId)) return true;
|
|
637
671
|
return resolveGroupFold({
|
|
638
672
|
choice: foldChoices[groupId] || null,
|
|
639
673
|
active: groupIsActive(described.summary),
|
|
640
|
-
holdsOpenConversation,
|
|
641
674
|
filterActive: !!activeFilter,
|
|
642
675
|
});
|
|
643
676
|
},
|
|
644
677
|
});
|
|
645
678
|
lastConvListDragItems = describeDragItems(items);
|
|
679
|
+
syncConversationGroupsFoldAllButton(items, !!activeFilter);
|
|
646
680
|
const footerHtml = (() => {
|
|
647
681
|
if (activeFilter) {
|
|
648
682
|
// While a filter is active the drain loads older pages by itself; a
|
|
@@ -664,30 +698,31 @@ function paintConvList() {
|
|
|
664
698
|
const emptyText = activeFilter && inView.length > 0
|
|
665
699
|
? 'No conversations match your filter'
|
|
666
700
|
: (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
|
|
667
|
-
list.innerHTML = `<div style="padding:
|
|
701
|
+
list.innerHTML = `<div style="padding:calc(6px * var(--ui-scale)) calc(7px * var(--ui-scale));color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
|
|
668
702
|
lastConvListHtml = '';
|
|
669
703
|
scheduleConversationListBoundaryCheck();
|
|
670
704
|
return;
|
|
671
705
|
}
|
|
672
706
|
// The head row's pill: the member count and what the members are doing;
|
|
673
707
|
// while folded, its "waiting" part opens the member the card sits in.
|
|
674
|
-
|
|
708
|
+
// withCount: false on a synthetic head, whose meta line says the count.
|
|
709
|
+
const groupPillHtml = (item, { withCount = true } = {}) => {
|
|
675
710
|
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
676
|
-
const text = groupSummaryText(described.summary);
|
|
711
|
+
const text = withCount ? groupSummaryText(described.summary) : groupActivityText(described.summary);
|
|
677
712
|
if (!text) return '';
|
|
678
713
|
const waitingId = described.firstWaiting ? String(described.firstWaiting.id) : '';
|
|
679
714
|
// Inline like the row's own buttons: the row's click handler must not
|
|
680
715
|
// run (it would open the head and re-render the list first).
|
|
681
716
|
// The full text is the title: a narrow sidebar shortens the pill with an ellipsis.
|
|
682
717
|
const title = waitingId ? `${text} · open the session that is waiting for you` : text;
|
|
683
|
-
return `<span class="conv-group-summary conv-group-summary-${described.summary.strongest}" title="${escHtml(title)}"${waitingId ? ` data-open-waiting="${escHtml(waitingId)}" role="button" tabindex="0" onclick="event.stopPropagation();openConversation('${escHtml(waitingId)}')"` : ''}>${escHtml(text)}</span>`;
|
|
718
|
+
return `<span class="conv-group-summary conv-group-summary-${described.summary.strongest}" title="${escHtml(title)}"${waitingId ? ` data-open-waiting="${escHtml(waitingId)}" role="button" tabindex="0" onclick="event.stopPropagation();openConversation('${escHtml(waitingId)}',{fromList:true})"` : ''}>${escHtml(text)}</span>`;
|
|
684
719
|
};
|
|
685
720
|
const foldButtonHtml = (item) => {
|
|
686
721
|
const forced = (groupDescriptions.get(item.group.id) || {}).forcedOpen === true || item.group.contextOnly === true || !!activeFilter;
|
|
687
722
|
const label = forced
|
|
688
|
-
?
|
|
723
|
+
? 'Open while the filter is on'
|
|
689
724
|
: (item.group.open ? 'Fold group' : 'Unfold group');
|
|
690
|
-
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)}')"
|
|
725
|
+
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)}')"><span class="conv-group-chevron${convListFoldTurning.has(item.group.id) ? ' conv-group-chevron-turn' : ''}" aria-hidden="true">›</span></button>`;
|
|
691
726
|
};
|
|
692
727
|
const rowHtml = (c, item) => {
|
|
693
728
|
const view = viewOf(c);
|
|
@@ -696,30 +731,43 @@ function paintConvList() {
|
|
|
696
731
|
const providerIndicatorHtml = providerIndicatorLabel
|
|
697
732
|
? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
|
|
698
733
|
: '';
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
734
|
+
// The ⓘ and its menu: an agent started this conversation (here or on
|
|
735
|
+
// another relay), another conversation watches it (relay events), a
|
|
736
|
+
// schedule fires into it (relay_tool schedule). A member under its
|
|
737
|
+
// creator's head row says the origin already, so it is left out there.
|
|
738
|
+
const origin = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
|
|
739
|
+
? null
|
|
740
|
+
: conversationOriginInfo(c, { conversations });
|
|
741
|
+
const infoEntries = conversationRowInfoEntries({
|
|
742
|
+
origin,
|
|
743
|
+
watched: relayEventWatchedInfoText(c.id),
|
|
744
|
+
scheduled: relayScheduleInfoText(c.id),
|
|
745
|
+
});
|
|
746
|
+
if (infoEntries.length) convListRowInfo.set(String(c.id), infoEntries);
|
|
747
|
+
const infoButtonHtml = conversationRowInfoButtonHtml(c.id, infoEntries);
|
|
709
748
|
const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
|
|
710
749
|
const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
|
|
750
|
+
// A head row's title is the group's name, coloured by all its rows.
|
|
751
|
+
const toneAttr = item.kind === 'head' ? ` data-group-tone="${escHtml((groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf)).titleTone)}"` : '';
|
|
711
752
|
// A head row shows the name the group was given by hand; until then the
|
|
712
753
|
// conversation's own title (the group's name follows it).
|
|
713
754
|
const handName = item.kind === 'head' ? String(item.group.info?.name || '').trim() : '';
|
|
714
755
|
const rowTitle = handName || c.title;
|
|
715
756
|
const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
|
|
757
|
+
const titleHtml = `<div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}</div>`;
|
|
758
|
+
// With open questions the title and its "N open" pill share a row in
|
|
759
|
+
// which only the title gives way; the pill is never cut.
|
|
760
|
+
const openCount = pendingByConversation[c.id];
|
|
761
|
+
const titleRowHtml = openCount
|
|
762
|
+
? `<div class="conv-title-row">${titleHtml}<span class="conv-open-questions">${openCount} open</span></div>`
|
|
763
|
+
: titleHtml;
|
|
716
764
|
return `
|
|
717
|
-
<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}')">
|
|
718
|
-
|
|
719
|
-
<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) : ''}${
|
|
765
|
+
<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}${toneAttr} onclick="openConversation('${c.id}',{fromList:true})">
|
|
766
|
+
${titleRowHtml}
|
|
767
|
+
<div class="conv-meta"><span class="conv-meta-primary"><span class="conv-meta-date">${fmtDate(c.updatedAt)}</span><span class="conv-meta-count"> · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span></span>${item.kind === 'head' ? groupPillHtml(item) : ''}${infoButtonHtml}${providerIndicatorHtml}</div>
|
|
720
768
|
${c.archived
|
|
721
769
|
? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
|
|
722
|
-
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive"
|
|
770
|
+
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄️</button>`}
|
|
723
771
|
<button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
|
|
724
772
|
</div>`;
|
|
725
773
|
};
|
|
@@ -733,16 +781,17 @@ function paintConvList() {
|
|
|
733
781
|
return `
|
|
734
782
|
<div class="conv-item conv-group-head conv-group-synthetic conv-group-empty worker-ui-offline" data-group-id="${escHtml(item.group.id)}" title="An agent moved the last session out of this group; rename or dissolve it from its menu.">
|
|
735
783
|
<div class="conv-title">${escHtml(name)}</div>
|
|
736
|
-
<div class="conv-meta"><span class="conv-meta-primary"
|
|
784
|
+
<div class="conv-meta"><span class="conv-meta-primary">${groupSessionCountText(0)}</span></div>
|
|
737
785
|
</div>`;
|
|
738
786
|
}
|
|
739
787
|
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
740
788
|
return `
|
|
741
|
-
<div class="conv-item conv-group-head conv-group-synthetic worker-ui
|
|
789
|
+
<div class="conv-item conv-group-head conv-group-synthetic worker-ui-offline" data-group-id="${escHtml(item.group.id)}" data-group-tone="${escHtml(described.titleTone)}" onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">
|
|
742
790
|
<div class="conv-title">${foldButtonHtml(item)}${escHtml(name)}</div>
|
|
743
|
-
<div class="conv-meta"><span class="conv-meta-primary"
|
|
791
|
+
<div class="conv-meta"><span class="conv-meta-primary">${escHtml(groupMetaText(item.group, described.summary))}</span>${groupPillHtml(item, { withCount: false })}</div>
|
|
744
792
|
</div>`;
|
|
745
793
|
};
|
|
794
|
+
convListRowInfo.clear();
|
|
746
795
|
const listHtml = `${items.map((item) => (item.conversation ? rowHtml(item.conversation, item) : syntheticHeadHtml(item))).join('')}${footerHtml}`;
|
|
747
796
|
if (listHtml !== lastConvListHtml) {
|
|
748
797
|
list.innerHTML = listHtml;
|
|
@@ -750,6 +799,7 @@ function paintConvList() {
|
|
|
750
799
|
}
|
|
751
800
|
bindConversationContextMenu(list);
|
|
752
801
|
bindConversationGroupControls(list);
|
|
802
|
+
bindConversationInfoButtons(list);
|
|
753
803
|
window.syncChatTitleControls?.();
|
|
754
804
|
scheduleConversationListBoundaryCheck();
|
|
755
805
|
}
|
|
@@ -767,15 +817,105 @@ function bindConversationGroupControls(list) {
|
|
|
767
817
|
if (!waiting || !list.contains(waiting)) return;
|
|
768
818
|
event.preventDefault();
|
|
769
819
|
event.stopPropagation();
|
|
770
|
-
void (window.openConversation || openConversation)(waiting.dataset.openWaiting);
|
|
820
|
+
void (window.openConversation || openConversation)(waiting.dataset.openWaiting, { fromList: true });
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ─── The row's ⓘ: who started it, who watches it, what is scheduled ─────────
|
|
825
|
+
// One small button in the meta line stands for all three; its menu lists
|
|
826
|
+
// them, each with its action (or as a line of text where there is none).
|
|
827
|
+
|
|
828
|
+
// The menu entries of the rows as last drawn, by conversation id.
|
|
829
|
+
const convListRowInfo = new Map();
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The entries of a row's ⓘ menu, in a fixed order. `origin` is
|
|
833
|
+
* conversationOriginInfo()'s answer, `watched` and `scheduled` their lines
|
|
834
|
+
* ('' for none). `short` is the entry's word in the button's label.
|
|
835
|
+
*/
|
|
836
|
+
function conversationRowInfoEntries({ origin = null, watched = '', scheduled = '' } = {}) {
|
|
837
|
+
const entries = [];
|
|
838
|
+
if (origin?.label) entries.push({ id: 'origin', short: 'started by an agent', label: origin.label, origin });
|
|
839
|
+
if (watched) entries.push({ id: 'watched', short: 'watched', label: watched });
|
|
840
|
+
if (scheduled) entries.push({ id: 'scheduled', short: 'scheduled', label: scheduled });
|
|
841
|
+
return entries;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function conversationRowInfoButtonHtml(conversationId, entries) {
|
|
845
|
+
if (!entries.length) return '';
|
|
846
|
+
const label = `Details: ${entries.map((entry) => entry.short).join(', ')}`;
|
|
847
|
+
return `<button type="button" class="conv-info-btn" data-conv-info="${escHtml(conversationId)}" aria-haspopup="menu" aria-label="${escHtml(label)}" title="${escHtml(label)}">ⓘ</button>`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function conversationRowInfoMenuItems(entries) {
|
|
851
|
+
return entries.map((entry) => {
|
|
852
|
+
const origin = entry.id === 'origin' ? entry.origin : null;
|
|
853
|
+
if (origin?.kind === 'local' && origin.linkable) {
|
|
854
|
+
// As the header's "via agent" marker: openOriginConversation says so
|
|
855
|
+
// when that conversation is gone by now.
|
|
856
|
+
return { id: entry.id, label: entry.label, onSelect: () => { void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } }, { fromList: true }); } };
|
|
857
|
+
}
|
|
858
|
+
if (origin?.kind === 'remote' && origin.href) {
|
|
859
|
+
// As the message badge of such an agent: its session on that relay.
|
|
860
|
+
return { id: entry.id, label: entry.label, onSelect: () => { window.open(origin.href, '_blank', 'noopener'); } };
|
|
861
|
+
}
|
|
862
|
+
return { id: entry.id, label: entry.label, note: true };
|
|
771
863
|
});
|
|
772
864
|
}
|
|
773
865
|
|
|
866
|
+
/** Opens the ⓘ menu of a row under its button. */
|
|
867
|
+
function openConversationRowInfoMenu(button) {
|
|
868
|
+
const id = String(button?.dataset?.convInfo || '').trim();
|
|
869
|
+
const entries = convListRowInfo.get(id);
|
|
870
|
+
if (!entries?.length) return;
|
|
871
|
+
const rect = button.getBoundingClientRect();
|
|
872
|
+
const gap = 4 * getUiScale();
|
|
873
|
+
// Esc is a key, so the returned focus would show its ring (and the row's
|
|
874
|
+
// archive and delete icons) even after a mouse click: it shows only if
|
|
875
|
+
// the button had a visible focus when the menu opened.
|
|
876
|
+
const focusVisible = !!button.matches?.(':focus-visible');
|
|
877
|
+
openContextMenu(rect.left, rect.bottom + gap, conversationRowInfoMenuItems(entries), {
|
|
878
|
+
// The list may have been drawn anew meanwhile: the row's button by id.
|
|
879
|
+
onEscape: () => {
|
|
880
|
+
document.querySelector(`#conv-list .conv-info-btn[data-conv-info="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true, focusVisible });
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The ⓘ opens its menu and never the row: caught on the way down (capture),
|
|
887
|
+
* before the row's own click handler opens the conversation. Bound once.
|
|
888
|
+
*/
|
|
889
|
+
function bindConversationInfoButtons(list) {
|
|
890
|
+
if (!list || list.dataset.infoButtonsBound === '1') return;
|
|
891
|
+
list.dataset.infoButtonsBound = '1';
|
|
892
|
+
list.addEventListener('click', (event) => {
|
|
893
|
+
const button = event.target?.closest?.('.conv-info-btn');
|
|
894
|
+
if (!button || !list.contains(button)) return;
|
|
895
|
+
event.stopPropagation();
|
|
896
|
+
// The release of a long press that opened the row's menu: that menu stays.
|
|
897
|
+
if (event.defaultPrevented) return;
|
|
898
|
+
event.preventDefault();
|
|
899
|
+
openConversationRowInfoMenu(button);
|
|
900
|
+
}, true);
|
|
901
|
+
}
|
|
902
|
+
|
|
774
903
|
/**
|
|
775
|
-
* Folds or unfolds a group; the choice is kept until the user changes it
|
|
776
|
-
*
|
|
904
|
+
* Folds or unfolds a group; the choice is kept until the user changes it,
|
|
905
|
+
* apart for the live list and the Archived view. A group held open by a
|
|
906
|
+
* filter keeps its choice. A folded group still shows the open conversation.
|
|
777
907
|
* The fold button keeps the focus across the re-render.
|
|
778
908
|
*/
|
|
909
|
+
// Groups whose fold the user just toggled: their chevron turns once on the
|
|
910
|
+
// next paint (the list is drawn anew, so a transition has nothing to start from).
|
|
911
|
+
const convListFoldTurning = new Set();
|
|
912
|
+
|
|
913
|
+
function turnGroupChevrons(groupIds) {
|
|
914
|
+
for (const id of groupIds) convListFoldTurning.add(id);
|
|
915
|
+
renderConvList();
|
|
916
|
+
convListFoldTurning.clear();
|
|
917
|
+
}
|
|
918
|
+
|
|
779
919
|
export function toggleConversationGroupFold(event, groupId) {
|
|
780
920
|
event?.stopPropagation?.();
|
|
781
921
|
event?.preventDefault?.();
|
|
@@ -785,9 +925,53 @@ export function toggleConversationGroupFold(event, groupId) {
|
|
|
785
925
|
if (button?.disabled) return;
|
|
786
926
|
const currentlyOpen = button ? button.getAttribute('aria-expanded') === 'true' : true;
|
|
787
927
|
convListTransientOpenGroups.delete(id);
|
|
788
|
-
setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen));
|
|
789
|
-
|
|
790
|
-
|
|
928
|
+
setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen), { archived: conversationListArchivedView });
|
|
929
|
+
turnGroupChevrons([id]);
|
|
930
|
+
// Keyboard toggles keep the focus; a click or tap (detail > 0) does not,
|
|
931
|
+
// or the redrawn button would show a focus ring.
|
|
932
|
+
if (!(Number(event?.detail) > 0)) {
|
|
933
|
+
document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// The groups the fold-all button acts on: those in the list as last drawn.
|
|
938
|
+
let conversationListFoldableGroups = { ids: [], anyOpen: false };
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* The filter row's fold-all button: ➖ while a group in the list is open
|
|
942
|
+
* (folds them all), ➕ once all are folded (unfolds them all). Hidden
|
|
943
|
+
* without a group; disabled under a filter, which holds every group open.
|
|
944
|
+
*/
|
|
945
|
+
function syncConversationGroupsFoldAllButton(items, filterActive) {
|
|
946
|
+
const heads = items.filter((item) => item.kind === 'head' && !item.group.empty);
|
|
947
|
+
const anyOpen = heads.some((item) => item.group.open);
|
|
948
|
+
conversationListFoldableGroups = { ids: heads.map((item) => item.group.id), anyOpen };
|
|
949
|
+
const button = document.getElementById('conv-groups-fold-all');
|
|
950
|
+
if (!button) return;
|
|
951
|
+
button.hidden = heads.length === 0;
|
|
952
|
+
button.disabled = filterActive;
|
|
953
|
+
const label = filterActive
|
|
954
|
+
? 'Groups stay open while the filter is on'
|
|
955
|
+
: (anyOpen ? 'Fold all groups' : 'Unfold all groups');
|
|
956
|
+
const glyph = anyOpen ? '➖' : '➕';
|
|
957
|
+
if (button.textContent !== glyph) button.textContent = glyph;
|
|
958
|
+
if (button.title !== label) {
|
|
959
|
+
button.title = label;
|
|
960
|
+
button.setAttribute('aria-label', label);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Folds every group in the list, or unfolds them all once all are folded. */
|
|
965
|
+
export function toggleAllConversationGroupFolds(event) {
|
|
966
|
+
event?.stopPropagation?.();
|
|
967
|
+
event?.preventDefault?.();
|
|
968
|
+
const button = document.getElementById('conv-groups-fold-all');
|
|
969
|
+
if (button?.disabled) return;
|
|
970
|
+
const { ids, anyOpen } = conversationListFoldableGroups;
|
|
971
|
+
if (!ids.length) return;
|
|
972
|
+
for (const id of ids) convListTransientOpenGroups.delete(id);
|
|
973
|
+
setConversationGroupFoldChoices(ids, anyOpen ? 'folded' : 'open', { archived: conversationListArchivedView });
|
|
974
|
+
turnGroupChevrons(ids);
|
|
791
975
|
}
|
|
792
976
|
|
|
793
977
|
// keepTranscript: the page shows a window this payload must not replace (the
|
|
@@ -815,7 +999,15 @@ export function applyLoadedConversationState(id, response, {
|
|
|
815
999
|
mergeFrom = null,
|
|
816
1000
|
windowPayload = null,
|
|
817
1001
|
backgroundTasksAsOf = null,
|
|
1002
|
+
fromPoll = false,
|
|
818
1003
|
} = {}) {
|
|
1004
|
+
// What the page holds of the finished replies, for the live poll's
|
|
1005
|
+
// `heldDetails`: a full load replaces it, a poll or an older page adds to
|
|
1006
|
+
// it, and a poll trims it to the rows on screen.
|
|
1007
|
+
if (response && !IS_SHARED_VIEW) {
|
|
1008
|
+
const shown = fromPoll ? (response.messages || []).map((message) => message?.id) : null;
|
|
1009
|
+
heldDetailsStore.noteApplied(id, response, { full: !fromPoll && !keepTranscript && !mergeFrom, shownIds: shown });
|
|
1010
|
+
}
|
|
819
1011
|
if (!response) {
|
|
820
1012
|
if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
|
|
821
1013
|
setRepoBrowserSessionInfo('', '');
|
|
@@ -911,6 +1103,8 @@ export function applyLoadedConversationState(id, response, {
|
|
|
911
1103
|
? mergeMessagesIntoTranscript(mergeFrom, response.messages, response)
|
|
912
1104
|
: renderMessages(response.messages, !restoreScroll, response);
|
|
913
1105
|
}
|
|
1106
|
+
// 📓: a newly opened conversation loads its questions (a no-op on the poll).
|
|
1107
|
+
setQuestionsConversation(id);
|
|
914
1108
|
hydrateConversationDraft(id, {
|
|
915
1109
|
draftText: response.draftText,
|
|
916
1110
|
draftAttachments: response.draftAttachments,
|
|
@@ -1000,6 +1194,7 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
|
|
|
1000
1194
|
if ('hiddenMessages' in response) setConversationHiddenMessages(id, response.hiddenMessages, response.hiddenMessagesRevision);
|
|
1001
1195
|
setHiddenMessagesConversation(id);
|
|
1002
1196
|
const didRenderMessages = renderMessages(response.messages, false, response);
|
|
1197
|
+
setQuestionsConversation(id);
|
|
1003
1198
|
// The panel shows this conversation's tasks as the socket keeps them.
|
|
1004
1199
|
setBackgroundTasksConversation(id);
|
|
1005
1200
|
window.syncChatTitleControls?.();
|
|
@@ -1010,7 +1205,8 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
|
|
|
1010
1205
|
// The "via agent" marker (sidebar row, header, message badge): opens the
|
|
1011
1206
|
// conversation whose agent started this one. The marker sits inside a sidebar
|
|
1012
1207
|
// row that opens on click itself, so the click stops here.
|
|
1013
|
-
|
|
1208
|
+
// `options` go to openConversation (`fromList` for a pick from a list row's ⓘ).
|
|
1209
|
+
export function openOriginConversation(event, element, options = {}) {
|
|
1014
1210
|
event?.stopPropagation?.();
|
|
1015
1211
|
event?.preventDefault?.();
|
|
1016
1212
|
const id = String(element?.dataset?.originConversationId || '').trim();
|
|
@@ -1021,7 +1217,7 @@ export function openOriginConversation(event, element) {
|
|
|
1021
1217
|
renderConvList();
|
|
1022
1218
|
return null;
|
|
1023
1219
|
}
|
|
1024
|
-
return openConversation(id);
|
|
1220
|
+
return openConversation(id, options);
|
|
1025
1221
|
}
|
|
1026
1222
|
|
|
1027
1223
|
// The fresh answer for a conversation whose kept window is on the page (drawn
|
|
@@ -1085,6 +1281,53 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
|
|
|
1085
1281
|
saveConversationScrollTop(id, messagesEl.scrollTop);
|
|
1086
1282
|
}
|
|
1087
1283
|
|
|
1284
|
+
const LAST_OPENED_CONVERSATION_KEY = 'copilot_last_conv';
|
|
1285
|
+
|
|
1286
|
+
function readLastOpenedConversationId() {
|
|
1287
|
+
try {
|
|
1288
|
+
return String(localStorage.getItem(LAST_OPENED_CONVERSATION_KEY) || '').trim();
|
|
1289
|
+
} catch {
|
|
1290
|
+
return '';
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** The header and the document take the title the loaded payload gave the record. */
|
|
1295
|
+
function syncOpenedConversationTitle(id) {
|
|
1296
|
+
if (String(currentConvId || '').trim() !== String(id || '').trim()) return;
|
|
1297
|
+
const title = String(conversations[id]?.title || '').trim();
|
|
1298
|
+
const titleEl = document.getElementById('chat-title');
|
|
1299
|
+
if (title && titleEl && titleEl.textContent !== title && !document.body.classList.contains('chat-title-editing')) {
|
|
1300
|
+
titleEl.textContent = title;
|
|
1301
|
+
window.syncChatTitleControls?.();
|
|
1302
|
+
}
|
|
1303
|
+
syncSessionDocumentTitle();
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* The relay knows no conversation `id` (deleted elsewhere, a mistyped or
|
|
1308
|
+
* stale link). Back or forward onto it steps on past it; anything else
|
|
1309
|
+
* leaves nothing open, without `?conv=` in the address, and says so. The
|
|
1310
|
+
* conversation the next start opens stays the one it was.
|
|
1311
|
+
*/
|
|
1312
|
+
function showConversationUnknown(id, { lastOpenedBefore = '' } = {}) {
|
|
1313
|
+
conversationPayloadCache.forget(id);
|
|
1314
|
+
if (conversations[id]) {
|
|
1315
|
+
delete conversations[id];
|
|
1316
|
+
renderConvList();
|
|
1317
|
+
}
|
|
1318
|
+
// Stepped over: the pop that follows opens what lies there.
|
|
1319
|
+
const stepsOn = noteConversationMissing(id);
|
|
1320
|
+
clearOpenConversation({ history: !stepsOn });
|
|
1321
|
+
renderNoConversationState();
|
|
1322
|
+
window.syncComposerButtonState?.();
|
|
1323
|
+
window.syncComposerPlaceholder?.();
|
|
1324
|
+
try {
|
|
1325
|
+
if (lastOpenedBefore && lastOpenedBefore !== id) localStorage.setItem(LAST_OPENED_CONVERSATION_KEY, lastOpenedBefore);
|
|
1326
|
+
else localStorage.removeItem(LAST_OPENED_CONVERSATION_KEY);
|
|
1327
|
+
} catch {}
|
|
1328
|
+
showTransientRelayNotice('That conversation is not on this relay. It may have been deleted.', 6000);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1088
1331
|
export async function openConversation(id, options = {}) {
|
|
1089
1332
|
// The Inbox owns the transcript like the status view does.
|
|
1090
1333
|
const didLeaveInboxView = leaveInboxView();
|
|
@@ -1102,7 +1345,13 @@ export async function openConversation(id, options = {}) {
|
|
|
1102
1345
|
const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
|
|
1103
1346
|
if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
|
|
1104
1347
|
const capturedVersion = ++openConversationVersion;
|
|
1348
|
+
// Put back if the relay knows no such conversation: a wrong link must not
|
|
1349
|
+
// become the one the next start opens.
|
|
1350
|
+
const lastOpenedBefore = readLastOpenedConversationId();
|
|
1105
1351
|
setCurrentConv(id);
|
|
1352
|
+
// One history entry per switch (`history`: 'replace' at load, 'none' when
|
|
1353
|
+
// back or forward opened it); reopening the one on screen adds none.
|
|
1354
|
+
noteConversationShown(nextConversationId, options.history);
|
|
1106
1355
|
if (repoBrowserState.activeRoot === 'workspace') {
|
|
1107
1356
|
repoBrowserState.tree = null;
|
|
1108
1357
|
repoBrowserState.nodeMap = new Map();
|
|
@@ -1114,6 +1363,9 @@ export async function openConversation(id, options = {}) {
|
|
|
1114
1363
|
repoBrowserState.error = '';
|
|
1115
1364
|
}
|
|
1116
1365
|
closeSidebar();
|
|
1366
|
+
// A pick from the list (`fromList`) also collapses the persistent list of
|
|
1367
|
+
// a phone turned sideways; a long press for the row menu never opens.
|
|
1368
|
+
if (options.fromList) collapseSidebarAfterListPick();
|
|
1117
1369
|
// Reopening the conversation on screen (session bind, clicking it again, a
|
|
1118
1370
|
// push or search jump) must leave its composer alone: emptied attachments
|
|
1119
1371
|
// would read as a user edit and be saved, dropping them from the draft.
|
|
@@ -1156,9 +1408,13 @@ export async function openConversation(id, options = {}) {
|
|
|
1156
1408
|
? String(savedAnchor.messageId || '').trim()
|
|
1157
1409
|
: '';
|
|
1158
1410
|
const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
|
|
1411
|
+
let unknownToRelay = false;
|
|
1159
1412
|
const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
|
|
1160
1413
|
limit: around ? 40 : 20,
|
|
1161
1414
|
aroundMessageId: around || undefined,
|
|
1415
|
+
onErrorResponse: (response) => {
|
|
1416
|
+
if (response?.status === 404) unknownToRelay = true;
|
|
1417
|
+
},
|
|
1162
1418
|
});
|
|
1163
1419
|
const stillWanted = () => shouldApplyConversationLoad({
|
|
1164
1420
|
requestedConversationId: id,
|
|
@@ -1172,6 +1428,10 @@ export async function openConversation(id, options = {}) {
|
|
|
1172
1428
|
? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
|
|
1173
1429
|
: { response: await load(), stale: false };
|
|
1174
1430
|
if (!stillWanted()) return;
|
|
1431
|
+
if (!fetched && unknownToRelay) {
|
|
1432
|
+
showConversationUnknown(nextConversationId, { lastOpenedBefore });
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1175
1435
|
let r = fetched;
|
|
1176
1436
|
const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
|
|
1177
1437
|
if (r && anchorMessageId && !anchorFound) {
|
|
@@ -1217,6 +1477,9 @@ export async function openConversation(id, options = {}) {
|
|
|
1217
1477
|
restoreInFlightThinking(null);
|
|
1218
1478
|
renderMessages([]);
|
|
1219
1479
|
}
|
|
1480
|
+
// A conversation the list does not hold (an archived one, a link) had only
|
|
1481
|
+
// its id to show until now: the payload names it.
|
|
1482
|
+
if (r) syncOpenedConversationTitle(id);
|
|
1220
1483
|
await loadRelayQuestions(id);
|
|
1221
1484
|
await loadRelayBoards();
|
|
1222
1485
|
if (!shouldApplyConversationLoad({
|
|
@@ -1230,7 +1493,9 @@ export async function openConversation(id, options = {}) {
|
|
|
1230
1493
|
applyContextUsageBar(null);
|
|
1231
1494
|
scheduleContextUsageRefresh(id, 0);
|
|
1232
1495
|
const composer = document.getElementById('msg-input');
|
|
1233
|
-
|
|
1496
|
+
// A phone, either way round, gets no focus here: it would raise the
|
|
1497
|
+
// keyboard over the conversation just opened.
|
|
1498
|
+
if (isPhoneComposerViewport()) {
|
|
1234
1499
|
releaseComposerFocusAfterSend(composer);
|
|
1235
1500
|
} else {
|
|
1236
1501
|
composer.focus();
|
|
@@ -2646,6 +2911,8 @@ export function isArchivedConversationsView() {
|
|
|
2646
2911
|
}
|
|
2647
2912
|
|
|
2648
2913
|
export async function toggleArchivedConversations(next = !conversationListArchivedView) {
|
|
2914
|
+
// A group held open by a drop belongs to the view it happened in.
|
|
2915
|
+
if ((next === true) !== conversationListArchivedView) convListTransientOpenGroups.clear();
|
|
2649
2916
|
conversationListArchivedView = next === true;
|
|
2650
2917
|
const toggle = document.getElementById('conv-archived-toggle');
|
|
2651
2918
|
if (toggle) {
|
|
@@ -2699,7 +2966,7 @@ function askBeforeArchive(id, confirmation) {
|
|
|
2699
2966
|
<p>${escHtml(confirmation.message)}</p>
|
|
2700
2967
|
<p>${escHtml(confirmation.detail)}</p>
|
|
2701
2968
|
<div class="summary-modal-actions">
|
|
2702
|
-
<button class="chat-title-action-btn" type="button" data-archive-confirm="1"
|
|
2969
|
+
<button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄️ Archive</button>
|
|
2703
2970
|
<button class="chat-title-action-btn" type="button" data-archive-cancel="1">Cancel</button>
|
|
2704
2971
|
</div>
|
|
2705
2972
|
`,
|
|
@@ -2785,7 +3052,7 @@ function conversationMenuItems(id) {
|
|
|
2785
3052
|
{ id: 'group-remove', label: 'Remove from group', onSelect: () => { void removeConversationFromGroup(id); } },
|
|
2786
3053
|
];
|
|
2787
3054
|
return [
|
|
2788
|
-
{ id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
|
|
3055
|
+
{ id: 'open', label: 'Open', onSelect: () => { void openConversation(id, { fromList: true }); } },
|
|
2789
3056
|
{ id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
|
|
2790
3057
|
MENU_SEPARATOR,
|
|
2791
3058
|
{ id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
|
|
@@ -2875,7 +3142,7 @@ async function archiveConversationGroup(groupId) {
|
|
|
2875
3142
|
detail: busy
|
|
2876
3143
|
? `${busy} of them ${busy === 1 ? 'is' : 'are'} still working and will stay; stop them first to archive everything.`
|
|
2877
3144
|
: 'Archiving stops their idle processes. The chats stay in the Archived view.',
|
|
2878
|
-
confirmLabel: '
|
|
3145
|
+
confirmLabel: '🗄️ Archive group',
|
|
2879
3146
|
});
|
|
2880
3147
|
if (!confirmed) return;
|
|
2881
3148
|
const result = await archiveConversationGroupApi(groupId);
|
|
@@ -3218,6 +3485,7 @@ export async function deleteConv(e, id) {
|
|
|
3218
3485
|
return;
|
|
3219
3486
|
}
|
|
3220
3487
|
delete conversations[id];
|
|
3488
|
+
noteConversationGone(id);
|
|
3221
3489
|
conversationPayloadCache.forget(id);
|
|
3222
3490
|
renderConvList();
|
|
3223
3491
|
if (currentConvId === id) clearOpenConversation();
|
|
@@ -3226,10 +3494,12 @@ export async function deleteConv(e, id) {
|
|
|
3226
3494
|
/**
|
|
3227
3495
|
* The conversation on screen is gone (deleted here or from another device):
|
|
3228
3496
|
* nothing is open, the header says so, the transcript shows the empty state
|
|
3229
|
-
* and the
|
|
3497
|
+
* and the ︙ menu keeps its relay-wide entries. One place for both callers.
|
|
3230
3498
|
*/
|
|
3231
|
-
export function clearOpenConversation() {
|
|
3499
|
+
export function clearOpenConversation({ history = true } = {}) {
|
|
3232
3500
|
setCurrentConv(null);
|
|
3501
|
+
// Not when back or forward steps on past it: that pop writes history.
|
|
3502
|
+
if (history) noteConversationCleared();
|
|
3233
3503
|
clearAttachments();
|
|
3234
3504
|
setRepoBrowserSessionInfo('', '');
|
|
3235
3505
|
restoreInFlightThinking(null);
|