@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,225 @@
1
+ // A phone turned sideways: keeps :root[data-phone-landscape] in step
2
+ // (phone-landscape.mjs) and runs its typing mode (phone-typing-state.mjs).
3
+ //
4
+ // While the keyboard is up (body.phone-typing):
5
+ // - the label row folds into a floating ⚙ (#composer-float-settings) that
6
+ // opens the ⚙ panel and leaves the focus in the message box;
7
+ // - the background-task line folds into a floating 🤖
8
+ // (#background-tasks-float), shown only while the panel would show; a tap
9
+ // shows the panel as an overlay above the composer
10
+ // (body.phone-typing-tasks-open), a second tap or a tap elsewhere hides it.
11
+ // Both buttons sit on the composer's top edge on the right (index.html).
12
+ // Everything comes back when the keyboard goes down; an open ⚙ panel holds
13
+ // typing mode until it closes (phone-typing-state.mjs).
14
+
15
+ import {
16
+ closeComposerSettings,
17
+ isComposerSettingsOpen,
18
+ openComposerSettings,
19
+ repositionComposerSettings,
20
+ } from './composer-settings-ui.js';
21
+ import { PHONE_LANDSCAPE_CHANGE_EVENT, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
22
+ import { createPhoneTypingBaseline, resolvePhoneTyping } from './phone-typing-state.mjs';
23
+
24
+ export const PHONE_TYPING_CLASS = 'phone-typing';
25
+ export const PHONE_TYPING_TASKS_OPEN_CLASS = 'phone-typing-tasks-open';
26
+
27
+ let deps = {};
28
+ let bound = false;
29
+ let typing = false;
30
+ let taskOverlayOpen = false;
31
+ let baseline = createPhoneTypingBaseline();
32
+
33
+ function el(id) {
34
+ return typeof document === 'undefined' ? null : document.getElementById(id);
35
+ }
36
+
37
+ function isShared() {
38
+ try {
39
+ if (deps.isSharedReaderMode?.() === true) return true;
40
+ } catch {}
41
+ return document.body?.classList?.contains('shared-reader-mode') === true;
42
+ }
43
+
44
+ // The visible height, and the tallest seen at this width (the layout height
45
+ // with no keyboard under it; a turn of the phone starts from the old one).
46
+ function measureViewport() {
47
+ const innerHeight = Number(window.innerHeight) || 0;
48
+ const visualHeight = Number(window.visualViewport?.height) || innerHeight;
49
+ const layoutHeight = baseline.note(Number(window.innerWidth) || 0, Math.max(innerHeight, visualHeight));
50
+ return { layoutHeight, visualHeight };
51
+ }
52
+
53
+ function syncFloats() {
54
+ const gear = el('composer-float-settings');
55
+ if (gear) {
56
+ gear.hidden = !typing;
57
+ gear.setAttribute('aria-expanded', isComposerSettingsOpen() ? 'true' : 'false');
58
+ }
59
+ const robot = el('background-tasks-float');
60
+ const panel = el('background-tasks-panel');
61
+ const panelShows = !!panel && !panel.hidden;
62
+ if (taskOverlayOpen && !(typing && panelShows)) setTaskOverlayOpen(false);
63
+ if (robot) {
64
+ robot.hidden = !(typing && panelShows);
65
+ robot.setAttribute('aria-expanded', taskOverlayOpen ? 'true' : 'false');
66
+ const summary = String(el('background-tasks-summary')?.textContent || '').trim();
67
+ const label = summary ? `Background tasks: ${summary}` : 'Background tasks';
68
+ if (robot.title !== label) robot.title = label;
69
+ if (robot.getAttribute('aria-label') !== label) robot.setAttribute('aria-label', label);
70
+ }
71
+ }
72
+
73
+ function setTaskOverlayOpen(open) {
74
+ taskOverlayOpen = !!open && typing;
75
+ document.body?.classList?.toggle(PHONE_TYPING_TASKS_OPEN_CLASS, taskOverlayOpen);
76
+ el('background-tasks-float')?.setAttribute('aria-expanded', taskOverlayOpen ? 'true' : 'false');
77
+ }
78
+
79
+ function setTyping(next) {
80
+ if (next === typing) return;
81
+ typing = next;
82
+ document.body?.classList?.toggle(PHONE_TYPING_CLASS, typing);
83
+ if (!typing) setTaskOverlayOpen(false);
84
+ // An open ⚙ panel moves between the label row and the floating ⚙.
85
+ syncFloats();
86
+ repositionComposerSettings();
87
+ }
88
+
89
+ /** Recomputes phone landscape and typing mode; returns whether typing mode is on. */
90
+ export function syncPhoneTyping() {
91
+ if (typeof document === 'undefined' || !document.body) return false;
92
+ const phoneLandscape = syncPhoneLandscapeAttribute(window);
93
+ const input = el('msg-input');
94
+ const inputFocused = !!input && document.activeElement === input;
95
+ const { layoutHeight, visualHeight } = measureViewport();
96
+ // Only the ⚙ panel holds: its selects take the focus (the keyboard goes),
97
+ // while a press in the task overlay keeps it (bindEvents).
98
+ const held = typing && isComposerSettingsOpen();
99
+ setTyping(!isShared() && resolvePhoneTyping({ phoneLandscape, inputFocused, layoutHeight, visualHeight, held }));
100
+ syncFloats();
101
+ return typing;
102
+ }
103
+
104
+ export function isPhoneTypingActive() {
105
+ return typing;
106
+ }
107
+
108
+ export function isPhoneTypingTaskOverlayOpen() {
109
+ return taskOverlayOpen;
110
+ }
111
+
112
+ /** The floating 🤖's tap: shows or hides the task panel over the composer. */
113
+ export function togglePhoneTypingTaskOverlay() {
114
+ setTaskOverlayOpen(!taskOverlayOpen);
115
+ syncPhoneTyping();
116
+ }
117
+
118
+ /** The floating ⚙'s tap: opens the ⚙ panel, the message box keeping the focus. */
119
+ export function togglePhoneTypingSettings() {
120
+ if (isComposerSettingsOpen()) closeComposerSettings();
121
+ else openComposerSettings({ keepComposerFocus: true });
122
+ syncPhoneTyping();
123
+ }
124
+
125
+ function eventPath(event) {
126
+ return typeof event.composedPath === 'function' ? event.composedPath() : [event.target];
127
+ }
128
+
129
+ // A press that would take the focus from the message box (and the keyboard
130
+ // with it) is cancelled; the click still comes.
131
+ function keepComposerFocus(event) {
132
+ if (event.button !== undefined && event.button !== 0) return;
133
+ event.preventDefault();
134
+ }
135
+
136
+ function isTextEntry(node) {
137
+ return !!node && typeof node.closest === 'function'
138
+ && !!node.closest('input, textarea, select, [contenteditable="true"]');
139
+ }
140
+
141
+ function bindEvents() {
142
+ if (bound) return;
143
+ bound = true;
144
+ const sync = () => { syncPhoneTyping(); };
145
+ const gear = el('composer-float-settings');
146
+ if (gear) {
147
+ gear.addEventListener('pointerdown', keepComposerFocus);
148
+ gear.addEventListener('mousedown', keepComposerFocus);
149
+ gear.addEventListener('click', (event) => {
150
+ event.preventDefault();
151
+ togglePhoneTypingSettings();
152
+ });
153
+ }
154
+ const robot = el('background-tasks-float');
155
+ if (robot) {
156
+ robot.addEventListener('pointerdown', keepComposerFocus);
157
+ robot.addEventListener('mousedown', keepComposerFocus);
158
+ robot.addEventListener('click', (event) => {
159
+ event.preventDefault();
160
+ togglePhoneTypingTaskOverlay();
161
+ });
162
+ }
163
+ const panel = el('background-tasks-panel');
164
+ if (panel) {
165
+ // In the overlay the summary and the rows keep the keyboard up, so the
166
+ // panel does not move under the finger; a text field still takes the focus.
167
+ const holdFocus = (event) => {
168
+ if (!taskOverlayOpen || isTextEntry(event.target)) return;
169
+ keepComposerFocus(event);
170
+ };
171
+ panel.addEventListener('pointerdown', holdFocus);
172
+ panel.addEventListener('mousedown', holdFocus);
173
+ }
174
+ // A press anywhere but the overlay and its 🤖 hides the overlay. Capture
175
+ // phase, so a control that stops the event still counts.
176
+ document.addEventListener('pointerdown', (event) => {
177
+ if (!taskOverlayOpen) return;
178
+ const path = eventPath(event);
179
+ if ((panel && path.includes(panel)) || (robot && path.includes(robot))) return;
180
+ setTaskOverlayOpen(false);
181
+ syncPhoneTyping();
182
+ }, true);
183
+ document.addEventListener('keydown', (event) => {
184
+ if (!taskOverlayOpen || event.key !== 'Escape') return;
185
+ setTaskOverlayOpen(false);
186
+ syncPhoneTyping();
187
+ }, true);
188
+
189
+ const input = el('msg-input');
190
+ input?.addEventListener('focus', sync);
191
+ input?.addEventListener('blur', sync);
192
+ window.addEventListener('resize', sync, { passive: true });
193
+ window.addEventListener('orientationchange', sync, { passive: true });
194
+ window.visualViewport?.addEventListener?.('resize', sync, { passive: true });
195
+ // store.js flips the attribute on its own viewport pass too.
196
+ window.addEventListener(PHONE_LANDSCAPE_CHANGE_EVENT, sync);
197
+ try {
198
+ window.matchMedia?.('(pointer: coarse)')?.addEventListener?.('change', sync);
199
+ } catch {}
200
+ if (typeof MutationObserver === 'function') {
201
+ // The ⚙ panel closing ends a hold; the task panel showing or going
202
+ // shows or hides the 🤖; the summary names it.
203
+ const settings = el('composer-settings');
204
+ if (settings) new MutationObserver(sync).observe(settings, { attributes: true, attributeFilter: ['hidden'] });
205
+ if (panel) new MutationObserver(() => syncFloats()).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
206
+ const summary = el('background-tasks-summary');
207
+ if (summary) new MutationObserver(() => syncFloats()).observe(summary, { childList: true, characterData: true, subtree: true });
208
+ }
209
+ }
210
+
211
+ export function initPhoneLandscape(options = {}) {
212
+ if (typeof document === 'undefined') return;
213
+ deps = options && typeof options === 'object' ? options : {};
214
+ bindEvents();
215
+ syncPhoneTyping();
216
+ }
217
+
218
+ export function __resetPhoneLandscapeForTests() {
219
+ deps = {};
220
+ bound = false;
221
+ typing = false;
222
+ taskOverlayOpen = false;
223
+ baseline = createPhoneTypingBaseline();
224
+ document.body?.classList?.remove(PHONE_TYPING_CLASS, PHONE_TYPING_TASKS_OPEN_CLASS);
225
+ }
@@ -0,0 +1,99 @@
1
+ // "Phone landscape": a touch phone turned sideways. Its width is often over
2
+ // 680px, so it gets the desktop layout; :root[data-phone-landscape] lets the
3
+ // stylesheet and the views treat it as the phone it is (one-line header,
4
+ // typing mode, no composer focus on a session pick, the list closing after a
5
+ // pick).
6
+ //
7
+ // The same phone as :root[data-phone-screen] (the screen's short side is
8
+ // 500px or less) with a coarse pointer, held in landscape. The orientation
9
+ // comes from the screen, never from the viewport: the on-screen keyboard
10
+ // shrinks the viewport (interactive-widget=resizes-content), and a portrait
11
+ // phone mid-typing would read as landscape.
12
+
13
+ export const PHONE_SCREEN_MAX_SHORT_SIDE_PX = 500;
14
+ export const PHONE_LANDSCAPE_ATTRIBUTE = 'data-phone-landscape';
15
+ /** Fired on window when the attribute flips. */
16
+ export const PHONE_LANDSCAPE_CHANGE_EVENT = 'oar:phone-landscape-change';
17
+
18
+ /**
19
+ * @param {object} input
20
+ * @param {boolean} [input.coarsePointer] `(pointer: coarse)` matches
21
+ * @param {number} [input.screenWidth] screen.width
22
+ * @param {number} [input.screenHeight] screen.height
23
+ * @param {string} [input.orientationType] screen.orientation.type, when known
24
+ * @param {number|null} [input.orientationAngle] window.orientation (old iOS), when known
25
+ * @returns {boolean}
26
+ */
27
+ export function isPhoneLandscape({
28
+ coarsePointer = false,
29
+ screenWidth = 0,
30
+ screenHeight = 0,
31
+ orientationType = '',
32
+ orientationAngle = null,
33
+ } = {}) {
34
+ if (!coarsePointer) return false;
35
+ const width = Number(screenWidth) || 0;
36
+ const height = Number(screenHeight) || 0;
37
+ const shortSide = Math.min(width, height);
38
+ if (!(shortSide > 0) || shortSide > PHONE_SCREEN_MAX_SHORT_SIDE_PX) return false;
39
+ const type = String(orientationType || '');
40
+ if (type.startsWith('landscape')) return true;
41
+ if (type.startsWith('portrait')) return false;
42
+ // iOS before 16.4 has no screen.orientation and never swaps screen.width
43
+ // and screen.height; its window.orientation is 90 or -90 on the side.
44
+ const angle = Number(orientationAngle);
45
+ if (orientationAngle !== null && orientationAngle !== undefined && Number.isFinite(angle)) {
46
+ return Math.abs(angle) === 90;
47
+ }
48
+ return width > height;
49
+ }
50
+
51
+ /** The inputs of isPhoneLandscape, read from a window. */
52
+ export function readPhoneLandscapeInputs(win = globalThis.window) {
53
+ if (!win) return {};
54
+ let coarsePointer = false;
55
+ try { coarsePointer = !!win.matchMedia?.('(pointer: coarse)')?.matches; } catch {}
56
+ const screenRef = win.screen || {};
57
+ const rawAngle = win.orientation;
58
+ return {
59
+ coarsePointer,
60
+ screenWidth: Number(screenRef.width) || 0,
61
+ screenHeight: Number(screenRef.height) || 0,
62
+ orientationType: String(screenRef.orientation?.type || ''),
63
+ orientationAngle: typeof rawAngle === 'number' ? rawAngle : null,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Sets or clears :root[data-phone-landscape] from the window; announces a
69
+ * flip with PHONE_LANDSCAPE_CHANGE_EVENT. Returns the current value.
70
+ */
71
+ export function syncPhoneLandscapeAttribute(win = globalThis.window) {
72
+ const root = win?.document?.documentElement;
73
+ if (!root) return false;
74
+ const next = isPhoneLandscape(readPhoneLandscapeInputs(win));
75
+ const before = root.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE);
76
+ if (next !== before) {
77
+ root.toggleAttribute(PHONE_LANDSCAPE_ATTRIBUTE, next);
78
+ try {
79
+ if (typeof win.CustomEvent === 'function') {
80
+ win.dispatchEvent(new win.CustomEvent(PHONE_LANDSCAPE_CHANGE_EVENT, { detail: { phoneLandscape: next } }));
81
+ }
82
+ } catch {}
83
+ }
84
+ return next;
85
+ }
86
+
87
+ /** Whether the page is in phone landscape now (the attribute). */
88
+ export function isPhoneLandscapeLayout(doc = globalThis.document) {
89
+ return !!doc?.documentElement?.hasAttribute?.(PHONE_LANDSCAPE_ATTRIBUTE);
90
+ }
91
+
92
+ /**
93
+ * Whether a pick from the session list collapses the list. On a portrait
94
+ * phone the list is an overlay that openConversation closes; a phone turned
95
+ * sideways has the desktop's persistent list, which a pick now collapses too.
96
+ */
97
+ export function shouldCollapseListAfterPick({ phoneLandscape = false, overlayList = false, listOpen = false } = {}) {
98
+ return !!phoneLandscape && !overlayList && !!listOpen;
99
+ }
@@ -0,0 +1,104 @@
1
+ // Typing mode on a phone turned sideways (body.phone-typing): while the
2
+ // on-screen keyboard is up, the composer's label row and the background-task
3
+ // line fold into two small floating buttons, so the few rows the keyboard
4
+ // leaves go to the transcript and the message box. DOM-free; the wiring is in
5
+ // phone-landscape-ui.js.
6
+ //
7
+ // The keyboard is up when the message box has the focus and the visible area
8
+ // is clearly shorter than the layout height. The layout height is the
9
+ // tallest visible height seen at the current width (composer-keyboard.mjs's
10
+ // baseline): on Android the keyboard shrinks the layout viewport with the
11
+ // visual one (interactive-widget=resizes-content), so window.innerHeight
12
+ // alone would never show it. A browser's URL bar coming and going moves the
13
+ // height by about 56px, well under the threshold. A turn of the phone seeds
14
+ // the new width's height from the old one (createPhoneTypingBaseline).
15
+
16
+ /** A visible area this much shorter has a keyboard under it (either test). */
17
+ export const PHONE_TYPING_MIN_SHRINK_PX = 150;
18
+ export const PHONE_TYPING_MIN_SHRINK_RATIO = 0.25;
19
+
20
+ /** Whether the visible height is clearly shorter than the layout height. */
21
+ export function isKeyboardShrink({ layoutHeight = 0, visualHeight = 0 } = {}) {
22
+ const layout = Number(layoutHeight) || 0;
23
+ const visual = Number(visualHeight) || 0;
24
+ if (!(layout > 0) || !(visual > 0)) return false;
25
+ const shrink = layout - visual;
26
+ if (shrink <= 0) return false;
27
+ return shrink > PHONE_TYPING_MIN_SHRINK_PX || shrink >= layout * PHONE_TYPING_MIN_SHRINK_RATIO;
28
+ }
29
+
30
+ /**
31
+ * The layout height a turn of the phone should give, from the tallest height
32
+ * seen before it (`previous`: `{ width, tallest }`), or 0 when the new width
33
+ * is no turn of the old one. The bars (status, URL) cost about the same
34
+ * either way up, so the old width less those bars:
35
+ * `previous.width - (newWidth - previous.tallest)`.
36
+ */
37
+ export function rotatedLayoutHeight(previous, newWidth) {
38
+ const width = Math.round(Number(previous?.width) || 0);
39
+ const tallest = Number(previous?.tallest) || 0;
40
+ const next = Math.round(Number(newWidth) || 0);
41
+ if (!(width > 0) || !(tallest > 0) || !(next > 0) || next === width) return 0;
42
+ const wasPortrait = tallest > width;
43
+ // Upright to sideways makes the width grow, and back makes it shrink.
44
+ if (wasPortrait ? next <= width : next >= width) return 0;
45
+ const estimate = width + tallest - next;
46
+ if (!(estimate > 0)) return 0;
47
+ // The estimate must stand the other way up than before.
48
+ if (wasPortrait ? estimate >= next : estimate <= next) return 0;
49
+ return estimate;
50
+ }
51
+
52
+ /**
53
+ * The tallest visible height per width, like composer-keyboard.mjs's
54
+ * baseline, but a turn of the phone starts the new width from the old one
55
+ * (rotatedLayoutHeight) instead of from nothing: turned with the keyboard
56
+ * up, the first height seen is already the short one, and typing mode would
57
+ * stay off until the keyboard went down once. A height seen later that is
58
+ * taller still wins.
59
+ */
60
+ export function createPhoneTypingBaseline() {
61
+ const tallestByWidth = new Map();
62
+ let last = null;
63
+ return {
64
+ note(width, height) {
65
+ const key = Math.round(Number(width) || 0);
66
+ const value = Math.max(0, Number(height) || 0);
67
+ let tallest = tallestByWidth.get(key) || 0;
68
+ if (!tallest && last && last.width !== key) tallest = rotatedLayoutHeight(last, key);
69
+ tallest = Math.max(tallest, value);
70
+ tallestByWidth.set(key, tallest);
71
+ last = { width: key, tallest };
72
+ return tallest;
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Typing mode on or off.
79
+ *
80
+ * `held`: the ⚙ panel, opened in typing mode, is still open. Typing mode
81
+ * then stays on until it closes, so the panel does not jump from above the
82
+ * floating ⚙ to above the label row when a select in it takes the focus
83
+ * from the message box and the keyboard goes down.
84
+ *
85
+ * @param {object} input
86
+ * @param {boolean} [input.phoneLandscape] :root[data-phone-landscape]
87
+ * @param {boolean} [input.inputFocused] #msg-input has the focus
88
+ * @param {number} [input.layoutHeight] the tallest visible height at this width
89
+ * @param {number} [input.visualHeight] window.visualViewport.height
90
+ * @param {boolean} [input.held] typing mode is on and the ⚙ panel is open
91
+ * @returns {boolean}
92
+ */
93
+ export function resolvePhoneTyping({
94
+ phoneLandscape = false,
95
+ inputFocused = false,
96
+ layoutHeight = 0,
97
+ visualHeight = 0,
98
+ held = false,
99
+ } = {}) {
100
+ if (!phoneLandscape) return false;
101
+ if (held) return true;
102
+ if (!inputFocused) return false;
103
+ return isKeyboardShrink({ layoutHeight, visualHeight });
104
+ }
@@ -16,6 +16,7 @@ import {
16
16
  summaryModalState,
17
17
  } from './store.js';
18
18
  import { updateMessagePin } from './api-client.js';
19
+ import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
19
20
 
20
21
  export const PINNED_MESSAGES_MODAL_KIND = 'pinned-messages';
21
22
 
@@ -93,12 +94,16 @@ function syncPinnedHeaderButton() {
93
94
  const button = document.getElementById('pinned-messages-btn');
94
95
  if (!button) return;
95
96
  const count = getConversationPins(currentConversationId).length;
97
+ // Available or not; whether it fits in the header or goes into ︙ is the
98
+ // priority+ pass's call, which reads the count from here.
96
99
  button.hidden = IS_SHARED_VIEW || !count;
100
+ button.dataset.count = IS_SHARED_VIEW ? '0' : String(count);
97
101
  const countEl = button.querySelector('.header-icon-count');
98
102
  if (countEl) countEl.textContent = count ? String(count) : '';
99
103
  const label = count ? `Pinned messages (${count})` : 'Pinned messages';
100
104
  button.setAttribute('title', label);
101
105
  button.setAttribute('aria-label', label);
106
+ notifyHeaderActionsChanged();
102
107
  }
103
108
 
104
109
  function pinnedModalContent() {
@@ -0,0 +1,49 @@
1
+ // Browser mirror of the pieces of shared/plan-board-actions.mjs the web UI
2
+ // needs. The browser cannot import shared/ (only server/public is served), so
3
+ // these are hand-kept copies; plan-board-shared.test.mjs asserts they match.
4
+ // Below them, the web UI's own plan-only wording.
5
+
6
+ /** The actions that take the session out of plan mode for good. */
7
+ export const PLAN_BOARD_LEAVES_PLAN = Object.freeze(['autopilot', 'autopilot_fleet', 'interactive']);
8
+
9
+ export const PLAN_BOARD_LABELS = Object.freeze({
10
+ approve_only: 'Approve plan',
11
+ autopilot: 'Implement in autopilot',
12
+ autopilot_fleet: 'Implement with autopilot fleet',
13
+ interactive: 'Stop here and prompt myself',
14
+ exit_only: 'Stop here',
15
+ });
16
+
17
+ /** The button's label; an id the table does not know is spelled out. */
18
+ export function planBoardActionLabel(id) {
19
+ const key = String(id || '').trim().toLowerCase();
20
+ if (Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, key)) return PLAN_BOARD_LABELS[key];
21
+ return key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
22
+ }
23
+
24
+ /**
25
+ * On a plan-only session the actions that leave plan mode for good ask
26
+ * first; "Approve plan", "Stop here" and an action the relay does not know
27
+ * never do. The question, or '' when there is none.
28
+ */
29
+ export function planBoardConfirmText({ planOnly = false, actionId = '', label = '' } = {}) {
30
+ const id = String(actionId || '').trim().toLowerCase();
31
+ if (planOnly !== true || !PLAN_BOARD_LEAVES_PLAN.includes(id)) return '';
32
+ const name = String(label || '').trim() || planBoardActionLabel(id);
33
+ return `This session is plan-only. ${name} leaves plan mode. Go on?`;
34
+ }
35
+
36
+ /** The mode select's hint on a plan-only session (title and the plan option's text). */
37
+ export function planOnlyModeHint(planOnly) {
38
+ return planOnly === true
39
+ ? { title: 'Mode · plan-only: agents can\'t take this session out of plan mode', planLabel: 'Plan 🔒' }
40
+ : { title: 'Mode', planLabel: 'Plan' };
41
+ }
42
+ /**
43
+ * The plan-only flag after a conversation_preferences_updated broadcast: the
44
+ * one it carries, else the one already known (an older relay, or the mode
45
+ * follow-up of a plan card, says nothing about it).
46
+ */
47
+ export function planOnlyAfterBroadcast(broadcast, known) {
48
+ return typeof broadcast === 'boolean' ? broadcast : known === true;
49
+ }
@@ -280,7 +280,7 @@ function renderChart(chart) {
280
280
 
281
281
  function renderDetailSection(section) {
282
282
  const rows = section.rows.map((row) => `
283
- <tr>
283
+ <tr${row.title ? ` title="${escapeHtml(row.title)}"` : ''}>
284
284
  <td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
285
285
  <td class="plan-usage-detail-value">${String(row.value).split(' · ').map((part) => `<span class="plan-usage-detail-part">${escapeHtml(part)}</span>`).join(' · ')}</td>
286
286
  </tr>
@@ -130,7 +130,7 @@ function renderDeviceList() {
130
130
  || (localSubscriptionEndpoint && device.endpoint === localSubscriptionEndpoint);
131
131
  const row = document.createElement('div');
132
132
  row.className = 'push-device-row settings-row';
133
- row.style.gap = '8px';
133
+ row.style.gap = 'calc(5px * var(--ui-scale))';
134
134
 
135
135
  const info = document.createElement('div');
136
136
  info.style.flex = '1';
@@ -140,7 +140,7 @@ function renderDeviceList() {
140
140
  if (!device.preferences?.enabled) name.textContent += ' — disabled';
141
141
  const meta = document.createElement('div');
142
142
  meta.className = 'settings-help';
143
- meta.style.marginTop = '2px';
143
+ meta.style.marginTop = 'calc(2px * var(--ui-scale))';
144
144
  meta.textContent = `Last delivery: ${formatDeviceTimestamp(device.lastSuccessAt)}${device.lastError ? ` · last error: ${device.lastError}` : ''}`;
145
145
  info.appendChild(name);
146
146
  info.appendChild(meta);
@@ -1,5 +1,6 @@
1
1
  import { BASE, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
2
2
  import { loadPwaAppNameSetting, updatePwaAppNameSetting } from './api-client.js';
3
+ import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
3
4
 
4
5
  const THEME_COLOR_BASE = '#0d1117';
5
6
  const THEME_COLOR_IMMERSIVE = '#161b22';
@@ -265,31 +266,51 @@ export function updateFullscreenButton() {
265
266
  const btn = document.getElementById('fullscreen-btn');
266
267
  if (!btn) return;
267
268
  syncInstalledAppUiState();
269
+ // The header's priority+ pass (header-priority-actions.mjs) places the
270
+ // button or its ︙ twin; this only says whether fullscreen is offered at
271
+ // all (data-header-available) and keeps both labels current.
272
+ const twin = document.getElementById('chat-menu-fullscreen');
273
+ const offer = (available) => {
274
+ const next = available ? '1' : '0';
275
+ btn.style.display = '';
276
+ if (!available) btn.hidden = true;
277
+ if (btn.dataset.headerAvailable !== next) {
278
+ btn.dataset.headerAvailable = next;
279
+ notifyHeaderActionsChanged();
280
+ }
281
+ };
268
282
  if (isInstalledAppMode() || isDisplayModeFullscreen()) {
269
- btn.style.display = 'none';
283
+ offer(false);
270
284
  return;
271
285
  }
272
286
 
273
287
  const mobile = window.matchMedia('(max-width: 680px)').matches;
274
288
  if (!mobile) {
275
- btn.style.display = 'none';
289
+ offer(false);
276
290
  return;
277
291
  }
278
292
 
279
293
  const full = !!document.fullscreenElement;
280
294
  const supported = canToggleFullscreen();
281
- btn.style.display = 'inline-flex';
295
+ const wasDisabled = btn.disabled;
282
296
  btn.disabled = !supported;
297
+ // The ︙ twin is disabled with the button (chat-actions-menu-state.mjs reads
298
+ // btn.disabled) and says why.
299
+ if (twin) twin.title = supported ? '' : 'Fullscreen not supported on this browser';
300
+ if (wasDisabled !== btn.disabled) notifyHeaderActionsChanged();
283
301
 
284
302
  if (full) {
285
303
  btn.textContent = '⤢';
286
304
  btn.title = 'Exit fullscreen';
305
+ if (twin) twin.textContent = '⤢ Exit fullscreen';
287
306
  } else {
288
307
  btn.textContent = '⛶';
289
308
  btn.title = isInstalledAppMode()
290
309
  ? (supported ? 'Enter fullscreen (recommended for installed app)' : 'Fullscreen not supported on this browser')
291
310
  : (supported ? 'Enter fullscreen' : 'Fullscreen not supported on this browser');
311
+ if (twin) twin.textContent = '⛶ Fullscreen';
292
312
  }
313
+ offer(true);
293
314
  }
294
315
 
295
316
  export function initFullscreenButton() {
@@ -11,6 +11,8 @@
11
11
  // board when the agent wrote its plan as the answer. Then the text is shown
12
12
  // once, as the reply, and the board adds only its buttons.
13
13
 
14
+ import { PLAN_BOARD_LABELS } from './plan-board-shared.mjs';
15
+
14
16
  function plain(text) {
15
17
  return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
16
18
  }
@@ -32,7 +34,8 @@ export function chosenActionLabel(board) {
32
34
  if (!chosen) return '';
33
35
  const actions = Array.isArray(board?.actions) ? board.actions : [];
34
36
  const action = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen);
35
- return String(action?.label || '').trim() || chosen.replace(/[_-]+/g, ' ');
37
+ // A card stored before "Approve plan" existed does not list it.
38
+ return String(action?.label || '').trim() || (Object.hasOwn(PLAN_BOARD_LABELS, chosen) ? PLAN_BOARD_LABELS[chosen] : chosen.replace(/[_-]+/g, ' '));
36
39
  }
37
40
 
38
41
  /** The line that replaces the buttons once the board is settled; '' while it is pending. */