@oar-sh/oar 0.9.20 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. 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
 
@@ -350,6 +350,74 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
350
350
  `;
351
351
  }
352
352
 
353
+ const THOUGHTS_EXPANDED_CHOICES = Object.freeze([
354
+ { key: 'on', label: 'Expanded' },
355
+ { key: 'off', label: 'Collapsed' },
356
+ ]);
357
+
358
+ /**
359
+ * The mirror of Settings › General "Expand thoughts while an agent works", for
360
+ * every provider. A setting of this browser, so nothing goes to the relay:
361
+ * the buttons write thoughts-preference.mjs and both places follow it.
362
+ *
363
+ * @returns {string} HTML
364
+ */
365
+ export function renderThoughtsPreferenceHtml({ expanded = true } = {}) {
366
+ const activeKey = expanded === false ? 'off' : 'on';
367
+ const buttons = THOUGHTS_EXPANDED_CHOICES.map(({ key, label }) => `
368
+ <button
369
+ type="button"
370
+ class="ctx-thoughts-btn${key === activeKey ? ' is-active' : ''}"
371
+ data-thoughts-expanded="${key}"
372
+ aria-pressed="${key === activeKey ? 'true' : 'false'}"
373
+ >${escapeHtml(label)}</button>`).join('');
374
+ return `
375
+ <div class="ctx-thinking ctx-thoughts">
376
+ <div class="ctx-thinking-row">
377
+ <span class="ctx-thinking-label">💭 Thoughts</span>
378
+ <div class="ctx-thinking-group" role="group" aria-label="Thoughts while an agent works">
379
+ ${buttons}
380
+ </div>
381
+ </div>
382
+ <div class="ctx-thinking-note">Whether thoughts open while an agent works, in sub-agent bubbles and in the sub-agent viewer. A thought you fold or open by hand stays that way. Saved in this browser.</div>
383
+ </div>
384
+ `;
385
+ }
386
+
387
+ const STREAM_VIEW_CHOICES = Object.freeze([
388
+ { key: 'on', label: 'On' },
389
+ { key: 'off', label: 'Off' },
390
+ ]);
391
+
392
+ /**
393
+ * The mirror of Settings › General "Stream view", for every provider. A
394
+ * setting of this browser (stream-view-preference.mjs); nothing goes to the
395
+ * relay.
396
+ *
397
+ * @returns {string} HTML
398
+ */
399
+ export function renderStreamViewPreferenceHtml({ on = false } = {}) {
400
+ const activeKey = on === true ? 'on' : 'off';
401
+ const buttons = STREAM_VIEW_CHOICES.map(({ key, label }) => `
402
+ <button
403
+ type="button"
404
+ class="ctx-thoughts-btn ctx-stream-view-btn${key === activeKey ? ' is-active' : ''}"
405
+ data-stream-view="${key}"
406
+ aria-pressed="${key === activeKey ? 'true' : 'false'}"
407
+ >${escapeHtml(label)}</button>`).join('');
408
+ return `
409
+ <div class="ctx-thinking ctx-stream-view">
410
+ <div class="ctx-thinking-row">
411
+ <span class="ctx-thinking-label">🧵 Stream view</span>
412
+ <div class="ctx-thinking-group" role="group" aria-label="Stream view">
413
+ ${buttons}
414
+ </div>
415
+ </div>
416
+ <div class="ctx-thinking-note">Thoughts, tool calls and the reply of a turn as one list in the order they happened. Turns recorded before this version keep the grouped layout. Saved in this browser.</div>
417
+ </div>
418
+ `;
419
+ }
420
+
353
421
  /**
354
422
  * The owner's switch for a session an agent started: may its agents start
355
423
  * sessions themselves (the relay_tool's one-level rule lifted for it)?
@@ -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;