@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,442 @@
1
+ // The composer's ⚙ settings: a label row above the message box
2
+ // ("⚙ 🔒 Opus 5.5 · high · Autopilot [Subscription] 🗂 📎") and a panel
3
+ // that holds Mode, Model (with the lock note), Effort, Context window,
4
+ // Billing and Context usage. A popover above the label row on desktop, a
5
+ // bottom sheet on a phone (max-width 680px).
6
+ //
7
+ // The selects live in the panel's markup with their old ids; their change
8
+ // listeners stay in bootstrap.js. This module only reads them: the label is
9
+ // redrawn whenever a select, the lock note or the billing toggle changes,
10
+ // and on the body's chat-kind classes (cloud chat, shared reader).
11
+
12
+ import {
13
+ composerBillingPill,
14
+ composerSettingsLabelParts,
15
+ composerSettingsLabelText,
16
+ } from './composer-settings-label.mjs';
17
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
18
+
19
+ const PANEL_ID = 'composer-settings';
20
+ const PHONE_QUERY = '(max-width: 680px)';
21
+ // The panel field a label part (or the pill) opens, and its control.
22
+ const FIELD_CONTROL_IDS = Object.freeze({
23
+ mode: 'mode-select',
24
+ model: 'model-select',
25
+ effort: 'reasoning-effort-select',
26
+ tier: 'context-tier-select',
27
+ billing: 'claude-billing-toggle',
28
+ });
29
+ // Gap between the label row and the popover, and the room kept free above it.
30
+ const POPOVER_GAP_PX = 6;
31
+ const POPOVER_EDGE_PX = 8;
32
+ const POPOVER_MIN_HEIGHT_PX = 160;
33
+ const POPOVER_TYPING_MIN_HEIGHT_PX = 64;
34
+ const POPOVER_MIN_WIDTH_PX = 200;
35
+
36
+ let deps = {};
37
+ let panelOpen = false;
38
+ let syncQueued = false;
39
+ let bound = false;
40
+ let repositionBound = false;
41
+
42
+ function el(id) {
43
+ return typeof document === 'undefined' ? null : document.getElementById(id);
44
+ }
45
+
46
+ function isPhone() {
47
+ try { return window.matchMedia(PHONE_QUERY).matches; } catch { return false; }
48
+ }
49
+
50
+ function isShared() {
51
+ try {
52
+ if (deps.isSharedReaderMode?.() === true) return true;
53
+ } catch {}
54
+ return document.body?.classList?.contains('shared-reader-mode') === true;
55
+ }
56
+
57
+ function shownOptionText(select) {
58
+ if (!select) return '';
59
+ const option = select.options?.[select.selectedIndex];
60
+ return option ? option.textContent : '';
61
+ }
62
+
63
+ function setText(node, text) {
64
+ if (node && node.textContent !== text) node.textContent = text;
65
+ }
66
+
67
+ function readLabelInput() {
68
+ const mode = el('mode-select');
69
+ const model = el('model-select');
70
+ const effort = el('reasoning-effort-select');
71
+ const tier = el('context-tier-select');
72
+ const note = el('model-lock-note');
73
+ const noteLocked = !!note && !note.hidden && String(note.textContent || '').trim() !== '';
74
+ return {
75
+ model: shownOptionText(model),
76
+ locked: noteLocked || model?.dataset?.runtimeModelLocked === '1',
77
+ effort: shownOptionText(effort),
78
+ effortValue: effort ? String(effort.value || '') : '',
79
+ mode: shownOptionText(mode),
80
+ tier: shownOptionText(tier),
81
+ tierValue: tier ? String(tier.value || '') : '',
82
+ cloud: document.body?.classList?.contains('claude-cloud-conversation') === true,
83
+ hidden: {
84
+ model: !!model?.hidden,
85
+ effort: !!effort?.hidden,
86
+ mode: !!mode?.hidden,
87
+ tier: !!tier?.hidden,
88
+ },
89
+ };
90
+ }
91
+
92
+ function renderLabel() {
93
+ const label = el('composer-settings-label');
94
+ if (!label) return;
95
+ const parts = composerSettingsLabelParts(readLabelInput());
96
+ const key = JSON.stringify(parts);
97
+ if (label.dataset.renderKey !== key) {
98
+ const nodes = [];
99
+ parts.forEach((part, index) => {
100
+ if (index) {
101
+ const separator = document.createElement('span');
102
+ separator.className = 'composer-settings-sep';
103
+ separator.setAttribute('aria-hidden', 'true');
104
+ separator.textContent = ' · ';
105
+ nodes.push(separator);
106
+ }
107
+ const node = document.createElement('span');
108
+ node.className = 'composer-settings-part';
109
+ node.dataset.field = part.field;
110
+ if (part.field === 'model' && part.locked) {
111
+ const lock = document.createElement('span');
112
+ lock.className = 'composer-settings-lock';
113
+ lock.textContent = '🔒';
114
+ lock.setAttribute('aria-label', 'locked');
115
+ node.append(lock, ' ');
116
+ }
117
+ node.append(part.text);
118
+ nodes.push(node);
119
+ });
120
+ if (!nodes.length) {
121
+ const empty = document.createElement('span');
122
+ empty.className = 'composer-settings-part composer-settings-empty';
123
+ empty.textContent = 'Chat settings';
124
+ nodes.push(empty);
125
+ }
126
+ label.replaceChildren(...nodes);
127
+ label.dataset.renderKey = key;
128
+ }
129
+ const text = composerSettingsLabelText(parts);
130
+ const title = text ? `Chat settings: ${text}` : 'Chat settings';
131
+ if (label.title !== title) label.title = title;
132
+ if (label.getAttribute('aria-label') !== title) label.setAttribute('aria-label', title);
133
+ }
134
+
135
+ // Image models turn the effort select into Quality and the context-window
136
+ // select into the image size (bootstrap.js sets their titles).
137
+ function renderFieldLabels() {
138
+ const effort = el('reasoning-effort-select');
139
+ const tier = el('context-tier-select');
140
+ setText(el('reasoning-effort-label'), effort?.title === 'Quality' ? 'Quality' : 'Effort');
141
+ setText(el('context-tier-label'), tier?.title === 'Image size' ? 'Image size' : 'Context window');
142
+ }
143
+
144
+ function renderBillingPill() {
145
+ const pill = el('composer-billing-pill');
146
+ if (!pill) return;
147
+ const toggle = el('claude-billing-toggle');
148
+ const model = composerBillingPill({
149
+ toggleVisible: !!toggle && !toggle.hidden,
150
+ billing: toggle?.dataset?.billing || '',
151
+ });
152
+ const visible = model.visible && !isShared();
153
+ if (pill.hidden !== !visible) pill.hidden = !visible;
154
+ setText(pill, model.text);
155
+ if (model.billing) {
156
+ if (pill.dataset.billing !== model.billing) pill.dataset.billing = model.billing;
157
+ } else if ('billing' in pill.dataset) {
158
+ delete pill.dataset.billing;
159
+ }
160
+ const title = visible ? `Billing: ${model.text}` : '';
161
+ if (pill.title !== title) pill.title = title;
162
+ }
163
+
164
+ /** Redraws the label, the field names and the billing pill now. */
165
+ export function syncComposerSettings() {
166
+ if (typeof document === 'undefined') return;
167
+ syncQueued = false;
168
+ renderFieldLabels();
169
+ renderLabel();
170
+ renderBillingPill();
171
+ if (panelOpen && isShared()) closeComposerSettings();
172
+ }
173
+
174
+ /** Redraws once after the current task: callers may set several values in a row. */
175
+ export function scheduleComposerSettingsSync() {
176
+ if (syncQueued) return;
177
+ syncQueued = true;
178
+ queueMicrotask(syncComposerSettings);
179
+ }
180
+
181
+ function isRendered(node) {
182
+ return !!node && !node.hidden && node.getClientRects().length > 0;
183
+ }
184
+
185
+ function fieldFocusTarget(field) {
186
+ const control = el(FIELD_CONTROL_IDS[field] || '');
187
+ if (!isRendered(control)) return null;
188
+ if (field === 'billing') {
189
+ return control.querySelector('.claude-billing-option[aria-pressed="true"]')
190
+ || control.querySelector('.claude-billing-option');
191
+ }
192
+ return control.disabled ? null : control;
193
+ }
194
+
195
+ function firstFocusableField() {
196
+ for (const field of ['mode', 'model', 'effort', 'tier', 'billing']) {
197
+ const target = fieldFocusTarget(field);
198
+ if (target) return target;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ // `moveFocus` false: the field is marked but the focus stays where it is (the
204
+ // message box, whose keyboard stays up in a phone's typing mode).
205
+ function focusField(field, { moveFocus = true } = {}) {
206
+ const panel = el(PANEL_ID);
207
+ if (!panel) return;
208
+ for (const node of panel.querySelectorAll('.composer-settings-field-focus')) {
209
+ node.classList.remove('composer-settings-field-focus');
210
+ }
211
+ let target = field ? fieldFocusTarget(field) : null;
212
+ if (field) {
213
+ const wrapper = el(FIELD_CONTROL_IDS[field] || '')?.closest?.('.composer-settings-field');
214
+ wrapper?.classList?.add('composer-settings-field-focus');
215
+ wrapper?.scrollIntoView?.({ block: 'nearest' });
216
+ }
217
+ if (!moveFocus) return;
218
+ // A phone gets no select focused by itself: the sheet is for the thumb,
219
+ // and a focused select there reads as an open picker.
220
+ if (!target && !field && !isPhone()) target = firstFocusableField();
221
+ try { (target || panel).focus({ preventScroll: true }); } catch {}
222
+ }
223
+
224
+ function positionPanel() {
225
+ const panel = el(PANEL_ID);
226
+ const backdrop = el('composer-settings-backdrop');
227
+ if (!panel || panel.hidden) return;
228
+ const phone = isPhone();
229
+ if (backdrop) backdrop.hidden = !phone;
230
+ if (phone) {
231
+ // The bottom sheet comes from the media query alone.
232
+ panel.style.removeProperty('max-height');
233
+ panel.style.removeProperty('max-width');
234
+ return;
235
+ }
236
+ // The popover sits above the label row (CSS) and must not run off the
237
+ // top of the transcript. In a phone's typing mode (body.phone-typing,
238
+ // phone-landscape-ui.js) the row is folded away: the popover sits above
239
+ // the floating ⚙ and may cover the header, the keyboard leaves so little.
240
+ const typing = document.body?.classList?.contains('phone-typing') === true;
241
+ const floatGear = typing ? el('composer-float-settings') : null;
242
+ const anchor = floatGear && !floatGear.hidden ? floatGear : el('composer-label-row');
243
+ const messages = el('messages');
244
+ const rowTop = anchor?.getBoundingClientRect?.().top ?? 0;
245
+ const ceiling = typing ? 0 : Math.max(0, messages?.getBoundingClientRect?.().top ?? 0);
246
+ // The gap, the edge and the floor are design sizes at 100 %; the zoom
247
+ // scales them like the stylesheet does (the CSS gap is 6px * --ui-scale).
248
+ const scale = getUiScale();
249
+ const room = Math.round(rowTop - ceiling - (POPOVER_GAP_PX + POPOVER_EDGE_PX) * scale);
250
+ // With the keyboard up the usual floor would push the popover off the
251
+ // top: it takes the room there is and scrolls.
252
+ const floor = typing ? POPOVER_TYPING_MIN_HEIGHT_PX : POPOVER_MIN_HEIGHT_PX;
253
+ panel.style.maxHeight = `${Math.max(Math.round(floor * scale), room)}px`;
254
+ // The CSS width counts from the screen's left edge, but the popover starts
255
+ // at the label row (or, in typing mode, ends at the floating ⚙): under
256
+ // zoom it would run off the screen with its ✕. The width it may take is
257
+ // the room from where it is pinned to the far edge, less the edge gap.
258
+ panel.style.removeProperty('max-width');
259
+ const viewportWidth = Number(document.documentElement?.clientWidth) || Number(window.innerWidth) || 0;
260
+ const box = panel.getBoundingClientRect?.();
261
+ if (!(viewportWidth > 0) || !box || !(box.width > 0)) return;
262
+ const edge = POPOVER_EDGE_PX * scale;
263
+ const widthRoom = typing ? box.right - edge : viewportWidth - box.left - edge;
264
+ if (box.width > widthRoom) {
265
+ panel.style.maxWidth = `${Math.max(Math.round(POPOVER_MIN_WIDTH_PX * scale), Math.floor(widthRoom))}px`;
266
+ }
267
+ }
268
+
269
+ /** Fits an open panel to where it now sits (typing mode came or went). */
270
+ export function repositionComposerSettings() {
271
+ if (panelOpen) positionPanel();
272
+ }
273
+
274
+ function bindReposition() {
275
+ if (repositionBound) return;
276
+ repositionBound = true;
277
+ window.addEventListener('resize', positionPanel);
278
+ // A zoom moves the label row and rescales the popover's gap and floor.
279
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, positionPanel);
280
+ window.visualViewport?.addEventListener?.('resize', positionPanel);
281
+ }
282
+
283
+ function unbindReposition() {
284
+ if (!repositionBound) return;
285
+ repositionBound = false;
286
+ window.removeEventListener('resize', positionPanel);
287
+ window.removeEventListener(FONT_SCALE_CHANGE_EVENT, positionPanel);
288
+ window.visualViewport?.removeEventListener?.('resize', positionPanel);
289
+ }
290
+
291
+ function setExpanded(expanded) {
292
+ for (const id of ['composer-settings-btn', 'composer-settings-label', 'composer-float-settings']) {
293
+ el(id)?.setAttribute('aria-expanded', expanded ? 'true' : 'false');
294
+ }
295
+ }
296
+
297
+ export function isComposerSettingsOpen() {
298
+ return panelOpen;
299
+ }
300
+
301
+ /**
302
+ * Opens the panel; `field` ('mode' | 'model' | 'effort' | 'tier' | 'billing')
303
+ * gets the focus. `keepComposerFocus`: the message box keeps the focus (the
304
+ * floating ⚙ of a phone's typing mode), except where the bottom sheet takes
305
+ * the whole screen and the keyboard has to go anyway.
306
+ */
307
+ export function openComposerSettings({ field = '', keepComposerFocus = false } = {}) {
308
+ if (isShared()) return;
309
+ const panel = el(PANEL_ID);
310
+ if (!panel) return;
311
+ if (!panelOpen) {
312
+ panelOpen = true;
313
+ syncComposerSettings();
314
+ panel.hidden = false;
315
+ setExpanded(true);
316
+ document.body.classList.add('composer-settings-open');
317
+ // The keyboard would resize the viewport under the sheet.
318
+ if (isPhone()) el('msg-input')?.blur?.();
319
+ positionPanel();
320
+ bindReposition();
321
+ }
322
+ focusField(String(field || ''), { moveFocus: !keepComposerFocus || isPhone() });
323
+ }
324
+
325
+ export function closeComposerSettings({ focusButton = false } = {}) {
326
+ if (!panelOpen) return;
327
+ panelOpen = false;
328
+ const panel = el(PANEL_ID);
329
+ const backdrop = el('composer-settings-backdrop');
330
+ const hadFocus = !!panel && panel.contains(document.activeElement);
331
+ if (panel) panel.hidden = true;
332
+ if (backdrop) backdrop.hidden = true;
333
+ setExpanded(false);
334
+ document.body.classList.remove('composer-settings-open');
335
+ unbindReposition();
336
+ if (focusButton || hadFocus) {
337
+ try { closeFocusTarget()?.focus({ preventScroll: true }); } catch {}
338
+ }
339
+ }
340
+
341
+ // Where the focus goes when the panel closes: its ⚙. In a phone's typing
342
+ // mode the label row with that ⚙ is folded away: the message box then, so
343
+ // the keyboard (and with it typing mode and the floating ⚙) stays.
344
+ function closeFocusTarget() {
345
+ if (document.body?.classList?.contains('phone-typing') === true) {
346
+ const input = el('msg-input');
347
+ if (input && !input.disabled) return input;
348
+ const floatGear = el('composer-float-settings');
349
+ if (floatGear && !floatGear.hidden) return floatGear;
350
+ }
351
+ return el('composer-settings-btn');
352
+ }
353
+
354
+ export function toggleComposerSettings() {
355
+ if (panelOpen) closeComposerSettings({ focusButton: true });
356
+ else openComposerSettings();
357
+ }
358
+
359
+ function isPanelTrigger(node) {
360
+ return node instanceof Element
361
+ && (node.id === 'composer-settings-btn'
362
+ || node.id === 'composer-settings-label'
363
+ || node.id === 'composer-billing-pill'
364
+ || node.id === 'composer-float-settings');
365
+ }
366
+
367
+ function bindEvents() {
368
+ if (bound) return;
369
+ bound = true;
370
+ el('composer-settings-btn')?.addEventListener('click', (event) => {
371
+ event.preventDefault();
372
+ toggleComposerSettings();
373
+ });
374
+ el('composer-settings-label')?.addEventListener('click', (event) => {
375
+ event.preventDefault();
376
+ const part = event.target instanceof Element ? event.target.closest('[data-field]') : null;
377
+ openComposerSettings({ field: part?.dataset?.field || '' });
378
+ });
379
+ el('composer-billing-pill')?.addEventListener('click', (event) => {
380
+ event.preventDefault();
381
+ openComposerSettings({ field: 'billing' });
382
+ });
383
+ el('composer-settings-close')?.addEventListener('click', (event) => {
384
+ event.preventDefault();
385
+ closeComposerSettings({ focusButton: true });
386
+ });
387
+ el('composer-settings-backdrop')?.addEventListener('click', () => closeComposerSettings());
388
+ el('composer-settings-context-btn')?.addEventListener('click', (event) => {
389
+ event.preventDefault();
390
+ closeComposerSettings();
391
+ try { void deps.showContext?.(); } catch {}
392
+ });
393
+ const panel = el(PANEL_ID);
394
+ panel?.addEventListener('change', () => scheduleComposerSettingsSync());
395
+ // Outside the panel and its triggers: close. Capture phase, so a control
396
+ // that stops the event still counts.
397
+ document.addEventListener('pointerdown', (event) => {
398
+ if (!panelOpen) return;
399
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target];
400
+ const current = el(PANEL_ID);
401
+ if (current && path.includes(current)) return;
402
+ if (path.some(isPanelTrigger)) return;
403
+ closeComposerSettings();
404
+ }, true);
405
+ document.addEventListener('keydown', (event) => {
406
+ if (!panelOpen || event.key !== 'Escape') return;
407
+ event.stopPropagation();
408
+ closeComposerSettings({ focusButton: true });
409
+ }, true);
410
+ // Option rebuilds, the lock note, the billing toggle and the selects'
411
+ // titles all change inside the panel; the chat kind changes on <body>.
412
+ if (typeof MutationObserver === 'function') {
413
+ if (panel) {
414
+ new MutationObserver(() => scheduleComposerSettingsSync()).observe(panel, {
415
+ subtree: true,
416
+ childList: true,
417
+ characterData: true,
418
+ attributes: true,
419
+ attributeFilter: ['hidden', 'title', 'disabled', 'data-billing', 'data-runtime-model-locked', 'data-plan-only'],
420
+ });
421
+ }
422
+ new MutationObserver(() => scheduleComposerSettingsSync()).observe(document.body, {
423
+ attributes: true,
424
+ attributeFilter: ['class'],
425
+ });
426
+ }
427
+ }
428
+
429
+ export function initComposerSettings(options = {}) {
430
+ if (typeof document === 'undefined') return;
431
+ deps = options && typeof options === 'object' ? options : {};
432
+ bindEvents();
433
+ syncComposerSettings();
434
+ }
435
+
436
+ export function __resetComposerSettingsForTests() {
437
+ deps = {};
438
+ panelOpen = false;
439
+ syncQueued = false;
440
+ bound = false;
441
+ repositionBound = false;
442
+ }
@@ -1,8 +1,8 @@
1
1
  // A context menu for the conversation list: right-click on a desktop, a long
2
- // press on a phone. One menu at a time, closed by a pointer-down outside it,
2
+ // press on a phone (and the row's ⓘ details menu). One menu at a time, closed by a pointer-down outside it,
3
3
  // Escape, a scroll or a resize, like a native menu; flipped at the screen
4
4
  // edge rather than clipped. The entries are buttons, so keyboard users get
5
- // focus on the first one.
5
+ // focus on the first one; a menu of text lines only takes the focus itself.
6
6
 
7
7
  const LONG_PRESS_MS = 500;
8
8
  const LONG_PRESS_SLOP_PX = 10;
@@ -24,9 +24,12 @@ export function closeContextMenu() {
24
24
 
25
25
  /**
26
26
  * Open a menu at viewport coordinates. `items`: `{ label, onSelect, danger?,
27
- * disabled?, id? }`, or MENU_SEPARATOR between groups. Returns the menu node.
27
+ * disabled?, id? }`, `{ label, note: true, id? }` for a line of text without
28
+ * an action, or MENU_SEPARATOR between groups. `onEscape` runs after Escape
29
+ * closed the menu. `label` names the menu for a screen reader. A menu of
30
+ * text lines only is focused itself. Returns the menu node.
28
31
  */
29
- export function openContextMenu(x, y, items, { documentRef = document, windowRef = window } = {}) {
32
+ export function openContextMenu(x, y, items, { documentRef = document, windowRef = window, onEscape = null, label = '' } = {}) {
30
33
  closeOpenMenu?.();
31
34
  const menu = documentRef.createElement('div');
32
35
  menu.className = 'context-menu';
@@ -48,6 +51,8 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
48
51
  if (event.key === 'Escape') {
49
52
  event.stopPropagation();
50
53
  close();
54
+ // The opener can take the focus back (a button that opened the menu).
55
+ onEscape?.();
51
56
  return;
52
57
  }
53
58
  if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
@@ -70,6 +75,17 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
70
75
  continue;
71
76
  }
72
77
  if (!item) continue;
78
+ if (item.note === true) {
79
+ // Read in the menu, skipped by the arrow keys (they move between buttons).
80
+ const line = documentRef.createElement('div');
81
+ line.className = 'context-menu-note';
82
+ line.setAttribute('role', 'menuitem');
83
+ line.setAttribute('aria-disabled', 'true');
84
+ if (item.id) line.dataset.menuItem = String(item.id);
85
+ line.textContent = String(item.label ?? '');
86
+ menu.append(line);
87
+ continue;
88
+ }
73
89
  const entry = documentRef.createElement('button');
74
90
  entry.type = 'button';
75
91
  entry.className = `context-menu-item${item.danger ? ' context-menu-item-danger' : ''}`;
@@ -99,7 +115,22 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
99
115
  documentRef.addEventListener('keydown', onKey, true);
100
116
  windowRef.addEventListener('resize', close);
101
117
  windowRef.addEventListener('scroll', close, true);
102
- queueMicrotask(() => menu.querySelector('button:not([disabled])')?.focus());
118
+ const firstButton = menu.querySelector('button:not([disabled])');
119
+ if (label) menu.setAttribute('aria-label', String(label));
120
+ if (!firstButton) {
121
+ // Only lines of text: the menu itself takes the focus, so a screen
122
+ // reader reads them out and a second Enter does not reopen it. Its name
123
+ // is those lines unless the opener gave one.
124
+ menu.tabIndex = -1;
125
+ if (!label) {
126
+ const lines = [...menu.querySelectorAll('.context-menu-note')].map((line) => line.textContent.trim()).filter(Boolean);
127
+ menu.setAttribute('aria-label', lines.join('. ') || 'Details');
128
+ }
129
+ }
130
+ queueMicrotask(() => {
131
+ if (closeOpenMenu !== close) return;
132
+ (firstButton || menu).focus?.({ preventScroll: true });
133
+ });
103
134
  return menu;
104
135
  }
105
136
 
@@ -367,6 +367,22 @@ export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateS
367
367
  `;
368
368
  }
369
369
 
370
+ /**
371
+ * The owner's plan-only switch, for every session: agents cannot take it out
372
+ * of plan mode while it is on (the owner still changes the mode freely).
373
+ */
374
+ export function renderPlanOnlyControlHtml({ conversationId = '', planOnly = false } = {}) {
375
+ return `
376
+ <div class="ctx-agent-sessions ctx-plan-only">
377
+ <label class="ctx-agent-sessions-row">
378
+ <input type="checkbox" id="ctx-plan-only" data-conversation-id="${escapeHtml(conversationId)}"${planOnly === true ? ' checked' : ''}>
379
+ <span>Plan only: agents can't take this session out of plan mode</span>
380
+ </label>
381
+ <div class="ctx-thinking-note">While this is on, an agent's message or settings change that names another mode is refused; you can still change the mode yourself. "Approve plan" runs one turn with write access and the session plans again after it. Agents may switch it off.</div>
382
+ </div>
383
+ `;
384
+ }
385
+
370
386
  // A model limit is a round figure: 1M rather than 1.0M.
371
387
  function formatModelLimit(value) {
372
388
  const n = toNullableNumber(value);
@@ -1,16 +1,17 @@
1
1
  // Links that open one conversation when the app loads:
2
2
  // ?push_conv=<id> a notification tap (sw.js opens a fresh window with it)
3
- // ?conv=<id> a link from another relay's provenance badge
3
+ // ?conv=<id> the address of the session on screen (session-history.mjs):
4
+ // a reload, a copied link, another relay's provenance badge
4
5
  // (remoteConversationUrl in shared/remote-relay-contract.mjs)
5
- // Both are consumed once and stripped from the address bar, so a reload does
6
- // not jump back to that conversation.
6
+ // `push_conv` is consumed once and becomes `conv`; `conv` stays in the
7
+ // address bar, so a reload opens the same conversation.
7
8
 
8
9
  export const CONVERSATION_DEEP_LINK_PARAMS = Object.freeze(['push_conv', 'conv']);
9
10
 
10
11
  /**
11
12
  * Reads the conversation a URL asks for. `cleanedPath` is the URL to put back
12
- * in the address bar (path + remaining query + hash), or null when the URL
13
- * carried none of the parameters and needs no rewrite.
13
+ * in the address bar (path + query with `conv=<id>` + hash), or null when the
14
+ * URL needs no rewrite.
14
15
  */
15
16
  export function readConversationDeepLink(href) {
16
17
  let url;
@@ -20,23 +21,26 @@ export function readConversationDeepLink(href) {
20
21
  return { conversationId: '', cleanedPath: null };
21
22
  }
22
23
  let conversationId = '';
23
- let found = false;
24
24
  for (const param of CONVERSATION_DEEP_LINK_PARAMS) {
25
25
  if (!url.searchParams.has(param)) continue;
26
- found = true;
27
26
  const value = String(url.searchParams.get(param) || '').trim();
28
27
  if (!conversationId && value) conversationId = value;
29
- url.searchParams.delete(param);
30
28
  }
29
+ const before = `${url.pathname}${url.search}${url.hash}`;
30
+ url.searchParams.delete('push_conv');
31
+ if (conversationId) url.searchParams.set('conv', conversationId);
32
+ else url.searchParams.delete('conv');
33
+ const after = `${url.pathname}${url.search}${url.hash}`;
31
34
  return {
32
35
  conversationId,
33
- cleanedPath: found ? `${url.pathname}${url.search}${url.hash}` : null,
36
+ cleanedPath: after === before ? null : after,
34
37
  };
35
38
  }
36
39
 
37
- /** Reads and strips the deep link from the current location. */
40
+ /** Reads the deep link from the current location and settles the address on `?conv=`. */
38
41
  export function consumeConversationDeepLink({ location = window.location, history = window.history, title = document.title } = {}) {
39
42
  const { conversationId, cleanedPath } = readConversationDeepLink(location?.href);
40
- if (cleanedPath !== null) history?.replaceState?.(null, title, cleanedPath);
43
+ // The entry keeps its state: the back guard and the session history mark it.
44
+ if (cleanedPath !== null) history?.replaceState?.(history?.state ?? null, title, cleanedPath);
41
45
  return conversationId;
42
46
  }
@@ -43,8 +43,9 @@ export function dragItemKey({ conversationId = null, groupId = null } = {}) {
43
43
 
44
44
  /**
45
45
  * Plain items from arrangeConversationList(): `{ key, kind, conversationId,
46
- * groupId }`, and on heads `open` and `memberIds` (the group's rows after
47
- * the head, in list order).
46
+ * groupId }`, on heads `open` and `memberIds` (the group's rows after the
47
+ * head, in list order), and `kept` on the open conversation's row under a
48
+ * folded head.
48
49
  */
49
50
  export function describeDragItems(arranged) {
50
51
  const items = [];
@@ -54,6 +55,8 @@ export function describeDragItems(arranged) {
54
55
  const groupId = item.kind === 'row' ? null : (toText(item.group?.id) || null);
55
56
  if (!conversationId && !groupId) continue;
56
57
  const described = { key: dragItemKey({ conversationId, groupId }), kind: item.kind, conversationId, groupId };
58
+ // The open conversation, shown under its folded group's head.
59
+ if (item.kind === 'member' && item.kept === true) described.kept = true;
57
60
  if (item.kind === 'head') {
58
61
  described.open = item.group?.open !== false;
59
62
  described.memberIds = (Array.isArray(item.group?.members) ? item.group.members : [])
@@ -104,6 +107,13 @@ export function resolveDropTarget({ items = [], draggedId = '', hover = null } =
104
107
  const y = Math.min(1, Math.max(0, Number(hover.y) || 0));
105
108
  const half = y < 0.5 ? 'before' : 'after';
106
109
 
110
+ // A kept row stands alone under a folded head: a drop on it is a drop on
111
+ // the head, not a place among members the user cannot see.
112
+ if (target.kind === 'member' && target.kept === true) {
113
+ const head = headOf(target.groupId);
114
+ if (head) return resolveDropTarget({ items: list, draggedId: id, hover: { key: head.key, y: 0.5 } });
115
+ }
116
+
107
117
  if (target.kind === 'member') {
108
118
  const groupId = target.groupId;
109
119
  if (draggedIsHead && draggedGroupId === groupId) return NO_DROP;
@@ -211,6 +211,9 @@ export function pageConversationRows(rows, groups = null, { limit = 40, beforeCo
211
211
  * match a filter (a group shows when any row matches: its head as context
212
212
  * with the matching members, or whole when the head or its name matches).
213
213
  *
214
+ * `keepVisibleId` is the open conversation: a member of a folded group with
215
+ * that id still shows under its head, as `{ kind: 'member', kept: true }`.
216
+ *
214
217
  * With `emptyGroups`, an owner's group an agent emptied (`keptEmptyAt`, no
215
218
  * row here) shows too: a head item without members (`group.empty`), placed
216
219
  * by the time it was emptied; under a filter only when its name matches.
@@ -220,8 +223,9 @@ export function arrangeConversationList(rows, groups = null, {
220
223
  matches = null,
221
224
  groupNameMatches = null,
222
225
  emptyGroups = false,
226
+ keepVisibleId = null,
223
227
  } = {}) {
224
- const items = arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches });
228
+ const items = arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches, keepVisibleId: toText(keepVisibleId) });
225
229
  if (!emptyGroups || !groups) return items;
226
230
  const present = new Set((Array.isArray(rows) ? rows : []).map(rowGroupId).filter(Boolean));
227
231
  const entries = typeof groups.entries === 'function' && typeof groups.get === 'function' ? [...groups.entries()] : Object.entries(groups);
@@ -250,7 +254,7 @@ export function arrangeConversationList(rows, groups = null, {
250
254
  return out;
251
255
  }
252
256
 
253
- function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches }) {
257
+ function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches, keepVisibleId = '' }) {
254
258
  const ordered = orderConversationRows(rows, groups);
255
259
  const activity = groupActivityTimes(ordered);
256
260
  const items = [];
@@ -294,6 +298,9 @@ function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches })
294
298
  items.push({ kind: 'head', conversation: headRow, group });
295
299
  if (group.open) {
296
300
  for (const member of shownMembers) items.push({ kind: 'member', conversation: member, group });
301
+ } else if (keepVisibleId) {
302
+ const kept = shownMembers.find((member) => rowId(member) === keepVisibleId);
303
+ if (kept) items.push({ kind: 'member', conversation: kept, group, kept: true });
297
304
  }
298
305
  }
299
306
  return items;