@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
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// Back and forward through sessions, and back closing what is open first.
|
|
2
|
+
//
|
|
3
|
+
// Each session switch is one history entry, marked with the conversation's
|
|
4
|
+
// id and shown as `?conv=<id>` in the address. Above the session entry each
|
|
5
|
+
// open overlay (the phone's sidebar, the ⚙ panel, the ︙ menu, a modal)
|
|
6
|
+
// holds one entry of its own, and back closes the top one first. On a phone
|
|
7
|
+
// the visit's first session has a "list" entry beneath it: back onto it
|
|
8
|
+
// opens the session list. In the installed app the back guard's floor lies
|
|
9
|
+
// beneath all of it (back-guard.mjs).
|
|
10
|
+
//
|
|
11
|
+
// Every entry written here carries its place in the stack, so a pop tells
|
|
12
|
+
// back from forward. An entry that leads nowhere any more (an overlay closed
|
|
13
|
+
// since, a deleted conversation) is stepped over in the same direction.
|
|
14
|
+
// The viewers (file viewer, sub-agent viewer, task console) keep their own
|
|
15
|
+
// entries and listeners (viewer-history.mjs); here their entries only close
|
|
16
|
+
// the overlays that lay above them.
|
|
17
|
+
//
|
|
18
|
+
// History is written in one place, `settle()`, once the changes of a tick
|
|
19
|
+
// have landed. A switch made from an open overlay replaces that overlay's
|
|
20
|
+
// entry rather than popping it first, and whatever does need a pop waits for
|
|
21
|
+
// it before it writes again: a pushState right after back() would land
|
|
22
|
+
// beneath the pop.
|
|
23
|
+
//
|
|
24
|
+
// Forward history survives an overlay: one opened while sessions lie ahead
|
|
25
|
+
// (back was pressed) takes no entry, since a push would drop them. Back then
|
|
26
|
+
// lands on the entry below, closes the overlay and goes forward again, so it
|
|
27
|
+
// still only closes the overlay. At the visit's base nothing of ours lies
|
|
28
|
+
// below (back would leave the app), so there the overlay takes its entry and
|
|
29
|
+
// what lay ahead goes, as before.
|
|
30
|
+
//
|
|
31
|
+
// Until the app has shown a session (or said that none is open) the address
|
|
32
|
+
// may still hold a link it has not read yet (`?conv=`, `push_conv`): entries
|
|
33
|
+
// written meanwhile keep the address as it is.
|
|
34
|
+
//
|
|
35
|
+
// No DOM here: history, location, timers and the app's callbacks come in.
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
BACK_GUARD_APP,
|
|
39
|
+
BACK_GUARD_FLOOR,
|
|
40
|
+
FIRST_INTERACTION_EVENTS,
|
|
41
|
+
HISTORY_POSITION,
|
|
42
|
+
VISIT_BASE,
|
|
43
|
+
VISIT_LAID,
|
|
44
|
+
inheritedVisitMarks,
|
|
45
|
+
} from './back-guard.mjs';
|
|
46
|
+
|
|
47
|
+
export const SESSION_ENTRY = 'oarConv';
|
|
48
|
+
export const LIST_ENTRY = 'oarList';
|
|
49
|
+
export const OVERLAY_ENTRY = 'oarOverlay';
|
|
50
|
+
export const SESSION_PARAM = 'conv';
|
|
51
|
+
const PUSH_PARAM = 'push_conv';
|
|
52
|
+
// A pop of our own that never answers (it should within a frame or two)
|
|
53
|
+
// stops holding up the writes after this long.
|
|
54
|
+
export const OWN_POP_FALLBACK_MS = 1500;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What an entry is: 'floor' (the back guard's), 'overlay', 'list',
|
|
58
|
+
* 'session', 'other' (a viewer's entry, the guard's bare app entry) or
|
|
59
|
+
* 'none' (no state: the page's first entry before anything was written).
|
|
60
|
+
*/
|
|
61
|
+
export function entryKind(state) {
|
|
62
|
+
if (!state || typeof state !== 'object') return 'none';
|
|
63
|
+
if (state[BACK_GUARD_FLOOR]) return 'floor';
|
|
64
|
+
if (state[OVERLAY_ENTRY]) return 'overlay';
|
|
65
|
+
if (state[LIST_ENTRY]) return 'list';
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(state, SESSION_ENTRY)) return 'session';
|
|
67
|
+
return Object.keys(state).length ? 'other' : 'none';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function positionOf(state) {
|
|
71
|
+
const value = state?.[HISTORY_POSITION];
|
|
72
|
+
return Number.isFinite(value) ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The address of a session entry: `?conv=<id>` set (or gone), `push_conv` gone, the rest kept. */
|
|
76
|
+
export function sessionUrl(href, conversationId) {
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(String(href || ''));
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
url.searchParams.delete(PUSH_PARAM);
|
|
84
|
+
if (conversationId) url.searchParams.set(SESSION_PARAM, conversationId);
|
|
85
|
+
else url.searchParams.delete(SESSION_PARAM);
|
|
86
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const GONE_STORAGE_KEY = 'oar_gone_conversations';
|
|
90
|
+
const GONE_LIMIT = 200;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The conversations found gone on this tab (deleted, or never there), kept
|
|
94
|
+
* in the tab's sessionStorage: its history outlives a reload, so must the
|
|
95
|
+
* knowledge that some of its entries lead nowhere. The newest `limit` stay.
|
|
96
|
+
*/
|
|
97
|
+
export function createGoneList({ storage = null, key = GONE_STORAGE_KEY, limit = GONE_LIMIT } = {}) {
|
|
98
|
+
const ids = new Set();
|
|
99
|
+
try {
|
|
100
|
+
const stored = JSON.parse(storage?.getItem?.(key) || '[]');
|
|
101
|
+
if (Array.isArray(stored)) {
|
|
102
|
+
for (const id of stored) if (typeof id === 'string' && id.trim()) ids.add(id.trim());
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
return {
|
|
106
|
+
has: (id) => ids.has(String(id || '').trim()),
|
|
107
|
+
add(id) {
|
|
108
|
+
const value = String(id || '').trim();
|
|
109
|
+
if (!value) return;
|
|
110
|
+
ids.delete(value);
|
|
111
|
+
ids.add(value);
|
|
112
|
+
while (ids.size > limit) ids.delete(ids.values().next().value);
|
|
113
|
+
try {
|
|
114
|
+
storage?.setItem?.(key, JSON.stringify([...ids]));
|
|
115
|
+
} catch {}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reports overlays opening and closing from a list of
|
|
122
|
+
* `{ name, isOpen(), close() }`; `check()` runs on every DOM change that may
|
|
123
|
+
* have touched one. Closings are reported before openings, so an overlay
|
|
124
|
+
* that hands over to another (the ︙ menu opening Settings) gives up its
|
|
125
|
+
* entry to the next one.
|
|
126
|
+
*/
|
|
127
|
+
export function createOverlayWatch({ overlays = [], onOpened = () => {}, onClosed = () => {} } = {}) {
|
|
128
|
+
const isOpen = (overlay) => {
|
|
129
|
+
try {
|
|
130
|
+
return overlay.isOpen() === true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const known = new Map(overlays.map((overlay) => [overlay.name, isOpen(overlay)]));
|
|
136
|
+
return {
|
|
137
|
+
check() {
|
|
138
|
+
const opened = [];
|
|
139
|
+
const closed = [];
|
|
140
|
+
for (const overlay of overlays) {
|
|
141
|
+
const now = isOpen(overlay);
|
|
142
|
+
if (now === known.get(overlay.name)) continue;
|
|
143
|
+
known.set(overlay.name, now);
|
|
144
|
+
(now ? opened : closed).push(overlay);
|
|
145
|
+
}
|
|
146
|
+
for (const overlay of closed) onClosed(overlay.name);
|
|
147
|
+
for (const overlay of opened) onOpened(overlay);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `open(id)` opens a session for a pop (without writing history again; the
|
|
154
|
+
* app answers with `shown(id, { mode: 'none' })`). `isGone(id)` names a
|
|
155
|
+
* deleted session. `onList()` / `onLeaveList()` open and close the session
|
|
156
|
+
* list. `setTitle(id)` names the current entry (Chrome's long-press back
|
|
157
|
+
* list shows it). `listOverlay` is the overlay that the list entry stands
|
|
158
|
+
* for (the phone's sidebar): it takes no entry of its own while there.
|
|
159
|
+
*/
|
|
160
|
+
export function createSessionHistory({
|
|
161
|
+
history,
|
|
162
|
+
location = null,
|
|
163
|
+
open = () => {},
|
|
164
|
+
isGone = () => false,
|
|
165
|
+
onList = () => {},
|
|
166
|
+
onLeaveList = () => {},
|
|
167
|
+
setTitle = () => {},
|
|
168
|
+
schedule = (fn, ms) => setTimeout(fn, ms),
|
|
169
|
+
cancel = (handle) => clearTimeout(handle),
|
|
170
|
+
listOverlay = 'sidebar',
|
|
171
|
+
loadId = Math.random().toString(36).slice(2, 8),
|
|
172
|
+
} = {}) {
|
|
173
|
+
const start = history.state && typeof history.state === 'object' ? history.state : {};
|
|
174
|
+
let sticky = inheritedVisitMarks(start);
|
|
175
|
+
let lastPos = positionOf(start);
|
|
176
|
+
// The top of the stack as far as we know: forward from there leads nowhere.
|
|
177
|
+
let highestPos = lastPos;
|
|
178
|
+
// The highest session or list entry in the stack: above the current
|
|
179
|
+
// position, there is forward history an overlay's push would drop.
|
|
180
|
+
let sessionTop = ['session', 'list'].includes(entryKind(start)) ? lastPos : undefined;
|
|
181
|
+
// The visit's lowest own entry (back-guard.mjs, VISIT_BASE).
|
|
182
|
+
let basePos = Number.isFinite(sticky[VISIT_BASE]) ? sticky[VISIT_BASE] : undefined;
|
|
183
|
+
let conv = null;
|
|
184
|
+
// Nothing shown yet: the address may hold a link not read yet.
|
|
185
|
+
let shownOnce = false;
|
|
186
|
+
// The session the last back or forward opened, and which way it went.
|
|
187
|
+
let popped = null;
|
|
188
|
+
let pending = null; // { conv, mode: 'push' | 'replace' } not written yet
|
|
189
|
+
// Open overlays, in the order they opened: { key, name, close, isOpen, unentered }.
|
|
190
|
+
// `unentered`: opened above forward history, it took no entry (see the top).
|
|
191
|
+
let overlays = [];
|
|
192
|
+
let written = []; // our overlay entries above the session entry: { key, pos }
|
|
193
|
+
let atList = false;
|
|
194
|
+
let listForward = false; // the session entry we came back from lies ahead
|
|
195
|
+
let awaitingPop = false;
|
|
196
|
+
let fallback = null;
|
|
197
|
+
let settleQueued = false;
|
|
198
|
+
let counter = 0;
|
|
199
|
+
|
|
200
|
+
const here = () => positionOf(history.state) ?? lastPos ?? 0;
|
|
201
|
+
const urlFor = (id) => (!id && !shownOnce ? undefined : sessionUrl(location?.href, id));
|
|
202
|
+
const sessionFields = (id, pos) => ({ [SESSION_ENTRY]: id || null, [HISTORY_POSITION]: pos });
|
|
203
|
+
const overlayFields = (key, pos) => ({ [OVERLAY_ENTRY]: key, [HISTORY_POSITION]: pos });
|
|
204
|
+
const entried = () => overlays.filter((overlay) => !overlay.unentered);
|
|
205
|
+
|
|
206
|
+
function noteOwnEntry(pos) {
|
|
207
|
+
if (pos === undefined) return;
|
|
208
|
+
sessionTop = Math.max(sessionTop ?? pos, pos);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function write(method, fields, url) {
|
|
212
|
+
const state = { ...sticky, ...fields };
|
|
213
|
+
if (url === undefined) history[method](state, '');
|
|
214
|
+
else history[method](state, '', url);
|
|
215
|
+
lastPos = state[HISTORY_POSITION];
|
|
216
|
+
highestPos = method === 'pushState' ? lastPos : Math.max(highestPos ?? lastPos, lastPos);
|
|
217
|
+
// A push drops everything above it.
|
|
218
|
+
if (method === 'pushState' && sessionTop !== undefined && sessionTop >= lastPos) sessionTop = lastPos - 1;
|
|
219
|
+
if (['session', 'list'].includes(entryKind(fields))) noteOwnEntry(lastPos);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function callClose(overlay) {
|
|
223
|
+
try {
|
|
224
|
+
overlay.close?.();
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Closes `closing` (the top one first). One that stays open (a "discard?"
|
|
230
|
+
* answered no) is open again as far as history goes, and takes an entry.
|
|
231
|
+
*/
|
|
232
|
+
function closeAll(closing) {
|
|
233
|
+
for (const overlay of closing) callClose(overlay);
|
|
234
|
+
const stayed = closing.filter((overlay) => {
|
|
235
|
+
try {
|
|
236
|
+
return overlay.isOpen?.() === true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
if (!stayed.length) return;
|
|
242
|
+
for (const overlay of stayed.reverse()) overlays.push({ ...overlay, unentered: false });
|
|
243
|
+
requestSettle();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Closes every open overlay from `index` up, the top one first. */
|
|
247
|
+
function closeFrom(index) {
|
|
248
|
+
const closing = overlays.slice(index).reverse();
|
|
249
|
+
overlays = overlays.slice(0, index);
|
|
250
|
+
closeAll(closing);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Closes the overlays whose entries lay above position `pos`. */
|
|
254
|
+
function closeAbove(pos) {
|
|
255
|
+
if (pos === undefined) return;
|
|
256
|
+
const above = new Set(written.filter((entry) => entry.pos > pos).map((entry) => entry.key));
|
|
257
|
+
written = written.filter((entry) => entry.pos <= pos);
|
|
258
|
+
const closing = overlays.filter((overlay) => above.has(overlay.key)).reverse();
|
|
259
|
+
overlays = overlays.filter((overlay) => !above.has(overlay.key));
|
|
260
|
+
closeAll(closing);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* An overlay opening now may take no entry: sessions lie ahead, and below
|
|
265
|
+
* the session on screen lies one of ours to land on (see the top).
|
|
266
|
+
*/
|
|
267
|
+
function mayLeaveEntryOut() {
|
|
268
|
+
if (awaitingPop || pending || written.length || atList || entried().length) return false;
|
|
269
|
+
if (entryKind(history.state) !== 'session') return false;
|
|
270
|
+
const pos = here();
|
|
271
|
+
return sessionTop !== undefined && sessionTop > pos && basePos !== undefined && pos > basePos;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Steps over a dead entry; forward at the top of the stack bounces back instead. */
|
|
275
|
+
function step(direction, pos) {
|
|
276
|
+
if (direction < 0) history.back();
|
|
277
|
+
else if (direction > 0 && pos !== undefined && pos >= (highestPos ?? pos)) history.back();
|
|
278
|
+
else if (direction > 0) history.forward();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function requestSettle() {
|
|
282
|
+
if (settleQueued) return;
|
|
283
|
+
settleQueued = true;
|
|
284
|
+
schedule(() => {
|
|
285
|
+
settleQueued = false;
|
|
286
|
+
settle();
|
|
287
|
+
}, 0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A traversal of our own: its pop opens nothing, and writes wait for it. */
|
|
291
|
+
function traverse(delta) {
|
|
292
|
+
awaitingPop = true;
|
|
293
|
+
fallback = schedule(() => {
|
|
294
|
+
fallback = null;
|
|
295
|
+
if (!awaitingPop) return;
|
|
296
|
+
awaitingPop = false;
|
|
297
|
+
// Never pop again on a guess (a late pop and a second one would
|
|
298
|
+
// overshoot): what we meant to pop stays behind, stepped over later.
|
|
299
|
+
written = [];
|
|
300
|
+
settle();
|
|
301
|
+
}, OWN_POP_FALLBACK_MS);
|
|
302
|
+
history.go(delta);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function popBy(count) {
|
|
306
|
+
traverse(-count);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function resync() {
|
|
310
|
+
const pos = positionOf(history.state);
|
|
311
|
+
if (pos === undefined) return;
|
|
312
|
+
lastPos = pos;
|
|
313
|
+
written = written.filter((entry) => entry.pos <= pos);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function settle() {
|
|
317
|
+
if (awaitingPop) return;
|
|
318
|
+
// An overlay the page shows closed, though no one has said so yet.
|
|
319
|
+
overlays = overlays.filter((overlay) => typeof overlay.isOpen !== 'function' || overlay.isOpen() !== false);
|
|
320
|
+
const top = history.state;
|
|
321
|
+
const topKind = entryKind(top);
|
|
322
|
+
// Ours can only be taken out while the top one is current; under
|
|
323
|
+
// someone else's entry they stay behind, stepped over later.
|
|
324
|
+
if (written.length && !(topKind === 'overlay' && top[OVERLAY_ENTRY] === written[written.length - 1].key)) {
|
|
325
|
+
written = [];
|
|
326
|
+
}
|
|
327
|
+
if (pending) {
|
|
328
|
+
const target = pending.conv;
|
|
329
|
+
const above = written.length;
|
|
330
|
+
if (pending.mode === 'replace' && !above && topKind === 'list') {
|
|
331
|
+
// A reload on the list entry: the list stays, the session is shown.
|
|
332
|
+
pending = null;
|
|
333
|
+
atList = true;
|
|
334
|
+
setTitle(target);
|
|
335
|
+
} else if (!above && topKind === 'session' && (top[SESSION_ENTRY] || null) === target) {
|
|
336
|
+
pending = null; // there and back before anything was written
|
|
337
|
+
setTitle(target);
|
|
338
|
+
} else {
|
|
339
|
+
// An overlay entry on top that is not ours any more (a reload on it) is replaced too.
|
|
340
|
+
const replace = pending.mode === 'replace' && (above > 0 || topKind === 'session' || topKind === 'none' || topKind === 'overlay');
|
|
341
|
+
if (replace && above) return popBy(above);
|
|
342
|
+
if (!replace && above > 1) return popBy(above - 1);
|
|
343
|
+
pending = null;
|
|
344
|
+
if (replace) {
|
|
345
|
+
write('replaceState', sessionFields(target, here()), urlFor(target));
|
|
346
|
+
} else if (above === 1) {
|
|
347
|
+
// Chosen from an open overlay: the session takes the overlay's entry.
|
|
348
|
+
write('replaceState', sessionFields(target, written[0].pos), urlFor(target));
|
|
349
|
+
written = [];
|
|
350
|
+
} else {
|
|
351
|
+
write('pushState', sessionFields(target, here() + 1), urlFor(target));
|
|
352
|
+
}
|
|
353
|
+
setTitle(target);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Only the overlays that take an entry are written.
|
|
357
|
+
const withEntry = entried();
|
|
358
|
+
let common = 0;
|
|
359
|
+
while (common < written.length && common < withEntry.length && written[common].key === withEntry[common].key) common += 1;
|
|
360
|
+
const extra = written.length - common;
|
|
361
|
+
const add = withEntry.length - common;
|
|
362
|
+
if (extra > 1 || (extra === 1 && add === 0)) return popBy(add ? extra - 1 : extra);
|
|
363
|
+
for (let index = common; index < withEntry.length; index += 1) {
|
|
364
|
+
const { key } = withEntry[index];
|
|
365
|
+
if (index < written.length) {
|
|
366
|
+
// The top entry belonged to an overlay that just closed: the next one takes it.
|
|
367
|
+
const { pos } = written[index];
|
|
368
|
+
write('replaceState', overlayFields(key, pos));
|
|
369
|
+
written[index] = { key, pos };
|
|
370
|
+
} else {
|
|
371
|
+
const pos = here() + 1;
|
|
372
|
+
write('pushState', overlayFields(key, pos));
|
|
373
|
+
written.push({ key, pos });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
get conversation() { return conv; },
|
|
381
|
+
get atList() { return atList; },
|
|
382
|
+
get openOverlays() { return overlays.map((overlay) => overlay.name); },
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The app shows `id`. `mode`: 'push' (a switch), 'replace' (the page's
|
|
386
|
+
* first session, a compaction's successor, nothing open any more) or
|
|
387
|
+
* 'none' (a pop opened it: history is there already).
|
|
388
|
+
*/
|
|
389
|
+
shown(id, { mode = 'push' } = {}) {
|
|
390
|
+
const next = String(id || '').trim() || null;
|
|
391
|
+
shownOnce = true;
|
|
392
|
+
if (mode === 'none') {
|
|
393
|
+
conv = next;
|
|
394
|
+
setTitle(next);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (next === conv) return;
|
|
398
|
+
const replace = mode === 'replace' || pending?.mode === 'replace' || !conv;
|
|
399
|
+
conv = next;
|
|
400
|
+
popped = null;
|
|
401
|
+
atList = false;
|
|
402
|
+
listForward = false;
|
|
403
|
+
pending = { conv: next, mode: replace ? 'replace' : 'push' };
|
|
404
|
+
requestSettle();
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The relay says `id` does not exist (any more). True when back or
|
|
409
|
+
* forward led onto it: history steps on past it the same way, and the
|
|
410
|
+
* next pop opens what lies there. False otherwise: the app clears the
|
|
411
|
+
* view, and with it the entry (`shown(null, { mode: 'replace' })`).
|
|
412
|
+
*/
|
|
413
|
+
missing(id) {
|
|
414
|
+
const value = String(id || '').trim();
|
|
415
|
+
if (!value || conv !== value || popped?.id !== value || awaitingPop) return false;
|
|
416
|
+
const top = history.state;
|
|
417
|
+
if (entryKind(top) !== 'session' || top[SESSION_ENTRY] !== value) return false;
|
|
418
|
+
const pos = positionOf(top);
|
|
419
|
+
const { direction } = popped;
|
|
420
|
+
// Back past the visit's lowest own entry would leave the app.
|
|
421
|
+
if (!direction || (direction < 0 && !(pos !== undefined && basePos !== undefined && pos > basePos))) return false;
|
|
422
|
+
popped = null;
|
|
423
|
+
conv = null;
|
|
424
|
+
pending = null;
|
|
425
|
+
closeFrom(0);
|
|
426
|
+
written = [];
|
|
427
|
+
step(direction, pos);
|
|
428
|
+
return true;
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* `{ name, close(), isOpen() }` opened: it takes one entry, or none above
|
|
433
|
+
* forward history (see the top).
|
|
434
|
+
*/
|
|
435
|
+
overlayOpened(overlay) {
|
|
436
|
+
if (!overlay?.name) return;
|
|
437
|
+
if (atList && overlay.name === listOverlay) return;
|
|
438
|
+
if (overlays.some((open) => open.name === overlay.name)) return;
|
|
439
|
+
counter += 1;
|
|
440
|
+
const unentered = mayLeaveEntryOut();
|
|
441
|
+
overlays.push({ key: `${loadId}.${counter}`, name: overlay.name, close: overlay.close, isOpen: overlay.isOpen, unentered });
|
|
442
|
+
if (!unentered) requestSettle();
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
/** An overlay closed by other means than back: its entry goes. */
|
|
446
|
+
overlayClosed(name) {
|
|
447
|
+
if (atList && name === listOverlay) {
|
|
448
|
+
// The list closed by hand: back where we came from, if that lies ahead.
|
|
449
|
+
if (listForward) {
|
|
450
|
+
listForward = false;
|
|
451
|
+
history.forward();
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const before = overlays.length;
|
|
456
|
+
overlays = overlays.filter((overlay) => overlay.name !== name);
|
|
457
|
+
if (overlays.length !== before) requestSettle();
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
/** Feed every popstate here. */
|
|
461
|
+
onPopState() {
|
|
462
|
+
const state = history.state;
|
|
463
|
+
const kind = entryKind(state);
|
|
464
|
+
const pos = positionOf(state);
|
|
465
|
+
if (awaitingPop) {
|
|
466
|
+
awaitingPop = false;
|
|
467
|
+
if (fallback !== null) cancel(fallback);
|
|
468
|
+
fallback = null;
|
|
469
|
+
resync();
|
|
470
|
+
settle();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const direction = pos === undefined || lastPos === undefined ? 0 : Math.sign(pos - lastPos);
|
|
474
|
+
if (pos !== undefined) {
|
|
475
|
+
lastPos = pos;
|
|
476
|
+
highestPos = Math.max(highestPos ?? pos, pos);
|
|
477
|
+
}
|
|
478
|
+
if (kind === 'session' || kind === 'list') noteOwnEntry(pos);
|
|
479
|
+
if (direction < 0 && !written.length && (kind === 'session' || kind === 'list') && overlays.some((overlay) => overlay.unentered)) {
|
|
480
|
+
// Back with an overlay open that took no entry: it closes, and
|
|
481
|
+
// history goes forward again to the session it lay over, keeping
|
|
482
|
+
// what lies ahead.
|
|
483
|
+
closeFrom(0);
|
|
484
|
+
traverse(1);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (kind === 'overlay') {
|
|
488
|
+
const at = overlays.findIndex((overlay) => overlay.key === state[OVERLAY_ENTRY]);
|
|
489
|
+
if (at >= 0) {
|
|
490
|
+
closeFrom(at + 1);
|
|
491
|
+
written = written.filter((entry) => entry.pos <= pos);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// Its overlay closed since (or a reload came between): step over it.
|
|
495
|
+
closeAbove(pos);
|
|
496
|
+
step(direction, pos);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (kind === 'other') {
|
|
500
|
+
closeAbove(pos);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (kind === 'none') return;
|
|
504
|
+
closeFrom(0);
|
|
505
|
+
written = [];
|
|
506
|
+
if (kind === 'floor') return; // the back guard answers
|
|
507
|
+
if (kind === 'list') {
|
|
508
|
+
if (direction < 0) listForward = true;
|
|
509
|
+
if (!atList) {
|
|
510
|
+
atList = true;
|
|
511
|
+
onList();
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const wasAtList = atList;
|
|
516
|
+
atList = false;
|
|
517
|
+
listForward = false;
|
|
518
|
+
const target = state[SESSION_ENTRY] || null;
|
|
519
|
+
if (!target || isGone(target)) {
|
|
520
|
+
step(direction, pos);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (target !== conv) {
|
|
524
|
+
conv = target;
|
|
525
|
+
pending = null;
|
|
526
|
+
popped = { id: target, direction };
|
|
527
|
+
setTitle(target);
|
|
528
|
+
open(target);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (wasAtList) onLeaveList();
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* At the visit's first touch or key, after the back guard: on a phone a
|
|
536
|
+
* list entry beneath the session entry; everywhere the mark that the
|
|
537
|
+
* base is laid, so a reload lays nothing again.
|
|
538
|
+
*/
|
|
539
|
+
layBase({ phone = false } = {}) {
|
|
540
|
+
const top = history.state && typeof history.state === 'object' ? history.state : {};
|
|
541
|
+
if (top[VISIT_LAID] || sticky[VISIT_LAID]) return false;
|
|
542
|
+
const kind = entryKind(top);
|
|
543
|
+
if (kind === 'overlay' || kind === 'list' || kind === 'floor') return false;
|
|
544
|
+
// Of the others, only the bare app entry the back guard just pushed.
|
|
545
|
+
if (kind === 'other' && !top[BACK_GUARD_APP]) return false;
|
|
546
|
+
if (!awaitingPop && pending) settle();
|
|
547
|
+
if (awaitingPop) return false;
|
|
548
|
+
const pos = positionOf(top) ?? (lastPos === undefined ? 0 : lastPos + 1);
|
|
549
|
+
sticky = { ...sticky, ...inheritedVisitMarks(top), [VISIT_LAID]: phone ? 'list' : 'plain', [VISIT_BASE]: pos };
|
|
550
|
+
basePos = pos;
|
|
551
|
+
written = [];
|
|
552
|
+
if (phone) {
|
|
553
|
+
write('replaceState', { [LIST_ENTRY]: true, [HISTORY_POSITION]: pos });
|
|
554
|
+
write('pushState', sessionFields(conv, pos + 1), urlFor(conv));
|
|
555
|
+
} else {
|
|
556
|
+
write('replaceState', sessionFields(conv, pos), urlFor(conv));
|
|
557
|
+
}
|
|
558
|
+
setTitle(conv);
|
|
559
|
+
return true;
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
/** The entry the back guard pushes again above its floor. */
|
|
563
|
+
entryAgain() {
|
|
564
|
+
const pos = (positionOf(history.state) ?? lastPos ?? 0) + 1;
|
|
565
|
+
closeFrom(0);
|
|
566
|
+
written = [];
|
|
567
|
+
lastPos = pos;
|
|
568
|
+
highestPos = pos;
|
|
569
|
+
sessionTop = pos;
|
|
570
|
+
// Pushed again right above the floor: the visit's lowest own entry.
|
|
571
|
+
basePos = pos;
|
|
572
|
+
sticky = { ...sticky, [VISIT_BASE]: pos };
|
|
573
|
+
if (sticky[VISIT_LAID] === 'list') {
|
|
574
|
+
listForward = false;
|
|
575
|
+
if (!atList) {
|
|
576
|
+
atList = true;
|
|
577
|
+
onList();
|
|
578
|
+
}
|
|
579
|
+
return { state: { ...sticky, [LIST_ENTRY]: true, [HISTORY_POSITION]: pos } };
|
|
580
|
+
}
|
|
581
|
+
atList = false;
|
|
582
|
+
return { state: { ...sticky, ...sessionFields(conv, pos) }, url: urlFor(conv) };
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Lays the visit's base at the first touch or key (see back-guard.mjs for why not earlier). */
|
|
588
|
+
export function armVisitBaseOnFirstInteraction({ target, sessionHistory, isPhone = () => false } = {}) {
|
|
589
|
+
if (!target || !sessionHistory) return null;
|
|
590
|
+
const state = { armed: false };
|
|
591
|
+
const arm = () => {
|
|
592
|
+
for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
|
|
593
|
+
if (state.armed) return;
|
|
594
|
+
state.armed = true;
|
|
595
|
+
sessionHistory.layBase({ phone: isPhone() });
|
|
596
|
+
};
|
|
597
|
+
for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
|
|
598
|
+
return { get armed() { return state.armed; } };
|
|
599
|
+
}
|
|
@@ -52,7 +52,7 @@ import { syncFontScaleSelect } from './font-scaling.js';
|
|
|
52
52
|
import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
|
|
53
53
|
import { normalizeKnownCwdPath } from './cwd-picker.js';
|
|
54
54
|
import { refreshPushSettingsSection } from './push-settings.js';
|
|
55
|
-
import { selectSettingsTab } from './settings-tabs.js';
|
|
55
|
+
import { revealSettingsTabs, selectSettingsTab } from './settings-tabs.js';
|
|
56
56
|
import { refreshClaudeAuthSection } from './claude-auth-ui.js';
|
|
57
57
|
import { refreshGrokAuthSection } from './grok-auth-ui.js';
|
|
58
58
|
import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
|
|
@@ -1697,9 +1697,7 @@ function syncFeatureFlagsSection() {
|
|
|
1697
1697
|
const flags = Array.isArray(featureFlagsState?.flags) ? featureFlagsState.flags : [];
|
|
1698
1698
|
for (const flag of flags) {
|
|
1699
1699
|
const row = document.createElement('div');
|
|
1700
|
-
row.className = 'settings-row';
|
|
1701
|
-
row.style.marginTop = '12px';
|
|
1702
|
-
row.style.marginBottom = '0';
|
|
1700
|
+
row.className = 'settings-row settings-gap';
|
|
1703
1701
|
|
|
1704
1702
|
const label = document.createElement('label');
|
|
1705
1703
|
label.htmlFor = featureToggleId(flag.name);
|
|
@@ -1719,8 +1717,7 @@ function syncFeatureFlagsSection() {
|
|
|
1719
1717
|
list.appendChild(row);
|
|
1720
1718
|
|
|
1721
1719
|
const help = document.createElement('div');
|
|
1722
|
-
help.className = 'settings-help';
|
|
1723
|
-
help.style.marginTop = '6px';
|
|
1720
|
+
help.className = 'settings-help settings-gap-xs';
|
|
1724
1721
|
let text = String(flag.description || '');
|
|
1725
1722
|
if (flag.envOverride !== null) {
|
|
1726
1723
|
text += ` Pinned ${flag.envOverride ? 'on' : 'off'} by COPILOT_REMOTE_${flag.name} in the relay's environment; the toggle is locked while that variable is set.`;
|
|
@@ -1994,12 +1991,70 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1994
1991
|
void refreshAgentSessionsSection();
|
|
1995
1992
|
void refreshFeatureFlagsSection();
|
|
1996
1993
|
void refreshUpdateSection();
|
|
1994
|
+
const wasOpen = !!modal?.classList.contains('visible');
|
|
1997
1995
|
modal?.classList.add('visible');
|
|
1998
1996
|
modal?.setAttribute('aria-hidden', 'false');
|
|
1997
|
+
// Laid out now: the selected tabs scroll into view, clear of the fades.
|
|
1998
|
+
revealSettingsTabs();
|
|
1999
|
+
if (modal && !wasOpen) focusSettingsOnOpen(modal);
|
|
1999
2000
|
}
|
|
2000
2001
|
|
|
2001
2002
|
export function closeSettingsModal() {
|
|
2002
2003
|
const modal = document.getElementById('settings-modal');
|
|
2004
|
+
const wasOpen = !!modal?.classList.contains('visible');
|
|
2005
|
+
const focusWasInside = !!modal?.contains(document.activeElement);
|
|
2003
2006
|
modal?.classList.remove('visible');
|
|
2004
2007
|
modal?.setAttribute('aria-hidden', 'true');
|
|
2008
|
+
const back = settingsFocusBefore;
|
|
2009
|
+
settingsFocusBefore = null;
|
|
2010
|
+
// Focus goes back where it was (︙, the gear) when it was in the dialog;
|
|
2011
|
+
// elsewhere (Back, an action that moved on) it stays where it is.
|
|
2012
|
+
if (wasOpen && focusWasInside && back?.isConnected && !modal?.contains(back)) {
|
|
2013
|
+
try { back.focus({ preventScroll: true }); } catch {}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// What had the focus when Settings opened, to give it back on close.
|
|
2018
|
+
let settingsFocusBefore = null;
|
|
2019
|
+
let settingsEscapeBound = false;
|
|
2020
|
+
|
|
2021
|
+
// The dialog takes the focus: its selected tab, so the arrows walk the tabs
|
|
2022
|
+
// and Escape reaches the dialog from the first key on.
|
|
2023
|
+
function focusSettingsOnOpen(modal) {
|
|
2024
|
+
ensureSettingsEscape();
|
|
2025
|
+
let active = document.activeElement;
|
|
2026
|
+
// An entry of ︙ closes with its menu: the focus goes back to ︙.
|
|
2027
|
+
if (active?.closest?.('#chat-actions-menu')) active = document.getElementById('chat-actions-menu-btn');
|
|
2028
|
+
settingsFocusBefore = active && active !== document.body && !modal.contains(active) ? active : null;
|
|
2029
|
+
const target = modal.querySelector('[data-settings-tab][aria-selected="true"]')
|
|
2030
|
+
|| modal.querySelector('[role="dialog"]');
|
|
2031
|
+
if (!target) return;
|
|
2032
|
+
if (target.getAttribute('role') === 'dialog' && !target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
|
2033
|
+
try { target.focus({ preventScroll: true }); } catch {}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// True while nothing is drawn over the Settings dialog: a confirmation, the
|
|
2037
|
+
// explorer or a preview opened from it takes Escape first.
|
|
2038
|
+
function settingsDialogOnTop(modal) {
|
|
2039
|
+
const dialog = modal.querySelector('[role="dialog"]') || modal;
|
|
2040
|
+
const rect = dialog.getBoundingClientRect();
|
|
2041
|
+
if (!rect.width || !rect.height) return false;
|
|
2042
|
+
const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + Math.min(rect.height / 2, 24));
|
|
2043
|
+
return !top || modal.contains(top);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Escape closes Settings wherever the focus is, and only Settings: window,
|
|
2047
|
+
// capture phase, so the document's Escape handlers for what lies underneath
|
|
2048
|
+
// (the ︙ menu, the chips' lists, the composer's panel) never see that key.
|
|
2049
|
+
function ensureSettingsEscape() {
|
|
2050
|
+
if (settingsEscapeBound) return;
|
|
2051
|
+
settingsEscapeBound = true;
|
|
2052
|
+
window.addEventListener('keydown', (event) => {
|
|
2053
|
+
if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing) return;
|
|
2054
|
+
const modal = document.getElementById('settings-modal');
|
|
2055
|
+
if (!modal?.classList.contains('visible') || !settingsDialogOnTop(modal)) return;
|
|
2056
|
+
event.preventDefault();
|
|
2057
|
+
event.stopImmediatePropagation();
|
|
2058
|
+
closeSettingsModal();
|
|
2059
|
+
}, true);
|
|
2005
2060
|
}
|