@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
|
|
2
|
+
|
|
1
3
|
const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
|
|
2
4
|
const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
3
5
|
|
|
@@ -109,6 +111,7 @@ export function ensureSettingsTabsBound() {
|
|
|
109
111
|
const modal = document.getElementById('settings-modal');
|
|
110
112
|
if (!modal) return;
|
|
111
113
|
settingsTabsBound = true;
|
|
114
|
+
watchTabStripFades();
|
|
112
115
|
modal.addEventListener('click', handleTabClick);
|
|
113
116
|
modal.addEventListener('keydown', handleTabKeydown);
|
|
114
117
|
}
|
|
@@ -125,5 +128,14 @@ export function selectSettingsTab(tab, providerTab) {
|
|
|
125
128
|
if (applyTabState('data-settings-provider-tab', 'data-settings-provider-panel', nextProviderTab)) {
|
|
126
129
|
writeLocalStorage(SETTINGS_PROVIDER_TAB_STORAGE_KEY, nextProviderTab);
|
|
127
130
|
}
|
|
131
|
+
// The selected tabs whole, clear of the strips' fades (a closed dialog
|
|
132
|
+
// is not laid out: openSettingsModal does it again once it shows).
|
|
133
|
+
revealSettingsTabs();
|
|
128
134
|
return { tab: nextTab, providerTab: nextProviderTab };
|
|
129
135
|
}
|
|
136
|
+
|
|
137
|
+
/** Each Settings strip: its selected tab scrolled into view, its fade marked. */
|
|
138
|
+
export function revealSettingsTabs() {
|
|
139
|
+
const modal = document.getElementById('settings-modal');
|
|
140
|
+
if (modal) revealSelectedTabs(modal);
|
|
141
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The relay's active public shares, as it last reported them: one entry per
|
|
2
2
|
// shared conversation (Settings → Shared conversations) and whether the open
|
|
3
|
-
// conversation has one (the
|
|
3
|
+
// conversation has one (the ︙ menu's "Stop sharing"). Read from GET /api/shares
|
|
4
4
|
// when the socket connects and again on every share event; no DOM here.
|
|
5
5
|
|
|
6
6
|
import { listConversationShares } from './api-client.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Settings → Sharing: every conversation with a public link, its access log,
|
|
2
|
-
// and the actions on a link (copy, renew, stop). The
|
|
2
|
+
// and the actions on a link (copy, renew, stop). The ︙ menu's "Stop sharing"
|
|
3
3
|
// uses the same confirmation. The list lives in share-state.js; the socket's
|
|
4
4
|
// share events keep it current while the modal is open.
|
|
5
5
|
|
|
@@ -461,7 +461,7 @@ export function openRenewShareConfirmation(conversationId, title = '') {
|
|
|
461
461
|
}));
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
/**
|
|
464
|
+
/** ︙ → Share conversation: the link, made if there is none, to the clipboard. */
|
|
465
465
|
export async function shareConversationFromMenu(conversationId) {
|
|
466
466
|
const result = await createConversationShareLink(conversationId);
|
|
467
467
|
const shareUrl = String(result?.shareUrl || '').trim();
|
|
@@ -105,6 +105,7 @@ import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
|
|
|
105
105
|
import { mergeRelayThoughts } from './relay-thoughts.mjs';
|
|
106
106
|
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
107
107
|
import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
|
|
108
|
+
import { noteConversationGone } from './session-history-ui.js';
|
|
108
109
|
|
|
109
110
|
// A live row of the conversation on screen joins its kept window, so a
|
|
110
111
|
// switch away and back still shows it. Rows of other conversations do not:
|
|
@@ -720,7 +721,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
720
721
|
setPreviews(previews);
|
|
721
722
|
});
|
|
722
723
|
// Public shares: the events name the conversation only; the list (Settings
|
|
723
|
-
// → Sharing, the
|
|
724
|
+
// → Sharing, the ︙ menu's Stop sharing) is read again.
|
|
724
725
|
for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
|
|
725
726
|
socket.on(event, () => {
|
|
726
727
|
void refreshShares().catch(() => {});
|
|
@@ -842,7 +843,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
842
843
|
conversationPayloadCache.forget(targetConversationId);
|
|
843
844
|
await refreshConversations();
|
|
844
845
|
if (currentConvId === sourceConversationId) {
|
|
845
|
-
|
|
846
|
+
// The successor takes the compacted one's history entry: no step back into it.
|
|
847
|
+
await openConversation(targetConversationId, { history: 'replace' });
|
|
846
848
|
} else {
|
|
847
849
|
updateCompactButton();
|
|
848
850
|
}
|
|
@@ -1010,6 +1012,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
1010
1012
|
});
|
|
1011
1013
|
socket.on('conversation_deleted', ({ conversationId }) => {
|
|
1012
1014
|
delete conversations[conversationId];
|
|
1015
|
+
noteConversationGone(conversationId);
|
|
1013
1016
|
conversationPayloadCache.forget(conversationId);
|
|
1014
1017
|
for (const [id, question] of relayQuestions.entries()) {
|
|
1015
1018
|
if (question?.conversationId === conversationId) relayQuestions.delete(id);
|
|
@@ -285,12 +285,28 @@ export function leaveStatusView() {
|
|
|
285
285
|
return wasActive;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Nothing to go back to (opened with nothing open, as after an unknown
|
|
290
|
+
* link): the header and the transcript give way to the empty screen.
|
|
291
|
+
* Loaded on demand: the transcript's module is already in the page.
|
|
292
|
+
*/
|
|
293
|
+
async function showNothingOpen() {
|
|
294
|
+
const { clearOpenConversation } = await import('./journal-view.js');
|
|
295
|
+
clearOpenConversation({ history: false });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Opens Status, or leaves it for the conversation it was opened from. Opened
|
|
300
|
+
* from the Inbox there is none stored: `returnTo` (the session the history
|
|
301
|
+
* stands on) is opened instead, so the transcript never keeps Status's rows.
|
|
302
|
+
*/
|
|
303
|
+
export async function toggleStatusView({ returnTo = '' } = {}) {
|
|
289
304
|
if (statusViewActive) {
|
|
290
305
|
leaveStatusView();
|
|
291
|
-
const restoreId = previousConversationId;
|
|
306
|
+
const restoreId = previousConversationId || String(returnTo || '').trim();
|
|
292
307
|
previousConversationId = '';
|
|
293
308
|
if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
|
|
309
|
+
else await showNothingOpen();
|
|
294
310
|
return;
|
|
295
311
|
}
|
|
296
312
|
previousConversationId = String(currentConvId || '').trim();
|
|
@@ -7,6 +7,8 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
|
7
7
|
import { nextSubagentStatus } from './subagent-fold.mjs';
|
|
8
8
|
import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
|
|
9
9
|
import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
|
|
10
|
+
import { isPhoneLandscapeLayout, shouldCollapseListAfterPick, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
|
|
11
|
+
import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
|
|
10
12
|
|
|
11
13
|
function resolveAppBase() {
|
|
12
14
|
const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
|
|
@@ -265,26 +267,37 @@ export function pruneConversationGroups(keepGroupIds) {
|
|
|
265
267
|
return dropped;
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
// The live list and the Archived view keep their fold choices apart.
|
|
271
|
+
function conversationGroupFoldsStorageKey(archived) {
|
|
272
|
+
return archived === true ? `${CONVERSATION_GROUP_FOLDS_STORAGE_KEY}_archived` : CONVERSATION_GROUP_FOLDS_STORAGE_KEY;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function readConversationGroupFoldChoices({ archived = false } = {}) {
|
|
276
|
+
return parseGroupFoldChoices(localStorage.getItem(conversationGroupFoldsStorageKey(archived)) || '');
|
|
270
277
|
}
|
|
271
278
|
|
|
272
279
|
// The stored fold choices are trimmed to the loaded groups only past this
|
|
273
|
-
// size: a choice for a group on an older page
|
|
274
|
-
// not go with every toggle.
|
|
280
|
+
// size: a choice for a group on an older page must not go with every toggle.
|
|
275
281
|
const CONVERSATION_GROUP_FOLDS_MAX = 300;
|
|
276
282
|
|
|
277
|
-
/** Stores the user's fold choice for a group. */
|
|
278
|
-
export function setConversationGroupFoldChoice(groupId, choice) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
/** Stores the user's fold choice for a group, in the live list or the Archived view. */
|
|
284
|
+
export function setConversationGroupFoldChoice(groupId, choice, { archived = false } = {}) {
|
|
285
|
+
setConversationGroupFoldChoices([groupId], choice, { archived });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Stores one fold choice for several groups at once (fold all, unfold all). */
|
|
289
|
+
export function setConversationGroupFoldChoices(groupIds, choice, { archived = false } = {}) {
|
|
290
|
+
const ids = (Array.isArray(groupIds) ? groupIds : []).map((groupId) => String(groupId || '').trim()).filter(Boolean);
|
|
291
|
+
if (!ids.length) return;
|
|
292
|
+
let next = readConversationGroupFoldChoices({ archived });
|
|
293
|
+
if (Object.keys(next).length + ids.length > CONVERSATION_GROUP_FOLDS_MAX) {
|
|
294
|
+
next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), ...ids]);
|
|
295
|
+
}
|
|
296
|
+
for (const id of ids) {
|
|
297
|
+
if (choice === 'open' || choice === 'folded') next[id] = choice;
|
|
298
|
+
else delete next[id];
|
|
284
299
|
}
|
|
285
|
-
|
|
286
|
-
else delete next[id];
|
|
287
|
-
localStorage.setItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY, JSON.stringify(next));
|
|
300
|
+
localStorage.setItem(conversationGroupFoldsStorageKey(archived), JSON.stringify(next));
|
|
288
301
|
}
|
|
289
302
|
|
|
290
303
|
export function setConversationWatcherCount(conversationId, count) {
|
|
@@ -744,6 +757,14 @@ export function isMobileComposerViewport() {
|
|
|
744
757
|
return window.matchMedia('(max-width: 680px)').matches;
|
|
745
758
|
}
|
|
746
759
|
|
|
760
|
+
// A phone either way round: the portrait layout above, or a phone turned
|
|
761
|
+
// sideways, which has the desktop layout (:root[data-phone-landscape]).
|
|
762
|
+
// Opening a session leaves the message box unfocused here, so no keyboard
|
|
763
|
+
// comes up by itself.
|
|
764
|
+
export function isPhoneComposerViewport() {
|
|
765
|
+
return isMobileComposerViewport() || isPhoneLandscapeLayout();
|
|
766
|
+
}
|
|
767
|
+
|
|
747
768
|
export function releaseComposerFocusAfterSend(input) {
|
|
748
769
|
if (!input || !isMobileComposerViewport()) return;
|
|
749
770
|
try { input.blur(); } catch {}
|
|
@@ -1131,18 +1152,30 @@ function measureSidebarHeaderMinWidthPx() {
|
|
|
1131
1152
|
sectionCount += 1;
|
|
1132
1153
|
}
|
|
1133
1154
|
|
|
1155
|
+
// New Chat grows into the free room, so its box says nothing: its label's
|
|
1156
|
+
// own width is what the head wraps by.
|
|
1134
1157
|
const newConversationButton = document.getElementById('new-conv-btn');
|
|
1135
1158
|
if (isVisibleElement(newConversationButton)) {
|
|
1136
1159
|
const buttonStyle = window.getComputedStyle(newConversationButton);
|
|
1137
|
-
|
|
1160
|
+
let labelWidth = 0;
|
|
1161
|
+
try {
|
|
1162
|
+
const range = document.createRange();
|
|
1163
|
+
range.selectNodeContents(newConversationButton);
|
|
1164
|
+
labelWidth = range.getBoundingClientRect().width || 0;
|
|
1165
|
+
} catch {}
|
|
1166
|
+
const natural = labelWidth
|
|
1167
|
+
+ parseCssPx(buttonStyle.paddingLeft) + parseCssPx(buttonStyle.paddingRight)
|
|
1168
|
+
+ parseCssPx(buttonStyle.borderLeftWidth) + parseCssPx(buttonStyle.borderRightWidth);
|
|
1169
|
+
neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), natural));
|
|
1138
1170
|
sectionCount += 1;
|
|
1139
1171
|
}
|
|
1140
1172
|
|
|
1173
|
+
// These two keep their own width (Inbox grows with its count).
|
|
1141
1174
|
for (const id of ['status-btn', 'inbox-btn']) {
|
|
1142
1175
|
const headerButton = document.getElementById(id);
|
|
1143
1176
|
if (!isVisibleElement(headerButton)) continue;
|
|
1144
1177
|
const buttonStyle = window.getComputedStyle(headerButton);
|
|
1145
|
-
neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
|
|
1178
|
+
neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), headerButton.getBoundingClientRect().width || 0));
|
|
1146
1179
|
sectionCount += 1;
|
|
1147
1180
|
}
|
|
1148
1181
|
|
|
@@ -1260,6 +1293,23 @@ export function toggleSidebar(forceOpen = null) {
|
|
|
1260
1293
|
setDesktopSidebarCollapsed(!nextOpen);
|
|
1261
1294
|
}
|
|
1262
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* A session picked from the list (a tap on its row, or Open in its menu). A
|
|
1298
|
+
* phone turned sideways has the desktop's persistent list; the pick collapses
|
|
1299
|
+
* it, as the overlay list closes on a portrait phone (openConversation's
|
|
1300
|
+
* closeSidebar). A long press never gets here: it opens the row menu, and
|
|
1301
|
+
* the click its release makes is swallowed (context-menu.mjs).
|
|
1302
|
+
*/
|
|
1303
|
+
export function collapseSidebarAfterListPick() {
|
|
1304
|
+
const collapse = shouldCollapseListAfterPick({
|
|
1305
|
+
phoneLandscape: isPhoneLandscapeLayout(),
|
|
1306
|
+
overlayList: isOverlaySidebarViewport(),
|
|
1307
|
+
listOpen: isSidebarOpen(),
|
|
1308
|
+
});
|
|
1309
|
+
if (collapse) toggleSidebar(false);
|
|
1310
|
+
return collapse;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1263
1313
|
export function syncSidebarLayoutState() {
|
|
1264
1314
|
applySidebarWidthPercent(readSidebarWidthPercentForCurrentOrientation(), { persist: false });
|
|
1265
1315
|
if (isOverlaySidebarViewport()) {
|
|
@@ -1312,11 +1362,30 @@ export function initSidebarLayout() {
|
|
|
1312
1362
|
const resync = () => syncSidebarLayoutState();
|
|
1313
1363
|
window.addEventListener('resize', resync, { passive: true });
|
|
1314
1364
|
window.addEventListener('orientationchange', resync, { passive: true });
|
|
1365
|
+
// A zoom changes the head's buttons, and with them the narrowest list.
|
|
1366
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, resync);
|
|
1315
1367
|
const chatHeader = document.getElementById('chat-header');
|
|
1316
1368
|
if (chatHeader && typeof ResizeObserver === 'function') {
|
|
1317
1369
|
const observer = new ResizeObserver(syncMobileSidebarTopOffset);
|
|
1318
1370
|
observer.observe(chatHeader);
|
|
1319
1371
|
}
|
|
1372
|
+
// The Inbox count arrives after the first pass (and changes): the list
|
|
1373
|
+
// widens to keep the head on one row where it can. Neither button takes
|
|
1374
|
+
// its width from the list's, so this cannot feed back.
|
|
1375
|
+
if (typeof ResizeObserver === 'function') {
|
|
1376
|
+
const headButtons = ['status-btn', 'inbox-btn'].map((id) => document.getElementById(id)).filter(Boolean);
|
|
1377
|
+
const widths = new Map();
|
|
1378
|
+
const headObserver = new ResizeObserver((entries) => {
|
|
1379
|
+
let changed = false;
|
|
1380
|
+
for (const entry of entries) {
|
|
1381
|
+
const width = Math.round(entry.contentRect?.width || 0);
|
|
1382
|
+
if (widths.get(entry.target) !== width) changed = true;
|
|
1383
|
+
widths.set(entry.target, width);
|
|
1384
|
+
}
|
|
1385
|
+
if (changed && !isOverlaySidebarViewport()) syncSidebarLayoutState();
|
|
1386
|
+
});
|
|
1387
|
+
for (const button of headButtons) headObserver.observe(button);
|
|
1388
|
+
}
|
|
1320
1389
|
}
|
|
1321
1390
|
|
|
1322
1391
|
export function setCompactInFlight(value) {
|
|
@@ -1436,6 +1505,8 @@ function syncScreenShortSide() {
|
|
|
1436
1505
|
if (!shortSide) return;
|
|
1437
1506
|
document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
|
|
1438
1507
|
document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
|
|
1508
|
+
// The same screen, turned sideways: :root[data-phone-landscape].
|
|
1509
|
+
syncPhoneLandscapeAttribute(window);
|
|
1439
1510
|
}
|
|
1440
1511
|
|
|
1441
1512
|
export function syncViewportMetrics() {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
|
|
18
18
|
import { renderMarkdownPreview } from './router.js';
|
|
19
|
-
import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
|
|
19
|
+
import { closeSummaryModal, currentConvId, getSubagentRun, IS_SHARED_VIEW, openSummaryModal, upsertSubagentRun } from './store.js';
|
|
20
20
|
import { createViewerHistory } from './viewer-history.mjs';
|
|
21
21
|
import { getSocket } from './socket-handlers.js';
|
|
22
22
|
import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
|
|
@@ -360,6 +360,31 @@ async function openParent() {
|
|
|
360
360
|
await loadRun(top.runId);
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/** Stop asks first: the agent's work so far stays, but it ends here. */
|
|
364
|
+
function askBeforeStopRun() {
|
|
365
|
+
const run = viewer.run;
|
|
366
|
+
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
367
|
+
openSummaryModal({
|
|
368
|
+
title: 'Stop agent',
|
|
369
|
+
subtitle: run.displayName || '',
|
|
370
|
+
kind: 'subagent-stop',
|
|
371
|
+
bodyHtml: `
|
|
372
|
+
<p>Stop <strong>${escHtml(run.displayName || 'this agent')}</strong>?</p>
|
|
373
|
+
<p>It ends where it is now; what it has done so far stays.</p>
|
|
374
|
+
<div class="summary-modal-actions">
|
|
375
|
+
<button class="chat-title-action-btn danger-btn" type="button" data-subagent-stop-confirm="1">⏹ Stop agent</button>
|
|
376
|
+
<button class="chat-title-action-btn" type="button" data-subagent-stop-cancel="1">Cancel</button>
|
|
377
|
+
</div>
|
|
378
|
+
`,
|
|
379
|
+
});
|
|
380
|
+
const dialog = document.getElementById('summary-modal');
|
|
381
|
+
dialog?.querySelector('[data-subagent-stop-confirm]')?.addEventListener('click', () => {
|
|
382
|
+
closeSummaryModal();
|
|
383
|
+
void stopRun();
|
|
384
|
+
}, { once: true });
|
|
385
|
+
dialog?.querySelector('[data-subagent-stop-cancel]')?.addEventListener('click', () => closeSummaryModal(), { once: true });
|
|
386
|
+
}
|
|
387
|
+
|
|
363
388
|
async function stopRun() {
|
|
364
389
|
const run = viewer.run;
|
|
365
390
|
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
@@ -518,7 +543,7 @@ export function initSubagentViewer() {
|
|
|
518
543
|
if (event.target === modal) closeSubagentViewer();
|
|
519
544
|
});
|
|
520
545
|
modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
|
|
521
|
-
stop?.addEventListener('click', () =>
|
|
546
|
+
stop?.addEventListener('click', () => askBeforeStopRun());
|
|
522
547
|
parent?.addEventListener('click', () => void openParent());
|
|
523
548
|
jump?.addEventListener('click', () => {
|
|
524
549
|
if (body) body.scrollTop = body.scrollHeight;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The fade on a tab strip that scrolls sideways (Settings, its provider tabs,
|
|
2
|
+
// the usage dialog's providers): the strip has no scrollbar, so the side with
|
|
3
|
+
// more tabs fades out. Marked from the strip's own scroll as `data-fade`
|
|
4
|
+
// ("right" at the start, "both" in between, "left" at the end, none when the
|
|
5
|
+
// strip fits or is not laid out); the CSS draws the mask from it.
|
|
6
|
+
|
|
7
|
+
import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
|
|
8
|
+
|
|
9
|
+
export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip';
|
|
10
|
+
|
|
11
|
+
// Sub-pixel scroll ends (a zoomed page) count as the end.
|
|
12
|
+
const EDGE_SLACK_PX = 1;
|
|
13
|
+
|
|
14
|
+
let watching = false;
|
|
15
|
+
|
|
16
|
+
/** The `data-fade` value for a strip's scroll metrics ('' for none). */
|
|
17
|
+
export function tabStripFadeState({ scrollLeft = 0, scrollWidth = 0, clientWidth = 0 } = {}) {
|
|
18
|
+
const room = Number(scrollWidth) - Number(clientWidth);
|
|
19
|
+
if (!(room > EDGE_SLACK_PX)) return '';
|
|
20
|
+
const offset = Math.abs(Number(scrollLeft) || 0);
|
|
21
|
+
const before = offset > EDGE_SLACK_PX;
|
|
22
|
+
const after = offset < room - EDGE_SLACK_PX;
|
|
23
|
+
if (before && after) return 'both';
|
|
24
|
+
if (before) return 'left';
|
|
25
|
+
return after ? 'right' : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function syncTabStripFade(strip) {
|
|
29
|
+
if (!strip?.setAttribute) return '';
|
|
30
|
+
const state = tabStripFadeState(strip);
|
|
31
|
+
if (state) {
|
|
32
|
+
if (strip.getAttribute('data-fade') !== state) strip.setAttribute('data-fade', state);
|
|
33
|
+
} else if (strip.hasAttribute('data-fade')) {
|
|
34
|
+
strip.removeAttribute('data-fade');
|
|
35
|
+
}
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fadeWidth(strip) {
|
|
40
|
+
try {
|
|
41
|
+
// The CSS gives the strip the fade's width as its scroll padding.
|
|
42
|
+
const value = parseFloat(getComputedStyle(strip).scrollPaddingLeft);
|
|
43
|
+
return Number.isFinite(value) ? value : 0;
|
|
44
|
+
} catch {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scrolls the strip (and nothing around it) so `tab` shows whole and clear of
|
|
51
|
+
* the fade; at either end the strip stops there, and that edge has none.
|
|
52
|
+
*/
|
|
53
|
+
export function revealTab(strip, tab) {
|
|
54
|
+
if (!strip || !tab || !strip.contains(tab)) return;
|
|
55
|
+
const room = strip.scrollWidth - strip.clientWidth;
|
|
56
|
+
if (room > EDGE_SLACK_PX) {
|
|
57
|
+
const stripBox = strip.getBoundingClientRect();
|
|
58
|
+
const tabBox = tab.getBoundingClientRect();
|
|
59
|
+
const margin = fadeWidth(strip);
|
|
60
|
+
const left = tabBox.left - stripBox.left - strip.clientLeft;
|
|
61
|
+
const right = left + tabBox.width;
|
|
62
|
+
let next = strip.scrollLeft;
|
|
63
|
+
if (left - margin < 0) next += left - margin;
|
|
64
|
+
else if (right + margin > strip.clientWidth) next += right + margin - strip.clientWidth;
|
|
65
|
+
next = Math.max(0, Math.min(room, next));
|
|
66
|
+
if (Math.abs(next - strip.scrollLeft) >= 0.5) strip.scrollLeft = next;
|
|
67
|
+
}
|
|
68
|
+
syncTabStripFade(strip);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Each strip under `root`: its selected tab in view, its fade marked. */
|
|
72
|
+
export function revealSelectedTabs(root = document) {
|
|
73
|
+
if (!root?.querySelectorAll) return;
|
|
74
|
+
const strips = root.matches?.(TAB_STRIP_SELECTOR) ? [root] : Array.from(root.querySelectorAll(TAB_STRIP_SELECTOR));
|
|
75
|
+
for (const strip of strips) {
|
|
76
|
+
const selected = strip.querySelector('[aria-selected="true"]');
|
|
77
|
+
if (selected) revealTab(strip, selected);
|
|
78
|
+
else syncTabStripFade(strip);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Once per page: a strip's scroll re-marks it (scroll does not bubble, so the
|
|
84
|
+
* listener captures), and so does a resize or a turn of the phone.
|
|
85
|
+
*/
|
|
86
|
+
export function watchTabStripFades() {
|
|
87
|
+
if (watching || typeof document === 'undefined') return;
|
|
88
|
+
watching = true;
|
|
89
|
+
document.addEventListener('scroll', (event) => {
|
|
90
|
+
const strip = event.target;
|
|
91
|
+
if (strip?.matches?.(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
|
|
92
|
+
}, { capture: true, passive: true });
|
|
93
|
+
if (typeof window !== 'undefined') {
|
|
94
|
+
const syncAll = () => {
|
|
95
|
+
for (const strip of document.querySelectorAll(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
|
|
96
|
+
};
|
|
97
|
+
window.addEventListener('resize', syncAll, { passive: true });
|
|
98
|
+
// The zoom (font-scaling.js) resizes the tabs, not the window.
|
|
99
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => requestAnimationFrame(syncAll));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -35,6 +35,71 @@ export function bindTapAction(element, handler) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// The ︙ menu's shield (see lockChatActionsMenuShield in bootstrap.js).
|
|
39
|
+
export const CHAT_ACTIONS_MENU_SHIELD_ID = 'chat-actions-menu-backdrop';
|
|
40
|
+
// Longer than the shield's lock and the gap between a touch's pointerup and
|
|
41
|
+
// its compatibility click.
|
|
42
|
+
export const OPENING_CLICK_GRACE_MS = 400;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True for the click that belongs to the gesture which opened a popover, so
|
|
46
|
+
* a document-level "click outside closes it" handler must leave it alone.
|
|
47
|
+
* A ︙ entry runs on pointerup and closes the menu; the touch's compatibility
|
|
48
|
+
* click then lands on the menu's shield and bubbles to `document`, where it
|
|
49
|
+
* would close the popover that entry has just opened. Later clicks on the
|
|
50
|
+
* shield (a tap beside the open ︙ menu) still count as outside.
|
|
51
|
+
*
|
|
52
|
+
* `openedAt` is the Date.now() of the opening, 0 when unknown.
|
|
53
|
+
*/
|
|
54
|
+
export function isOpeningGestureClick(event, openedAt, now = Date.now()) {
|
|
55
|
+
const opened = Number(openedAt) || 0;
|
|
56
|
+
if (opened <= 0) return false;
|
|
57
|
+
const sinceOpen = now - opened;
|
|
58
|
+
if (sinceOpen < 0 || sinceOpen >= OPENING_CLICK_GRACE_MS) return false;
|
|
59
|
+
return event?.target?.id === CHAT_ACTIONS_MENU_SHIELD_ID;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The one click a touch still owes after a ︙ entry ran on its pointerup.
|
|
64
|
+
* The browser hit-tests that compatibility click when it fires, a few ms
|
|
65
|
+
* later, so it lands on whatever the entry opened: a dialog's backdrop
|
|
66
|
+
* (which closes on a click on itself) or the shield (whose click reaches
|
|
67
|
+
* `document`, where a list closes on a click outside it). `arm()` on the
|
|
68
|
+
* pointerup; the next pointer click within the window is that click.
|
|
69
|
+
*/
|
|
70
|
+
export function createGhostClickGuard({ now = () => Date.now() } = {}) {
|
|
71
|
+
let until = 0;
|
|
72
|
+
return {
|
|
73
|
+
arm(ms = OPENING_CLICK_GRACE_MS) {
|
|
74
|
+
until = now() + Math.max(0, Number(ms) || 0);
|
|
75
|
+
},
|
|
76
|
+
// Disarms on the first click it sees, swallowed or not.
|
|
77
|
+
take(event) {
|
|
78
|
+
if (!until) return false;
|
|
79
|
+
const live = now() < until;
|
|
80
|
+
until = 0;
|
|
81
|
+
// detail 0: Enter or Space on a button, or a script, never a touch.
|
|
82
|
+
return live && Number(event?.detail) !== 0;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ghostClickGuard = createGhostClickGuard();
|
|
88
|
+
let ghostClickListenerBound = false;
|
|
89
|
+
|
|
90
|
+
function swallowGhostClickAfterTouch(event) {
|
|
91
|
+
// A mouse's click goes to the entry it pressed, which drops it itself.
|
|
92
|
+
if (!event || event.pointerType === 'mouse') return;
|
|
93
|
+
ghostClickGuard.arm();
|
|
94
|
+
if (ghostClickListenerBound || typeof window === 'undefined') return;
|
|
95
|
+
ghostClickListenerBound = true;
|
|
96
|
+
window.addEventListener('click', (clickEvent) => {
|
|
97
|
+
if (!ghostClickGuard.take(clickEvent)) return;
|
|
98
|
+
clickEvent.preventDefault();
|
|
99
|
+
clickEvent.stopImmediatePropagation();
|
|
100
|
+
}, true);
|
|
101
|
+
}
|
|
102
|
+
|
|
38
103
|
export function bindMenuAction(element, handler) {
|
|
39
104
|
if (!element || element.dataset.menuTapBound === '1') return;
|
|
40
105
|
element.dataset.menuTapBound = '1';
|
|
@@ -48,6 +113,7 @@ export function bindMenuAction(element, handler) {
|
|
|
48
113
|
event.stopPropagation();
|
|
49
114
|
event.stopImmediatePropagation();
|
|
50
115
|
markSuppressed();
|
|
116
|
+
swallowGhostClickAfterTouch(event);
|
|
51
117
|
handler(event);
|
|
52
118
|
}, true);
|
|
53
119
|
element.addEventListener('click', (event) => {
|
|
@@ -29,6 +29,8 @@ let currentConversationId = '';
|
|
|
29
29
|
let currentProviderType = '';
|
|
30
30
|
let actionInFlight = false;
|
|
31
31
|
let wakeTimer = null;
|
|
32
|
+
// The report whose one-line warning was tapped open (its dismissedKey).
|
|
33
|
+
let expandedKey = '';
|
|
32
34
|
|
|
33
35
|
function notifyBilling(conversationId) {
|
|
34
36
|
const id = String(conversationId || '').trim();
|
|
@@ -88,6 +90,30 @@ export function usageLimitAccountText(state, nowMs = Date.now()) {
|
|
|
88
90
|
return `Claude usage at ${Math.round(utilization * 100)} % of the ${label} — resets ${resets}`;
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/** "04:00" today, "Tue 04:00" on another day: the one-line banner's time. */
|
|
94
|
+
export function formatUsageLimitShortTime(iso, nowMs = Date.now()) {
|
|
95
|
+
const at = new Date(String(iso || ''));
|
|
96
|
+
if (Number.isNaN(at.getTime())) return '';
|
|
97
|
+
const clock = at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
98
|
+
if (new Date(nowMs).toDateString() === at.toDateString()) return clock;
|
|
99
|
+
return `${at.toLocaleDateString([], { weekday: 'short' })} ${clock}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The account's line in one short line, for the compact banner:
|
|
104
|
+
* "⚠ Claude 94 % weekly · resets Tue 04:00", or at the limit
|
|
105
|
+
* "⚠ Claude weekly limit reached · resets Tue 04:00". '' when
|
|
106
|
+
* usageLimitAccountText says nothing.
|
|
107
|
+
*/
|
|
108
|
+
export function usageLimitAccountShortText(state, nowMs = Date.now()) {
|
|
109
|
+
if (!usageLimitAccountText(state, nowMs)) return '';
|
|
110
|
+
const label = String(state.label || 'usage limit');
|
|
111
|
+
const resets = formatUsageLimitShortTime(state.resetsAt, nowMs);
|
|
112
|
+
if (state.status === 'rejected') return `⚠ Claude ${label} reached · resets ${resets}`;
|
|
113
|
+
const limitWindow = label.replace(/\s*limit$/i, '').trim() || label;
|
|
114
|
+
return `⚠ Claude ${Math.round(Number(state.utilization) * 100)} % ${limitWindow} · resets ${resets}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
/**
|
|
92
118
|
* Whether the usage-limit note offers to move the chat to the API key: a
|
|
93
119
|
* subscription chat (never a cloud one) whose relay says the switch is
|
|
@@ -201,6 +227,8 @@ export function renderUsageLimitBanner(nowMs = Date.now()) {
|
|
|
201
227
|
const banner = document.getElementById(BANNER_ID);
|
|
202
228
|
if (!banner) return;
|
|
203
229
|
const children = [];
|
|
230
|
+
let compact = false;
|
|
231
|
+
let expanded = false;
|
|
204
232
|
const pause = activeUsageLimitPause(currentConversationId, nowMs);
|
|
205
233
|
if (pause) {
|
|
206
234
|
const text = document.createElement('span');
|
|
@@ -225,14 +253,46 @@ export function renderUsageLimitBanner(nowMs = Date.now()) {
|
|
|
225
253
|
? usageLimitAccountText(account, nowMs)
|
|
226
254
|
: '';
|
|
227
255
|
if (line) {
|
|
256
|
+
// One line with the full sentence in the title; a tap on it opens the
|
|
257
|
+
// full sentence until the report changes.
|
|
258
|
+
if (expandedKey && expandedKey !== dismissedKey(account)) expandedKey = '';
|
|
259
|
+
expanded = expandedKey !== '' && expandedKey === dismissedKey(account);
|
|
228
260
|
const text = document.createElement('span');
|
|
229
261
|
text.className = 'usage-limit-text';
|
|
230
|
-
text.textContent = line;
|
|
231
|
-
|
|
262
|
+
text.textContent = expanded ? line : (usageLimitAccountShortText(account, nowMs) || line);
|
|
263
|
+
text.title = line;
|
|
264
|
+
text.setAttribute('role', 'button');
|
|
265
|
+
text.tabIndex = 0;
|
|
266
|
+
text.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
267
|
+
const toggle = (event) => {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
expandedKey = expanded ? '' : dismissedKey(account);
|
|
270
|
+
renderUsageLimitBanner();
|
|
271
|
+
};
|
|
272
|
+
text.addEventListener('click', toggle);
|
|
273
|
+
text.addEventListener('keydown', (event) => {
|
|
274
|
+
if (event.key === 'Enter' || event.key === ' ') toggle(event);
|
|
275
|
+
});
|
|
276
|
+
const hide = button('✕', 'usage-limit-dismiss', async () => dismissWarning());
|
|
277
|
+
hide.title = 'Hide';
|
|
278
|
+
hide.setAttribute('aria-label', 'Hide');
|
|
279
|
+
children.push(text, hide);
|
|
232
280
|
banner.dataset.state = account.status === 'rejected' ? 'reached' : 'warning';
|
|
281
|
+
compact = true;
|
|
233
282
|
}
|
|
234
283
|
}
|
|
284
|
+
banner.classList.toggle('usage-limit-compact', compact);
|
|
285
|
+
banner.classList.toggle('usage-limit-expanded', compact && expanded);
|
|
286
|
+
// The banner is drawn anew: the control that had the focus (the line's
|
|
287
|
+
// toggle, a button) gets it back in its new node, found by its class.
|
|
288
|
+
const focused = typeof document.activeElement?.className === 'string' && banner.contains(document.activeElement)
|
|
289
|
+
? document.activeElement.className
|
|
290
|
+
: '';
|
|
235
291
|
banner.replaceChildren(...children);
|
|
292
|
+
if (focused) {
|
|
293
|
+
const again = children.find((child) => child.className === focused);
|
|
294
|
+
try { again?.focus({ preventScroll: true }); } catch {}
|
|
295
|
+
}
|
|
236
296
|
banner.classList.toggle('visible', children.length > 0);
|
|
237
297
|
banner.hidden = children.length === 0;
|
|
238
298
|
if (!children.length) delete banner.dataset.state;
|
|
@@ -350,6 +410,7 @@ export function __resetUsageLimitUiForTests() {
|
|
|
350
410
|
currentConversationId = '';
|
|
351
411
|
currentProviderType = '';
|
|
352
412
|
actionInFlight = false;
|
|
413
|
+
expandedKey = '';
|
|
353
414
|
if (wakeTimer) { clearTimeout(wakeTimer); wakeTimer = null; }
|
|
354
415
|
try { localStorage.removeItem(DISMISSED_KEY); } catch {}
|
|
355
416
|
renderUsageLimitBanner();
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
// and the pop closes the viewer. Closing by any other means (the × button,
|
|
5
5
|
// Escape, a swipe down) pops that entry itself so history is left as it
|
|
6
6
|
// was, and the pop that follows is recognised as our own and ignored. The
|
|
7
|
-
// address never changes: the entry carries only its state
|
|
7
|
+
// address never changes: the entry carries only its state, its place in the
|
|
8
|
+
// stack and the visit's marks of the entry below (session-history.mjs, the
|
|
9
|
+
// back guard).
|
|
10
|
+
|
|
11
|
+
import { HISTORY_POSITION, inheritedVisitMarks } from './back-guard.mjs';
|
|
8
12
|
|
|
9
13
|
export const VIEWER_HISTORY_MARK = 'oarFileViewer';
|
|
10
14
|
const OWN_POP_WINDOW_MS = 1500;
|
|
@@ -18,7 +22,11 @@ export function createViewerHistory({ history, onBack, now = () => Date.now(), m
|
|
|
18
22
|
/** The viewer came up: one entry, however many files it then shows. */
|
|
19
23
|
opened() {
|
|
20
24
|
if (state.pushed) return;
|
|
21
|
-
|
|
25
|
+
const below = history.state;
|
|
26
|
+
const entry = { ...inheritedVisitMarks(below), [mark]: true };
|
|
27
|
+
const position = below?.[HISTORY_POSITION];
|
|
28
|
+
if (Number.isFinite(position)) entry[HISTORY_POSITION] = position + 1;
|
|
29
|
+
history.pushState(entry, '');
|
|
22
30
|
state.pushed = true;
|
|
23
31
|
},
|
|
24
32
|
/** The viewer went away by itself: take its entry back out of history. */
|
|
@@ -38,6 +46,9 @@ export function createViewerHistory({ history, onBack, now = () => Date.now(), m
|
|
|
38
46
|
}
|
|
39
47
|
state.ownPopAt = 0;
|
|
40
48
|
if (!state.pushed) return;
|
|
49
|
+
// Our entry is still the current one: what was popped lay above it
|
|
50
|
+
// (a dialog opened over the viewer), the viewer stays.
|
|
51
|
+
if (history.state?.[mark]) return;
|
|
41
52
|
state.pushed = false;
|
|
42
53
|
onBack?.();
|
|
43
54
|
},
|