@oar-sh/oar 0.9.6 → 0.9.8

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 (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. package/shared/steer-settle-failure.mjs +1 -0
@@ -32,6 +32,7 @@ import {
32
32
  openSummaryModal,
33
33
  closeSummaryModal,
34
34
  refreshSummaryModal,
35
+ setSummaryModalLeaveHandler,
35
36
  syncViewportMetrics,
36
37
  isMobileComposerViewport,
37
38
  releaseComposerFocusAfterSend,
@@ -70,6 +71,7 @@ import {
70
71
  refreshConversationHistory,
71
72
  updateConversationTitle,
72
73
  updateConversationPreferences,
74
+ updateWorkspaceRootAttribution,
73
75
  createConversationShareLink,
74
76
  updateDefaultSessionWorkspaceRoot,
75
77
  scheduleContextUsageRefresh,
@@ -83,6 +85,12 @@ import {
83
85
  confirmNewConversationModel,
84
86
  closeNewConversationModelModal,
85
87
  deleteConv,
88
+ archiveConv,
89
+ unarchiveConv,
90
+ toggleArchivedConversations,
91
+ isArchivedConversationsView,
92
+ refreshNewConversationCloudRepoList,
93
+ openOriginConversation,
86
94
  } from './journal-view.js';
87
95
  import {
88
96
  loadRelayQuestions,
@@ -113,6 +121,7 @@ import {
113
121
  clearImageEditTarget,
114
122
  jumpToImageParent,
115
123
  flushDeferredMessageRender,
124
+ conversationHasActiveTurn,
116
125
  } from './conversation-view.js';
117
126
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
118
127
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
@@ -190,16 +199,19 @@ import {
190
199
  registerPwaShell,
191
200
  updatePwaAppName,
192
201
  } from './pwa-install.js';
202
+ import { initScrollbarHover } from './scrollbar-hover.mjs';
193
203
  import {
204
+ autoCompactWindowNotes,
194
205
  renderAutoCompactControlHtml,
206
+ renderAutoCompactNotesHtml,
195
207
  renderContextUsageHtml,
196
208
  renderThinkingControlHtml,
197
209
  thinkingEnabledFromKey,
198
210
  thinkingEnabledToKey,
199
211
  } from './context-usage-view.mjs';
212
+ import { renderAttributionControlHtml } from './attribution-control.mjs';
200
213
  import {
201
214
  autoCompactWindowFromIndex,
202
- autoCompactWindowToIndex,
203
215
  formatAutoCompactWindowLabel,
204
216
  } from './auto-compact-window-options.mjs';
205
217
  import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
@@ -329,6 +341,22 @@ import {
329
341
  openGrokLogoutConfirmation,
330
342
  confirmGrokLogout,
331
343
  } from './grok-auth-ui.js';
344
+ import {
345
+ getClaudeCloudSettings,
346
+ openClaudeCloudLoginTab,
347
+ refreshClaudeCloudSettingsState,
348
+ saveClaudeCloudSettings,
349
+ subscribeClaudeCloudSettings,
350
+ toggleClaudeCloudProvider,
351
+ } from './claude-cloud-settings-ui.js';
352
+ import { openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
353
+ import { syncConversationOriginHeader } from './conversation-origin-ui.js';
354
+ import {
355
+ previewAgentSessionsMaxWait,
356
+ saveAgentSessionsMaxWait,
357
+ toggleAgentSessions,
358
+ } from './agent-sessions-settings-ui.js';
359
+ import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
332
360
  import {
333
361
  confirmCliInstall,
334
362
  runCliInstall,
@@ -360,6 +388,7 @@ const PROVIDER_LABELS = {
360
388
  openai: 'OpenAI',
361
389
  'openai-byok': 'OpenAI (BYOK)',
362
390
  claude: 'Claude SDK',
391
+ 'claude-cloud': 'Claude Cloud',
363
392
  cursor: 'Cursor SDK',
364
393
  grok: 'Grok',
365
394
  'github-copilot': 'GitHub Copilot',
@@ -801,7 +830,11 @@ function initNetworkLifecycleHandling() {
801
830
  window.addEventListener('pageshow', () => {
802
831
  handleForegroundTransition('pageshow', { immediate: true });
803
832
  });
804
- window.addEventListener('pagehide', () => {
833
+ window.addEventListener('pagehide', (event) => {
834
+ // A page unloaded with the context modal still open (tab closed, app
835
+ // swiped away, reload) saves the window slider's draft like a modal
836
+ // close. A page only put aside (`persisted`) keeps its draft.
837
+ if (!event.persisted) commitAutoCompactDraft({ pageClosing: true });
805
838
  // The page is going away; no grace period, suspend immediately.
806
839
  cancelBackgroundSuspend();
807
840
  setForegroundNetworkWorkEnabled(false);
@@ -1197,6 +1230,9 @@ function currentGrokModelLock() {
1197
1230
  };
1198
1231
  }
1199
1232
 
1233
+ // A cloud session's model is switched between turns like a local Claude
1234
+ // chat's (the worker sends set_model before the message), so there is no
1235
+ // cloud lock on the picker.
1200
1236
  function currentRuntimeModelLock() {
1201
1237
  return currentOpenAIModelLock() || currentGrokModelLock();
1202
1238
  }
@@ -1351,6 +1387,7 @@ function normalizeModelSelectorProviderType(providerType = '') {
1351
1387
  const normalized = String(providerType || '').trim().toLowerCase();
1352
1388
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
1353
1389
  if (normalized === 'claude') return 'claude';
1390
+ if (normalized === CLAUDE_CLOUD_PROVIDER) return CLAUDE_CLOUD_PROVIDER;
1354
1391
  if (normalized === 'cursor') return 'cursor';
1355
1392
  if (normalized === 'grok') return 'grok';
1356
1393
  return 'github';
@@ -1380,18 +1417,43 @@ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByM
1380
1417
  const hasClaude = providers.includes('claude');
1381
1418
  const hasCursor = providers.includes('cursor');
1382
1419
  const hasGrok = providers.includes('grok');
1420
+ // Only present if a relay tags catalog rows for Claude Cloud; such a row is
1421
+ // then as exclusive as the other runtimes' rows.
1422
+ const hasClaudeCloud = providers.includes(CLAUDE_CLOUD_PROVIDER);
1383
1423
  const hasNonExclusiveProvider = providers.some((provider) => (
1384
1424
  provider !== 'openai-byok'
1385
1425
  && provider !== 'claude'
1386
1426
  && provider !== 'cursor'
1387
1427
  && provider !== 'grok'
1428
+ && provider !== CLAUDE_CLOUD_PROVIDER
1388
1429
  ));
1389
1430
  const activeProvider = normalizeModelSelectorProviderType(activeProviderType);
1390
1431
  if (activeProvider === 'openai') return hasOpenAIByok;
1391
1432
  if (activeProvider === 'claude') return hasClaude;
1392
1433
  if (activeProvider === 'cursor') return hasCursor;
1393
1434
  if (activeProvider === 'grok') return hasGrok;
1394
- return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok) && !hasNonExclusiveProvider);
1435
+ return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok || hasClaudeCloud) && !hasNonExclusiveProvider);
1436
+ }
1437
+
1438
+ // A Claude Cloud conversation does not pick from the shared catalog: its
1439
+ // models are the list of its settings tab (plain ids, no Auto), plus the model
1440
+ // the conversation is bound to.
1441
+ function buildClaudeCloudModelSelectorOptions(models, providersByModel, metadataByModel) {
1442
+ const conversation = currentConvId ? conversations[currentConvId] : null;
1443
+ const cloudModels = claudeCloudComposerModelIds({
1444
+ settings: getClaudeCloudSettings(),
1445
+ catalogModels: models,
1446
+ providersByModel,
1447
+ conversationModel: conversation?.runtimeProviderModel
1448
+ || conversation?.runtime_provider_model
1449
+ || conversation?.preferredModel
1450
+ || '',
1451
+ });
1452
+ return buildCatalogModelOptions(cloudModels, {
1453
+ autoValue: AUTO_MODEL_OPTION,
1454
+ metadataByModel,
1455
+ annotateContextWindow: false,
1456
+ }).filter((option) => option.value.toLowerCase() !== AUTO_MODEL_OPTION);
1395
1457
  }
1396
1458
 
1397
1459
  // metadataByModel is a parameter because the catalog refresh builds the next
@@ -1403,6 +1465,9 @@ function buildModelSelectorOptions(
1403
1465
  activeProviderType = '',
1404
1466
  metadataByModel = modelCatalogState.modelMetadataByModel,
1405
1467
  ) {
1468
+ if (normalizeModelSelectorProviderType(activeProviderType) === CLAUDE_CLOUD_PROVIDER) {
1469
+ return buildClaudeCloudModelSelectorOptions(models, providersByModel, metadataByModel);
1470
+ }
1406
1471
  const normalizedOptions = buildCatalogModelOptions(models.length ? models : [FALLBACK_MODEL], {
1407
1472
  autoValue: AUTO_MODEL_OPTION,
1408
1473
  metadataByModel,
@@ -1419,6 +1484,11 @@ function reasoningOptionsForModel(modelId = '') {
1419
1484
  if (!isModelMetadataHealthy()) return [];
1420
1485
  const key = String(modelId || '').trim().toLowerCase();
1421
1486
  const provider = activeComposerProviderType();
1487
+ // A cloud session has no effort choice; the catalog's efforts for the same
1488
+ // model id belong to the local Claude and Copilot runtimes. The one value is
1489
+ // still listed: the (hidden) select must hold it, because a send without an
1490
+ // effort is refused as "model metadata missing".
1491
+ if (provider === CLAUDE_CLOUD_PROVIDER) return ['none'];
1422
1492
  const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
1423
1493
  if (Array.isArray(providerOptions)) return normalizeReasoningEffortList(providerOptions);
1424
1494
  return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
@@ -1757,7 +1827,11 @@ function updateModelPricingDetails(modelId, tier) {
1757
1827
  const grid = document.getElementById('model-pricing-grid');
1758
1828
  if (!details || !summary || !grid) return;
1759
1829
  const metadata = modelCatalogState.modelMetadataByModel?.[modelId] || {};
1760
- const pricing = metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
1830
+ // The catalog's prices are Copilot credits; a cloud session is billed to the
1831
+ // Claude account, so they would be the wrong numbers for it.
1832
+ const pricing = activeComposerProviderType() === CLAUDE_CLOUD_PROVIDER
1833
+ ? null
1834
+ : metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
1761
1835
  if (!pricing || typeof pricing !== 'object') {
1762
1836
  details.open = false;
1763
1837
  details.style.display = 'none';
@@ -1784,12 +1858,14 @@ function updateModelPricingDetails(modelId, tier) {
1784
1858
  details.style.display = grid.childElementCount ? '' : 'none';
1785
1859
  }
1786
1860
 
1787
- // Relay modes each provider backend actually honors. All four providers
1788
- // currently support the full set — Copilot/OpenAI via prompt-context
1861
+ // Relay modes each provider backend actually honors. The local providers
1862
+ // support the full set — Copilot/OpenAI via prompt-context
1789
1863
  // injection, Claude via permissionMode + system-prompt appends, Cursor via
1790
1864
  // native plan mode plus message-text nudges for ask/autopilot — but the
1791
1865
  // composer builds its options from this table so a provider that loses (or
1792
- // gains) a mode only needs a change here.
1866
+ // gains) a mode only needs a change here. A Claude Cloud session runs in the
1867
+ // cloud's own permission mode and ignores relay modes, so it has the one
1868
+ // entry and its selector is hidden (body.claude-cloud-conversation).
1793
1869
  const RELAY_MODE_LABELS = { agent: 'Agent', ask: 'Ask', plan: 'Plan', autopilot: 'Autopilot' };
1794
1870
  const RELAY_MODES_BY_PROVIDER = {
1795
1871
  github: ['agent', 'ask', 'plan', 'autopilot'],
@@ -1797,6 +1873,7 @@ const RELAY_MODES_BY_PROVIDER = {
1797
1873
  claude: ['agent', 'ask', 'plan', 'autopilot'],
1798
1874
  cursor: ['agent', 'ask', 'plan', 'autopilot'],
1799
1875
  grok: ['agent', 'ask', 'plan', 'autopilot'],
1876
+ 'claude-cloud': ['agent'],
1800
1877
  };
1801
1878
 
1802
1879
  function relayModesForProvider(providerType = '') {
@@ -1848,8 +1925,9 @@ async function persistCurrentConversationPreferences() {
1848
1925
  // record an effort the user never picked when they only changed the mode.
1849
1926
  const reasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
1850
1927
  // These keys hold the last explicit choice, so they are not rolled back when
1851
- // the write below fails: the user did make the choice either way.
1852
- localStorage.setItem(MODE_STORAGE_KEY, mode);
1928
+ // the write below fails: the user did make the choice either way. A cloud
1929
+ // chat has no mode to choose, so its fixed "agent" is not such a choice.
1930
+ if (activeComposerProviderType() !== CLAUDE_CLOUD_PROVIDER) localStorage.setItem(MODE_STORAGE_KEY, mode);
1853
1931
  if (model) localStorage.setItem(MODEL_STORAGE_KEY, model);
1854
1932
  if (reasoningEffort) localStorage.setItem(REASONING_STORAGE_KEY, reasoningEffort);
1855
1933
 
@@ -2337,6 +2415,9 @@ function renderModelVariantCatalogBody() {
2337
2415
  // shows Claude-SDK rows exclusively, the Cursor SDK tab Cursor rows, the
2338
2416
  // Grok tab Grok rows, and Copilot never shows rows that a different runtime
2339
2417
  // contributed (there is no cross-runtime switching).
2418
+ // Claude Cloud has no rows of its own to select: its picker is fed by the
2419
+ // Claude Cloud settings tab, so a row tagged for it belongs to no tab.
2420
+ if (entryProvider === CLAUDE_CLOUD_PROVIDER) return false;
2340
2421
  if (activeTab === 'anthropic') {
2341
2422
  return entryProvider === 'claude';
2342
2423
  }
@@ -2360,6 +2441,7 @@ function renderModelVariantCatalogBody() {
2360
2441
  && provider !== 'claude'
2361
2442
  && provider !== 'cursor'
2362
2443
  && provider !== 'grok'
2444
+ && provider !== CLAUDE_CLOUD_PROVIDER
2363
2445
  ));
2364
2446
  }
2365
2447
  return true;
@@ -2786,6 +2868,66 @@ async function saveSelectedModelsFromModal() {
2786
2868
  // click pins a provider so the modal's Refresh re-renders onto the same tab
2787
2869
  // instead of snapping back. Reset on every fresh open of the modal.
2788
2870
  let usageTabProviderOverride = null;
2871
+ // The last usage report, shown at once on the next open while the open tab's
2872
+ // provider is read live; kept across reloads in sessionStorage. `fetchedAt`
2873
+ // (per card id) decides whether a tab needs a live read when it is shown.
2874
+ const USAGE_REPORT_STORAGE_KEY = 'oar.usage.lastReport.v1';
2875
+ const USAGE_LIVE_FRESH_MS = 60_000;
2876
+ let lastUsageReport = null;
2877
+ let usageRefreshInFlight = null;
2878
+
2879
+ function readStoredUsageReport() {
2880
+ try {
2881
+ const parsed = JSON.parse(sessionStorage.getItem(USAGE_REPORT_STORAGE_KEY) || 'null');
2882
+ return parsed && Array.isArray(parsed.providers) ? parsed : null;
2883
+ } catch {
2884
+ return null;
2885
+ }
2886
+ }
2887
+
2888
+ function storeUsageReport(report) {
2889
+ lastUsageReport = report;
2890
+ try {
2891
+ sessionStorage.setItem(USAGE_REPORT_STORAGE_KEY, JSON.stringify(report));
2892
+ } catch {
2893
+ // Storage full or unavailable: the module copy still serves this page.
2894
+ }
2895
+ }
2896
+
2897
+ /**
2898
+ * The next report: cards the answer read live replace the kept ones; a card
2899
+ * the answer only served from its cache replaces nothing newer we hold (a
2900
+ * restarted relay has an empty cache and would otherwise blank good cards).
2901
+ */
2902
+ function mergeUsageReports(kept, answer) {
2903
+ if (!kept || !Array.isArray(kept.providers)) return answer;
2904
+ const live = new Set(Array.isArray(answer?.live) ? answer.live : []);
2905
+ const fetchedAt = { ...(kept.fetchedAt || {}) };
2906
+ const keptById = new Map(kept.providers.map((card) => [normalizeUsageProvider(card?.provider), card]));
2907
+ const providers = (answer.providers || []).map((card) => {
2908
+ const id = normalizeUsageProvider(card?.provider);
2909
+ const previous = keptById.get(id);
2910
+ if (live.has(id) || !previous) {
2911
+ fetchedAt[id] = answer.fetchedAt?.[id] || new Date().toISOString();
2912
+ return card;
2913
+ }
2914
+ // Unavailable from the cache but known from before: keep what we had.
2915
+ return card?.status === 'unavailable' && previous.status !== 'unavailable' ? previous : card;
2916
+ });
2917
+ return { ...answer, providers, fetchedAt };
2918
+ }
2919
+
2920
+ function usageCardIsFresh(provider) {
2921
+ const at = Date.parse(String(lastUsageReport?.fetchedAt?.[provider] || ''));
2922
+ return Number.isFinite(at) && Date.now() - at < USAGE_LIVE_FRESH_MS;
2923
+ }
2924
+
2925
+ /** The "Updating…" marker on one card, while its live read runs. */
2926
+ function markUsageCardUpdating(provider, updating) {
2927
+ const body = document.getElementById('summary-modal-body');
2928
+ const card = body?.querySelector(`[data-usage-panel="${CSS.escape(provider)}"]`);
2929
+ if (card) card.toggleAttribute('data-updating', !!updating);
2930
+ }
2789
2931
 
2790
2932
  /**
2791
2933
  * Delegated because #summary-modal-body's innerHTML is replaced on every render
@@ -2803,7 +2945,7 @@ function initUsageTabControl() {
2803
2945
  });
2804
2946
  }
2805
2947
 
2806
- /** Pure DOM toggle: switching tabs never refetches `/api/usage`. */
2948
+ /** DOM toggle first; a tab whose card is older than a minute is then read live. */
2807
2949
  function selectUsageTab(root, provider) {
2808
2950
  const target = String(provider || '').trim();
2809
2951
  if (!target) return;
@@ -2819,6 +2961,78 @@ function selectUsageTab(root, provider) {
2819
2961
  panel.hidden = panel.dataset.usagePanel !== target;
2820
2962
  }
2821
2963
  usageTabProviderOverride = target;
2964
+ if (!usageCardIsFresh(target)) {
2965
+ refreshUsageProvider(target).catch(() => {});
2966
+ }
2967
+ }
2968
+
2969
+ /**
2970
+ * Read one provider live and swap the result into the modal. Serialised: a
2971
+ * second request while one runs waits for it and is then made if still
2972
+ * needed (the tab may have changed).
2973
+ */
2974
+ async function refreshUsageProvider(provider) {
2975
+ const target = String(provider || '').trim();
2976
+ if (!target) return;
2977
+ if (usageRefreshInFlight) {
2978
+ await usageRefreshInFlight.catch(() => {});
2979
+ if (usageCardIsFresh(target)) return;
2980
+ }
2981
+ markUsageCardUpdating(target, true);
2982
+ const run = (async () => {
2983
+ const [usageResult, authResult] = await Promise.allSettled([
2984
+ loadUsageSummary({ providers: [target] }),
2985
+ getClaudeAuthStatus(),
2986
+ ]);
2987
+ if (usageResult.status === 'rejected') throw usageResult.reason;
2988
+ const answer = usageResult.value;
2989
+ if (!answer || !Array.isArray(answer.providers)) return;
2990
+ if (summaryModalState.kind !== 'usage') {
2991
+ storeUsageReport(mergeUsageReports(lastUsageReport, answer));
2992
+ return;
2993
+ }
2994
+ const account = authResult.status === 'fulfilled' ? claudeAccountFromAuthStatus(authResult.value) : null;
2995
+ renderUsageReport(mergeUsageReports(lastUsageReport, answer), account);
2996
+ })();
2997
+ usageRefreshInFlight = run;
2998
+ try {
2999
+ await run;
3000
+ } finally {
3001
+ if (usageRefreshInFlight === run) usageRefreshInFlight = null;
3002
+ markUsageCardUpdating(target, false);
3003
+ }
3004
+ }
3005
+
3006
+ /** Render a report into the open modal and keep it for the next open. */
3007
+ function renderUsageReport(report, account, { updating = '' } = {}) {
3008
+ storeUsageReport(report);
3009
+ const activeProvider = usageTabProviderOverride
3010
+ || normalizeUsageProvider(activeComposerProviderType());
3011
+ const planHtml = renderPlanUsageHtml(withClaudeAccount(report, account), { activeProvider });
3012
+ if (!planHtml) return false;
3013
+ initUsageTabControl();
3014
+ renderSummaryModalContent({
3015
+ title: 'Plan usage',
3016
+ subtitle: `${planUsageSubtitle(report)}${updating ? ' · updating…' : ''}`,
3017
+ bodyHtml: planHtml,
3018
+ refresh: refreshOpenUsageTab,
3019
+ kind: 'usage',
3020
+ });
3021
+ if (updating) markUsageCardUpdating(updating, true);
3022
+ return true;
3023
+ }
3024
+
3025
+ /** The modal's Refresh button: the open tab live, nothing else. */
3026
+ async function refreshOpenUsageTab() {
3027
+ const body = document.getElementById('summary-modal-body');
3028
+ const active = body?.querySelector('[data-usage-tab].active')?.dataset.usageTab
3029
+ || usageTabProviderOverride
3030
+ || normalizeUsageProvider(activeComposerProviderType());
3031
+ if (!lastUsageReport || !active) {
3032
+ await loadUsageSummaryAndRender();
3033
+ return;
3034
+ }
3035
+ await refreshUsageProvider(active);
2822
3036
  }
2823
3037
 
2824
3038
  /**
@@ -2848,11 +3062,18 @@ function withClaudeAccount(report, account) {
2848
3062
  };
2849
3063
  }
2850
3064
 
3065
+ /**
3066
+ * The first open with nothing kept: the open tab's provider is read live and
3067
+ * the other cards come from the relay's cache (or are read once when it has
3068
+ * none). With a kept report, showUsage renders it first and only refreshes.
3069
+ */
2851
3070
  async function loadUsageSummaryAndRender() {
3071
+ const activeProvider = usageTabProviderOverride
3072
+ || normalizeUsageProvider(activeComposerProviderType());
2852
3073
  // Usage is the point of the modal; the account line is a garnish, so the two
2853
3074
  // are settled independently and a failing auth probe cannot blank the cards.
2854
3075
  const [usageResult, authResult] = await Promise.allSettled([
2855
- loadUsageSummary(),
3076
+ loadUsageSummary({ providers: activeProvider ? [activeProvider] : null }),
2856
3077
  getClaudeAuthStatus(),
2857
3078
  ]);
2858
3079
  if (usageResult.status === 'rejected') throw usageResult.reason;
@@ -2861,20 +3082,7 @@ async function loadUsageSummaryAndRender() {
2861
3082
  const account = authResult.status === 'fulfilled'
2862
3083
  ? claudeAccountFromAuthStatus(authResult.value)
2863
3084
  : null;
2864
- const activeProvider = usageTabProviderOverride
2865
- || normalizeUsageProvider(activeComposerProviderType());
2866
- const planHtml = renderPlanUsageHtml(withClaudeAccount(d, account), { activeProvider });
2867
- if (planHtml) {
2868
- initUsageTabControl();
2869
- renderSummaryModalContent({
2870
- title: 'Plan usage',
2871
- subtitle: planUsageSubtitle(d),
2872
- bodyHtml: planHtml,
2873
- refresh: loadUsageSummaryAndRender,
2874
- kind: 'usage',
2875
- });
2876
- return;
2877
- }
3085
+ if (Array.isArray(d.providers) && renderUsageReport(mergeUsageReports(lastUsageReport, d), account)) return;
2878
3086
  // Legacy relay (or a relay without the plan-usage service): fall back to the
2879
3087
  // Copilot-only text summary rather than showing an empty modal.
2880
3088
  const pct = d.premiumInteractions?.percentRemaining != null
@@ -2894,18 +3102,37 @@ async function loadUsageSummaryAndRender() {
2894
3102
  // The context modal is rendered from scratch on every refresh, so the slider's
2895
3103
  // conversation binding lives here rather than on the element.
2896
3104
  let autoCompactControlConversationId = '';
2897
- let autoCompactWindowUpdateInFlight = false;
2898
- // The server's last known window for the rendered control. Kept so a rejected
2899
- // PATCH can put the slider back instead of leaving the rejected value on
2900
- // screen until the next refresh.
3105
+ // The save in flight, if any: saves run one after another, never side by side.
3106
+ let autoCompactWindowUpdateInFlight = null;
3107
+ // The server's last known window for the rendered control: what a draft is
3108
+ // compared against.
2901
3109
  let autoCompactControlStoredWindow = null;
3110
+ // What the last payload said about the running session, for the notes.
3111
+ let autoCompactControlSession = { activeWindow: null, deferred: null, usedTokens: null };
3112
+ // The slider is a draft while the modal is open: moving it only updates the
3113
+ // label and the notes, and the value is saved when the modal is left
3114
+ // (setSummaryModalLeaveHandler). `window` undefined = untouched. Kept outside
3115
+ // the DOM so a refresh of the open modal keeps the position.
3116
+ let autoCompactDraft = { conversationId: '', window: undefined };
3117
+
3118
+ function autoCompactNotesOptions(draftWindow, storedWindow = autoCompactControlStoredWindow) {
3119
+ return {
3120
+ draftWindow,
3121
+ storedWindow,
3122
+ activeWindow: autoCompactControlSession.activeWindow,
3123
+ deferred: autoCompactControlSession.deferred,
3124
+ turnRunning: !!autoCompactControlConversationId && conversationHasActiveTurn(autoCompactControlConversationId),
3125
+ usedTokens: autoCompactControlSession.usedTokens,
3126
+ };
3127
+ }
2902
3128
 
2903
- // Snaps both the range input and its label back to `window`.
2904
- function setAutoCompactControlValue(window) {
2905
- const slider = document.getElementById('ctx-autocompact-slider');
2906
- if (slider) slider.value = String(autoCompactWindowToIndex(window));
3129
+ function trackAutoCompactDraft(slider) {
3130
+ const window = autoCompactWindowFromIndex(slider.value);
3131
+ autoCompactDraft = { conversationId: autoCompactControlConversationId, window };
2907
3132
  const label = document.getElementById('ctx-autocompact-value');
2908
3133
  if (label) label.textContent = formatAutoCompactWindowLabel(window);
3134
+ const notes = document.getElementById('ctx-autocompact-notes');
3135
+ if (notes) notes.innerHTML = renderAutoCompactNotesHtml(autoCompactNotesOptions(window));
2909
3136
  }
2910
3137
 
2911
3138
  /**
@@ -2921,18 +3148,37 @@ function initAutoCompactWindowControl() {
2921
3148
  const target = event?.target;
2922
3149
  return target && target.id === 'ctx-autocompact-slider' ? target : null;
2923
3150
  };
2924
- // Dragging only previews the label; nothing is persisted until the value
2925
- // settles, matching the settings-modal sliders.
2926
- body.addEventListener('input', (event) => {
2927
- const slider = sliderFrom(event);
2928
- if (!slider) return;
2929
- const label = document.getElementById('ctx-autocompact-value');
2930
- if (label) label.textContent = formatAutoCompactWindowLabel(autoCompactWindowFromIndex(slider.value));
2931
- });
2932
- body.addEventListener('change', (event) => {
2933
- const slider = sliderFrom(event);
2934
- if (!slider) return;
2935
- void applyAutoCompactWindowSelection(autoCompactWindowFromIndex(slider.value));
3151
+ // Nothing is written while the modal is open; see commitAutoCompactDraft.
3152
+ for (const type of ['input', 'change']) {
3153
+ body.addEventListener(type, (event) => {
3154
+ const slider = sliderFrom(event);
3155
+ if (slider) trackAutoCompactDraft(slider);
3156
+ });
3157
+ }
3158
+ }
3159
+
3160
+ // Runs when the context modal is left (or re-bound to another conversation):
3161
+ // a draft that differs from the stored value is saved; one set back to the
3162
+ // stored value sends nothing.
3163
+ function commitAutoCompactDraft({ pageClosing = false } = {}) {
3164
+ const { conversationId, window } = autoCompactDraft;
3165
+ autoCompactDraft = { conversationId: '', window: undefined };
3166
+ if (!conversationId || window === undefined) return;
3167
+ if (conversationId === autoCompactControlConversationId && window === autoCompactControlStoredWindow) return;
3168
+ if (pageClosing) {
3169
+ // Sent at once, not queued behind an earlier save: the request has to
3170
+ // leave before the page does.
3171
+ void saveAutoCompactWindow(conversationId, window, '', { pageClosing: true });
3172
+ return;
3173
+ }
3174
+ const notes = autoCompactWindowNotes(autoCompactNotesOptions(window, window));
3175
+ const previous = autoCompactWindowUpdateInFlight || Promise.resolve();
3176
+ const run = previous
3177
+ .catch(() => {})
3178
+ .then(() => saveAutoCompactWindow(conversationId, window, notes.pending));
3179
+ autoCompactWindowUpdateInFlight = run;
3180
+ void run.finally(() => {
3181
+ if (autoCompactWindowUpdateInFlight === run) autoCompactWindowUpdateInFlight = null;
2936
3182
  });
2937
3183
  }
2938
3184
 
@@ -2954,6 +3200,36 @@ function setThinkingControlValue({ enabled, display }) {
2954
3200
  }
2955
3201
  }
2956
3202
 
3203
+ // The folder's commit attribution select in the 🧠 modal: one request per
3204
+ // change, then the modal is re-read so the effect text matches the relay.
3205
+ let attributionControlUpdateInFlight = false;
3206
+ function initAttributionControl() {
3207
+ const body = document.getElementById('summary-modal-body');
3208
+ if (!body || body.dataset.attributionControlBound === 'true') return;
3209
+ body.dataset.attributionControlBound = 'true';
3210
+ body.addEventListener('change', async (event) => {
3211
+ const select = event.target?.closest?.('#ctx-attribution-select');
3212
+ if (!select) return;
3213
+ const path = String(select.dataset.workspaceRoot || '').trim();
3214
+ if (!path || attributionControlUpdateInFlight) return;
3215
+ attributionControlUpdateInFlight = true;
3216
+ select.disabled = true;
3217
+ try {
3218
+ const response = await updateWorkspaceRootAttribution(path, select.value || null);
3219
+ if (!response || response.ok === false) throw new Error(String(response?.error || 'The relay refused the change'));
3220
+ showTransientRelayNotice(select.value
3221
+ ? `Commit attribution for this folder: ${select.value}.`
3222
+ : 'Commit attribution for this folder follows the provider setting again.');
3223
+ await refreshSummaryModal();
3224
+ } catch (error) {
3225
+ showTransientRelayNotice(`Could not change the commit attribution: ${error?.message || error}`);
3226
+ select.disabled = false;
3227
+ } finally {
3228
+ attributionControlUpdateInFlight = false;
3229
+ }
3230
+ });
3231
+ }
3232
+
2957
3233
  function initThinkingControl() {
2958
3234
  const body = document.getElementById('summary-modal-body');
2959
3235
  if (!body || body.dataset.thinkingBound === '1') return;
@@ -3009,41 +3285,32 @@ async function applyThinkingSelection(axis, key) {
3009
3285
  }
3010
3286
  }
3011
3287
 
3012
- async function applyAutoCompactWindowSelection(autoCompactWindow) {
3013
- const convId = String(autoCompactControlConversationId || '').trim();
3014
- const slider = document.getElementById('ctx-autocompact-slider');
3015
- if (!convId || autoCompactWindowUpdateInFlight) {
3016
- // Nothing will be written, so leave the control showing what is stored
3017
- // rather than a position the server never heard about.
3018
- setAutoCompactControlValue(autoCompactControlStoredWindow);
3019
- return;
3020
- }
3021
- autoCompactWindowUpdateInFlight = true;
3022
- if (slider) slider.disabled = true;
3288
+ async function saveAutoCompactWindow(convId, autoCompactWindow, pendingNote = '', { pageClosing = false } = {}) {
3023
3289
  try {
3024
3290
  // Only the window is sent: the route keeps every unmentioned preference as
3025
3291
  // stored, so this cannot clobber the composer's model/effort choice.
3026
3292
  const response = await updateConversationPreferences(convId, {
3027
3293
  clientId: CLIENT_ID,
3028
3294
  autoCompactWindow,
3029
- });
3295
+ }, { keepalive: pageClosing });
3296
+ // Nobody is left to tell on a page that is going away.
3297
+ if (pageClosing) return;
3030
3298
  if (!response) throw new Error('Failed to update the auto-compact window');
3031
3299
  // The server, not the slider, decides what got stored (values snap to a
3032
- // stop), so the control follows the response.
3300
+ // stop).
3033
3301
  const stored = response.autoCompactWindow ?? null;
3034
- autoCompactControlStoredWindow = stored;
3035
- setAutoCompactControlValue(stored);
3302
+ if (convId === autoCompactControlConversationId) autoCompactControlStoredWindow = stored;
3303
+ // No note when the running session already uses this value.
3304
+ const when = pendingNote ? ` ${pendingNote}` : '';
3036
3305
  showTransientRelayNotice(stored === null
3037
- ? 'Auto-compact window back to the model default; applies to the next message.'
3038
- : `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens; applies to the next message.`);
3306
+ ? `Auto-compact window back to the model default.${when}`
3307
+ : `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens.${when}`);
3308
+ // A modal reopened on this conversation meanwhile shows the new value.
3309
+ if (summaryModalState.kind === 'context' && convId === autoCompactControlConversationId) {
3310
+ await refreshSummaryModal();
3311
+ }
3039
3312
  } catch (error) {
3040
- // The write was refused, so the rejected position is a lie: put the
3041
- // control back on the last value the server confirmed.
3042
- setAutoCompactControlValue(autoCompactControlStoredWindow);
3043
3313
  alert(error?.message || 'Failed to update the auto-compact window.');
3044
- } finally {
3045
- autoCompactWindowUpdateInFlight = false;
3046
- if (slider) slider.disabled = false;
3047
3314
  }
3048
3315
  }
3049
3316
 
@@ -3055,14 +3322,42 @@ async function loadContextSummaryAndRender(convId) {
3055
3322
  const refreshLookupId = sessionId || trimmedConvId || null;
3056
3323
  const providerLabel = payload.providerType === 'claude'
3057
3324
  ? 'Claude'
3058
- : (payload.providerType === 'cursor'
3059
- ? 'Cursor'
3060
- : (payload.providerType === 'grok' ? 'Grok' : 'Copilot'));
3325
+ : (payload.providerType === CLAUDE_CLOUD_PROVIDER
3326
+ ? 'Claude Cloud'
3327
+ : (payload.providerType === 'cursor'
3328
+ ? 'Cursor'
3329
+ : (payload.providerType === 'grok' ? 'Grok' : 'Copilot')));
3061
3330
  const subtitle = sessionId
3062
3331
  ? `${providerLabel} session ${sessionId.slice(0, 8)}`
3063
3332
  : (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
3064
3333
 
3065
- const usageHtml = renderContextUsageHtml(payload.contextUsage);
3334
+ // The session fields come with the payload when the relay knows them
3335
+ // (older relays send neither, and the notes fall back to draft vs stored).
3336
+ const sessionField = (key) => payload[key] ?? payload.contextUsage?.[key] ?? null;
3337
+ const modelContextLimit = sessionField('modelContextLimit');
3338
+ const usageHtml = renderContextUsageHtml(payload.contextUsage, {
3339
+ compactionWindow: payload.providerType === 'claude',
3340
+ modelContextLimit,
3341
+ });
3342
+ // The slider's writes need the conversation id even when the modal was
3343
+ // opened through an sdk_session_id.
3344
+ const boundConversationId = String(
3345
+ payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
3346
+ ).trim();
3347
+ // A draft for another conversation is saved before this one takes over.
3348
+ if (autoCompactDraft.conversationId && autoCompactDraft.conversationId !== boundConversationId) {
3349
+ commitAutoCompactDraft();
3350
+ }
3351
+ autoCompactControlConversationId = boundConversationId;
3352
+ // What the server says is stored right now, refreshed every time the
3353
+ // control is re-rendered: the draft is compared against it.
3354
+ autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
3355
+ autoCompactControlSession = {
3356
+ activeWindow: sessionField('activeAutoCompactWindow'),
3357
+ deferred: sessionField('autoCompactWindowDeferred'),
3358
+ usedTokens: payload.contextUsage?.totalTokens ?? null,
3359
+ };
3360
+ const draftWindow = autoCompactDraft.conversationId === boundConversationId ? autoCompactDraft.window : undefined;
3066
3361
  // Claude-only: no other provider exposes a compaction window (or any
3067
3362
  // session-settings surface) to steer.
3068
3363
  const autoCompactHtml = payload.providerType === 'claude'
@@ -3078,6 +3373,11 @@ async function loadContextSummaryAndRender(convId) {
3078
3373
  ? (payload.contextUsage.isAutoCompactEnabled ?? null)
3079
3374
  : null,
3080
3375
  maxTokens: payload.contextUsage?.maxTokens ?? null,
3376
+ draftWindow,
3377
+ activeAutoCompactWindow: autoCompactControlSession.activeWindow,
3378
+ autoCompactWindowDeferred: autoCompactControlSession.deferred,
3379
+ turnRunning: autoCompactNotesOptions(draftWindow).turnRunning,
3380
+ usedTokens: autoCompactControlSession.usedTokens,
3081
3381
  })
3082
3382
  : '';
3083
3383
  const thinkingHtml = payload.providerType === 'claude'
@@ -3086,14 +3386,11 @@ async function loadContextSummaryAndRender(convId) {
3086
3386
  thinkingDisplay: payload.thinkingDisplay ?? null,
3087
3387
  })
3088
3388
  : '';
3089
- // The slider's writes need the conversation id even when the modal was
3090
- // opened through an sdk_session_id.
3091
- autoCompactControlConversationId = String(
3092
- payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
3093
- ).trim();
3094
- // Baseline for the failure path below: what the server says is stored right
3095
- // now, refreshed every time the control is re-rendered.
3096
- autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
3389
+ // What this folder's commits say, with the folder override: Claude, and a
3390
+ // Claude Cloud chat that was started from a folder (no path, no row).
3391
+ const attributionHtml = payload.providerType === 'claude' || payload.providerType === 'claude-cloud'
3392
+ ? renderAttributionControlHtml(payload.attribution || null)
3393
+ : '';
3097
3394
  thinkingControlStored = {
3098
3395
  enabled: payload.thinkingEnabled !== false,
3099
3396
  display: payload.thinkingDisplay || 'summarized',
@@ -3102,10 +3399,10 @@ async function loadContextSummaryAndRender(convId) {
3102
3399
  // The structured breakdown is the answer; the runtime's own text dump stays
3103
3400
  // available underneath for the details it carries that categories don't.
3104
3401
  const bodyHtml = usageHtml
3105
- ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${detailText
3402
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${detailText
3106
3403
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3107
3404
  : ''}`
3108
- : `${autoCompactHtml}${thinkingHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3405
+ : `${autoCompactHtml}${thinkingHtml}${attributionHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3109
3406
 
3110
3407
  renderSummaryModalContent({
3111
3408
  title: 'Context usage',
@@ -3114,6 +3411,7 @@ async function loadContextSummaryAndRender(convId) {
3114
3411
  refresh: () => loadContextSummaryAndRender(refreshLookupId),
3115
3412
  kind: 'context',
3116
3413
  });
3414
+ setSummaryModalLeaveHandler('context', () => commitAutoCompactDraft());
3117
3415
  }
3118
3416
 
3119
3417
  async function showUsage() {
@@ -3125,15 +3423,27 @@ async function showUsage() {
3125
3423
  btn.textContent = '⏳';
3126
3424
  btn.disabled = true;
3127
3425
  }
3426
+ // What was shown last time is shown again at once; only the open tab's
3427
+ // provider is read live, and its card is swapped in when the answer comes.
3428
+ const kept = lastUsageReport || readStoredUsageReport();
3429
+ const activeProvider = normalizeUsageProvider(activeComposerProviderType());
3128
3430
  openSummaryModal({
3129
3431
  title: 'Plan usage',
3130
3432
  subtitle: 'Loading…',
3131
- bodyHtml: '<div class="summary-loading">Fetching plan usage across providers…</div>',
3132
- refresh: loadUsageSummaryAndRender,
3433
+ bodyHtml: '<div class="summary-loading">Fetching plan usage…</div>',
3434
+ refresh: refreshOpenUsageTab,
3133
3435
  kind: 'usage',
3134
3436
  });
3135
- setSummaryModalLoading(true);
3136
3437
  try {
3438
+ if (kept && renderUsageReport(kept, null, { updating: activeProvider })) {
3439
+ if (btn) {
3440
+ btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3441
+ btn.disabled = false;
3442
+ }
3443
+ await refreshUsageProvider(activeProvider);
3444
+ return;
3445
+ }
3446
+ setSummaryModalLoading(true);
3137
3447
  await loadUsageSummaryAndRender();
3138
3448
  } catch (e) {
3139
3449
  renderSummaryModalContent({
@@ -3747,6 +4057,11 @@ function syncChatTitleControls() {
3747
4057
  killBtn.disabled = sharedMode || !convId || !sdkSessionId;
3748
4058
  killBtn.hidden = sharedMode || !convId;
3749
4059
  }
4060
+ const archiveBtn = document.getElementById('chat-menu-archive');
4061
+ if (archiveBtn) {
4062
+ archiveBtn.hidden = sharedMode || !convId;
4063
+ archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
4064
+ }
3750
4065
  if (editor) {
3751
4066
  editor.hidden = !editing;
3752
4067
  }
@@ -3762,6 +4077,11 @@ function syncChatTitleControls() {
3762
4077
  if (!isStatusViewActive()) {
3763
4078
  syncChatHeaderWorkspaceLabel();
3764
4079
  }
4080
+ // Cloud line, hidden controls and the image-only attach button all follow
4081
+ // the conversation on screen, like the header does.
4082
+ syncClaudeCloudConversationUi();
4083
+ // "via agent": the conversation an agent on this relay started this one from.
4084
+ syncConversationOriginHeader();
3765
4085
  syncChatTitleWatcherIndicator();
3766
4086
  }
3767
4087
 
@@ -3919,12 +4239,31 @@ initTmuxInspectorView({
3919
4239
  closeChatActionsMenu,
3920
4240
  });
3921
4241
 
4242
+ // The composer's model list of a cloud conversation comes from the Claude
4243
+ // Cloud settings, which load (and change) independently of the catalog.
4244
+ function refreshClaudeCloudComposerModels() {
4245
+ if (isSharedReaderMode()) return;
4246
+ if (normalizeModelSelectorProviderType(activeComposerProviderType()) !== CLAUDE_CLOUD_PROVIDER) return;
4247
+ const select = document.getElementById('model-select');
4248
+ // Without a healthy catalog there is nothing to rebuild from (and a rebuild
4249
+ // would raise the metadata blocker); the next catalog load builds the list
4250
+ // with whatever settings have arrived by then.
4251
+ if (!select || !isModelMetadataHealthy()) return;
4252
+ // Dropping the scope makes syncAutoModelAvailability rebuild the options.
4253
+ delete select.dataset.providerScope;
4254
+ syncAutoModelAvailability();
4255
+ }
4256
+
3922
4257
  function showAuthGate(error = '') {
3923
- document.getElementById('startup-loading')?.remove();
3924
4258
  document.getElementById('auth-gate').style.display = 'flex';
3925
4259
  document.getElementById('app').classList.remove('visible');
3926
4260
  showAuthError(error);
3927
- document.getElementById('token-input')?.focus();
4261
+ // The start screen hands its logo over to the box first (installed app);
4262
+ // focusing earlier would open the keyboard and move the box under it.
4263
+ const focusToken = () => document.getElementById('token-input')?.focus();
4264
+ const handover = window.__startupSplash?.toAuth?.();
4265
+ if (handover && window.__startupSplash.animated) void handover.then(focusToken);
4266
+ else focusToken();
3928
4267
  }
3929
4268
 
3930
4269
  // Runs before anything reads MODEL_STORAGE_KEY so the first load after an
@@ -3950,6 +4289,7 @@ async function initApp() {
3950
4289
  setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
3951
4290
  const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
3952
4291
  initTheme();
4292
+ initScrollbarHover();
3953
4293
  initThemeMenuToggle();
3954
4294
  if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
3955
4295
  initFontScaling();
@@ -3982,7 +4322,8 @@ async function initApp() {
3982
4322
  void flushConversationDraft(currentConvId);
3983
4323
  closeTmuxInspectorView();
3984
4324
  });
3985
- document.getElementById('startup-loading')?.remove();
4325
+ // The start screen lies over the app and leaves once its animation is done.
4326
+ void window.__startupSplash?.finish?.();
3986
4327
  document.getElementById('auth-gate').style.display = 'none';
3987
4328
  document.getElementById('app').classList.add('visible');
3988
4329
  initSidebarLayout();
@@ -4023,6 +4364,17 @@ async function initApp() {
4023
4364
  });
4024
4365
  });
4025
4366
  }
4367
+ const chatMenuArchiveBtn = document.getElementById('chat-menu-archive');
4368
+ bindMenuAction(chatMenuArchiveBtn, (event) => {
4369
+ event.preventDefault();
4370
+ event.stopPropagation();
4371
+ lockChatActionsMenuShield(350);
4372
+ closeChatActionsMenu();
4373
+ const convId = String(currentConvId || '').trim();
4374
+ if (!convId) return;
4375
+ const archived = conversations[convId]?.archived === true;
4376
+ void (archived ? unarchiveConv(null, convId) : archiveConv(null, convId));
4377
+ });
4026
4378
  const chatMenuShareConversationBtn = document.getElementById('chat-menu-share-conversation');
4027
4379
  bindMenuAction(chatMenuShareConversationBtn, (event) => {
4028
4380
  event.preventDefault();
@@ -4082,6 +4434,14 @@ async function initApp() {
4082
4434
  closeChatActionsMenu();
4083
4435
  openGitChangesModal();
4084
4436
  });
4437
+ const chatMenuOpenCloudSessionBtn = document.getElementById('chat-menu-open-cloud-session');
4438
+ bindMenuAction(chatMenuOpenCloudSessionBtn, (event) => {
4439
+ event.preventDefault();
4440
+ event.stopPropagation();
4441
+ lockChatActionsMenuShield(350);
4442
+ closeChatActionsMenu();
4443
+ openCurrentCloudSession(showExternalLinkFallback);
4444
+ });
4085
4445
  const chatMenuSettingsBtn = document.getElementById('chat-menu-settings');
4086
4446
  bindMenuAction(chatMenuSettingsBtn, (event) => {
4087
4447
  event.preventDefault();
@@ -4172,6 +4532,7 @@ async function initApp() {
4172
4532
  }
4173
4533
  initAutoCompactWindowControl();
4174
4534
  initThinkingControl();
4535
+ initAttributionControl();
4175
4536
  initModeSelector();
4176
4537
  initModelSelector();
4177
4538
  initReasoningSelector();
@@ -4190,6 +4551,11 @@ async function initApp() {
4190
4551
  await refreshOpenAISettingsState();
4191
4552
  await refreshCursorSettingsState();
4192
4553
  await refreshGrokSettingsState();
4554
+ // Not awaited: only a Claude Cloud conversation needs it (its model list),
4555
+ // and the subscription below rebuilds that list when the answer arrives. A
4556
+ // relay without the provider answers nothing and nothing changes.
4557
+ subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
4558
+ void refreshClaudeCloudSettingsState();
4193
4559
  await refreshModelCatalog(true);
4194
4560
  initFullscreenButton();
4195
4561
  initInstallButton();
@@ -4311,6 +4677,9 @@ window.dismissAvailableUpdate = dismissAvailableUpdate;
4311
4677
  window.dismissUpdateOutcome = dismissUpdateOutcome;
4312
4678
  window.saveGrokSettings = saveGrokSettings;
4313
4679
  window.toggleGrokProvider = toggleGrokProvider;
4680
+ window.saveClaudeCloudSettings = saveClaudeCloudSettings;
4681
+ window.toggleClaudeCloudProvider = toggleClaudeCloudProvider;
4682
+ window.openClaudeCloudLoginTab = openClaudeCloudLoginTab;
4314
4683
  window.saveCursorSettings = saveCursorSettings;
4315
4684
  window.removeCursorSettings = removeCursorSettings;
4316
4685
  window.saveCursorAllowanceSettings = saveCursorAllowanceSettings;
@@ -4334,6 +4703,9 @@ window.addRemoteRelayFromForm = addRemoteRelayFromForm;
4334
4703
  window.showRemoteRelayTokenField = showRemoteRelayTokenField;
4335
4704
  window.saveRemoteRelayPublicUrl = saveRemoteRelayPublicUrl;
4336
4705
  window.toggleRemoteRelayInbound = toggleRemoteRelayInbound;
4706
+ window.toggleAgentSessions = toggleAgentSessions;
4707
+ window.previewAgentSessionsMaxWait = previewAgentSessionsMaxWait;
4708
+ window.saveAgentSessionsMaxWait = saveAgentSessionsMaxWait;
4337
4709
  window.doAuth = doAuth;
4338
4710
  window.initApp = initApp;
4339
4711
  window.connectSocket = connectSocket;
@@ -4370,7 +4742,12 @@ window.newConversation = newConversation;
4370
4742
  window.confirmNewConversationModel = confirmNewConversationModel;
4371
4743
  window.closeNewConversationModelModal = closeNewConversationModelModal;
4372
4744
  window.deleteConv = deleteConv;
4745
+ window.archiveConv = archiveConv;
4746
+ window.unarchiveConv = unarchiveConv;
4747
+ window.toggleArchivedConversations = toggleArchivedConversations;
4748
+ window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
4373
4749
  window.openConversation = openConversation;
4750
+ window.openOriginConversation = openOriginConversation;
4374
4751
  window.toggleStatusView = toggleStatusView;
4375
4752
  window.refreshConversations = refreshConversations;
4376
4753
  window.renderConvList = renderConvList;
@@ -4428,6 +4805,9 @@ window.syncChatTitleControls = syncChatTitleControls;
4428
4805
  window.closeChatActionsMenu = closeChatActionsMenu;
4429
4806
  window.openSuspendHostConfirmation = openSuspendHostConfirmation;
4430
4807
  window.confirmKillCurrentSession = confirmKillCurrentSession;
4808
+ // The conversation list's context menu reaches these for the open conversation.
4809
+ window.openKillSessionConfirmation = openKillSessionConfirmation;
4810
+ window.openChatTitleEditor = openChatTitleEditor;
4431
4811
  window.confirmRestartWebRelay = confirmRestartWebRelay;
4432
4812
  window.confirmSuspendHost = confirmSuspendHost;
4433
4813
  window.cancelQueuedHostSuspend = cancelQueuedHostSuspend;