@oar-sh/oar 0.9.20 → 0.9.21
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 +212 -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 +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-session-process.mjs +7 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +3 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -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/api-client.js +4 -2
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +556 -68
- package/server/public/app/chat-actions-menu-state.mjs +139 -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/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 +3 -0
- 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 +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +18 -2
- package/server/public/app/journal-view.js +311 -58
- 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/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/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -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 +5 -2
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -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 +2249 -1590
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +23 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +57 -1
- package/server/routes/sessions-routes.mjs +41 -7
- package/server/server-runtime.mjs +190 -40
- 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/remote-relay-dispatcher.mjs +61 -14
- package/server/services/session-activity-tracker.mjs +1 -1
- 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-websocket-service.mjs +40 -16
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/build-id.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- 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,
|
|
@@ -136,9 +143,16 @@ import {
|
|
|
136
143
|
resolveCloudSourceAutoFill,
|
|
137
144
|
validateCloudSourceInputs,
|
|
138
145
|
} from './claude-cloud-ui.mjs';
|
|
139
|
-
import {
|
|
140
|
-
import {
|
|
141
|
-
import {
|
|
146
|
+
import { conversationOriginInfo } from './remote-relay-origin-view.mjs';
|
|
147
|
+
import { relayEventWatchedInfoText } from './relay-event-subscriptions-ui.js';
|
|
148
|
+
import { relayScheduleInfoText } from './relay-schedules-ui.js';
|
|
149
|
+
import {
|
|
150
|
+
noteConversationCleared,
|
|
151
|
+
noteConversationGone,
|
|
152
|
+
noteConversationMissing,
|
|
153
|
+
noteConversationShown,
|
|
154
|
+
syncSessionDocumentTitle,
|
|
155
|
+
} from './session-history-ui.js';
|
|
142
156
|
import {
|
|
143
157
|
normalizeConversationFilter,
|
|
144
158
|
conversationMatchesFilter,
|
|
@@ -364,10 +378,11 @@ function isConversationProcessing(conversation, workerState) {
|
|
|
364
378
|
return runtimeStatus === 'processing';
|
|
365
379
|
}
|
|
366
380
|
|
|
367
|
-
|
|
381
|
+
// `openLast: false` when the address names the conversation to open instead.
|
|
382
|
+
export async function loadConversations({ openLast = true } = {}) {
|
|
368
383
|
await refreshConversations({ preservePagination: false });
|
|
369
|
-
const lastId = localStorage.getItem('copilot_last_conv');
|
|
370
|
-
if (lastId) await openConversation(lastId, { restoreScroll: true });
|
|
384
|
+
const lastId = openLast ? localStorage.getItem('copilot_last_conv') : '';
|
|
385
|
+
if (lastId) await openConversation(lastId, { restoreScroll: true, history: 'replace' });
|
|
371
386
|
}
|
|
372
387
|
|
|
373
388
|
export async function refreshConversations(options = {}) {
|
|
@@ -540,6 +555,13 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
|
|
|
540
555
|
// event or fetch said (the worker was killed, or the relay restarted).
|
|
541
556
|
let sessionState = String(conversation?.sessionState || '').trim();
|
|
542
557
|
if (sessionState === 'background_only' && hasSessionWorkerStatus() && !isWorkerStateLive(workerState)) sessionState = 'idle';
|
|
558
|
+
const memberState = memberStateClass({
|
|
559
|
+
pendingCount,
|
|
560
|
+
visualState,
|
|
561
|
+
processing,
|
|
562
|
+
activeTurn: conversation?.activeTurn === true,
|
|
563
|
+
sessionState,
|
|
564
|
+
});
|
|
543
565
|
return {
|
|
544
566
|
visualState,
|
|
545
567
|
processing,
|
|
@@ -549,13 +571,9 @@ function conversationRowState(conversation, pendingByConversation = getPendingQu
|
|
|
549
571
|
// "Stop all running" acts on, and what keeps an archive from it.
|
|
550
572
|
live: processing || pendingCount > 0 || conversation?.activeTurn === true || sessionState === 'running_turn' || sessionState === 'background_only',
|
|
551
573
|
// 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
|
-
}),
|
|
574
|
+
memberState,
|
|
575
|
+
// Its colour in the group's name.
|
|
576
|
+
tone: memberTone({ memberState, visualState }),
|
|
559
577
|
};
|
|
560
578
|
}
|
|
561
579
|
|
|
@@ -571,6 +589,14 @@ function groupDisplayName(groupId) {
|
|
|
571
589
|
return String(info?.name || head?.title || info?.displayName || 'Group').trim() || 'Group';
|
|
572
590
|
}
|
|
573
591
|
|
|
592
|
+
/** A synthetic head's meta line: when its newest row was active, and how many rows it has. */
|
|
593
|
+
function groupMetaText(group, summary) {
|
|
594
|
+
const ms = Number(group?.activityMs) || 0;
|
|
595
|
+
const when = ms > 0 ? fmtDate(new Date(ms).toISOString()) : '';
|
|
596
|
+
const count = groupSessionCountText(summary?.count || 0);
|
|
597
|
+
return when ? `${when} · ${count}` : count;
|
|
598
|
+
}
|
|
599
|
+
|
|
574
600
|
/** The loaded rows of a group, in the current view and across it. */
|
|
575
601
|
function groupMemberRows(groupId, { inViewOnly = true } = {}) {
|
|
576
602
|
return Object.values(conversations).filter((conversation) => String(conversation?.groupId || '') === groupId
|
|
@@ -580,15 +606,16 @@ function groupMemberRows(groupId, { inViewOnly = true } = {}) {
|
|
|
580
606
|
/**
|
|
581
607
|
* What a head row shows for its group: the classes of every row in it, the
|
|
582
608
|
* head included (the menu's "Archive group (N)" and "Stop all running (N)"
|
|
583
|
-
* count the same rows), their summary,
|
|
584
|
-
* sits in.
|
|
609
|
+
* count the same rows), their summary, the colour of the group's name, and
|
|
610
|
+
* the first row a waiting card sits in.
|
|
585
611
|
*/
|
|
586
612
|
function describeGroupForHead(group, viewOf) {
|
|
587
613
|
const rows = group.head ? [group.head, ...group.members] : [...group.members];
|
|
588
614
|
const states = rows.map((row) => viewOf(row).memberState);
|
|
589
615
|
const summary = summarizeGroupMembers(states);
|
|
616
|
+
const titleTone = groupTitleTone(rows.map((row) => viewOf(row).tone));
|
|
590
617
|
const firstWaiting = rows.find((row) => viewOf(row).memberState === 'waiting') || null;
|
|
591
|
-
return { summary, firstWaiting, forcedOpen: false };
|
|
618
|
+
return { summary, titleTone, firstWaiting, forcedOpen: false };
|
|
592
619
|
}
|
|
593
620
|
|
|
594
621
|
export function renderConvList() {
|
|
@@ -618,31 +645,32 @@ function paintConvList() {
|
|
|
618
645
|
if (!rowViews.has(id)) rowViews.set(id, conversationRowState(conversation, pendingByConversation));
|
|
619
646
|
return rowViews.get(id);
|
|
620
647
|
};
|
|
621
|
-
const foldChoices = readConversationGroupFoldChoices();
|
|
648
|
+
const foldChoices = readConversationGroupFoldChoices({ archived: conversationListArchivedView });
|
|
622
649
|
const groupDescriptions = new Map();
|
|
623
650
|
// Groups order with the same rules the list route pages by; a group is
|
|
624
651
|
// folded once nothing in it runs or waits, unless the user chose otherwise.
|
|
652
|
+
// A folded group still shows the open conversation under its head.
|
|
625
653
|
const items = arrangeConversationList(inView, conversationGroups, {
|
|
626
654
|
matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
|
|
627
655
|
groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
|
|
628
656
|
// An owner's group an agent emptied shows in the live list, empty.
|
|
629
657
|
emptyGroups: !conversationListArchivedView,
|
|
658
|
+
keepVisibleId: currentConvId || null,
|
|
630
659
|
isOpen: (groupId, group) => {
|
|
631
660
|
const described = describeGroupForHead(group, viewOf);
|
|
632
|
-
const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
|
|
633
661
|
// Open whatever the choice: the fold button then says so and does nothing.
|
|
634
|
-
described.forcedOpen =
|
|
662
|
+
described.forcedOpen = !!activeFilter;
|
|
635
663
|
groupDescriptions.set(groupId, described);
|
|
636
664
|
if (convListTransientOpenGroups.has(groupId)) return true;
|
|
637
665
|
return resolveGroupFold({
|
|
638
666
|
choice: foldChoices[groupId] || null,
|
|
639
667
|
active: groupIsActive(described.summary),
|
|
640
|
-
holdsOpenConversation,
|
|
641
668
|
filterActive: !!activeFilter,
|
|
642
669
|
});
|
|
643
670
|
},
|
|
644
671
|
});
|
|
645
672
|
lastConvListDragItems = describeDragItems(items);
|
|
673
|
+
syncConversationGroupsFoldAllButton(items, !!activeFilter);
|
|
646
674
|
const footerHtml = (() => {
|
|
647
675
|
if (activeFilter) {
|
|
648
676
|
// While a filter is active the drain loads older pages by itself; a
|
|
@@ -664,30 +692,31 @@ function paintConvList() {
|
|
|
664
692
|
const emptyText = activeFilter && inView.length > 0
|
|
665
693
|
? 'No conversations match your filter'
|
|
666
694
|
: (conversationListArchivedView ? 'No archived conversations' : 'No conversations yet');
|
|
667
|
-
list.innerHTML = `<div style="padding:
|
|
695
|
+
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
696
|
lastConvListHtml = '';
|
|
669
697
|
scheduleConversationListBoundaryCheck();
|
|
670
698
|
return;
|
|
671
699
|
}
|
|
672
700
|
// The head row's pill: the member count and what the members are doing;
|
|
673
701
|
// while folded, its "waiting" part opens the member the card sits in.
|
|
674
|
-
|
|
702
|
+
// withCount: false on a synthetic head, whose meta line says the count.
|
|
703
|
+
const groupPillHtml = (item, { withCount = true } = {}) => {
|
|
675
704
|
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
676
|
-
const text = groupSummaryText(described.summary);
|
|
705
|
+
const text = withCount ? groupSummaryText(described.summary) : groupActivityText(described.summary);
|
|
677
706
|
if (!text) return '';
|
|
678
707
|
const waitingId = described.firstWaiting ? String(described.firstWaiting.id) : '';
|
|
679
708
|
// Inline like the row's own buttons: the row's click handler must not
|
|
680
709
|
// run (it would open the head and re-render the list first).
|
|
681
710
|
// The full text is the title: a narrow sidebar shortens the pill with an ellipsis.
|
|
682
711
|
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>`;
|
|
712
|
+
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
713
|
};
|
|
685
714
|
const foldButtonHtml = (item) => {
|
|
686
715
|
const forced = (groupDescriptions.get(item.group.id) || {}).forcedOpen === true || item.group.contextOnly === true || !!activeFilter;
|
|
687
716
|
const label = forced
|
|
688
|
-
?
|
|
717
|
+
? 'Open while the filter is on'
|
|
689
718
|
: (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)}')"
|
|
719
|
+
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
720
|
};
|
|
692
721
|
const rowHtml = (c, item) => {
|
|
693
722
|
const view = viewOf(c);
|
|
@@ -696,30 +725,43 @@ function paintConvList() {
|
|
|
696
725
|
const providerIndicatorHtml = providerIndicatorLabel
|
|
697
726
|
? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
|
|
698
727
|
: '';
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
728
|
+
// The ⓘ and its menu: an agent started this conversation (here or on
|
|
729
|
+
// another relay), another conversation watches it (relay events), a
|
|
730
|
+
// schedule fires into it (relay_tool schedule). A member under its
|
|
731
|
+
// creator's head row says the origin already, so it is left out there.
|
|
732
|
+
const origin = item.kind === 'member' && item.group.head && c.origin?.local === true && c.origin.conversationId === item.group.head.id
|
|
733
|
+
? null
|
|
734
|
+
: conversationOriginInfo(c, { conversations });
|
|
735
|
+
const infoEntries = conversationRowInfoEntries({
|
|
736
|
+
origin,
|
|
737
|
+
watched: relayEventWatchedInfoText(c.id),
|
|
738
|
+
scheduled: relayScheduleInfoText(c.id),
|
|
739
|
+
});
|
|
740
|
+
if (infoEntries.length) convListRowInfo.set(String(c.id), infoEntries);
|
|
741
|
+
const infoButtonHtml = conversationRowInfoButtonHtml(c.id, infoEntries);
|
|
709
742
|
const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
|
|
710
743
|
const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
|
|
744
|
+
// A head row's title is the group's name, coloured by all its rows.
|
|
745
|
+
const toneAttr = item.kind === 'head' ? ` data-group-tone="${escHtml((groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf)).titleTone)}"` : '';
|
|
711
746
|
// A head row shows the name the group was given by hand; until then the
|
|
712
747
|
// conversation's own title (the group's name follows it).
|
|
713
748
|
const handName = item.kind === 'head' ? String(item.group.info?.name || '').trim() : '';
|
|
714
749
|
const rowTitle = handName || c.title;
|
|
715
750
|
const titleAttr = handName && handName !== c.title ? ` title="${escHtml(`${handName} · ${c.title}`)}"` : '';
|
|
751
|
+
const titleHtml = `<div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}</div>`;
|
|
752
|
+
// With open questions the title and its "N open" pill share a row in
|
|
753
|
+
// which only the title gives way; the pill is never cut.
|
|
754
|
+
const openCount = pendingByConversation[c.id];
|
|
755
|
+
const titleRowHtml = openCount
|
|
756
|
+
? `<div class="conv-title-row">${titleHtml}<span class="conv-open-questions">${openCount} open</span></div>`
|
|
757
|
+
: titleHtml;
|
|
716
758
|
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) : ''}${
|
|
759
|
+
<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})">
|
|
760
|
+
${titleRowHtml}
|
|
761
|
+
<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
762
|
${c.archived
|
|
721
763
|
? `<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"
|
|
764
|
+
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄️</button>`}
|
|
723
765
|
<button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
|
|
724
766
|
</div>`;
|
|
725
767
|
};
|
|
@@ -733,16 +775,17 @@ function paintConvList() {
|
|
|
733
775
|
return `
|
|
734
776
|
<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
777
|
<div class="conv-title">${escHtml(name)}</div>
|
|
736
|
-
<div class="conv-meta"><span class="conv-meta-primary"
|
|
778
|
+
<div class="conv-meta"><span class="conv-meta-primary">${groupSessionCountText(0)}</span></div>
|
|
737
779
|
</div>`;
|
|
738
780
|
}
|
|
739
781
|
const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
|
|
740
782
|
return `
|
|
741
|
-
<div class="conv-item conv-group-head conv-group-synthetic worker-ui
|
|
783
|
+
<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
784
|
<div class="conv-title">${foldButtonHtml(item)}${escHtml(name)}</div>
|
|
743
|
-
<div class="conv-meta"><span class="conv-meta-primary"
|
|
785
|
+
<div class="conv-meta"><span class="conv-meta-primary">${escHtml(groupMetaText(item.group, described.summary))}</span>${groupPillHtml(item, { withCount: false })}</div>
|
|
744
786
|
</div>`;
|
|
745
787
|
};
|
|
788
|
+
convListRowInfo.clear();
|
|
746
789
|
const listHtml = `${items.map((item) => (item.conversation ? rowHtml(item.conversation, item) : syntheticHeadHtml(item))).join('')}${footerHtml}`;
|
|
747
790
|
if (listHtml !== lastConvListHtml) {
|
|
748
791
|
list.innerHTML = listHtml;
|
|
@@ -750,6 +793,7 @@ function paintConvList() {
|
|
|
750
793
|
}
|
|
751
794
|
bindConversationContextMenu(list);
|
|
752
795
|
bindConversationGroupControls(list);
|
|
796
|
+
bindConversationInfoButtons(list);
|
|
753
797
|
window.syncChatTitleControls?.();
|
|
754
798
|
scheduleConversationListBoundaryCheck();
|
|
755
799
|
}
|
|
@@ -767,15 +811,105 @@ function bindConversationGroupControls(list) {
|
|
|
767
811
|
if (!waiting || !list.contains(waiting)) return;
|
|
768
812
|
event.preventDefault();
|
|
769
813
|
event.stopPropagation();
|
|
770
|
-
void (window.openConversation || openConversation)(waiting.dataset.openWaiting);
|
|
814
|
+
void (window.openConversation || openConversation)(waiting.dataset.openWaiting, { fromList: true });
|
|
771
815
|
});
|
|
772
816
|
}
|
|
773
817
|
|
|
818
|
+
// ─── The row's ⓘ: who started it, who watches it, what is scheduled ─────────
|
|
819
|
+
// One small button in the meta line stands for all three; its menu lists
|
|
820
|
+
// them, each with its action (or as a line of text where there is none).
|
|
821
|
+
|
|
822
|
+
// The menu entries of the rows as last drawn, by conversation id.
|
|
823
|
+
const convListRowInfo = new Map();
|
|
824
|
+
|
|
774
825
|
/**
|
|
775
|
-
*
|
|
776
|
-
*
|
|
826
|
+
* The entries of a row's ⓘ menu, in a fixed order. `origin` is
|
|
827
|
+
* conversationOriginInfo()'s answer, `watched` and `scheduled` their lines
|
|
828
|
+
* ('' for none). `short` is the entry's word in the button's label.
|
|
829
|
+
*/
|
|
830
|
+
function conversationRowInfoEntries({ origin = null, watched = '', scheduled = '' } = {}) {
|
|
831
|
+
const entries = [];
|
|
832
|
+
if (origin?.label) entries.push({ id: 'origin', short: 'started by an agent', label: origin.label, origin });
|
|
833
|
+
if (watched) entries.push({ id: 'watched', short: 'watched', label: watched });
|
|
834
|
+
if (scheduled) entries.push({ id: 'scheduled', short: 'scheduled', label: scheduled });
|
|
835
|
+
return entries;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function conversationRowInfoButtonHtml(conversationId, entries) {
|
|
839
|
+
if (!entries.length) return '';
|
|
840
|
+
const label = `Details: ${entries.map((entry) => entry.short).join(', ')}`;
|
|
841
|
+
return `<button type="button" class="conv-info-btn" data-conv-info="${escHtml(conversationId)}" aria-haspopup="menu" aria-label="${escHtml(label)}" title="${escHtml(label)}">ⓘ</button>`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function conversationRowInfoMenuItems(entries) {
|
|
845
|
+
return entries.map((entry) => {
|
|
846
|
+
const origin = entry.id === 'origin' ? entry.origin : null;
|
|
847
|
+
if (origin?.kind === 'local' && origin.linkable) {
|
|
848
|
+
// As the header's "via agent" marker: openOriginConversation says so
|
|
849
|
+
// when that conversation is gone by now.
|
|
850
|
+
return { id: entry.id, label: entry.label, onSelect: () => { void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } }, { fromList: true }); } };
|
|
851
|
+
}
|
|
852
|
+
if (origin?.kind === 'remote' && origin.href) {
|
|
853
|
+
// As the message badge of such an agent: its session on that relay.
|
|
854
|
+
return { id: entry.id, label: entry.label, onSelect: () => { window.open(origin.href, '_blank', 'noopener'); } };
|
|
855
|
+
}
|
|
856
|
+
return { id: entry.id, label: entry.label, note: true };
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Opens the ⓘ menu of a row under its button. */
|
|
861
|
+
function openConversationRowInfoMenu(button) {
|
|
862
|
+
const id = String(button?.dataset?.convInfo || '').trim();
|
|
863
|
+
const entries = convListRowInfo.get(id);
|
|
864
|
+
if (!entries?.length) return;
|
|
865
|
+
const rect = button.getBoundingClientRect();
|
|
866
|
+
const gap = 4 * getUiScale();
|
|
867
|
+
// Esc is a key, so the returned focus would show its ring (and the row's
|
|
868
|
+
// archive and delete icons) even after a mouse click: it shows only if
|
|
869
|
+
// the button had a visible focus when the menu opened.
|
|
870
|
+
const focusVisible = !!button.matches?.(':focus-visible');
|
|
871
|
+
openContextMenu(rect.left, rect.bottom + gap, conversationRowInfoMenuItems(entries), {
|
|
872
|
+
// The list may have been drawn anew meanwhile: the row's button by id.
|
|
873
|
+
onEscape: () => {
|
|
874
|
+
document.querySelector(`#conv-list .conv-info-btn[data-conv-info="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true, focusVisible });
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The ⓘ opens its menu and never the row: caught on the way down (capture),
|
|
881
|
+
* before the row's own click handler opens the conversation. Bound once.
|
|
882
|
+
*/
|
|
883
|
+
function bindConversationInfoButtons(list) {
|
|
884
|
+
if (!list || list.dataset.infoButtonsBound === '1') return;
|
|
885
|
+
list.dataset.infoButtonsBound = '1';
|
|
886
|
+
list.addEventListener('click', (event) => {
|
|
887
|
+
const button = event.target?.closest?.('.conv-info-btn');
|
|
888
|
+
if (!button || !list.contains(button)) return;
|
|
889
|
+
event.stopPropagation();
|
|
890
|
+
// The release of a long press that opened the row's menu: that menu stays.
|
|
891
|
+
if (event.defaultPrevented) return;
|
|
892
|
+
event.preventDefault();
|
|
893
|
+
openConversationRowInfoMenu(button);
|
|
894
|
+
}, true);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Folds or unfolds a group; the choice is kept until the user changes it,
|
|
899
|
+
* apart for the live list and the Archived view. A group held open by a
|
|
900
|
+
* filter keeps its choice. A folded group still shows the open conversation.
|
|
777
901
|
* The fold button keeps the focus across the re-render.
|
|
778
902
|
*/
|
|
903
|
+
// Groups whose fold the user just toggled: their chevron turns once on the
|
|
904
|
+
// next paint (the list is drawn anew, so a transition has nothing to start from).
|
|
905
|
+
const convListFoldTurning = new Set();
|
|
906
|
+
|
|
907
|
+
function turnGroupChevrons(groupIds) {
|
|
908
|
+
for (const id of groupIds) convListFoldTurning.add(id);
|
|
909
|
+
renderConvList();
|
|
910
|
+
convListFoldTurning.clear();
|
|
911
|
+
}
|
|
912
|
+
|
|
779
913
|
export function toggleConversationGroupFold(event, groupId) {
|
|
780
914
|
event?.stopPropagation?.();
|
|
781
915
|
event?.preventDefault?.();
|
|
@@ -785,9 +919,53 @@ export function toggleConversationGroupFold(event, groupId) {
|
|
|
785
919
|
if (button?.disabled) return;
|
|
786
920
|
const currentlyOpen = button ? button.getAttribute('aria-expanded') === 'true' : true;
|
|
787
921
|
convListTransientOpenGroups.delete(id);
|
|
788
|
-
setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen));
|
|
789
|
-
|
|
790
|
-
|
|
922
|
+
setConversationGroupFoldChoice(id, toggledGroupFoldChoice(currentlyOpen), { archived: conversationListArchivedView });
|
|
923
|
+
turnGroupChevrons([id]);
|
|
924
|
+
// Keyboard toggles keep the focus; a click or tap (detail > 0) does not,
|
|
925
|
+
// or the redrawn button would show a focus ring.
|
|
926
|
+
if (!(Number(event?.detail) > 0)) {
|
|
927
|
+
document.querySelector(`#conv-list [data-group-fold="${CSS.escape(id)}"]`)?.focus?.({ preventScroll: true });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// The groups the fold-all button acts on: those in the list as last drawn.
|
|
932
|
+
let conversationListFoldableGroups = { ids: [], anyOpen: false };
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The filter row's fold-all button: ➖ while a group in the list is open
|
|
936
|
+
* (folds them all), ➕ once all are folded (unfolds them all). Hidden
|
|
937
|
+
* without a group; disabled under a filter, which holds every group open.
|
|
938
|
+
*/
|
|
939
|
+
function syncConversationGroupsFoldAllButton(items, filterActive) {
|
|
940
|
+
const heads = items.filter((item) => item.kind === 'head' && !item.group.empty);
|
|
941
|
+
const anyOpen = heads.some((item) => item.group.open);
|
|
942
|
+
conversationListFoldableGroups = { ids: heads.map((item) => item.group.id), anyOpen };
|
|
943
|
+
const button = document.getElementById('conv-groups-fold-all');
|
|
944
|
+
if (!button) return;
|
|
945
|
+
button.hidden = heads.length === 0;
|
|
946
|
+
button.disabled = filterActive;
|
|
947
|
+
const label = filterActive
|
|
948
|
+
? 'Groups stay open while the filter is on'
|
|
949
|
+
: (anyOpen ? 'Fold all groups' : 'Unfold all groups');
|
|
950
|
+
const glyph = anyOpen ? '➖' : '➕';
|
|
951
|
+
if (button.textContent !== glyph) button.textContent = glyph;
|
|
952
|
+
if (button.title !== label) {
|
|
953
|
+
button.title = label;
|
|
954
|
+
button.setAttribute('aria-label', label);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Folds every group in the list, or unfolds them all once all are folded. */
|
|
959
|
+
export function toggleAllConversationGroupFolds(event) {
|
|
960
|
+
event?.stopPropagation?.();
|
|
961
|
+
event?.preventDefault?.();
|
|
962
|
+
const button = document.getElementById('conv-groups-fold-all');
|
|
963
|
+
if (button?.disabled) return;
|
|
964
|
+
const { ids, anyOpen } = conversationListFoldableGroups;
|
|
965
|
+
if (!ids.length) return;
|
|
966
|
+
for (const id of ids) convListTransientOpenGroups.delete(id);
|
|
967
|
+
setConversationGroupFoldChoices(ids, anyOpen ? 'folded' : 'open', { archived: conversationListArchivedView });
|
|
968
|
+
turnGroupChevrons(ids);
|
|
791
969
|
}
|
|
792
970
|
|
|
793
971
|
// keepTranscript: the page shows a window this payload must not replace (the
|
|
@@ -1010,7 +1188,8 @@ function applyCachedConversationState(id, response, { viewAnchor = null } = {})
|
|
|
1010
1188
|
// The "via agent" marker (sidebar row, header, message badge): opens the
|
|
1011
1189
|
// conversation whose agent started this one. The marker sits inside a sidebar
|
|
1012
1190
|
// row that opens on click itself, so the click stops here.
|
|
1013
|
-
|
|
1191
|
+
// `options` go to openConversation (`fromList` for a pick from a list row's ⓘ).
|
|
1192
|
+
export function openOriginConversation(event, element, options = {}) {
|
|
1014
1193
|
event?.stopPropagation?.();
|
|
1015
1194
|
event?.preventDefault?.();
|
|
1016
1195
|
const id = String(element?.dataset?.originConversationId || '').trim();
|
|
@@ -1021,7 +1200,7 @@ export function openOriginConversation(event, element) {
|
|
|
1021
1200
|
renderConvList();
|
|
1022
1201
|
return null;
|
|
1023
1202
|
}
|
|
1024
|
-
return openConversation(id);
|
|
1203
|
+
return openConversation(id, options);
|
|
1025
1204
|
}
|
|
1026
1205
|
|
|
1027
1206
|
// The fresh answer for a conversation whose kept window is on the page (drawn
|
|
@@ -1085,6 +1264,53 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
|
|
|
1085
1264
|
saveConversationScrollTop(id, messagesEl.scrollTop);
|
|
1086
1265
|
}
|
|
1087
1266
|
|
|
1267
|
+
const LAST_OPENED_CONVERSATION_KEY = 'copilot_last_conv';
|
|
1268
|
+
|
|
1269
|
+
function readLastOpenedConversationId() {
|
|
1270
|
+
try {
|
|
1271
|
+
return String(localStorage.getItem(LAST_OPENED_CONVERSATION_KEY) || '').trim();
|
|
1272
|
+
} catch {
|
|
1273
|
+
return '';
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** The header and the document take the title the loaded payload gave the record. */
|
|
1278
|
+
function syncOpenedConversationTitle(id) {
|
|
1279
|
+
if (String(currentConvId || '').trim() !== String(id || '').trim()) return;
|
|
1280
|
+
const title = String(conversations[id]?.title || '').trim();
|
|
1281
|
+
const titleEl = document.getElementById('chat-title');
|
|
1282
|
+
if (title && titleEl && titleEl.textContent !== title && !document.body.classList.contains('chat-title-editing')) {
|
|
1283
|
+
titleEl.textContent = title;
|
|
1284
|
+
window.syncChatTitleControls?.();
|
|
1285
|
+
}
|
|
1286
|
+
syncSessionDocumentTitle();
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* The relay knows no conversation `id` (deleted elsewhere, a mistyped or
|
|
1291
|
+
* stale link). Back or forward onto it steps on past it; anything else
|
|
1292
|
+
* leaves nothing open, without `?conv=` in the address, and says so. The
|
|
1293
|
+
* conversation the next start opens stays the one it was.
|
|
1294
|
+
*/
|
|
1295
|
+
function showConversationUnknown(id, { lastOpenedBefore = '' } = {}) {
|
|
1296
|
+
conversationPayloadCache.forget(id);
|
|
1297
|
+
if (conversations[id]) {
|
|
1298
|
+
delete conversations[id];
|
|
1299
|
+
renderConvList();
|
|
1300
|
+
}
|
|
1301
|
+
// Stepped over: the pop that follows opens what lies there.
|
|
1302
|
+
const stepsOn = noteConversationMissing(id);
|
|
1303
|
+
clearOpenConversation({ history: !stepsOn });
|
|
1304
|
+
renderNoConversationState();
|
|
1305
|
+
window.syncComposerButtonState?.();
|
|
1306
|
+
window.syncComposerPlaceholder?.();
|
|
1307
|
+
try {
|
|
1308
|
+
if (lastOpenedBefore && lastOpenedBefore !== id) localStorage.setItem(LAST_OPENED_CONVERSATION_KEY, lastOpenedBefore);
|
|
1309
|
+
else localStorage.removeItem(LAST_OPENED_CONVERSATION_KEY);
|
|
1310
|
+
} catch {}
|
|
1311
|
+
showTransientRelayNotice('That conversation is not on this relay. It may have been deleted.', 6000);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1088
1314
|
export async function openConversation(id, options = {}) {
|
|
1089
1315
|
// The Inbox owns the transcript like the status view does.
|
|
1090
1316
|
const didLeaveInboxView = leaveInboxView();
|
|
@@ -1102,7 +1328,13 @@ export async function openConversation(id, options = {}) {
|
|
|
1102
1328
|
const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
|
|
1103
1329
|
if (switchingConversation && previousConversationId && !IS_SHARED_VIEW) keepPlaceOfConversationLeft(previousConversationId);
|
|
1104
1330
|
const capturedVersion = ++openConversationVersion;
|
|
1331
|
+
// Put back if the relay knows no such conversation: a wrong link must not
|
|
1332
|
+
// become the one the next start opens.
|
|
1333
|
+
const lastOpenedBefore = readLastOpenedConversationId();
|
|
1105
1334
|
setCurrentConv(id);
|
|
1335
|
+
// One history entry per switch (`history`: 'replace' at load, 'none' when
|
|
1336
|
+
// back or forward opened it); reopening the one on screen adds none.
|
|
1337
|
+
noteConversationShown(nextConversationId, options.history);
|
|
1106
1338
|
if (repoBrowserState.activeRoot === 'workspace') {
|
|
1107
1339
|
repoBrowserState.tree = null;
|
|
1108
1340
|
repoBrowserState.nodeMap = new Map();
|
|
@@ -1114,6 +1346,9 @@ export async function openConversation(id, options = {}) {
|
|
|
1114
1346
|
repoBrowserState.error = '';
|
|
1115
1347
|
}
|
|
1116
1348
|
closeSidebar();
|
|
1349
|
+
// A pick from the list (`fromList`) also collapses the persistent list of
|
|
1350
|
+
// a phone turned sideways; a long press for the row menu never opens.
|
|
1351
|
+
if (options.fromList) collapseSidebarAfterListPick();
|
|
1117
1352
|
// Reopening the conversation on screen (session bind, clicking it again, a
|
|
1118
1353
|
// push or search jump) must leave its composer alone: emptied attachments
|
|
1119
1354
|
// would read as a user edit and be saved, dropping them from the draft.
|
|
@@ -1156,9 +1391,13 @@ export async function openConversation(id, options = {}) {
|
|
|
1156
1391
|
? String(savedAnchor.messageId || '').trim()
|
|
1157
1392
|
: '';
|
|
1158
1393
|
const cachedJumpEpoch = cached ? getTranscriptJumpEpoch() : 0;
|
|
1394
|
+
let unknownToRelay = false;
|
|
1159
1395
|
const load = (around = aroundMessageId || anchorMessageId) => loadConversation(id, {
|
|
1160
1396
|
limit: around ? 40 : 20,
|
|
1161
1397
|
aroundMessageId: around || undefined,
|
|
1398
|
+
onErrorResponse: (response) => {
|
|
1399
|
+
if (response?.status === 404) unknownToRelay = true;
|
|
1400
|
+
},
|
|
1162
1401
|
});
|
|
1163
1402
|
const stillWanted = () => shouldApplyConversationLoad({
|
|
1164
1403
|
requestedConversationId: id,
|
|
@@ -1172,6 +1411,10 @@ export async function openConversation(id, options = {}) {
|
|
|
1172
1411
|
? await loadKeepingLiveRows(load, getTranscriptLiveRowEpoch)
|
|
1173
1412
|
: { response: await load(), stale: false };
|
|
1174
1413
|
if (!stillWanted()) return;
|
|
1414
|
+
if (!fetched && unknownToRelay) {
|
|
1415
|
+
showConversationUnknown(nextConversationId, { lastOpenedBefore });
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1175
1418
|
let r = fetched;
|
|
1176
1419
|
const anchorFound = !!anchorMessageId && payloadHasMessage(r, anchorMessageId);
|
|
1177
1420
|
if (r && anchorMessageId && !anchorFound) {
|
|
@@ -1217,6 +1460,9 @@ export async function openConversation(id, options = {}) {
|
|
|
1217
1460
|
restoreInFlightThinking(null);
|
|
1218
1461
|
renderMessages([]);
|
|
1219
1462
|
}
|
|
1463
|
+
// A conversation the list does not hold (an archived one, a link) had only
|
|
1464
|
+
// its id to show until now: the payload names it.
|
|
1465
|
+
if (r) syncOpenedConversationTitle(id);
|
|
1220
1466
|
await loadRelayQuestions(id);
|
|
1221
1467
|
await loadRelayBoards();
|
|
1222
1468
|
if (!shouldApplyConversationLoad({
|
|
@@ -1230,7 +1476,9 @@ export async function openConversation(id, options = {}) {
|
|
|
1230
1476
|
applyContextUsageBar(null);
|
|
1231
1477
|
scheduleContextUsageRefresh(id, 0);
|
|
1232
1478
|
const composer = document.getElementById('msg-input');
|
|
1233
|
-
|
|
1479
|
+
// A phone, either way round, gets no focus here: it would raise the
|
|
1480
|
+
// keyboard over the conversation just opened.
|
|
1481
|
+
if (isPhoneComposerViewport()) {
|
|
1234
1482
|
releaseComposerFocusAfterSend(composer);
|
|
1235
1483
|
} else {
|
|
1236
1484
|
composer.focus();
|
|
@@ -2646,6 +2894,8 @@ export function isArchivedConversationsView() {
|
|
|
2646
2894
|
}
|
|
2647
2895
|
|
|
2648
2896
|
export async function toggleArchivedConversations(next = !conversationListArchivedView) {
|
|
2897
|
+
// A group held open by a drop belongs to the view it happened in.
|
|
2898
|
+
if ((next === true) !== conversationListArchivedView) convListTransientOpenGroups.clear();
|
|
2649
2899
|
conversationListArchivedView = next === true;
|
|
2650
2900
|
const toggle = document.getElementById('conv-archived-toggle');
|
|
2651
2901
|
if (toggle) {
|
|
@@ -2699,7 +2949,7 @@ function askBeforeArchive(id, confirmation) {
|
|
|
2699
2949
|
<p>${escHtml(confirmation.message)}</p>
|
|
2700
2950
|
<p>${escHtml(confirmation.detail)}</p>
|
|
2701
2951
|
<div class="summary-modal-actions">
|
|
2702
|
-
<button class="chat-title-action-btn" type="button" data-archive-confirm="1"
|
|
2952
|
+
<button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄️ Archive</button>
|
|
2703
2953
|
<button class="chat-title-action-btn" type="button" data-archive-cancel="1">Cancel</button>
|
|
2704
2954
|
</div>
|
|
2705
2955
|
`,
|
|
@@ -2785,7 +3035,7 @@ function conversationMenuItems(id) {
|
|
|
2785
3035
|
{ id: 'group-remove', label: 'Remove from group', onSelect: () => { void removeConversationFromGroup(id); } },
|
|
2786
3036
|
];
|
|
2787
3037
|
return [
|
|
2788
|
-
{ id: 'open', label: 'Open', onSelect: () => { void openConversation(id); } },
|
|
3038
|
+
{ id: 'open', label: 'Open', onSelect: () => { void openConversation(id, { fromList: true }); } },
|
|
2789
3039
|
{ id: 'edit-title', label: 'Edit title', onSelect: () => { void openFirst(() => window.openChatTitleEditor?.()); } },
|
|
2790
3040
|
MENU_SEPARATOR,
|
|
2791
3041
|
{ id: 'stop-turn', label: 'Stop turn', disabled: !running, onSelect: () => { void openFirst(() => openStopTurnConfirmationForConversation(id)); } },
|
|
@@ -2875,7 +3125,7 @@ async function archiveConversationGroup(groupId) {
|
|
|
2875
3125
|
detail: busy
|
|
2876
3126
|
? `${busy} of them ${busy === 1 ? 'is' : 'are'} still working and will stay; stop them first to archive everything.`
|
|
2877
3127
|
: 'Archiving stops their idle processes. The chats stay in the Archived view.',
|
|
2878
|
-
confirmLabel: '
|
|
3128
|
+
confirmLabel: '🗄️ Archive group',
|
|
2879
3129
|
});
|
|
2880
3130
|
if (!confirmed) return;
|
|
2881
3131
|
const result = await archiveConversationGroupApi(groupId);
|
|
@@ -3218,6 +3468,7 @@ export async function deleteConv(e, id) {
|
|
|
3218
3468
|
return;
|
|
3219
3469
|
}
|
|
3220
3470
|
delete conversations[id];
|
|
3471
|
+
noteConversationGone(id);
|
|
3221
3472
|
conversationPayloadCache.forget(id);
|
|
3222
3473
|
renderConvList();
|
|
3223
3474
|
if (currentConvId === id) clearOpenConversation();
|
|
@@ -3226,10 +3477,12 @@ export async function deleteConv(e, id) {
|
|
|
3226
3477
|
/**
|
|
3227
3478
|
* The conversation on screen is gone (deleted here or from another device):
|
|
3228
3479
|
* nothing is open, the header says so, the transcript shows the empty state
|
|
3229
|
-
* and the
|
|
3480
|
+
* and the ︙ menu keeps its relay-wide entries. One place for both callers.
|
|
3230
3481
|
*/
|
|
3231
|
-
export function clearOpenConversation() {
|
|
3482
|
+
export function clearOpenConversation({ history = true } = {}) {
|
|
3232
3483
|
setCurrentConv(null);
|
|
3484
|
+
// Not when back or forward steps on past it: that pop writes history.
|
|
3485
|
+
if (history) noteConversationCleared();
|
|
3233
3486
|
clearAttachments();
|
|
3234
3487
|
setRepoBrowserSessionInfo('', '');
|
|
3235
3488
|
restoreInFlightThinking(null);
|