@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
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
6
6
  import { subagentRowHtml } from './subagent-row.mjs';
7
+ import { isThoughtsExpanded, thoughtFoldOpen } from './thoughts-preference.mjs';
7
8
 
8
9
  function escHtml(value) {
9
10
  return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
@@ -100,20 +101,26 @@ function firstLine(text, max = 140) {
100
101
  /**
101
102
  * One entry's markup. `renderMarkdown(text)` renders reply text (and
102
103
  * thoughts); `children` maps a child run id to its header so a child entry
103
- * renders as a clickable row; `openDetails` keeps a fold the reader opened
104
- * open across re-renders.
104
+ * renders as a clickable row; `openDetails` keeps the reader's folds across
105
+ * re-renders: a Map or a function from seq to 'open' | 'folded' | undefined
106
+ * (`true` still means "open"). A thought without a choice, streaming or
107
+ * done, opens as `thoughtsExpanded` (the "Expand thoughts" setting) says.
105
108
  */
106
- export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, clickable = true, now = Date.now() } = {}) {
109
+ export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, thoughtsExpanded = isThoughtsExpanded(), clickable = true, now = Date.now() } = {}) {
107
110
  const kind = String(entry?.kind || '');
108
111
  const seq = Number(entry?.seq) || 0;
109
112
  const open = entry?.done === false;
110
113
  const text = String(entry?.text || '');
111
114
  const wrap = (inner, extraClass = '') => `<div class="sav-entry sav-entry-${escHtml(kind)}${open ? ' is-open' : ''}${extraClass}" data-seq="${seq}">${inner}</div>`;
112
- const openAttr = openDetails ? ' open' : '';
115
+ const choice = typeof openDetails === 'function'
116
+ ? openDetails(seq)
117
+ : (openDetails instanceof Map ? openDetails.get(seq) : (openDetails === true ? 'open' : undefined));
118
+ const openAttr = choice === 'open' ? ' open' : '';
113
119
 
114
120
  if (kind === 'thought') {
115
121
  const label = open && !text.trim() ? 'Thinking…' : (firstLine(text, 110) || 'Thought');
116
- return wrap(`<details class="sav-fold"${open || openDetails ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
122
+ const thoughtOpen = thoughtFoldOpen({ choice: choice || null, expanded: thoughtsExpanded });
123
+ return wrap(`<details class="sav-fold"${thoughtOpen ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
117
124
  }
118
125
 
119
126
  if (kind === 'text') {
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
18
18
  import { renderMarkdownPreview } from './router.js';
19
- import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
19
+ import { closeSummaryModal, currentConvId, getSubagentRun, IS_SHARED_VIEW, openSummaryModal, upsertSubagentRun } from './store.js';
20
20
  import { createViewerHistory } from './viewer-history.mjs';
21
21
  import { getSocket } from './socket-handlers.js';
22
22
  import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
@@ -50,7 +50,9 @@ const viewer = {
50
50
  stopTimer: null,
51
51
  tickTimer: null,
52
52
  sharedPollTimer: null,
53
- openFolds: new Set(),
53
+ // The reader's folds by seq ('open' | 'folded'); a thought without one
54
+ // follows the "Expand thoughts" setting.
55
+ openFolds: new Map(),
54
56
  };
55
57
 
56
58
  let viewerHistory = null;
@@ -151,7 +153,7 @@ function buildEntryNode(entry) {
151
153
  holder.innerHTML = timelineEntryHtml(entry, {
152
154
  renderMarkdown,
153
155
  children: viewer.children,
154
- openDetails: viewer.openFolds.has(entry.seq),
156
+ openDetails: viewer.openFolds,
155
157
  clickable: true,
156
158
  });
157
159
  return holder.firstElementChild;
@@ -283,7 +285,7 @@ async function loadRun(runId) {
283
285
  if (!id) return;
284
286
  viewer.state = createTimelineState(id);
285
287
  viewer.children = new Map();
286
- viewer.openFolds = new Set();
288
+ viewer.openFolds = new Map();
287
289
  viewer.error = '';
288
290
  viewer.stopping = false;
289
291
  clearTimeout(viewer.stopTimer);
@@ -360,6 +362,31 @@ async function openParent() {
360
362
  await loadRun(top.runId);
361
363
  }
362
364
 
365
+ /** Stop asks first: the agent's work so far stays, but it ends here. */
366
+ function askBeforeStopRun() {
367
+ const run = viewer.run;
368
+ if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
369
+ openSummaryModal({
370
+ title: 'Stop agent',
371
+ subtitle: run.displayName || '',
372
+ kind: 'subagent-stop',
373
+ bodyHtml: `
374
+ <p>Stop <strong>${escHtml(run.displayName || 'this agent')}</strong>?</p>
375
+ <p>It ends where it is now; what it has done so far stays.</p>
376
+ <div class="summary-modal-actions">
377
+ <button class="chat-title-action-btn danger-btn" type="button" data-subagent-stop-confirm="1">⏹ Stop agent</button>
378
+ <button class="chat-title-action-btn" type="button" data-subagent-stop-cancel="1">Cancel</button>
379
+ </div>
380
+ `,
381
+ });
382
+ const dialog = document.getElementById('summary-modal');
383
+ dialog?.querySelector('[data-subagent-stop-confirm]')?.addEventListener('click', () => {
384
+ closeSummaryModal();
385
+ void stopRun();
386
+ }, { once: true });
387
+ dialog?.querySelector('[data-subagent-stop-cancel]')?.addEventListener('click', () => closeSummaryModal(), { once: true });
388
+ }
389
+
363
390
  async function stopRun() {
364
391
  const run = viewer.run;
365
392
  if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
@@ -518,7 +545,7 @@ export function initSubagentViewer() {
518
545
  if (event.target === modal) closeSubagentViewer();
519
546
  });
520
547
  modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
521
- stop?.addEventListener('click', () => void stopRun());
548
+ stop?.addEventListener('click', () => askBeforeStopRun());
522
549
  parent?.addEventListener('click', () => void openParent());
523
550
  jump?.addEventListener('click', () => {
524
551
  if (body) body.scrollTop = body.scrollHeight;
@@ -527,7 +554,8 @@ export function initSubagentViewer() {
527
554
  body?.addEventListener('scroll', () => {
528
555
  if (jump && !jump.hidden && isAtBottom(body)) jump.hidden = true;
529
556
  }, { passive: true });
530
- // A fold the reader opened stays open while the entry keeps changing.
557
+ // A fold the reader opened or closed stays that way while the entry keeps
558
+ // changing.
531
559
  timeline?.addEventListener('click', (event) => {
532
560
  const summary = event.target?.closest?.('summary');
533
561
  const entry = summary?.closest?.('.sav-entry');
@@ -535,8 +563,7 @@ export function initSubagentViewer() {
535
563
  const seq = Number(entry.dataset.seq);
536
564
  setTimeout(() => {
537
565
  const details = summary.parentElement;
538
- if (details?.open) viewer.openFolds.add(seq);
539
- else viewer.openFolds.delete(seq);
566
+ viewer.openFolds.set(seq, details?.open ? 'open' : 'folded');
540
567
  }, 0);
541
568
  });
542
569
  document.addEventListener('keydown', (event) => {
@@ -0,0 +1,101 @@
1
+ // The fade on a tab strip that scrolls sideways (Settings, its provider tabs,
2
+ // the usage dialog's providers): the strip has no scrollbar, so the side with
3
+ // more tabs fades out. Marked from the strip's own scroll as `data-fade`
4
+ // ("right" at the start, "both" in between, "left" at the end, none when the
5
+ // strip fits or is not laid out); the CSS draws the mask from it.
6
+
7
+ import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
8
+
9
+ export const TAB_STRIP_SELECTOR = '.settings-tab-strip, .plan-usage-tab-strip';
10
+
11
+ // Sub-pixel scroll ends (a zoomed page) count as the end.
12
+ const EDGE_SLACK_PX = 1;
13
+
14
+ let watching = false;
15
+
16
+ /** The `data-fade` value for a strip's scroll metrics ('' for none). */
17
+ export function tabStripFadeState({ scrollLeft = 0, scrollWidth = 0, clientWidth = 0 } = {}) {
18
+ const room = Number(scrollWidth) - Number(clientWidth);
19
+ if (!(room > EDGE_SLACK_PX)) return '';
20
+ const offset = Math.abs(Number(scrollLeft) || 0);
21
+ const before = offset > EDGE_SLACK_PX;
22
+ const after = offset < room - EDGE_SLACK_PX;
23
+ if (before && after) return 'both';
24
+ if (before) return 'left';
25
+ return after ? 'right' : '';
26
+ }
27
+
28
+ export function syncTabStripFade(strip) {
29
+ if (!strip?.setAttribute) return '';
30
+ const state = tabStripFadeState(strip);
31
+ if (state) {
32
+ if (strip.getAttribute('data-fade') !== state) strip.setAttribute('data-fade', state);
33
+ } else if (strip.hasAttribute('data-fade')) {
34
+ strip.removeAttribute('data-fade');
35
+ }
36
+ return state;
37
+ }
38
+
39
+ function fadeWidth(strip) {
40
+ try {
41
+ // The CSS gives the strip the fade's width as its scroll padding.
42
+ const value = parseFloat(getComputedStyle(strip).scrollPaddingLeft);
43
+ return Number.isFinite(value) ? value : 0;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Scrolls the strip (and nothing around it) so `tab` shows whole and clear of
51
+ * the fade; at either end the strip stops there, and that edge has none.
52
+ */
53
+ export function revealTab(strip, tab) {
54
+ if (!strip || !tab || !strip.contains(tab)) return;
55
+ const room = strip.scrollWidth - strip.clientWidth;
56
+ if (room > EDGE_SLACK_PX) {
57
+ const stripBox = strip.getBoundingClientRect();
58
+ const tabBox = tab.getBoundingClientRect();
59
+ const margin = fadeWidth(strip);
60
+ const left = tabBox.left - stripBox.left - strip.clientLeft;
61
+ const right = left + tabBox.width;
62
+ let next = strip.scrollLeft;
63
+ if (left - margin < 0) next += left - margin;
64
+ else if (right + margin > strip.clientWidth) next += right + margin - strip.clientWidth;
65
+ next = Math.max(0, Math.min(room, next));
66
+ if (Math.abs(next - strip.scrollLeft) >= 0.5) strip.scrollLeft = next;
67
+ }
68
+ syncTabStripFade(strip);
69
+ }
70
+
71
+ /** Each strip under `root`: its selected tab in view, its fade marked. */
72
+ export function revealSelectedTabs(root = document) {
73
+ if (!root?.querySelectorAll) return;
74
+ const strips = root.matches?.(TAB_STRIP_SELECTOR) ? [root] : Array.from(root.querySelectorAll(TAB_STRIP_SELECTOR));
75
+ for (const strip of strips) {
76
+ const selected = strip.querySelector('[aria-selected="true"]');
77
+ if (selected) revealTab(strip, selected);
78
+ else syncTabStripFade(strip);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Once per page: a strip's scroll re-marks it (scroll does not bubble, so the
84
+ * listener captures), and so does a resize or a turn of the phone.
85
+ */
86
+ export function watchTabStripFades() {
87
+ if (watching || typeof document === 'undefined') return;
88
+ watching = true;
89
+ document.addEventListener('scroll', (event) => {
90
+ const strip = event.target;
91
+ if (strip?.matches?.(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
92
+ }, { capture: true, passive: true });
93
+ if (typeof window !== 'undefined') {
94
+ const syncAll = () => {
95
+ for (const strip of document.querySelectorAll(TAB_STRIP_SELECTOR)) syncTabStripFade(strip);
96
+ };
97
+ window.addEventListener('resize', syncAll, { passive: true });
98
+ // The zoom (font-scaling.js) resizes the tabs, not the window.
99
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, () => requestAnimationFrame(syncAll));
100
+ }
101
+ }
@@ -35,6 +35,71 @@ export function bindTapAction(element, handler) {
35
35
  });
36
36
  }
37
37
 
38
+ // The ︙ menu's shield (see lockChatActionsMenuShield in bootstrap.js).
39
+ export const CHAT_ACTIONS_MENU_SHIELD_ID = 'chat-actions-menu-backdrop';
40
+ // Longer than the shield's lock and the gap between a touch's pointerup and
41
+ // its compatibility click.
42
+ export const OPENING_CLICK_GRACE_MS = 400;
43
+
44
+ /**
45
+ * True for the click that belongs to the gesture which opened a popover, so
46
+ * a document-level "click outside closes it" handler must leave it alone.
47
+ * A ︙ entry runs on pointerup and closes the menu; the touch's compatibility
48
+ * click then lands on the menu's shield and bubbles to `document`, where it
49
+ * would close the popover that entry has just opened. Later clicks on the
50
+ * shield (a tap beside the open ︙ menu) still count as outside.
51
+ *
52
+ * `openedAt` is the Date.now() of the opening, 0 when unknown.
53
+ */
54
+ export function isOpeningGestureClick(event, openedAt, now = Date.now()) {
55
+ const opened = Number(openedAt) || 0;
56
+ if (opened <= 0) return false;
57
+ const sinceOpen = now - opened;
58
+ if (sinceOpen < 0 || sinceOpen >= OPENING_CLICK_GRACE_MS) return false;
59
+ return event?.target?.id === CHAT_ACTIONS_MENU_SHIELD_ID;
60
+ }
61
+
62
+ /**
63
+ * The one click a touch still owes after a ︙ entry ran on its pointerup.
64
+ * The browser hit-tests that compatibility click when it fires, a few ms
65
+ * later, so it lands on whatever the entry opened: a dialog's backdrop
66
+ * (which closes on a click on itself) or the shield (whose click reaches
67
+ * `document`, where a list closes on a click outside it). `arm()` on the
68
+ * pointerup; the next pointer click within the window is that click.
69
+ */
70
+ export function createGhostClickGuard({ now = () => Date.now() } = {}) {
71
+ let until = 0;
72
+ return {
73
+ arm(ms = OPENING_CLICK_GRACE_MS) {
74
+ until = now() + Math.max(0, Number(ms) || 0);
75
+ },
76
+ // Disarms on the first click it sees, swallowed or not.
77
+ take(event) {
78
+ if (!until) return false;
79
+ const live = now() < until;
80
+ until = 0;
81
+ // detail 0: Enter or Space on a button, or a script, never a touch.
82
+ return live && Number(event?.detail) !== 0;
83
+ },
84
+ };
85
+ }
86
+
87
+ const ghostClickGuard = createGhostClickGuard();
88
+ let ghostClickListenerBound = false;
89
+
90
+ function swallowGhostClickAfterTouch(event) {
91
+ // A mouse's click goes to the entry it pressed, which drops it itself.
92
+ if (!event || event.pointerType === 'mouse') return;
93
+ ghostClickGuard.arm();
94
+ if (ghostClickListenerBound || typeof window === 'undefined') return;
95
+ ghostClickListenerBound = true;
96
+ window.addEventListener('click', (clickEvent) => {
97
+ if (!ghostClickGuard.take(clickEvent)) return;
98
+ clickEvent.preventDefault();
99
+ clickEvent.stopImmediatePropagation();
100
+ }, true);
101
+ }
102
+
38
103
  export function bindMenuAction(element, handler) {
39
104
  if (!element || element.dataset.menuTapBound === '1') return;
40
105
  element.dataset.menuTapBound = '1';
@@ -48,6 +113,7 @@ export function bindMenuAction(element, handler) {
48
113
  event.stopPropagation();
49
114
  event.stopImmediatePropagation();
50
115
  markSuppressed();
116
+ swallowGhostClickAfterTouch(event);
51
117
  handler(event);
52
118
  }, true);
53
119
  element.addEventListener('click', (event) => {
@@ -0,0 +1,79 @@
1
+ // "Expand thoughts while an agent works" (Settings › General, and its mirror
2
+ // in the 🧠 modal). A setting of this browser, kept in localStorage like the
3
+ // scroll-on-send one. On unless switched off: it decides whether a thought
4
+ // starts open in the live turn, in a live sub-agent bubble and in the
5
+ // sub-agent viewer. A fold the reader opened or closed by hand wins over it.
6
+ // A finished message's 💭 fold stays closed either way. The document, the
7
+ // storage and the event target are passed in by the tests.
8
+
9
+ export const THOUGHTS_EXPANDED_STORAGE_KEY = 'copilot_thoughts_expanded';
10
+ export const THOUGHTS_EXPANDED_TOGGLE_ID = 'thoughts-expanded-toggle';
11
+ export const THOUGHTS_EXPANDED_CHANGE_EVENT = 'oar:thoughts-expanded-change';
12
+ // The 🧠 modal's two buttons: `data-thoughts-expanded` is "on" or "off".
13
+ export const THOUGHTS_EXPANDED_BUTTON_SELECTOR = '.ctx-thoughts-btn[data-thoughts-expanded]';
14
+
15
+ /** Anything but an explicit off is on: a missing or damaged value included. */
16
+ export function parseThoughtsExpandedSetting(stored) {
17
+ const value = String(stored ?? '').trim().toLowerCase();
18
+ if (!value) return true;
19
+ return value !== '0' && value !== 'false';
20
+ }
21
+
22
+ export function serializeThoughtsExpandedSetting(expanded) {
23
+ return expanded ? '1' : '0';
24
+ }
25
+
26
+ function storage() {
27
+ try { return globalThis.localStorage || null; } catch { return null; }
28
+ }
29
+
30
+ export function isThoughtsExpanded(store = storage()) {
31
+ try {
32
+ return parseThoughtsExpandedSetting(store?.getItem(THOUGHTS_EXPANDED_STORAGE_KEY));
33
+ } catch {
34
+ return true;
35
+ }
36
+ }
37
+
38
+ /** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
39
+ export function syncThoughtsExpandedToggle(doc = globalThis.document, store = storage()) {
40
+ const expanded = isThoughtsExpanded(store);
41
+ const checkbox = doc?.getElementById?.(THOUGHTS_EXPANDED_TOGGLE_ID);
42
+ if (checkbox) checkbox.checked = expanded;
43
+ const buttons = doc?.querySelectorAll?.(THOUGHTS_EXPANDED_BUTTON_SELECTOR) || [];
44
+ for (const btn of buttons) {
45
+ const active = (btn.dataset?.thoughtsExpanded === 'on') === expanded;
46
+ btn.classList?.toggle('is-active', active);
47
+ btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
48
+ }
49
+ }
50
+
51
+ export function updateThoughtsExpandedSetting(expanded, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
52
+ const value = !!expanded;
53
+ try { store?.setItem(THOUGHTS_EXPANDED_STORAGE_KEY, serializeThoughtsExpandedSetting(value)); } catch {}
54
+ syncThoughtsExpandedToggle(doc, store);
55
+ try {
56
+ target?.dispatchEvent?.(new CustomEvent(THOUGHTS_EXPANDED_CHANGE_EVENT, { detail: { expanded: value } }));
57
+ } catch {}
58
+ }
59
+
60
+ /**
61
+ * Whether a thought fold shows open. `choice` is what the reader did by hand
62
+ * ('open' | 'folded'), and it wins; everything else, a thought still
63
+ * streaming included, follows the setting (`expanded`).
64
+ */
65
+ export function thoughtFoldOpen({ choice = null, expanded = true } = {}) {
66
+ if (choice === 'open') return true;
67
+ if (choice === 'folded') return false;
68
+ return !!expanded;
69
+ }
70
+
71
+ export const MAX_THOUGHT_FOLD_CHOICES = 500;
72
+
73
+ /** Record a hand fold; the oldest choice goes once the map is full. */
74
+ export function rememberThoughtFoldChoice(choices, key, open, max = MAX_THOUGHT_FOLD_CHOICES) {
75
+ if (!choices || key === undefined || key === null || key === '') return;
76
+ choices.delete(key);
77
+ while (choices.size >= max) choices.delete(choices.keys().next().value);
78
+ choices.set(key, open ? 'open' : 'folded');
79
+ }
@@ -0,0 +1,124 @@
1
+ // The stream view of one agent turn, without a DOM: its thoughts, tool calls
2
+ // and reply as one list in the order they happened. The order is the turn's
3
+ // shared number on every thought and tool line (turnSeq, migration 0038).
4
+ // A turn with a row that has none (recorded before, or by an older relay)
5
+ // returns null and keeps the grouped layout. Main thread only: a sub-agent's
6
+ // rows stay in its own row and viewer.
7
+
8
+ import {
9
+ isOmittedActivityMarker,
10
+ normalizeRelayActivityEntry,
11
+ normalizeTurnSeq,
12
+ } from './activity-replay-state.mjs';
13
+
14
+ function escHtml(value) {
15
+ return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
16
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
17
+ }[ch]));
18
+ }
19
+
20
+ /** A '● ' line is a progress note: it stays where the grouped layout puts it. */
21
+ export function isProgressActivityText(text) {
22
+ return String(text || '').startsWith('● ');
23
+ }
24
+
25
+ function firstLine(text, max = 110) {
26
+ const line = String(text || '').split('\n').map((part) => part.trim()).find(Boolean) || '';
27
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
28
+ }
29
+
30
+ /**
31
+ * The turn's main-thread entries in order, the reply last:
32
+ * `[{ kind: 'thought'|'tool'|'omitted'|'reply', turnSeq, … }]`, or null when
33
+ * the turn cannot be ordered (a main-thread thought or tool line without a
34
+ * turnSeq) or has no thought or tool line at all. `activities` are the
35
+ * bubble's visible ones (the promoted compaction boundary already taken out);
36
+ * progress lines ('● ') are left out here. The "… earlier tool lines
37
+ * omitted" row the relay's cap adds goes where the first line it stands for
38
+ * was (`metadata.fromTurnSeq`), else right before the first tool line kept
39
+ * after it.
40
+ */
41
+ export function turnStreamEntries({ thoughts = [], activities = [], text = '' } = {}) {
42
+ const entries = [];
43
+ let missing = false;
44
+ for (const thought of Array.isArray(thoughts) ? thoughts : []) {
45
+ if (!thought || thought.subagentRunId || !String(thought.text || '').trim()) continue;
46
+ const turnSeq = normalizeTurnSeq(thought.turnSeq);
47
+ if (turnSeq === null) { missing = true; break; }
48
+ entries.push({
49
+ kind: 'thought',
50
+ turnSeq,
51
+ order: turnSeq,
52
+ reasoningId: String(thought.reasoningId || ''),
53
+ text: String(thought.text),
54
+ });
55
+ }
56
+ if (missing) return null;
57
+
58
+ let pendingMarkers = [];
59
+ for (const item of Array.isArray(activities) ? activities : []) {
60
+ const entry = normalizeRelayActivityEntry(item);
61
+ if (!entry || entry.subagentRunId) continue;
62
+ if (isOmittedActivityMarker(entry)) {
63
+ const marker = { kind: 'omitted', turnSeq: null, text: entry.text, count: Number(entry.metadata.count) || 0 };
64
+ // Where the first line it stands for was; else before the next line kept.
65
+ const from = normalizeTurnSeq(entry.metadata.fromTurnSeq);
66
+ if (from !== null) entries.push({ ...marker, order: from });
67
+ else pendingMarkers.push(marker);
68
+ continue;
69
+ }
70
+ if (isProgressActivityText(entry.text)) continue;
71
+ if (entry.turnSeq === undefined) return null;
72
+ for (const [index, marker] of pendingMarkers.entries()) {
73
+ entries.push({ ...marker, order: entry.turnSeq - 0.5 + index * 0.001 });
74
+ }
75
+ pendingMarkers = [];
76
+ entries.push({
77
+ kind: 'tool',
78
+ turnSeq: entry.turnSeq,
79
+ order: entry.turnSeq,
80
+ text: entry.text,
81
+ ...(entry.metadata ? { metadata: entry.metadata } : {}),
82
+ });
83
+ }
84
+ if (!entries.length) return null;
85
+ const lastOrder = Math.max(...entries.map((entry) => entry.order));
86
+ for (const [index, marker] of pendingMarkers.entries()) {
87
+ entries.push({ ...marker, order: lastOrder + 0.5 + index * 0.001 });
88
+ }
89
+ entries.sort((a, b) => a.order - b.order);
90
+ const ordered = entries.map(({ order, ...entry }) => entry);
91
+ if (String(text || '').trim()) ordered.push({ kind: 'reply', turnSeq: null, text: String(text) });
92
+ return ordered;
93
+ }
94
+
95
+ /**
96
+ * The list's markup. `renderMarkdown(text)` renders a thought and the reply;
97
+ * `decorate(text)` is the tool line's display text (its emoji);
98
+ * `thoughtsOpen` opens the thought folds (closed on a finished turn).
99
+ */
100
+ export function renderTurnStreamMarkup(entries, {
101
+ thoughtsOpen = false,
102
+ renderMarkdown = (text) => `<p>${escHtml(text)}</p>`,
103
+ decorate = (text) => text,
104
+ } = {}) {
105
+ const rows = (Array.isArray(entries) ? entries : []).map((entry) => {
106
+ const seqAttr = entry.turnSeq != null ? ` data-turn-seq="${escHtml(entry.turnSeq)}"` : '';
107
+ if (entry.kind === 'thought') {
108
+ return `<details class="msg-stream-thought"${seqAttr}${entry.reasoningId ? ` data-reasoning-id="${escHtml(entry.reasoningId)}"` : ''}${thoughtsOpen ? ' open' : ''}>`
109
+ + `<summary>💭 ${escHtml(firstLine(entry.text) || 'Thought')}</summary>`
110
+ + `<div class="msg-stream-thought-body">${renderMarkdown(entry.text)}</div></details>`;
111
+ }
112
+ if (entry.kind === 'tool') {
113
+ return `<div class="msg-activity-item msg-stream-tool"${seqAttr}>${escHtml(decorate(entry.text))}</div>`;
114
+ }
115
+ if (entry.kind === 'omitted') {
116
+ return `<div class="msg-stream-omitted">${escHtml(entry.text)}</div>`;
117
+ }
118
+ if (entry.kind === 'reply') {
119
+ return `<div class="msg-stream-reply">${renderMarkdown(entry.text)}</div>`;
120
+ }
121
+ return '';
122
+ });
123
+ return `<div class="msg-turn-stream">${rows.join('')}</div>`;
124
+ }
@@ -29,6 +29,8 @@ let currentConversationId = '';
29
29
  let currentProviderType = '';
30
30
  let actionInFlight = false;
31
31
  let wakeTimer = null;
32
+ // The report whose one-line warning was tapped open (its dismissedKey).
33
+ let expandedKey = '';
32
34
 
33
35
  function notifyBilling(conversationId) {
34
36
  const id = String(conversationId || '').trim();
@@ -88,6 +90,30 @@ export function usageLimitAccountText(state, nowMs = Date.now()) {
88
90
  return `Claude usage at ${Math.round(utilization * 100)} % of the ${label} — resets ${resets}`;
89
91
  }
90
92
 
93
+ /** "04:00" today, "Tue 04:00" on another day: the one-line banner's time. */
94
+ export function formatUsageLimitShortTime(iso, nowMs = Date.now()) {
95
+ const at = new Date(String(iso || ''));
96
+ if (Number.isNaN(at.getTime())) return '';
97
+ const clock = at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
98
+ if (new Date(nowMs).toDateString() === at.toDateString()) return clock;
99
+ return `${at.toLocaleDateString([], { weekday: 'short' })} ${clock}`;
100
+ }
101
+
102
+ /**
103
+ * The account's line in one short line, for the compact banner:
104
+ * "⚠ Claude 94 % weekly · resets Tue 04:00", or at the limit
105
+ * "⚠ Claude weekly limit reached · resets Tue 04:00". '' when
106
+ * usageLimitAccountText says nothing.
107
+ */
108
+ export function usageLimitAccountShortText(state, nowMs = Date.now()) {
109
+ if (!usageLimitAccountText(state, nowMs)) return '';
110
+ const label = String(state.label || 'usage limit');
111
+ const resets = formatUsageLimitShortTime(state.resetsAt, nowMs);
112
+ if (state.status === 'rejected') return `⚠ Claude ${label} reached · resets ${resets}`;
113
+ const limitWindow = label.replace(/\s*limit$/i, '').trim() || label;
114
+ return `⚠ Claude ${Math.round(Number(state.utilization) * 100)} % ${limitWindow} · resets ${resets}`;
115
+ }
116
+
91
117
  /**
92
118
  * Whether the usage-limit note offers to move the chat to the API key: a
93
119
  * subscription chat (never a cloud one) whose relay says the switch is
@@ -201,6 +227,8 @@ export function renderUsageLimitBanner(nowMs = Date.now()) {
201
227
  const banner = document.getElementById(BANNER_ID);
202
228
  if (!banner) return;
203
229
  const children = [];
230
+ let compact = false;
231
+ let expanded = false;
204
232
  const pause = activeUsageLimitPause(currentConversationId, nowMs);
205
233
  if (pause) {
206
234
  const text = document.createElement('span');
@@ -225,14 +253,46 @@ export function renderUsageLimitBanner(nowMs = Date.now()) {
225
253
  ? usageLimitAccountText(account, nowMs)
226
254
  : '';
227
255
  if (line) {
256
+ // One line with the full sentence in the title; a tap on it opens the
257
+ // full sentence until the report changes.
258
+ if (expandedKey && expandedKey !== dismissedKey(account)) expandedKey = '';
259
+ expanded = expandedKey !== '' && expandedKey === dismissedKey(account);
228
260
  const text = document.createElement('span');
229
261
  text.className = 'usage-limit-text';
230
- text.textContent = line;
231
- children.push(text, button('Hide', 'usage-limit-dismiss', async () => dismissWarning()));
262
+ text.textContent = expanded ? line : (usageLimitAccountShortText(account, nowMs) || line);
263
+ text.title = line;
264
+ text.setAttribute('role', 'button');
265
+ text.tabIndex = 0;
266
+ text.setAttribute('aria-expanded', expanded ? 'true' : 'false');
267
+ const toggle = (event) => {
268
+ event.preventDefault();
269
+ expandedKey = expanded ? '' : dismissedKey(account);
270
+ renderUsageLimitBanner();
271
+ };
272
+ text.addEventListener('click', toggle);
273
+ text.addEventListener('keydown', (event) => {
274
+ if (event.key === 'Enter' || event.key === ' ') toggle(event);
275
+ });
276
+ const hide = button('✕', 'usage-limit-dismiss', async () => dismissWarning());
277
+ hide.title = 'Hide';
278
+ hide.setAttribute('aria-label', 'Hide');
279
+ children.push(text, hide);
232
280
  banner.dataset.state = account.status === 'rejected' ? 'reached' : 'warning';
281
+ compact = true;
233
282
  }
234
283
  }
284
+ banner.classList.toggle('usage-limit-compact', compact);
285
+ banner.classList.toggle('usage-limit-expanded', compact && expanded);
286
+ // The banner is drawn anew: the control that had the focus (the line's
287
+ // toggle, a button) gets it back in its new node, found by its class.
288
+ const focused = typeof document.activeElement?.className === 'string' && banner.contains(document.activeElement)
289
+ ? document.activeElement.className
290
+ : '';
235
291
  banner.replaceChildren(...children);
292
+ if (focused) {
293
+ const again = children.find((child) => child.className === focused);
294
+ try { again?.focus({ preventScroll: true }); } catch {}
295
+ }
236
296
  banner.classList.toggle('visible', children.length > 0);
237
297
  banner.hidden = children.length === 0;
238
298
  if (!children.length) delete banner.dataset.state;
@@ -350,6 +410,7 @@ export function __resetUsageLimitUiForTests() {
350
410
  currentConversationId = '';
351
411
  currentProviderType = '';
352
412
  actionInFlight = false;
413
+ expandedKey = '';
353
414
  if (wakeTimer) { clearTimeout(wakeTimer); wakeTimer = null; }
354
415
  try { localStorage.removeItem(DISMISSED_KEY); } catch {}
355
416
  renderUsageLimitBanner();
@@ -4,7 +4,11 @@
4
4
  // and the pop closes the viewer. Closing by any other means (the × button,
5
5
  // Escape, a swipe down) pops that entry itself so history is left as it
6
6
  // was, and the pop that follows is recognised as our own and ignored. The
7
- // address never changes: the entry carries only its state.
7
+ // address never changes: the entry carries only its state, its place in the
8
+ // stack and the visit's marks of the entry below (session-history.mjs, the
9
+ // back guard).
10
+
11
+ import { HISTORY_POSITION, inheritedVisitMarks } from './back-guard.mjs';
8
12
 
9
13
  export const VIEWER_HISTORY_MARK = 'oarFileViewer';
10
14
  const OWN_POP_WINDOW_MS = 1500;
@@ -18,7 +22,11 @@ export function createViewerHistory({ history, onBack, now = () => Date.now(), m
18
22
  /** The viewer came up: one entry, however many files it then shows. */
19
23
  opened() {
20
24
  if (state.pushed) return;
21
- history.pushState({ [mark]: true }, '');
25
+ const below = history.state;
26
+ const entry = { ...inheritedVisitMarks(below), [mark]: true };
27
+ const position = below?.[HISTORY_POSITION];
28
+ if (Number.isFinite(position)) entry[HISTORY_POSITION] = position + 1;
29
+ history.pushState(entry, '');
22
30
  state.pushed = true;
23
31
  },
24
32
  /** The viewer went away by itself: take its entry back out of history. */
@@ -38,6 +46,9 @@ export function createViewerHistory({ history, onBack, now = () => Date.now(), m
38
46
  }
39
47
  state.ownPopAt = 0;
40
48
  if (!state.pushed) return;
49
+ // Our entry is still the current one: what was popped lay above it
50
+ // (a dialog opened over the viewer), the viewer stays.
51
+ if (history.state?.[mark]) return;
41
52
  state.pushed = false;
42
53
  onBack?.();
43
54
  },