@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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  IS_SHARED_VIEW,
3
+ conversations,
3
4
  currentConvId,
4
5
  escHtml,
5
6
  relayBoards,
@@ -12,9 +13,11 @@ import {
12
13
  boardsOfConversation,
13
14
  } from './relay-board-inline.mjs';
14
15
  import {
16
+ loadRelayBoard as loadRelayBoardApi,
15
17
  loadRelayBoards as loadRelayBoardsApi,
16
18
  submitRelayBoardAction as submitRelayBoardActionApi,
17
19
  } from './api-client.js';
20
+ import { planBoardConfirmText } from './plan-board-shared.mjs';
18
21
  import { renderMarkdownPreview } from './router.js';
19
22
  import { attachCodeCopyButtons } from './code-copy.mjs';
20
23
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
@@ -203,27 +206,52 @@ export function renderRelayBoards() {
203
206
  return added;
204
207
  }
205
208
 
206
- export async function submitRelayBoardAction(boardId, actionId) {
209
+ /**
210
+ * Settle a board, from its card or from the Inbox (`board`: what the caller
211
+ * shows, when the store has no copy; `controls`: the buttons to hold while
212
+ * it goes). On a plan-only session an action that leaves plan mode asks
213
+ * first. A board the other view settled meanwhile is shown as settled, with
214
+ * no alert. Returns the board as it is now, or null when nothing was done.
215
+ */
216
+ export async function actOnRelayBoard(boardId, actionId, { board: shown = null, controls = [] } = {}) {
207
217
  const id = String(boardId || '').trim();
208
218
  const nextActionId = String(actionId || '').trim();
209
- if (!id || !nextActionId) return;
210
- const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
211
- const controls = card ? card.querySelectorAll('button') : [];
219
+ if (!id || !nextActionId) return null;
220
+ const board = relayBoards.get(id) || shown;
221
+ // The flag as the page knows it now; the board's own copy otherwise.
222
+ const conversation = conversations[String(board?.conversationId || shown?.session?.id || '').trim()] || null;
223
+ const planOnly = typeof conversation?.planOnly === 'boolean' ? conversation.planOnly : board?.planOnly === true;
224
+ const action = (Array.isArray(board?.actions) ? board.actions : [])
225
+ .find((item) => String(item?.id || '').trim().toLowerCase() === nextActionId.toLowerCase());
226
+ const question = planBoardConfirmText({ planOnly, actionId: nextActionId, label: action?.label });
227
+ if (question && !window.confirm(question)) return null;
212
228
  controls.forEach((control) => { control.disabled = true; });
213
- const send = beginOwnSend();
214
-
229
+ let status = 0;
215
230
  try {
216
- const r = await submitRelayBoardActionApi(id, nextActionId);
217
- if (!r?.board) throw new Error('Failed to submit board action');
231
+ const r = await submitRelayBoardActionApi(id, nextActionId, { onErrorResponse: (response) => { status = response.status; } });
232
+ let settled = r?.board || null;
233
+ // Settled by the other view (or another page) meanwhile: show what was chosen.
234
+ if (!settled && status === 409) settled = (await loadRelayBoardApi(id))?.board || null;
235
+ if (!settled) throw new Error('Failed to submit board action');
218
236
  // Kept once it is settled: the reply goes on showing the plan and says
219
237
  // what was chosen.
220
- relayBoards.set(id, r.board);
238
+ relayBoards.set(id, settled);
221
239
  window.renderConvList?.();
222
240
  renderRelayBoards();
223
- scrollAfterOwnSend(send);
241
+ return settled;
224
242
  } catch (error) {
225
243
  controls.forEach((control) => { control.disabled = false; });
226
244
  alert(error.message || 'Failed to submit board action');
245
+ return null;
227
246
  }
228
247
  }
229
248
 
249
+ export async function submitRelayBoardAction(boardId, actionId) {
250
+ const id = String(boardId || '').trim();
251
+ if (!id || !String(actionId || '').trim()) return;
252
+ const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
253
+ const controls = card ? [...card.querySelectorAll('button')] : [];
254
+ const send = beginOwnSend();
255
+ if (await actOnRelayBoard(id, actionId, { controls })) scrollAfterOwnSend(send);
256
+ }
257
+
@@ -1,14 +1,19 @@
1
1
  // Relay event subscriptions on the page: the header's "watching N sessions"
2
- // chip with its list and Unsubscribe buttons (on the watcher), and the
3
- // "watched" badge (on a watched session, in the header and the sidebar).
2
+ // chip with its list and Unsubscribe buttons (on the watcher), the "watched"
3
+ // badge in a watched session's header, and the "Watched by" line in its
4
+ // sidebar row's ⓘ menu.
4
5
  // The relay sends the whole active list on every change (socket
5
6
  // `relay_event_subscriptions`); the page loads it once at start.
6
7
 
7
8
  import { apiFetch } from './api-client.js';
9
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
10
+ import { isOpeningGestureClick } from './tap-actions.js';
8
11
  import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
9
12
  import {
10
13
  renderWatchedBadgeHtml,
14
+ renderWatchingChipHtml,
11
15
  renderWatchingPanelHtml,
16
+ watchedInfoText,
12
17
  watchersOf,
13
18
  watchingChipHint,
14
19
  watchingChipText,
@@ -19,12 +24,40 @@ let subscriptions = [];
19
24
  let renderedChipKey = '';
20
25
  let panelOpen = false;
21
26
  let panelConversationId = '';
27
+ // What the open list hangs from when the chip has no box (phone landscape
28
+ // hides the meta line): the ︙ button its menu entry was picked from.
29
+ let panelAnchor = null;
30
+ // When the list last opened: the touch that opened it from ︙ still has its
31
+ // click to come.
32
+ let panelOpenedAt = 0;
22
33
  const changeListeners = new Set();
23
34
 
24
35
  export function getRelayEventSubscriptions() {
25
36
  return subscriptions;
26
37
  }
27
38
 
39
+ /** The watching chip's text for a conversation ("watching 2 sessions"), or '' when it has none. */
40
+ export function relayWatchingChipText(conversationId) {
41
+ const id = String(conversationId || '').trim();
42
+ if (IS_SHARED_VIEW || !id) return '';
43
+ return watchingChipText(watchingSummary(subscriptions, id));
44
+ }
45
+
46
+ /**
47
+ * Opens the watching chip's list, as a tap on the chip does, from elsewhere
48
+ * (the ︙ entry in phone landscape). `anchor` places it while the chip is
49
+ * not drawn.
50
+ */
51
+ export function openRelayWatchPanel({ anchor = null } = {}) {
52
+ ensurePanel();
53
+ setPanelOpen(true, { anchor });
54
+ }
55
+
56
+ /** Closes the watching chip's list (back closes it before anything else). */
57
+ export function closeRelayWatchPanel() {
58
+ if (panelOpen) setPanelOpen(false);
59
+ }
60
+
28
61
  /** Runs after every change of the list (the sidebar re-renders its badges). */
29
62
  export function onRelayEventSubscriptionsChanged(listener) {
30
63
  changeListeners.add(listener);
@@ -45,10 +78,10 @@ export async function loadRelayEventSubscriptions() {
45
78
  if (response && Array.isArray(response.subscriptions)) applyRelayEventSubscriptions(response.subscriptions);
46
79
  }
47
80
 
48
- /** The sidebar's badge for one conversation: listed sessions only. */
49
- export function relayEventWatchedMarkerHtml(conversationId) {
81
+ /** The line of the sidebar row's ⓘ menu for one conversation, or '': listed sessions only. */
82
+ export function relayEventWatchedInfoText(conversationId) {
50
83
  if (IS_SHARED_VIEW) return '';
51
- return renderWatchedBadgeHtml(watchersOf(subscriptions, conversationId, { includeAll: false }), { conversations, subscriptions, escHtml });
84
+ return watchedInfoText(watchersOf(subscriptions, conversationId, { includeAll: false }), { conversations, subscriptions });
52
85
  }
53
86
 
54
87
  async function unsubscribe(subscriptionId, button) {
@@ -66,7 +99,8 @@ async function unsubscribe(subscriptionId, button) {
66
99
 
67
100
  // The list opens as a fixed popover on <body>, placed under the chip: the
68
101
  // title slot clips its overflow (that keeps the title's ellipsis), so a panel
69
- // inside it would be cut off.
102
+ // inside it would be cut off. Opened from ︙ (phone landscape, no chip
103
+ // drawn), it hangs from ︙ instead.
70
104
  function ensurePanel() {
71
105
  let panel = document.getElementById('relay-watch-panel');
72
106
  if (panel) return panel;
@@ -82,50 +116,68 @@ function ensurePanel() {
82
116
  void unsubscribe(unsubscribeButton.getAttribute('data-subscription-id'), unsubscribeButton);
83
117
  });
84
118
  document.addEventListener('click', (event) => {
85
- if (!panelOpen) return;
119
+ if (!panelOpen || isOpeningGestureClick(event, panelOpenedAt)) return;
86
120
  // composedPath: an Unsubscribe click may have re-rendered the panel already.
87
121
  const path = event.composedPath();
88
- if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
122
+ if (path.includes(panel) || (panelAnchor && path.includes(panelAnchor)) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
89
123
  setPanelOpen(false);
90
124
  });
91
125
  document.addEventListener('keydown', (event) => {
92
126
  if (!panelOpen || event.key !== 'Escape') return;
93
127
  event.stopPropagation();
128
+ const anchor = panelAnchor;
94
129
  setPanelOpen(false);
95
- document.querySelector('#chat-title-relay-events .relay-watch-chip')?.focus?.();
130
+ const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
131
+ (chip?.getClientRects?.().length ? chip : anchor || chip)?.focus?.();
96
132
  }, true);
97
133
  window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
134
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => { if (panelOpen) placePanel(); });
98
135
  return panel;
99
136
  }
100
137
 
101
- function setPanelOpen(open) {
138
+ function setPanelOpen(open, { anchor = null } = {}) {
139
+ if (open && !panelOpen) panelOpenedAt = Date.now();
102
140
  panelOpen = Boolean(open);
141
+ panelAnchor = panelOpen ? anchor : null;
103
142
  renderedChipKey = '';
104
143
  syncRelayEventHeader();
105
144
  }
106
145
 
146
+ // A box to place against, or null: an element that is not drawn has none.
147
+ function drawnRect(element) {
148
+ const rect = element?.getBoundingClientRect?.();
149
+ return rect && (rect.width || rect.height) ? rect : null;
150
+ }
151
+
107
152
  function placePanel() {
108
153
  const panel = document.getElementById('relay-watch-panel');
109
154
  const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
110
155
  if (!panel || panel.hidden) return;
111
- const rect = chip?.getBoundingClientRect?.();
112
- if (!rect || (!rect.width && !rect.height)) {
156
+ // Under the chip; with no chip drawn, under the anchor, right edges lined up.
157
+ const chipRect = drawnRect(chip);
158
+ const rect = chipRect || drawnRect(panelAnchor);
159
+ if (!rect) {
113
160
  panel.hidden = true;
114
161
  return;
115
162
  }
116
- const margin = 8;
163
+ // 8px edge, 4px gap and a 120px floor at 100 %, scaled with the zoom.
164
+ const scale = getUiScale();
165
+ const margin = 8 * scale;
117
166
  const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
118
- const top = Math.round(rect.bottom + 4);
167
+ const top = Math.round(rect.bottom + 4 * scale);
119
168
  panel.style.top = `${top}px`;
120
- panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
169
+ panel.style.maxHeight = `${Math.round(Math.max(120 * scale, window.innerHeight - top - margin))}px`;
121
170
  panel.style.left = '0px';
122
171
  const width = panel.offsetWidth;
123
- const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
172
+ const preferred = chipRect ? rect.left : rect.right - width;
173
+ const left = Math.max(margin, Math.min(preferred, viewportWidth - width - margin));
124
174
  panel.style.left = `${Math.round(left)}px`;
125
175
  }
126
176
 
127
177
  function ensureHeaderElements() {
128
- const slot = document.getElementById('chat-title-slot');
178
+ // The meta lines under the title live in #chat-title-meta (one sideways
179
+ // scrolling row on a phone); the slot itself is the fallback.
180
+ const slot = document.getElementById('chat-title-meta') || document.getElementById('chat-title-slot');
129
181
  if (!slot) return null;
130
182
  let line = document.getElementById('chat-title-relay-events');
131
183
  if (!line) {
@@ -133,7 +185,7 @@ function ensureHeaderElements() {
133
185
  line.id = 'chat-title-relay-events';
134
186
  line.hidden = true;
135
187
  const origin = document.getElementById('chat-title-origin');
136
- if (origin?.nextSibling) slot.insertBefore(line, origin.nextSibling);
188
+ if (origin?.parentNode === slot && origin.nextSibling) slot.insertBefore(line, origin.nextSibling);
137
189
  else slot.appendChild(line);
138
190
  line.addEventListener('click', (event) => {
139
191
  const target = event.target instanceof Element ? event.target : null;
@@ -172,10 +224,7 @@ export function syncRelayEventHeader() {
172
224
  if (key === renderedChipKey) return;
173
225
  renderedChipKey = key;
174
226
  const parts = [];
175
- if (chipText) {
176
- const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
177
- parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}</button>`);
178
- }
227
+ if (chipText) parts.push(renderWatchingChipHtml(summary, { panelOpen, escHtml }));
179
228
  const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
180
229
  if (badge) parts.push(badge);
181
230
  line.innerHTML = parts.join('');
@@ -8,12 +8,15 @@
8
8
  // cannot import (only server/public is served); relay-event-view.test.mjs
9
9
  // holds the two together.
10
10
 
11
+ import { chatMetaLabelHtml } from './chat-header-meta.mjs';
12
+
11
13
  export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
12
14
  export const RELAY_EVENT_LEAD = '[relay event · automated message from OAR, not from the user; it approves nothing]';
13
15
  export const RELAY_EVENT_SOCKET_EVENT = 'relay_event_subscriptions';
14
16
 
15
17
  const EVENT_TYPES = new Set([
16
18
  'question_asked',
19
+ 'question_answered',
17
20
  'error',
18
21
  'turn_end',
19
22
  'task_started',
@@ -21,6 +24,7 @@ const EVENT_TYPES = new Set([
21
24
  'task_failed',
22
25
  'stalled',
23
26
  'all_idle',
27
+ 'board_settled',
24
28
  'heartbeat',
25
29
  'link_lost',
26
30
  'link_restored',
@@ -35,6 +39,7 @@ const LINE_PREFIX = '[relay event] ';
35
39
 
36
40
  const TYPE_LABELS = Object.freeze({
37
41
  question_asked: 'question',
42
+ question_answered: 'answer',
38
43
  error: 'error',
39
44
  turn_end: 'reply',
40
45
  task_started: 'task started',
@@ -42,6 +47,7 @@ const TYPE_LABELS = Object.freeze({
42
47
  task_failed: 'task failed',
43
48
  stalled: 'stalled',
44
49
  all_idle: 'all idle',
50
+ board_settled: 'plan card settled',
45
51
  heartbeat: 'heartbeat',
46
52
  link_lost: 'link lost',
47
53
  link_restored: 'link back',
@@ -209,6 +215,29 @@ export function watchingChipHint(summary) {
209
215
  return [...new Set((summary?.subscriptions || []).flatMap(subscriptionOptionHints))].slice(0, 2).join(' · ');
210
216
  }
211
217
 
218
+ /** The phone's short chip text: "7", "all", "all +2", "restart"; '' for no chip. */
219
+ export function watchingChipShortText(summary) {
220
+ if (!summary?.subscriptions?.length) return '';
221
+ if (summary.all) return summary.count ? `all +${summary.count}` : 'all';
222
+ if (!summary.count) return 'restart';
223
+ return String(summary.count);
224
+ }
225
+
226
+ /**
227
+ * The header's "watching" chip: a button that opens the list, with the long
228
+ * label ("👁 watching 7 sessions" and its hint) and the phone's short one
229
+ * ("👁 7"). '' when the conversation watches nothing.
230
+ */
231
+ export function renderWatchingChipHtml(summary, { panelOpen = false, escHtml = defaultEscape } = {}) {
232
+ const chipText = watchingChipText(summary);
233
+ if (!chipText) return '';
234
+ const chipHint = watchingChipHint(summary);
235
+ const where = summary.relays?.length ? ` (also on ${summary.relays.join(', ')})` : '';
236
+ const longHtml = `👁 ${escHtml(chipText)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}`;
237
+ const shortHtml = `👁 ${escHtml(watchingChipShortText(summary))}`;
238
+ return `<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">${chatMetaLabelHtml(longHtml, shortHtml)}</button>`;
239
+ }
240
+
212
241
  function conversationTitle(conversations, id) {
213
242
  const title = String(conversations?.[id]?.title || '').trim();
214
243
  return title || `session ${String(id).slice(0, 8)}`;
@@ -257,6 +286,19 @@ function watcherName(id, conversations, subscriptions) {
257
286
  return `${title} on ${relay}`;
258
287
  }
259
288
 
289
+ /**
290
+ * The line of the sidebar row's ⓘ menu for a watched conversation: "Watched
291
+ * by title" for one watcher, "Watched by 3 sessions: a, b, c" for more (each
292
+ * named, as the header's list does); '' for none. Plain text, for the menu
293
+ * to escape.
294
+ */
295
+ export function watchedInfoText(watcherIds, { conversations = null, subscriptions = [] } = {}) {
296
+ if (!watcherIds?.length) return '';
297
+ const names = watcherIds.map((id) => watcherName(id, conversations, subscriptions));
298
+ if (names.length > 1) return `Watched by ${names.length} sessions: ${names.join(', ')}`;
299
+ return `Watched by ${names[0]}`;
300
+ }
301
+
260
302
  export function renderWatchedBadgeHtml(watcherIds, { conversations = null, subscriptions = [], escHtml = defaultEscape } = {}) {
261
303
  if (!watcherIds?.length) return '';
262
304
  const names = watcherIds.map((id) => watcherName(id, conversations, subscriptions)).join(', ');
@@ -313,7 +313,7 @@ let applyState = () => {};
313
313
  let inFlight = false;
314
314
  // What an open "now" button acts on: the action shown (a queued shutdown
315
315
  // stays a shutdown) and the queued request it was shown for, or null from
316
- // the ⋯ menu with nothing queued. The relay refuses a request that is gone.
316
+ // the ︙ menu with nothing queued. The relay refuses a request that is gone.
317
317
  let nowTarget = { action: 'restart', requestedAt: null };
318
318
 
319
319
  function targetFrom(state) {
@@ -379,7 +379,7 @@ async function loadPictureInto({ kind, title, subtitle, render }) {
379
379
  renderSummaryModalContent({ title, subtitle, kind, bodyHtml: render(result || {}) });
380
380
  }
381
381
 
382
- /** ⋯ → Restart web relay: shows what the restart would wait for first. */
382
+ /** ︙ → Restart web relay: shows what the restart would wait for first. */
383
383
  export function openRestartRelayConfirmation() {
384
384
  nowTarget = { action: 'restart', requestedAt: null };
385
385
  const title = 'Restart web relay';
@@ -1,5 +1,5 @@
1
- // Relay schedules in the browser: the "scheduled" marker on a conversation's
2
- // sidebar row and the header's "N scheduled" chip with its list and Cancel
1
+ // Relay schedules in the browser: the "Scheduled" line in the ⓘ menu of a
2
+ // conversation's sidebar row and the header's "N scheduled" chip with its list and Cancel
3
3
  // buttons.
4
4
  //
5
5
  // DOM-free on purpose, like relay-event-view.mjs: the callers own the
@@ -9,6 +9,8 @@
9
9
  // cannot import (only server/public is served); relay-schedule-view.test.mjs
10
10
  // holds the two together.
11
11
 
12
+ import { chatMetaLabelHtml } from './chat-header-meta.mjs';
13
+
12
14
  export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
13
15
  export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
14
16
 
@@ -76,18 +78,42 @@ export function scheduleChipText(schedules) {
76
78
  return count ? `${count} scheduled` : '';
77
79
  }
78
80
 
81
+ /** The phone's short chip text: the next time ("04:15"), else the count; '' for no chip. */
82
+ export function scheduleChipShortText(schedules, { nextText = '' } = {}) {
83
+ const count = activeList(schedules).length;
84
+ if (!count) return '';
85
+ return String(nextText || '').trim() || String(count);
86
+ }
87
+
88
+ /**
89
+ * The header's chip: a button that opens the list, with the long label
90
+ * ("⏰ 1 scheduled next 04:15 AM") and the phone's short one ("⏰ 04:15 AM").
91
+ * `nextText` is the next fire time as the caller formats it. '' for none.
92
+ */
93
+ export function renderScheduleChipHtml(schedules, { panelOpen = false, nextText = '', escHtml = defaultEscape } = {}) {
94
+ const chipText = scheduleChipText(schedules);
95
+ if (!chipText) return '';
96
+ const longHtml = `⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}`;
97
+ const shortHtml = `⏰ ${escHtml(scheduleChipShortText(schedules, { nextText }))}`;
98
+ return `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with relay_tool schedule)">${chatMetaLabelHtml(longHtml, shortHtml)}</button>`;
99
+ }
100
+
79
101
  function conversationTitle(conversations, id, fallback = '') {
80
102
  const title = String(conversations?.[id]?.title || fallback || '').trim();
81
103
  return title || `session ${String(id).slice(0, 8)}`;
82
104
  }
83
105
 
84
- /** The sidebar's marker for a conversation a schedule fires into, or ''. */
85
- export function renderScheduledBadgeHtml(schedules, conversationId, { escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
106
+ /**
107
+ * The line of the sidebar row's ⓘ menu for a conversation schedules fire
108
+ * into: "Scheduled · next 04:15", "2 schedules · next 04:15"; '' for none.
109
+ * Plain text, for the menu to escape.
110
+ */
111
+ export function scheduledInfoText(schedules, conversationId, { formatTime = defaultFormatTime } = {}) {
86
112
  const into = schedulesInto(schedules, conversationId);
87
113
  if (!into.length) return '';
88
114
  const next = nextScheduledAt(into);
89
- const hint = `${into.length} schedule${into.length === 1 ? '' : 's'}${next ? ` · next ${formatTime(next)}` : ''}`;
90
- return `<span class="conv-scheduled-badge" title="${escHtml(hint)}">scheduled</span>`;
115
+ const what = into.length === 1 ? 'Scheduled' : `${into.length} schedules`;
116
+ return next ? `${what} · next ${formatTime(next)}` : what;
91
117
  }
92
118
 
93
119
  /**
@@ -1,16 +1,19 @@
1
1
  // Relay schedules on the page: the header's "N scheduled" chip with its list
2
2
  // and Cancel buttons (on a conversation a schedule fires into or that set
3
- // one), and the "scheduled" marker on the sidebar row of a conversation a
4
- // schedule fires into. The relay sends the whole active list on every change
3
+ // one), and the "Scheduled" line in the sidebar row's ⓘ menu of a
4
+ // conversation a schedule fires into. The relay sends the whole active list on every change
5
5
  // (socket `relay_schedules`); the page loads it once at start.
6
6
 
7
7
  import { apiFetch } from './api-client.js';
8
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
9
+ import { isOpeningGestureClick } from './tap-actions.js';
8
10
  import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
9
11
  import {
10
12
  nextScheduledAt,
11
- renderScheduledBadgeHtml,
13
+ renderScheduleChipHtml,
12
14
  renderSchedulePanelHtml,
13
15
  scheduleChipText,
16
+ scheduledInfoText,
14
17
  schedulesOf,
15
18
  } from './relay-schedule-view.mjs';
16
19
 
@@ -18,8 +21,36 @@ let schedules = [];
18
21
  let renderedChipKey = '';
19
22
  let panelOpen = false;
20
23
  let panelConversationId = '';
24
+ // What the open list hangs from when the chip has no box (phone landscape
25
+ // hides the meta line): the ︙ button its menu entry was picked from.
26
+ let panelAnchor = null;
27
+ // When the list last opened: the touch that opened it from ︙ still has its
28
+ // click to come.
29
+ let panelOpenedAt = 0;
21
30
  const changeListeners = new Set();
22
31
 
32
+ /** The schedule chip's text for a conversation ("2 scheduled"), or '' when it has none. */
33
+ export function relayScheduleChipText(conversationId) {
34
+ const id = String(conversationId || '').trim();
35
+ if (IS_SHARED_VIEW || !id) return '';
36
+ return scheduleChipText(schedulesOf(schedules, id));
37
+ }
38
+
39
+ /**
40
+ * Opens the schedule chip's list, as a tap on the chip does, from elsewhere
41
+ * (the ︙ entry in phone landscape). `anchor` places it while the chip is
42
+ * not drawn.
43
+ */
44
+ export function openRelaySchedulePanel({ anchor = null } = {}) {
45
+ ensurePanel();
46
+ setPanelOpen(true, { anchor });
47
+ }
48
+
49
+ /** Closes the schedule chip's list (back closes it before anything else). */
50
+ export function closeRelaySchedulePanel() {
51
+ if (panelOpen) setPanelOpen(false);
52
+ }
53
+
23
54
  /** Runs after every change of the list (the sidebar re-renders its markers). */
24
55
  export function onRelaySchedulesChanged(listener) {
25
56
  changeListeners.add(listener);
@@ -40,10 +71,10 @@ export async function loadRelaySchedules() {
40
71
  if (response && Array.isArray(response.schedules)) applyRelaySchedules(response.schedules);
41
72
  }
42
73
 
43
- /** The sidebar's marker for one conversation. */
44
- export function relayScheduleMarkerHtml(conversationId) {
74
+ /** The line of the sidebar row's ⓘ menu for one conversation, or ''. */
75
+ export function relayScheduleInfoText(conversationId) {
45
76
  if (IS_SHARED_VIEW) return '';
46
- return renderScheduledBadgeHtml(schedules, conversationId, { escHtml });
77
+ return scheduledInfoText(schedules, conversationId);
47
78
  }
48
79
 
49
80
  async function cancelSchedule(scheduleId, button) {
@@ -60,7 +91,8 @@ async function cancelSchedule(scheduleId, button) {
60
91
  }
61
92
 
62
93
  // A fixed popover on <body>, placed under the chip, like the subscriptions'
63
- // list: the title slot clips its overflow.
94
+ // list: the title slot clips its overflow. Opened from ︙ (phone landscape,
95
+ // no chip drawn), it hangs from ︙ instead.
64
96
  function ensurePanel() {
65
97
  let panel = document.getElementById('relay-schedule-panel');
66
98
  if (panel) return panel;
@@ -76,50 +108,67 @@ function ensurePanel() {
76
108
  void cancelSchedule(cancelButton.getAttribute('data-schedule-id'), cancelButton);
77
109
  });
78
110
  document.addEventListener('click', (event) => {
79
- if (!panelOpen) return;
111
+ if (!panelOpen || isOpeningGestureClick(event, panelOpenedAt)) return;
80
112
  // composedPath: a Cancel click may have re-rendered the panel already.
81
113
  const path = event.composedPath();
82
- if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
114
+ if (path.includes(panel) || (panelAnchor && path.includes(panelAnchor)) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
83
115
  setPanelOpen(false);
84
116
  });
85
117
  document.addEventListener('keydown', (event) => {
86
118
  if (!panelOpen || event.key !== 'Escape') return;
87
119
  event.stopPropagation();
120
+ const anchor = panelAnchor;
88
121
  setPanelOpen(false);
89
- document.querySelector('#chat-title-relay-schedules .relay-schedule-chip')?.focus?.();
122
+ const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
123
+ (chip?.getClientRects?.().length ? chip : anchor || chip)?.focus?.();
90
124
  }, true);
91
125
  window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
126
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => { if (panelOpen) placePanel(); });
92
127
  return panel;
93
128
  }
94
129
 
95
- function setPanelOpen(open) {
130
+ function setPanelOpen(open, { anchor = null } = {}) {
131
+ if (open && !panelOpen) panelOpenedAt = Date.now();
96
132
  panelOpen = Boolean(open);
133
+ panelAnchor = panelOpen ? anchor : null;
97
134
  renderedChipKey = '';
98
135
  syncRelayScheduleHeader();
99
136
  }
100
137
 
138
+ // A box to place against, or null: an element that is not drawn has none.
139
+ function drawnRect(element) {
140
+ const rect = element?.getBoundingClientRect?.();
141
+ return rect && (rect.width || rect.height) ? rect : null;
142
+ }
143
+
101
144
  function placePanel() {
102
145
  const panel = document.getElementById('relay-schedule-panel');
103
146
  const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
104
147
  if (!panel || panel.hidden) return;
105
- const rect = chip?.getBoundingClientRect?.();
106
- if (!rect || (!rect.width && !rect.height)) {
148
+ // Under the chip; with no chip drawn, under the anchor, right edges lined up.
149
+ const chipRect = drawnRect(chip);
150
+ const rect = chipRect || drawnRect(panelAnchor);
151
+ if (!rect) {
107
152
  panel.hidden = true;
108
153
  return;
109
154
  }
110
- const margin = 8;
155
+ // 8px edge, 4px gap and a 120px floor at 100 %, scaled with the zoom.
156
+ const scale = getUiScale();
157
+ const margin = 8 * scale;
111
158
  const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
112
- const top = Math.round(rect.bottom + 4);
159
+ const top = Math.round(rect.bottom + 4 * scale);
113
160
  panel.style.top = `${top}px`;
114
- panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
161
+ panel.style.maxHeight = `${Math.round(Math.max(120 * scale, window.innerHeight - top - margin))}px`;
115
162
  panel.style.left = '0px';
116
163
  const width = panel.offsetWidth;
117
- const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
164
+ const preferred = chipRect ? rect.left : rect.right - width;
165
+ const left = Math.max(margin, Math.min(preferred, viewportWidth - width - margin));
118
166
  panel.style.left = `${Math.round(left)}px`;
119
167
  }
120
168
 
121
169
  function ensureHeaderElements() {
122
- const slot = document.getElementById('chat-title-slot');
170
+ // Beside the other meta lines in #chat-title-meta; the slot is the fallback.
171
+ const slot = document.getElementById('chat-title-meta') || document.getElementById('chat-title-slot');
123
172
  if (!slot) return null;
124
173
  let line = document.getElementById('chat-title-relay-schedules');
125
174
  if (!line) {
@@ -127,7 +176,7 @@ function ensureHeaderElements() {
127
176
  line.id = 'chat-title-relay-schedules';
128
177
  line.hidden = true;
129
178
  const after = document.getElementById('chat-title-relay-events') || document.getElementById('chat-title-origin');
130
- if (after?.nextSibling) slot.insertBefore(line, after.nextSibling);
179
+ if (after?.parentNode === slot && after.nextSibling) slot.insertBefore(line, after.nextSibling);
131
180
  else slot.appendChild(line);
132
181
  line.addEventListener('click', (event) => {
133
182
  const target = event.target instanceof Element ? event.target : null;
@@ -164,7 +213,7 @@ export function syncRelayScheduleHeader() {
164
213
  }
165
214
  const next = nextScheduledAt(mine);
166
215
  const nextText = next ? new Date(next).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
167
- line.innerHTML = `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with relay_tool schedule)">⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}</button>`;
216
+ line.innerHTML = renderScheduleChipHtml(mine, { panelOpen, nextText, escHtml });
168
217
  line.hidden = false;
169
218
  panel.innerHTML = panelOpen ? renderSchedulePanelHtml(schedules, id, { conversations, escHtml }) : '';
170
219
  panel.hidden = !panelOpen;