@oar-sh/oar 0.9.19 → 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.
Files changed (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. 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
  }