@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
@@ -0,0 +1,140 @@
1
+ // A context menu for the conversation list: right-click on a desktop, a long
2
+ // press on a phone. One menu at a time, closed by a pointer-down outside it,
3
+ // Escape, a scroll or a resize, like a native menu; flipped at the screen
4
+ // edge rather than clipped. The entries are buttons, so keyboard users get
5
+ // focus on the first one.
6
+
7
+ const LONG_PRESS_MS = 500;
8
+ const LONG_PRESS_SLOP_PX = 10;
9
+
10
+ /** A horizontal rule between groups. */
11
+ export const MENU_SEPARATOR = null;
12
+
13
+ let closeOpenMenu = null;
14
+
15
+ function esc(value) {
16
+ return String(value ?? '')
17
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
+ }
19
+
20
+ /** Close the open menu, if any. */
21
+ export function closeContextMenu() {
22
+ closeOpenMenu?.();
23
+ }
24
+
25
+ /**
26
+ * Open a menu at viewport coordinates. `items`: `{ label, onSelect, danger?,
27
+ * disabled?, id? }`, or MENU_SEPARATOR between groups. Returns the menu node.
28
+ */
29
+ export function openContextMenu(x, y, items, { documentRef = document, windowRef = window } = {}) {
30
+ closeOpenMenu?.();
31
+ const menu = documentRef.createElement('div');
32
+ menu.className = 'context-menu';
33
+ menu.setAttribute('role', 'menu');
34
+
35
+ const close = () => {
36
+ if (closeOpenMenu !== close) return;
37
+ closeOpenMenu = null;
38
+ documentRef.removeEventListener('pointerdown', onOutside, true);
39
+ documentRef.removeEventListener('keydown', onKey, true);
40
+ windowRef.removeEventListener('resize', close);
41
+ windowRef.removeEventListener('scroll', close, true);
42
+ menu.remove();
43
+ };
44
+ const onOutside = (event) => {
45
+ if (!menu.contains(event.target)) close();
46
+ };
47
+ const onKey = (event) => {
48
+ if (event.key === 'Escape') {
49
+ event.stopPropagation();
50
+ close();
51
+ return;
52
+ }
53
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
54
+ const buttons = [...menu.querySelectorAll('button:not([disabled])')];
55
+ if (!buttons.length) return;
56
+ const index = buttons.indexOf(documentRef.activeElement);
57
+ const next = event.key === 'ArrowDown'
58
+ ? buttons[(index + 1) % buttons.length]
59
+ : buttons[(index <= 0 ? buttons.length : index) - 1];
60
+ next.focus();
61
+ event.preventDefault();
62
+ };
63
+
64
+ for (const item of items) {
65
+ if (item === MENU_SEPARATOR) {
66
+ const rule = documentRef.createElement('div');
67
+ rule.className = 'context-menu-sep';
68
+ rule.setAttribute('role', 'separator');
69
+ menu.append(rule);
70
+ continue;
71
+ }
72
+ if (!item) continue;
73
+ const entry = documentRef.createElement('button');
74
+ entry.type = 'button';
75
+ entry.className = `context-menu-item${item.danger ? ' context-menu-item-danger' : ''}`;
76
+ entry.setAttribute('role', 'menuitem');
77
+ if (item.id) entry.dataset.menuItem = String(item.id);
78
+ entry.innerHTML = esc(item.label);
79
+ entry.disabled = item.disabled === true;
80
+ entry.addEventListener('click', (event) => {
81
+ event.preventDefault();
82
+ event.stopPropagation();
83
+ close();
84
+ item.onSelect?.();
85
+ });
86
+ menu.append(entry);
87
+ }
88
+
89
+ documentRef.body.append(menu);
90
+ // Positioned after insertion so the measured size is the real one.
91
+ const rect = menu.getBoundingClientRect();
92
+ const left = Math.max(4, Math.min(x, windowRef.innerWidth - rect.width - 4));
93
+ const top = Math.max(4, Math.min(y, windowRef.innerHeight - rect.height - 4));
94
+ menu.style.left = `${left}px`;
95
+ menu.style.top = `${top}px`;
96
+
97
+ closeOpenMenu = close;
98
+ documentRef.addEventListener('pointerdown', onOutside, true);
99
+ documentRef.addEventListener('keydown', onKey, true);
100
+ windowRef.addEventListener('resize', close);
101
+ windowRef.addEventListener('scroll', close, true);
102
+ queueMicrotask(() => menu.querySelector('button:not([disabled])')?.focus());
103
+ return menu;
104
+ }
105
+
106
+ /**
107
+ * Long-press as the touch equivalent of right-click, on every descendant of
108
+ * `root` that matches `selector`. Android fires no `contextmenu` for a long
109
+ * press on a non-selectable element. Cancelled by movement, so it never fires
110
+ * mid-scroll; mouse pointers are left to the real `contextmenu` event.
111
+ * `handler(target, x, y)`.
112
+ */
113
+ export function bindLongPress(root, selector, handler, { setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout } = {}) {
114
+ let timer = null;
115
+ let startX = 0;
116
+ let startY = 0;
117
+ const cancel = () => {
118
+ if (timer !== null) clearTimeoutImpl(timer);
119
+ timer = null;
120
+ };
121
+ root.addEventListener('pointerdown', (event) => {
122
+ if (event.pointerType === 'mouse') return;
123
+ const target = event.target?.closest?.(selector);
124
+ if (!target || !root.contains(target)) return;
125
+ startX = event.clientX;
126
+ startY = event.clientY;
127
+ cancel();
128
+ timer = setTimeoutImpl(() => {
129
+ timer = null;
130
+ handler(target, startX, startY);
131
+ }, LONG_PRESS_MS);
132
+ }, { passive: true });
133
+ root.addEventListener('pointermove', (event) => {
134
+ if (Math.abs(event.clientX - startX) > LONG_PRESS_SLOP_PX || Math.abs(event.clientY - startY) > LONG_PRESS_SLOP_PX) cancel();
135
+ }, { passive: true });
136
+ for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
137
+ root.addEventListener(name, cancel, { passive: true });
138
+ }
139
+ return cancel;
140
+ }
@@ -55,6 +55,10 @@ export function buildContextTierOptions({
55
55
  const meta = metadata && typeof metadata === 'object' ? metadata : {};
56
56
  const defaultLabel = tokenLabel(defaultWindowTokens(meta));
57
57
  const provider = String(providerType || '').trim().toLowerCase();
58
+ // A cloud session has no tier to choose and its window is not in the merged
59
+ // metadata either (that number is Copilot's): one blank default, as for a
60
+ // Claude model whose window is unknown.
61
+ if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
58
62
  const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
59
63
  if (claudeTierList) {
60
64
  return claudeTierList
@@ -134,6 +134,73 @@ function autocompactSourceLabel(source) {
134
134
  return AUTOCOMPACT_SOURCE_LABELS[key.toLowerCase()] || key;
135
135
  }
136
136
 
137
+ // The slider's notes. The window is read by the CLI only when it starts, so
138
+ // a new value takes effect through one session restart before a message, as
139
+ // soon as nothing is running.
140
+ export const AUTO_COMPACT_NOTE_TEXT = Object.freeze({
141
+ compactNext: 'The conversation will be compacted on the next message.',
142
+ afterTurn: 'Applies after the current turn.',
143
+ afterBackground: 'Applies when background work has finished.',
144
+ nextMessage: 'Applies on the next message (the session restarts once).',
145
+ });
146
+
147
+ function normalizeDeferred(value) {
148
+ const key = String(value || '').trim().toLowerCase();
149
+ return key === 'turn' || key === 'background' || key === 'next-message' ? key : null;
150
+ }
151
+
152
+ /**
153
+ * Which notes sit under the slider.
154
+ *
155
+ * - `warning`: the draft is a fixed window below the tokens already in use, so
156
+ * the next message starts with a compaction.
157
+ * - `pending`: what is on the slider is not what the running session uses
158
+ * yet — an unsaved draft, a stored value the session was not started with
159
+ * (`activeWindow`, known only as a number), or a change the relay reports
160
+ * as held back (`deferred`). Worded by what it waits for. Payloads without
161
+ * the session fields only ever compare draft and stored.
162
+ *
163
+ * @returns {{ warning: string, pending: string }}
164
+ */
165
+ export function autoCompactWindowNotes({
166
+ draftWindow,
167
+ storedWindow = null,
168
+ activeWindow = null,
169
+ deferred = null,
170
+ turnRunning = false,
171
+ usedTokens = null,
172
+ } = {}) {
173
+ const stored = parseAutoCompactWindow(storedWindow);
174
+ const draft = draftWindow === undefined ? stored : parseAutoCompactWindow(draftWindow);
175
+ const active = toNullableNumber(activeWindow);
176
+ const held = normalizeDeferred(deferred);
177
+ const used = toNullableNumber(usedTokens);
178
+ const warning = draft !== null && used !== null && draft < used ? AUTO_COMPACT_NOTE_TEXT.compactNext : '';
179
+ const differs = draft !== stored
180
+ || (active !== null && parseAutoCompactWindow(active) !== stored)
181
+ || held !== null;
182
+ let pending = '';
183
+ if (differs) {
184
+ if (held === 'turn' || turnRunning) pending = AUTO_COMPACT_NOTE_TEXT.afterTurn;
185
+ else if (held === 'background') pending = AUTO_COMPACT_NOTE_TEXT.afterBackground;
186
+ else pending = AUTO_COMPACT_NOTE_TEXT.nextMessage;
187
+ }
188
+ return { warning, pending };
189
+ }
190
+
191
+ /** The notes block's inner HTML (re-rendered live while the slider moves). */
192
+ export function renderAutoCompactNotesHtml(options = {}) {
193
+ const { warning, pending } = autoCompactWindowNotes(options);
194
+ return [
195
+ warning
196
+ ? `<div class="ctx-autocompact-note ctx-autocompact-warning" data-autocompact-note="compact-next">${escapeHtml(warning)}</div>`
197
+ : '',
198
+ pending
199
+ ? `<div class="ctx-autocompact-note" data-autocompact-note="pending">${escapeHtml(pending)}</div>`
200
+ : '',
201
+ ].join('');
202
+ }
203
+
137
204
  /**
138
205
  * The Claude-only auto-compact window control: a snap-stop slider over
139
206
  * AUTO_COMPACT_WINDOW_STOPS plus the measured effective threshold.
@@ -142,8 +209,10 @@ function autocompactSourceLabel(source) {
142
209
  * trustworthy model-limit field. `rawMaxTokens` tracks the ACTIVE setting, not
143
210
  * the model's own window (probed on claude-opus-5[1m]: no setting → 1000000,
144
211
  * setting 100000 → 100000), so annotating from it told a user who had just
145
- * pinned 100k that every larger stop was pointless. Applied on the next
146
- * message delivery, which is why there is no live preview of the effect.
212
+ * pinned 100k that every larger stop was pointless.
213
+ *
214
+ * The slider is a draft while the modal is open (`draftWindow`, defaulting to
215
+ * the stored window); the caller saves it when the modal closes.
147
216
  *
148
217
  * `isAutoCompactEnabled` is tri-state on purpose: `null`/`undefined` means the
149
218
  * runtime has not reported yet (no context-usage snapshot exists before the
@@ -158,9 +227,23 @@ export function renderAutoCompactControlHtml({
158
227
  autocompactSource = null,
159
228
  isAutoCompactEnabled = null,
160
229
  maxTokens = null,
230
+ draftWindow = undefined,
231
+ activeAutoCompactWindow = null,
232
+ autoCompactWindowDeferred = null,
233
+ turnRunning = false,
234
+ usedTokens = null,
161
235
  } = {}) {
162
- const window = parseAutoCompactWindow(autoCompactWindow);
236
+ const stored = parseAutoCompactWindow(autoCompactWindow);
237
+ const window = draftWindow === undefined ? stored : parseAutoCompactWindow(draftWindow);
163
238
  const index = autoCompactWindowToIndex(window);
239
+ const notesHtml = renderAutoCompactNotesHtml({
240
+ draftWindow: window,
241
+ storedWindow: stored,
242
+ activeWindow: activeAutoCompactWindow,
243
+ deferred: autoCompactWindowDeferred,
244
+ turnRunning,
245
+ usedTokens,
246
+ });
164
247
 
165
248
  const threshold = toNullableNumber(autoCompactThreshold);
166
249
  const sourceLabel = autocompactSourceLabel(autocompactSource);
@@ -195,7 +278,7 @@ export function renderAutoCompactControlHtml({
195
278
  >
196
279
  <div class="ctx-autocompact-effective">Effective: ${effective}</div>
197
280
  ${disabledNote}
198
- <div class="ctx-autocompact-note">Applied on the next message in this conversation.</div>
281
+ <div id="ctx-autocompact-notes" class="ctx-autocompact-notes">${notesHtml}</div>
199
282
  </div>
200
283
  `;
201
284
  }
@@ -267,20 +350,56 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
267
350
  `;
268
351
  }
269
352
 
353
+ // A model limit is a round figure: 1M rather than 1.0M.
354
+ function formatModelLimit(value) {
355
+ const n = toNullableNumber(value);
356
+ if (n === null) return '—';
357
+ if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
358
+ return formatCompactTokens(n);
359
+ }
360
+
361
+ /**
362
+ * The headline of a Claude session, whose snapshot max is the compaction
363
+ * window rather than the model's limit:
364
+ * `60.9k used · compaction window 100.0k · model limit 1M` (the limit only
365
+ * when known).
366
+ */
367
+ export function formatCompactionWindowHeadline({ totalTokens, maxTokens, modelContextLimit = null } = {}) {
368
+ if (toNullableNumber(totalTokens) === null || toNullableNumber(maxTokens) === null) return '';
369
+ const parts = [
370
+ `${formatCompactTokens(totalTokens)} used`,
371
+ `compaction window ${formatCompactTokens(maxTokens)}`,
372
+ ];
373
+ if (toNullableNumber(modelContextLimit) !== null && Number(modelContextLimit) > 0) {
374
+ parts.push(`model limit ${formatModelLimit(modelContextLimit)}`);
375
+ }
376
+ return parts.join(' · ');
377
+ }
378
+
270
379
  /**
271
380
  * @param {object|null} usage the `contextUsage` field of an /api/context response
381
+ * @param {object} [options]
382
+ * @param {boolean} [options.compactionWindow] the max is a compaction window
383
+ * (Claude): the headline names it, plus `modelContextLimit` when known
272
384
  * @returns {string} modal body HTML, or '' when there is nothing to render
273
385
  */
274
- export function renderContextUsageHtml(usage) {
386
+ export function renderContextUsageHtml(usage, { compactionWindow = false, modelContextLimit = null } = {}) {
275
387
  if (!usage || typeof usage !== 'object') return '';
276
388
  const categories = Array.isArray(usage.categories) ? usage.categories : [];
277
389
  const hasTotals = toNullableNumber(usage.totalTokens) !== null
278
390
  && toNullableNumber(usage.maxTokens) !== null;
279
391
  if (!categories.length && !hasTotals) return '';
280
392
 
281
- const headline = hasTotals
282
- ? `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.maxTokens)} tokens (${formatUsagePercent(usage.percentage, { digits: 0 })})`
283
- : '';
393
+ let headline = '';
394
+ if (hasTotals) {
395
+ headline = compactionWindow
396
+ ? formatCompactionWindowHeadline({
397
+ totalTokens: usage.totalTokens,
398
+ maxTokens: usage.maxTokens,
399
+ modelContextLimit,
400
+ })
401
+ : `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.maxTokens)} tokens (${formatUsagePercent(usage.percentage, { digits: 0 })})`;
402
+ }
284
403
 
285
404
  const rows = categories.map((category) => renderRow(category)).join('');
286
405
  const freeRow = toNullableNumber(usage.freeTokens) !== null
@@ -0,0 +1,27 @@
1
+ // The header's "via agent · “title”" line: the conversation on screen was
2
+ // started by an agent in another conversation on this relay, and the line
3
+ // opens that conversation. Derived from the conversation record alone, so the
4
+ // one sync function is idempotent and runs with every header sync. A
5
+ // conversation another relay's agent created has no header line (its sidebar
6
+ // marker is all there is), and neither has one a person started.
7
+
8
+ import { conversations, currentConvId, IS_SHARED_VIEW } from './store.js';
9
+ import { renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
10
+
11
+ let renderedHtml = '';
12
+
13
+ export function syncConversationOriginHeader() {
14
+ const line = document.getElementById('chat-title-origin');
15
+ if (!line) return;
16
+ const id = String(currentConvId || '').trim();
17
+ const conversation = id ? (conversations[id] || null) : null;
18
+ const html = conversation
19
+ ? renderConversationHeaderOriginHtml(conversation, { conversations, linkable: !IS_SHARED_VIEW })
20
+ : '';
21
+ // This runs on every list render; the button must not be rebuilt under a tap.
22
+ if (html !== renderedHtml) {
23
+ line.innerHTML = html;
24
+ renderedHtml = html;
25
+ }
26
+ line.hidden = !html;
27
+ }
@@ -23,23 +23,39 @@ export function isConversationUsingGrokProvider(conversation = null) {
23
23
  return resolveConversationProviderType(conversation) === 'grok';
24
24
  }
25
25
 
26
- // An unresolved provider stays unlabelled rather than defaulting to Copilot, so
27
- // a conversation the client has not loaded yet cannot claim the wrong provider.
28
- export function conversationProviderIndicatorLabel(conversation = null) {
26
+ // Its own provider, never a flavour of `claude`: the session runs in a sandbox
27
+ // at Anthropic, so nothing that is true of a local Claude worker (modes,
28
+ // effort, steering, a working directory) may be assumed for it.
29
+ export function isConversationUsingClaudeCloudProvider(conversation = null) {
30
+ return resolveConversationProviderType(conversation) === 'claude-cloud';
31
+ }
32
+
33
+ // The marker that tells a cloud chat from a local Claude one at a glance.
34
+ const CLAUDE_CLOUD_MARKER = '☁';
35
+
36
+ function conversationProviderName(conversation = null) {
29
37
  const providerType = resolveConversationProviderType(conversation);
30
38
  if (providerType === 'openai') return 'OpenAI';
31
39
  if (providerType === 'claude') return 'Claude';
40
+ if (providerType === 'claude-cloud') return 'Claude Cloud';
32
41
  if (providerType === 'cursor') return 'Cursor';
33
42
  if (providerType === 'grok') return 'Grok';
34
43
  if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
35
44
  return '';
36
45
  }
37
46
 
47
+ // An unresolved provider stays unlabelled rather than defaulting to Copilot, so
48
+ // a conversation the client has not loaded yet cannot claim the wrong provider.
49
+ export function conversationProviderIndicatorLabel(conversation = null) {
50
+ const name = conversationProviderName(conversation);
51
+ return isConversationUsingClaudeCloudProvider(conversation) ? `${CLAUDE_CLOUD_MARKER} ${name}` : name;
52
+ }
53
+
38
54
  // A question card names the runtime that asks, the way the list pill names it.
39
55
  // Unresolved reads plain "Question": every card used to say "Copilot question",
40
56
  // Claude's and Cursor's included.
41
57
  export function questionCardHeadLabel(conversation = null) {
42
- const provider = conversationProviderIndicatorLabel(conversation);
58
+ const provider = conversationProviderName(conversation);
43
59
  return provider ? `${provider} question` : 'Question';
44
60
  }
45
61
 
@@ -50,6 +66,7 @@ const SESSION_LOCK_LABELS = {
50
66
  claude: 'Claude SDK',
51
67
  cursor: 'Cursor SDK',
52
68
  grok: 'Grok',
69
+ 'claude-cloud': `${CLAUDE_CLOUD_MARKER} Claude Cloud`,
53
70
  };
54
71
 
55
72
  export function isOpenAIImageModelId(modelId = '') {
@@ -66,6 +83,7 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
66
83
  }
67
84
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
68
85
  if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
86
+ if (normalized === 'claude-cloud') return 'claude-cloud';
69
87
  if (normalized === 'cursor') return 'cursor';
70
88
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
71
89
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';