@oar-sh/oar 0.9.20 → 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 (95) hide show
  1. package/CHANGELOG.md +212 -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 +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -1,3 +1,5 @@
1
+ import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
2
+
1
3
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
4
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
5
 
@@ -109,6 +111,7 @@ export function ensureSettingsTabsBound() {
109
111
  const modal = document.getElementById('settings-modal');
110
112
  if (!modal) return;
111
113
  settingsTabsBound = true;
114
+ watchTabStripFades();
112
115
  modal.addEventListener('click', handleTabClick);
113
116
  modal.addEventListener('keydown', handleTabKeydown);
114
117
  }
@@ -125,5 +128,14 @@ export function selectSettingsTab(tab, providerTab) {
125
128
  if (applyTabState('data-settings-provider-tab', 'data-settings-provider-panel', nextProviderTab)) {
126
129
  writeLocalStorage(SETTINGS_PROVIDER_TAB_STORAGE_KEY, nextProviderTab);
127
130
  }
131
+ // The selected tabs whole, clear of the strips' fades (a closed dialog
132
+ // is not laid out: openSettingsModal does it again once it shows).
133
+ revealSettingsTabs();
128
134
  return { tab: nextTab, providerTab: nextProviderTab };
129
135
  }
136
+
137
+ /** Each Settings strip: its selected tab scrolled into view, its fade marked. */
138
+ export function revealSettingsTabs() {
139
+ const modal = document.getElementById('settings-modal');
140
+ if (modal) revealSelectedTabs(modal);
141
+ }
@@ -1,6 +1,6 @@
1
1
  // The relay's active public shares, as it last reported them: one entry per
2
2
  // shared conversation (Settings → Shared conversations) and whether the open
3
- // conversation has one (the ⋯ menu's "Stop sharing"). Read from GET /api/shares
3
+ // conversation has one (the ︙ menu's "Stop sharing"). Read from GET /api/shares
4
4
  // when the socket connects and again on every share event; no DOM here.
5
5
 
6
6
  import { listConversationShares } from './api-client.js';
@@ -1,5 +1,5 @@
1
1
  // Settings → Sharing: every conversation with a public link, its access log,
2
- // and the actions on a link (copy, renew, stop). The ⋯ menu's "Stop sharing"
2
+ // and the actions on a link (copy, renew, stop). The ︙ menu's "Stop sharing"
3
3
  // uses the same confirmation. The list lives in share-state.js; the socket's
4
4
  // share events keep it current while the modal is open.
5
5
 
@@ -461,7 +461,7 @@ export function openRenewShareConfirmation(conversationId, title = '') {
461
461
  }));
462
462
  }
463
463
 
464
- /** ⋯ → Share conversation: the link, made if there is none, to the clipboard. */
464
+ /** ︙ → Share conversation: the link, made if there is none, to the clipboard. */
465
465
  export async function shareConversationFromMenu(conversationId) {
466
466
  const result = await createConversationShareLink(conversationId);
467
467
  const shareUrl = String(result?.shareUrl || '').trim();
@@ -105,6 +105,7 @@ import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
105
105
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
106
106
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
107
107
  import { planOnlyAfterBroadcast } from './plan-board-shared.mjs';
108
+ import { noteConversationGone } from './session-history-ui.js';
108
109
 
109
110
  // A live row of the conversation on screen joins its kept window, so a
110
111
  // switch away and back still shows it. Rows of other conversations do not:
@@ -720,7 +721,7 @@ export async function connectSocket(overrideDeps) {
720
721
  setPreviews(previews);
721
722
  });
722
723
  // Public shares: the events name the conversation only; the list (Settings
723
- // → Sharing, the ⋯ menu's Stop sharing) is read again.
724
+ // → Sharing, the ︙ menu's Stop sharing) is read again.
724
725
  for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
725
726
  socket.on(event, () => {
726
727
  void refreshShares().catch(() => {});
@@ -842,7 +843,8 @@ export async function connectSocket(overrideDeps) {
842
843
  conversationPayloadCache.forget(targetConversationId);
843
844
  await refreshConversations();
844
845
  if (currentConvId === sourceConversationId) {
845
- await openConversation(targetConversationId);
846
+ // The successor takes the compacted one's history entry: no step back into it.
847
+ await openConversation(targetConversationId, { history: 'replace' });
846
848
  } else {
847
849
  updateCompactButton();
848
850
  }
@@ -1010,6 +1012,7 @@ export async function connectSocket(overrideDeps) {
1010
1012
  });
1011
1013
  socket.on('conversation_deleted', ({ conversationId }) => {
1012
1014
  delete conversations[conversationId];
1015
+ noteConversationGone(conversationId);
1013
1016
  conversationPayloadCache.forget(conversationId);
1014
1017
  for (const [id, question] of relayQuestions.entries()) {
1015
1018
  if (question?.conversationId === conversationId) relayQuestions.delete(id);
@@ -285,12 +285,28 @@ export function leaveStatusView() {
285
285
  return wasActive;
286
286
  }
287
287
 
288
- export async function toggleStatusView() {
288
+ /**
289
+ * Nothing to go back to (opened with nothing open, as after an unknown
290
+ * link): the header and the transcript give way to the empty screen.
291
+ * Loaded on demand: the transcript's module is already in the page.
292
+ */
293
+ async function showNothingOpen() {
294
+ const { clearOpenConversation } = await import('./journal-view.js');
295
+ clearOpenConversation({ history: false });
296
+ }
297
+
298
+ /**
299
+ * Opens Status, or leaves it for the conversation it was opened from. Opened
300
+ * from the Inbox there is none stored: `returnTo` (the session the history
301
+ * stands on) is opened instead, so the transcript never keeps Status's rows.
302
+ */
303
+ export async function toggleStatusView({ returnTo = '' } = {}) {
289
304
  if (statusViewActive) {
290
305
  leaveStatusView();
291
- const restoreId = previousConversationId;
306
+ const restoreId = previousConversationId || String(returnTo || '').trim();
292
307
  previousConversationId = '';
293
308
  if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
309
+ else await showNothingOpen();
294
310
  return;
295
311
  }
296
312
  previousConversationId = String(currentConvId || '').trim();
@@ -7,6 +7,8 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
7
7
  import { nextSubagentStatus } from './subagent-fold.mjs';
8
8
  import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
9
9
  import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
10
+ import { isPhoneLandscapeLayout, shouldCollapseListAfterPick, syncPhoneLandscapeAttribute } from './phone-landscape.mjs';
11
+ import { FONT_SCALE_CHANGE_EVENT } from './font-scaling.js';
10
12
 
11
13
  function resolveAppBase() {
12
14
  const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
@@ -265,26 +267,37 @@ export function pruneConversationGroups(keepGroupIds) {
265
267
  return dropped;
266
268
  }
267
269
 
268
- export function readConversationGroupFoldChoices() {
269
- return parseGroupFoldChoices(localStorage.getItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY) || '');
270
+ // The live list and the Archived view keep their fold choices apart.
271
+ function conversationGroupFoldsStorageKey(archived) {
272
+ return archived === true ? `${CONVERSATION_GROUP_FOLDS_STORAGE_KEY}_archived` : CONVERSATION_GROUP_FOLDS_STORAGE_KEY;
273
+ }
274
+
275
+ export function readConversationGroupFoldChoices({ archived = false } = {}) {
276
+ return parseGroupFoldChoices(localStorage.getItem(conversationGroupFoldsStorageKey(archived)) || '');
270
277
  }
271
278
 
272
279
  // The stored fold choices are trimmed to the loaded groups only past this
273
- // size: a choice for a group on an older page or in the other view must
274
- // not go with every toggle.
280
+ // size: a choice for a group on an older page must not go with every toggle.
275
281
  const CONVERSATION_GROUP_FOLDS_MAX = 300;
276
282
 
277
- /** Stores the user's fold choice for a group. */
278
- export function setConversationGroupFoldChoice(groupId, choice) {
279
- const id = String(groupId || '').trim();
280
- if (!id) return;
281
- let next = readConversationGroupFoldChoices();
282
- if (Object.keys(next).length > CONVERSATION_GROUP_FOLDS_MAX) {
283
- next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), id]);
283
+ /** Stores the user's fold choice for a group, in the live list or the Archived view. */
284
+ export function setConversationGroupFoldChoice(groupId, choice, { archived = false } = {}) {
285
+ setConversationGroupFoldChoices([groupId], choice, { archived });
286
+ }
287
+
288
+ /** Stores one fold choice for several groups at once (fold all, unfold all). */
289
+ export function setConversationGroupFoldChoices(groupIds, choice, { archived = false } = {}) {
290
+ const ids = (Array.isArray(groupIds) ? groupIds : []).map((groupId) => String(groupId || '').trim()).filter(Boolean);
291
+ if (!ids.length) return;
292
+ let next = readConversationGroupFoldChoices({ archived });
293
+ if (Object.keys(next).length + ids.length > CONVERSATION_GROUP_FOLDS_MAX) {
294
+ next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), ...ids]);
295
+ }
296
+ for (const id of ids) {
297
+ if (choice === 'open' || choice === 'folded') next[id] = choice;
298
+ else delete next[id];
284
299
  }
285
- if (choice === 'open' || choice === 'folded') next[id] = choice;
286
- else delete next[id];
287
- localStorage.setItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY, JSON.stringify(next));
300
+ localStorage.setItem(conversationGroupFoldsStorageKey(archived), JSON.stringify(next));
288
301
  }
289
302
 
290
303
  export function setConversationWatcherCount(conversationId, count) {
@@ -744,6 +757,14 @@ export function isMobileComposerViewport() {
744
757
  return window.matchMedia('(max-width: 680px)').matches;
745
758
  }
746
759
 
760
+ // A phone either way round: the portrait layout above, or a phone turned
761
+ // sideways, which has the desktop layout (:root[data-phone-landscape]).
762
+ // Opening a session leaves the message box unfocused here, so no keyboard
763
+ // comes up by itself.
764
+ export function isPhoneComposerViewport() {
765
+ return isMobileComposerViewport() || isPhoneLandscapeLayout();
766
+ }
767
+
747
768
  export function releaseComposerFocusAfterSend(input) {
748
769
  if (!input || !isMobileComposerViewport()) return;
749
770
  try { input.blur(); } catch {}
@@ -1131,18 +1152,30 @@ function measureSidebarHeaderMinWidthPx() {
1131
1152
  sectionCount += 1;
1132
1153
  }
1133
1154
 
1155
+ // New Chat grows into the free room, so its box says nothing: its label's
1156
+ // own width is what the head wraps by.
1134
1157
  const newConversationButton = document.getElementById('new-conv-btn');
1135
1158
  if (isVisibleElement(newConversationButton)) {
1136
1159
  const buttonStyle = window.getComputedStyle(newConversationButton);
1137
- neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
1160
+ let labelWidth = 0;
1161
+ try {
1162
+ const range = document.createRange();
1163
+ range.selectNodeContents(newConversationButton);
1164
+ labelWidth = range.getBoundingClientRect().width || 0;
1165
+ } catch {}
1166
+ const natural = labelWidth
1167
+ + parseCssPx(buttonStyle.paddingLeft) + parseCssPx(buttonStyle.paddingRight)
1168
+ + parseCssPx(buttonStyle.borderLeftWidth) + parseCssPx(buttonStyle.borderRightWidth);
1169
+ neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), natural));
1138
1170
  sectionCount += 1;
1139
1171
  }
1140
1172
 
1173
+ // These two keep their own width (Inbox grows with its count).
1141
1174
  for (const id of ['status-btn', 'inbox-btn']) {
1142
1175
  const headerButton = document.getElementById(id);
1143
1176
  if (!isVisibleElement(headerButton)) continue;
1144
1177
  const buttonStyle = window.getComputedStyle(headerButton);
1145
- neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
1178
+ neededWidth += Math.ceil(Math.max(parseCssPx(buttonStyle.minWidth), headerButton.getBoundingClientRect().width || 0));
1146
1179
  sectionCount += 1;
1147
1180
  }
1148
1181
 
@@ -1260,6 +1293,23 @@ export function toggleSidebar(forceOpen = null) {
1260
1293
  setDesktopSidebarCollapsed(!nextOpen);
1261
1294
  }
1262
1295
 
1296
+ /**
1297
+ * A session picked from the list (a tap on its row, or Open in its menu). A
1298
+ * phone turned sideways has the desktop's persistent list; the pick collapses
1299
+ * it, as the overlay list closes on a portrait phone (openConversation's
1300
+ * closeSidebar). A long press never gets here: it opens the row menu, and
1301
+ * the click its release makes is swallowed (context-menu.mjs).
1302
+ */
1303
+ export function collapseSidebarAfterListPick() {
1304
+ const collapse = shouldCollapseListAfterPick({
1305
+ phoneLandscape: isPhoneLandscapeLayout(),
1306
+ overlayList: isOverlaySidebarViewport(),
1307
+ listOpen: isSidebarOpen(),
1308
+ });
1309
+ if (collapse) toggleSidebar(false);
1310
+ return collapse;
1311
+ }
1312
+
1263
1313
  export function syncSidebarLayoutState() {
1264
1314
  applySidebarWidthPercent(readSidebarWidthPercentForCurrentOrientation(), { persist: false });
1265
1315
  if (isOverlaySidebarViewport()) {
@@ -1312,11 +1362,30 @@ export function initSidebarLayout() {
1312
1362
  const resync = () => syncSidebarLayoutState();
1313
1363
  window.addEventListener('resize', resync, { passive: true });
1314
1364
  window.addEventListener('orientationchange', resync, { passive: true });
1365
+ // A zoom changes the head's buttons, and with them the narrowest list.
1366
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, resync);
1315
1367
  const chatHeader = document.getElementById('chat-header');
1316
1368
  if (chatHeader && typeof ResizeObserver === 'function') {
1317
1369
  const observer = new ResizeObserver(syncMobileSidebarTopOffset);
1318
1370
  observer.observe(chatHeader);
1319
1371
  }
1372
+ // The Inbox count arrives after the first pass (and changes): the list
1373
+ // widens to keep the head on one row where it can. Neither button takes
1374
+ // its width from the list's, so this cannot feed back.
1375
+ if (typeof ResizeObserver === 'function') {
1376
+ const headButtons = ['status-btn', 'inbox-btn'].map((id) => document.getElementById(id)).filter(Boolean);
1377
+ const widths = new Map();
1378
+ const headObserver = new ResizeObserver((entries) => {
1379
+ let changed = false;
1380
+ for (const entry of entries) {
1381
+ const width = Math.round(entry.contentRect?.width || 0);
1382
+ if (widths.get(entry.target) !== width) changed = true;
1383
+ widths.set(entry.target, width);
1384
+ }
1385
+ if (changed && !isOverlaySidebarViewport()) syncSidebarLayoutState();
1386
+ });
1387
+ for (const button of headButtons) headObserver.observe(button);
1388
+ }
1320
1389
  }
1321
1390
 
1322
1391
  export function setCompactInFlight(value) {
@@ -1436,6 +1505,8 @@ function syncScreenShortSide() {
1436
1505
  if (!shortSide) return;
1437
1506
  document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
1438
1507
  document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
1508
+ // The same screen, turned sideways: :root[data-phone-landscape].
1509
+ syncPhoneLandscapeAttribute(window);
1439
1510
  }
1440
1511
 
1441
1512
  export function syncViewportMetrics() {
@@ -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';
@@ -360,6 +360,31 @@ async function openParent() {
360
360
  await loadRun(top.runId);
361
361
  }
362
362
 
363
+ /** Stop asks first: the agent's work so far stays, but it ends here. */
364
+ function askBeforeStopRun() {
365
+ const run = viewer.run;
366
+ if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
367
+ openSummaryModal({
368
+ title: 'Stop agent',
369
+ subtitle: run.displayName || '',
370
+ kind: 'subagent-stop',
371
+ bodyHtml: `
372
+ <p>Stop <strong>${escHtml(run.displayName || 'this agent')}</strong>?</p>
373
+ <p>It ends where it is now; what it has done so far stays.</p>
374
+ <div class="summary-modal-actions">
375
+ <button class="chat-title-action-btn danger-btn" type="button" data-subagent-stop-confirm="1">⏹ Stop agent</button>
376
+ <button class="chat-title-action-btn" type="button" data-subagent-stop-cancel="1">Cancel</button>
377
+ </div>
378
+ `,
379
+ });
380
+ const dialog = document.getElementById('summary-modal');
381
+ dialog?.querySelector('[data-subagent-stop-confirm]')?.addEventListener('click', () => {
382
+ closeSummaryModal();
383
+ void stopRun();
384
+ }, { once: true });
385
+ dialog?.querySelector('[data-subagent-stop-cancel]')?.addEventListener('click', () => closeSummaryModal(), { once: true });
386
+ }
387
+
363
388
  async function stopRun() {
364
389
  const run = viewer.run;
365
390
  if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
@@ -518,7 +543,7 @@ export function initSubagentViewer() {
518
543
  if (event.target === modal) closeSubagentViewer();
519
544
  });
520
545
  modal?.querySelector('.subagent-viewer-close')?.addEventListener('click', () => closeSubagentViewer());
521
- stop?.addEventListener('click', () => void stopRun());
546
+ stop?.addEventListener('click', () => askBeforeStopRun());
522
547
  parent?.addEventListener('click', () => void openParent());
523
548
  jump?.addEventListener('click', () => {
524
549
  if (body) body.scrollTop = body.scrollHeight;
@@ -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) => {
@@ -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
  },