@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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
6
6
|
import { subagentRowHtml } from './subagent-row.mjs';
|
|
7
|
+
import { isThoughtsExpanded, thoughtFoldOpen } from './thoughts-preference.mjs';
|
|
7
8
|
|
|
8
9
|
function escHtml(value) {
|
|
9
10
|
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
@@ -100,20 +101,26 @@ function firstLine(text, max = 140) {
|
|
|
100
101
|
/**
|
|
101
102
|
* One entry's markup. `renderMarkdown(text)` renders reply text (and
|
|
102
103
|
* thoughts); `children` maps a child run id to its header so a child entry
|
|
103
|
-
* renders as a clickable row; `openDetails` keeps
|
|
104
|
-
* open
|
|
104
|
+
* renders as a clickable row; `openDetails` keeps the reader's folds across
|
|
105
|
+
* re-renders: a Map or a function from seq to 'open' | 'folded' | undefined
|
|
106
|
+
* (`true` still means "open"). A thought without a choice, streaming or
|
|
107
|
+
* done, opens as `thoughtsExpanded` (the "Expand thoughts" setting) says.
|
|
105
108
|
*/
|
|
106
|
-
export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, clickable = true, now = Date.now() } = {}) {
|
|
109
|
+
export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, thoughtsExpanded = isThoughtsExpanded(), clickable = true, now = Date.now() } = {}) {
|
|
107
110
|
const kind = String(entry?.kind || '');
|
|
108
111
|
const seq = Number(entry?.seq) || 0;
|
|
109
112
|
const open = entry?.done === false;
|
|
110
113
|
const text = String(entry?.text || '');
|
|
111
114
|
const wrap = (inner, extraClass = '') => `<div class="sav-entry sav-entry-${escHtml(kind)}${open ? ' is-open' : ''}${extraClass}" data-seq="${seq}">${inner}</div>`;
|
|
112
|
-
const
|
|
115
|
+
const choice = typeof openDetails === 'function'
|
|
116
|
+
? openDetails(seq)
|
|
117
|
+
: (openDetails instanceof Map ? openDetails.get(seq) : (openDetails === true ? 'open' : undefined));
|
|
118
|
+
const openAttr = choice === 'open' ? ' open' : '';
|
|
113
119
|
|
|
114
120
|
if (kind === 'thought') {
|
|
115
121
|
const label = open && !text.trim() ? 'Thinking…' : (firstLine(text, 110) || 'Thought');
|
|
116
|
-
|
|
122
|
+
const thoughtOpen = thoughtFoldOpen({ choice: choice || null, expanded: thoughtsExpanded });
|
|
123
|
+
return wrap(`<details class="sav-fold"${thoughtOpen ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
if (kind === 'text') {
|
|
@@ -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';
|
|
@@ -50,7 +50,9 @@ const viewer = {
|
|
|
50
50
|
stopTimer: null,
|
|
51
51
|
tickTimer: null,
|
|
52
52
|
sharedPollTimer: null,
|
|
53
|
-
|
|
53
|
+
// The reader's folds by seq ('open' | 'folded'); a thought without one
|
|
54
|
+
// follows the "Expand thoughts" setting.
|
|
55
|
+
openFolds: new Map(),
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
let viewerHistory = null;
|
|
@@ -151,7 +153,7 @@ function buildEntryNode(entry) {
|
|
|
151
153
|
holder.innerHTML = timelineEntryHtml(entry, {
|
|
152
154
|
renderMarkdown,
|
|
153
155
|
children: viewer.children,
|
|
154
|
-
openDetails: viewer.openFolds
|
|
156
|
+
openDetails: viewer.openFolds,
|
|
155
157
|
clickable: true,
|
|
156
158
|
});
|
|
157
159
|
return holder.firstElementChild;
|
|
@@ -283,7 +285,7 @@ async function loadRun(runId) {
|
|
|
283
285
|
if (!id) return;
|
|
284
286
|
viewer.state = createTimelineState(id);
|
|
285
287
|
viewer.children = new Map();
|
|
286
|
-
viewer.openFolds = new
|
|
288
|
+
viewer.openFolds = new Map();
|
|
287
289
|
viewer.error = '';
|
|
288
290
|
viewer.stopping = false;
|
|
289
291
|
clearTimeout(viewer.stopTimer);
|
|
@@ -360,6 +362,31 @@ async function openParent() {
|
|
|
360
362
|
await loadRun(top.runId);
|
|
361
363
|
}
|
|
362
364
|
|
|
365
|
+
/** Stop asks first: the agent's work so far stays, but it ends here. */
|
|
366
|
+
function askBeforeStopRun() {
|
|
367
|
+
const run = viewer.run;
|
|
368
|
+
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
369
|
+
openSummaryModal({
|
|
370
|
+
title: 'Stop agent',
|
|
371
|
+
subtitle: run.displayName || '',
|
|
372
|
+
kind: 'subagent-stop',
|
|
373
|
+
bodyHtml: `
|
|
374
|
+
<p>Stop <strong>${escHtml(run.displayName || 'this agent')}</strong>?</p>
|
|
375
|
+
<p>It ends where it is now; what it has done so far stays.</p>
|
|
376
|
+
<div class="summary-modal-actions">
|
|
377
|
+
<button class="chat-title-action-btn danger-btn" type="button" data-subagent-stop-confirm="1">⏹ Stop agent</button>
|
|
378
|
+
<button class="chat-title-action-btn" type="button" data-subagent-stop-cancel="1">Cancel</button>
|
|
379
|
+
</div>
|
|
380
|
+
`,
|
|
381
|
+
});
|
|
382
|
+
const dialog = document.getElementById('summary-modal');
|
|
383
|
+
dialog?.querySelector('[data-subagent-stop-confirm]')?.addEventListener('click', () => {
|
|
384
|
+
closeSummaryModal();
|
|
385
|
+
void stopRun();
|
|
386
|
+
}, { once: true });
|
|
387
|
+
dialog?.querySelector('[data-subagent-stop-cancel]')?.addEventListener('click', () => closeSummaryModal(), { once: true });
|
|
388
|
+
}
|
|
389
|
+
|
|
363
390
|
async function stopRun() {
|
|
364
391
|
const run = viewer.run;
|
|
365
392
|
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
@@ -518,7 +545,7 @@ export function initSubagentViewer() {
|
|
|
518
545
|
if (event.target === modal) closeSubagentViewer();
|
|
519
546
|
});
|
|
520
547
|
modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
|
|
521
|
-
stop?.addEventListener('click', () =>
|
|
548
|
+
stop?.addEventListener('click', () => askBeforeStopRun());
|
|
522
549
|
parent?.addEventListener('click', () => void openParent());
|
|
523
550
|
jump?.addEventListener('click', () => {
|
|
524
551
|
if (body) body.scrollTop = body.scrollHeight;
|
|
@@ -527,7 +554,8 @@ export function initSubagentViewer() {
|
|
|
527
554
|
body?.addEventListener('scroll', () => {
|
|
528
555
|
if (jump && !jump.hidden && isAtBottom(body)) jump.hidden = true;
|
|
529
556
|
}, { passive: true });
|
|
530
|
-
// A fold the reader opened stays
|
|
557
|
+
// A fold the reader opened or closed stays that way while the entry keeps
|
|
558
|
+
// changing.
|
|
531
559
|
timeline?.addEventListener('click', (event) => {
|
|
532
560
|
const summary = event.target?.closest?.('summary');
|
|
533
561
|
const entry = summary?.closest?.('.sav-entry');
|
|
@@ -535,8 +563,7 @@ export function initSubagentViewer() {
|
|
|
535
563
|
const seq = Number(entry.dataset.seq);
|
|
536
564
|
setTimeout(() => {
|
|
537
565
|
const details = summary.parentElement;
|
|
538
|
-
|
|
539
|
-
else viewer.openFolds.delete(seq);
|
|
566
|
+
viewer.openFolds.set(seq, details?.open ? 'open' : 'folded');
|
|
540
567
|
}, 0);
|
|
541
568
|
});
|
|
542
569
|
document.addEventListener('keydown', (event) => {
|
|
@@ -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) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// "Expand thoughts while an agent works" (Settings › General, and its mirror
|
|
2
|
+
// in the 🧠 modal). A setting of this browser, kept in localStorage like the
|
|
3
|
+
// scroll-on-send one. On unless switched off: it decides whether a thought
|
|
4
|
+
// starts open in the live turn, in a live sub-agent bubble and in the
|
|
5
|
+
// sub-agent viewer. A fold the reader opened or closed by hand wins over it.
|
|
6
|
+
// A finished message's 💭 fold stays closed either way. The document, the
|
|
7
|
+
// storage and the event target are passed in by the tests.
|
|
8
|
+
|
|
9
|
+
export const THOUGHTS_EXPANDED_STORAGE_KEY = 'copilot_thoughts_expanded';
|
|
10
|
+
export const THOUGHTS_EXPANDED_TOGGLE_ID = 'thoughts-expanded-toggle';
|
|
11
|
+
export const THOUGHTS_EXPANDED_CHANGE_EVENT = 'oar:thoughts-expanded-change';
|
|
12
|
+
// The 🧠 modal's two buttons: `data-thoughts-expanded` is "on" or "off".
|
|
13
|
+
export const THOUGHTS_EXPANDED_BUTTON_SELECTOR = '.ctx-thoughts-btn[data-thoughts-expanded]';
|
|
14
|
+
|
|
15
|
+
/** Anything but an explicit off is on: a missing or damaged value included. */
|
|
16
|
+
export function parseThoughtsExpandedSetting(stored) {
|
|
17
|
+
const value = String(stored ?? '').trim().toLowerCase();
|
|
18
|
+
if (!value) return true;
|
|
19
|
+
return value !== '0' && value !== 'false';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeThoughtsExpandedSetting(expanded) {
|
|
23
|
+
return expanded ? '1' : '0';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function storage() {
|
|
27
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isThoughtsExpanded(store = storage()) {
|
|
31
|
+
try {
|
|
32
|
+
return parseThoughtsExpandedSetting(store?.getItem(THOUGHTS_EXPANDED_STORAGE_KEY));
|
|
33
|
+
} catch {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
|
|
39
|
+
export function syncThoughtsExpandedToggle(doc = globalThis.document, store = storage()) {
|
|
40
|
+
const expanded = isThoughtsExpanded(store);
|
|
41
|
+
const checkbox = doc?.getElementById?.(THOUGHTS_EXPANDED_TOGGLE_ID);
|
|
42
|
+
if (checkbox) checkbox.checked = expanded;
|
|
43
|
+
const buttons = doc?.querySelectorAll?.(THOUGHTS_EXPANDED_BUTTON_SELECTOR) || [];
|
|
44
|
+
for (const btn of buttons) {
|
|
45
|
+
const active = (btn.dataset?.thoughtsExpanded === 'on') === expanded;
|
|
46
|
+
btn.classList?.toggle('is-active', active);
|
|
47
|
+
btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function updateThoughtsExpandedSetting(expanded, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
|
|
52
|
+
const value = !!expanded;
|
|
53
|
+
try { store?.setItem(THOUGHTS_EXPANDED_STORAGE_KEY, serializeThoughtsExpandedSetting(value)); } catch {}
|
|
54
|
+
syncThoughtsExpandedToggle(doc, store);
|
|
55
|
+
try {
|
|
56
|
+
target?.dispatchEvent?.(new CustomEvent(THOUGHTS_EXPANDED_CHANGE_EVENT, { detail: { expanded: value } }));
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a thought fold shows open. `choice` is what the reader did by hand
|
|
62
|
+
* ('open' | 'folded'), and it wins; everything else, a thought still
|
|
63
|
+
* streaming included, follows the setting (`expanded`).
|
|
64
|
+
*/
|
|
65
|
+
export function thoughtFoldOpen({ choice = null, expanded = true } = {}) {
|
|
66
|
+
if (choice === 'open') return true;
|
|
67
|
+
if (choice === 'folded') return false;
|
|
68
|
+
return !!expanded;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const MAX_THOUGHT_FOLD_CHOICES = 500;
|
|
72
|
+
|
|
73
|
+
/** Record a hand fold; the oldest choice goes once the map is full. */
|
|
74
|
+
export function rememberThoughtFoldChoice(choices, key, open, max = MAX_THOUGHT_FOLD_CHOICES) {
|
|
75
|
+
if (!choices || key === undefined || key === null || key === '') return;
|
|
76
|
+
choices.delete(key);
|
|
77
|
+
while (choices.size >= max) choices.delete(choices.keys().next().value);
|
|
78
|
+
choices.set(key, open ? 'open' : 'folded');
|
|
79
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The stream view of one agent turn, without a DOM: its thoughts, tool calls
|
|
2
|
+
// and reply as one list in the order they happened. The order is the turn's
|
|
3
|
+
// shared number on every thought and tool line (turnSeq, migration 0038).
|
|
4
|
+
// A turn with a row that has none (recorded before, or by an older relay)
|
|
5
|
+
// returns null and keeps the grouped layout. Main thread only: a sub-agent's
|
|
6
|
+
// rows stay in its own row and viewer.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
isOmittedActivityMarker,
|
|
10
|
+
normalizeRelayActivityEntry,
|
|
11
|
+
normalizeTurnSeq,
|
|
12
|
+
} from './activity-replay-state.mjs';
|
|
13
|
+
|
|
14
|
+
function escHtml(value) {
|
|
15
|
+
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
16
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
17
|
+
}[ch]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A '● ' line is a progress note: it stays where the grouped layout puts it. */
|
|
21
|
+
export function isProgressActivityText(text) {
|
|
22
|
+
return String(text || '').startsWith('● ');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function firstLine(text, max = 110) {
|
|
26
|
+
const line = String(text || '').split('\n').map((part) => part.trim()).find(Boolean) || '';
|
|
27
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The turn's main-thread entries in order, the reply last:
|
|
32
|
+
* `[{ kind: 'thought'|'tool'|'omitted'|'reply', turnSeq, … }]`, or null when
|
|
33
|
+
* the turn cannot be ordered (a main-thread thought or tool line without a
|
|
34
|
+
* turnSeq) or has no thought or tool line at all. `activities` are the
|
|
35
|
+
* bubble's visible ones (the promoted compaction boundary already taken out);
|
|
36
|
+
* progress lines ('● ') are left out here. The "… earlier tool lines
|
|
37
|
+
* omitted" row the relay's cap adds goes where the first line it stands for
|
|
38
|
+
* was (`metadata.fromTurnSeq`), else right before the first tool line kept
|
|
39
|
+
* after it.
|
|
40
|
+
*/
|
|
41
|
+
export function turnStreamEntries({ thoughts = [], activities = [], text = '' } = {}) {
|
|
42
|
+
const entries = [];
|
|
43
|
+
let missing = false;
|
|
44
|
+
for (const thought of Array.isArray(thoughts) ? thoughts : []) {
|
|
45
|
+
if (!thought || thought.subagentRunId || !String(thought.text || '').trim()) continue;
|
|
46
|
+
const turnSeq = normalizeTurnSeq(thought.turnSeq);
|
|
47
|
+
if (turnSeq === null) { missing = true; break; }
|
|
48
|
+
entries.push({
|
|
49
|
+
kind: 'thought',
|
|
50
|
+
turnSeq,
|
|
51
|
+
order: turnSeq,
|
|
52
|
+
reasoningId: String(thought.reasoningId || ''),
|
|
53
|
+
text: String(thought.text),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (missing) return null;
|
|
57
|
+
|
|
58
|
+
let pendingMarkers = [];
|
|
59
|
+
for (const item of Array.isArray(activities) ? activities : []) {
|
|
60
|
+
const entry = normalizeRelayActivityEntry(item);
|
|
61
|
+
if (!entry || entry.subagentRunId) continue;
|
|
62
|
+
if (isOmittedActivityMarker(entry)) {
|
|
63
|
+
const marker = { kind: 'omitted', turnSeq: null, text: entry.text, count: Number(entry.metadata.count) || 0 };
|
|
64
|
+
// Where the first line it stands for was; else before the next line kept.
|
|
65
|
+
const from = normalizeTurnSeq(entry.metadata.fromTurnSeq);
|
|
66
|
+
if (from !== null) entries.push({ ...marker, order: from });
|
|
67
|
+
else pendingMarkers.push(marker);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (isProgressActivityText(entry.text)) continue;
|
|
71
|
+
if (entry.turnSeq === undefined) return null;
|
|
72
|
+
for (const [index, marker] of pendingMarkers.entries()) {
|
|
73
|
+
entries.push({ ...marker, order: entry.turnSeq - 0.5 + index * 0.001 });
|
|
74
|
+
}
|
|
75
|
+
pendingMarkers = [];
|
|
76
|
+
entries.push({
|
|
77
|
+
kind: 'tool',
|
|
78
|
+
turnSeq: entry.turnSeq,
|
|
79
|
+
order: entry.turnSeq,
|
|
80
|
+
text: entry.text,
|
|
81
|
+
...(entry.metadata ? { metadata: entry.metadata } : {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!entries.length) return null;
|
|
85
|
+
const lastOrder = Math.max(...entries.map((entry) => entry.order));
|
|
86
|
+
for (const [index, marker] of pendingMarkers.entries()) {
|
|
87
|
+
entries.push({ ...marker, order: lastOrder + 0.5 + index * 0.001 });
|
|
88
|
+
}
|
|
89
|
+
entries.sort((a, b) => a.order - b.order);
|
|
90
|
+
const ordered = entries.map(({ order, ...entry }) => entry);
|
|
91
|
+
if (String(text || '').trim()) ordered.push({ kind: 'reply', turnSeq: null, text: String(text) });
|
|
92
|
+
return ordered;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The list's markup. `renderMarkdown(text)` renders a thought and the reply;
|
|
97
|
+
* `decorate(text)` is the tool line's display text (its emoji);
|
|
98
|
+
* `thoughtsOpen` opens the thought folds (closed on a finished turn).
|
|
99
|
+
*/
|
|
100
|
+
export function renderTurnStreamMarkup(entries, {
|
|
101
|
+
thoughtsOpen = false,
|
|
102
|
+
renderMarkdown = (text) => `<p>${escHtml(text)}</p>`,
|
|
103
|
+
decorate = (text) => text,
|
|
104
|
+
} = {}) {
|
|
105
|
+
const rows = (Array.isArray(entries) ? entries : []).map((entry) => {
|
|
106
|
+
const seqAttr = entry.turnSeq != null ? ` data-turn-seq="${escHtml(entry.turnSeq)}"` : '';
|
|
107
|
+
if (entry.kind === 'thought') {
|
|
108
|
+
return `<details class="msg-stream-thought"${seqAttr}${entry.reasoningId ? ` data-reasoning-id="${escHtml(entry.reasoningId)}"` : ''}${thoughtsOpen ? ' open' : ''}>`
|
|
109
|
+
+ `<summary>💭 ${escHtml(firstLine(entry.text) || 'Thought')}</summary>`
|
|
110
|
+
+ `<div class="msg-stream-thought-body">${renderMarkdown(entry.text)}</div></details>`;
|
|
111
|
+
}
|
|
112
|
+
if (entry.kind === 'tool') {
|
|
113
|
+
return `<div class="msg-activity-item msg-stream-tool"${seqAttr}>${escHtml(decorate(entry.text))}</div>`;
|
|
114
|
+
}
|
|
115
|
+
if (entry.kind === 'omitted') {
|
|
116
|
+
return `<div class="msg-stream-omitted">${escHtml(entry.text)}</div>`;
|
|
117
|
+
}
|
|
118
|
+
if (entry.kind === 'reply') {
|
|
119
|
+
return `<div class="msg-stream-reply">${renderMarkdown(entry.text)}</div>`;
|
|
120
|
+
}
|
|
121
|
+
return '';
|
|
122
|
+
});
|
|
123
|
+
return `<div class="msg-turn-stream">${rows.join('')}</div>`;
|
|
124
|
+
}
|
|
@@ -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
|
},
|