@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,89 @@
1
+ // Which `$` in a run of text delimit inline maths, and which are plain dollar
2
+ // signs. Prices are the common case of the second kind: without a rule,
3
+ // "$5 for one and $9 for two" reads as one formula running from the first
4
+ // sign to the second.
5
+ //
6
+ // The rule is the one pandoc uses for dollar maths: the opening `$` has a
7
+ // non-space character directly after it, the closing `$` is the next dollar
8
+ // sign, has a non-space character directly before it and no digit directly
9
+ // after it. A `$$` pair is
10
+ // display maths and is left alone, and a `$` behind a backslash is escaped.
11
+
12
+ const TEX_LOOKING = /[\\^_{}=]/;
13
+
14
+ function isEscaped(text, index) {
15
+ let backslashes = 0;
16
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) backslashes += 1;
17
+ return backslashes % 2 === 1;
18
+ }
19
+
20
+ function isSpace(char) {
21
+ return char === undefined || /\s/.test(char);
22
+ }
23
+
24
+ // A formula holds no dollar sign of its own, so only the next one can close
25
+ // it. When that one does not qualify ("$5, and $x^2$": it follows a space),
26
+ // the opening sign is a plain dollar and the next one gets its own turn.
27
+ function findClosingDollar(text, openIndex) {
28
+ let i = openIndex + 1;
29
+ while (i < text.length && (text[i] !== '$' || isEscaped(text, i))) i += 1;
30
+ if (i >= text.length) return -1;
31
+ // Part of a `$$`: not the end of an inline formula.
32
+ if (text[i + 1] === '$') return -1;
33
+ if (isSpace(text[i - 1])) return -1;
34
+ const content = text.slice(openIndex + 1, i);
35
+ // "$5/$10": a digit right behind the sign means the sign starts a number.
36
+ // Real maths directly followed by a digit ("$x^2$3") is told apart by
37
+ // looking like TeX.
38
+ if (/\d/.test(text[i + 1] || '') && !TEX_LOOKING.test(content)) return -1;
39
+ return i;
40
+ }
41
+
42
+ /**
43
+ * Scan `text` for single-dollar maths.
44
+ * @returns {{ spans: Array<[number, number]>, literals: number[] }} `spans`
45
+ * are [open, close] index pairs of formulas, `literals` the indexes of `$`
46
+ * that are plain dollar signs.
47
+ */
48
+ export function scanInlineDollars(text) {
49
+ const source = String(text ?? '');
50
+ const spans = [];
51
+ const literals = [];
52
+ let index = 0;
53
+ while (index < source.length) {
54
+ if (source[index] !== '$' || isEscaped(source, index)) {
55
+ index += 1;
56
+ continue;
57
+ }
58
+ if (source[index + 1] === '$') {
59
+ const close = source.indexOf('$$', index + 2);
60
+ index = close < 0 ? index + 2 : close + 2;
61
+ continue;
62
+ }
63
+ const close = isSpace(source[index + 1]) ? -1 : findClosingDollar(source, index);
64
+ if (close < 0) {
65
+ literals.push(index);
66
+ index += 1;
67
+ continue;
68
+ }
69
+ spans.push([index, close]);
70
+ index = close + 1;
71
+ }
72
+ return { spans, literals };
73
+ }
74
+
75
+ /** `text` cut at its plain dollar signs: `[{ text, literal }]`, in order. */
76
+ export function splitAtLiteralDollars(text) {
77
+ const source = String(text ?? '');
78
+ const { literals } = scanInlineDollars(source);
79
+ if (!literals.length) return [{ text: source, literal: false }];
80
+ const parts = [];
81
+ let from = 0;
82
+ for (const index of literals) {
83
+ if (index > from) parts.push({ text: source.slice(from, index), literal: false });
84
+ parts.push({ text: '$', literal: true });
85
+ from = index + 1;
86
+ }
87
+ if (from < source.length) parts.push({ text: source.slice(from), literal: false });
88
+ return parts;
89
+ }
@@ -12,7 +12,7 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
12
12
  const provider = String(providerType || '').trim().toLowerCase();
13
13
  const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
14
14
  const providerFallback = () => {
15
- if (provider === 'claude') return 'Message Claude…';
15
+ if (provider === 'claude' || provider === 'claude-cloud') return 'Message Claude…';
16
16
  if (provider === 'grok') return 'Message Grok…';
17
17
  if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
18
  if (provider === 'cursor') return 'Message Cursor…';
@@ -32,6 +32,7 @@ const SOURCE_LABELS = Object.freeze(Object.assign(Object.create(null), {
32
32
  const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
33
33
  github: 'Copilot',
34
34
  claude: 'Claude',
35
+ 'claude-cloud': 'Claude Cloud',
35
36
  cursor: 'Cursor',
36
37
  grok: 'Grok',
37
38
  }));
@@ -96,26 +97,32 @@ export function formatAmount(value, unit) {
96
97
  return String(Math.round(n * 100) / 100);
97
98
  }
98
99
 
99
- export function formatResetCountdown(resetAt, now = new Date()) {
100
+ /**
101
+ * @param {object} [options]
102
+ * @param {boolean} [options.expiry] the date is when the allowance ends for
103
+ * good (a credit), so it reads "expires …" instead of "resets …"
104
+ */
105
+ export function formatResetCountdown(resetAt, now = new Date(), { expiry = false } = {}) {
100
106
  if (!resetAt) return null;
101
107
  const target = new Date(resetAt);
102
108
  if (Number.isNaN(target.getTime())) return null;
103
109
  const reference = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
110
+ const verb = expiry ? 'expires' : 'resets';
104
111
  const diffMs = target.getTime() - reference.getTime();
105
- if (diffMs <= 0) return 'resets now';
112
+ if (diffMs <= 0) return expiry ? 'expired' : 'resets now';
106
113
  const minutes = Math.floor(diffMs / 60000);
107
- if (minutes < 60) return `resets in ${Math.max(1, minutes)} min`;
114
+ if (minutes < 60) return `${verb} in ${Math.max(1, minutes)} min`;
108
115
  const hours = Math.floor(minutes / 60);
109
116
  if (hours < 24) {
110
117
  const remainderMinutes = minutes % 60;
111
- return remainderMinutes ? `resets in ${hours} h ${remainderMinutes} min` : `resets in ${hours} h`;
118
+ return remainderMinutes ? `${verb} in ${hours} h ${remainderMinutes} min` : `${verb} in ${hours} h`;
112
119
  }
113
120
  const days = Math.floor(hours / 24);
114
121
  if (days <= 14) {
115
122
  const remainderHours = hours % 24;
116
- return remainderHours ? `resets in ${days} d ${remainderHours} h` : `resets in ${days} d`;
123
+ return remainderHours ? `${verb} in ${days} d ${remainderHours} h` : `${verb} in ${days} d`;
117
124
  }
118
- return `resets ${target.toISOString().slice(0, 10)}`;
125
+ return `${verb} ${target.toISOString().slice(0, 10)}`;
119
126
  }
120
127
 
121
128
  /**
@@ -153,12 +160,17 @@ function renderMeter(meter, now) {
153
160
  const width = meter.unlimited ? 100 : Math.max(0, Math.min(100, utilization ?? 0));
154
161
  const color = meter.unlimited ? '#3fb950' : utilizationColor(utilization);
155
162
  const summary = meterSummaryText(meter);
156
- const countdown = formatResetCountdown(meter.resetAt, now);
163
+ const countdown = formatResetCountdown(meter.resetAt, now, { expiry: meter.resetKind === 'expiry' });
157
164
  const percentLabel = meter.unlimited
158
165
  ? '∞'
159
166
  : (utilization === null ? '—' : `${Math.round(utilization)}%`);
167
+ // The provider's own severity word; "normal" is the absence of a warning.
168
+ const severity = String(meter.severity ?? '').trim().toLowerCase();
160
169
  const flags = [
161
170
  meter.estimated ? '<span class="plan-usage-flag">estimated</span>' : '',
171
+ severity && severity !== 'normal'
172
+ ? `<span class="plan-usage-flag plan-usage-flag-severity" data-severity="${escapeHtml(severity)}">${escapeHtml(severity.replace(/[_-]+/g, ' '))}</span>`
173
+ : '',
162
174
  ].filter(Boolean).join('');
163
175
  const meta = [summary, countdown].filter(Boolean).join(' · ');
164
176
 
@@ -193,6 +205,19 @@ function renderDetailSection(section) {
193
205
  `;
194
206
  }
195
207
 
208
+ /** A line of text on the card itself, with an optional link behind it. */
209
+ function renderNote(note) {
210
+ const text = String(note?.text ?? '').trim();
211
+ if (!text) return '';
212
+ const url = String(note?.link?.url ?? '').trim();
213
+ const label = String(note?.link?.label ?? '').trim();
214
+ // Same rule as the server's: only an absolute https link becomes an href.
215
+ const link = label && /^https:\/\//i.test(url)
216
+ ? ` <a class="plan-usage-link" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
217
+ : '';
218
+ return `<div class="plan-usage-detail-note plan-usage-note" data-note-id="${escapeHtml(note?.id ?? '')}">${escapeHtml(text)}${link}</div>`;
219
+ }
220
+
196
221
  /**
197
222
  * The signed-in account under the card title. Three distinct states, because
198
223
  * "we could not ask" must not be drawn as "you are logged out": no `account`
@@ -218,6 +243,7 @@ function renderAccountLine(account) {
218
243
  function renderCard(card, now, { active = true } = {}) {
219
244
  const meters = Array.isArray(card.meters) ? card.meters : [];
220
245
  const details = Array.isArray(card.details) ? card.details : [];
246
+ const notes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
221
247
  const links = Array.isArray(card.links) ? card.links : [];
222
248
 
223
249
  const statusBadge = STATUS_BADGES[card.status];
@@ -255,6 +281,7 @@ function renderCard(card, now, { active = true } = {}) {
255
281
  </header>
256
282
  ${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
257
283
  ${metersHtml}
284
+ ${notes.map(renderNote).join('')}
258
285
  ${details.map(renderDetailSection).join('')}
259
286
  <div class="plan-usage-card-foot">
260
287
  ${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
@@ -296,8 +323,9 @@ export function planUsageSubtitle(report) {
296
323
  if (!providers.length) return 'No usage data available';
297
324
  const available = providers.filter((card) => Array.isArray(card.meters) && card.meters.length);
298
325
  if (!available.length) return 'No plan limits reported yet';
326
+ // A credit's expiry is not a reset: nothing refills on that date.
299
327
  const soonest = available
300
- .flatMap((card) => card.meters.map((meter) => meter.resetAt))
328
+ .flatMap((card) => card.meters.map((meter) => (meter?.resetKind === 'expiry' ? null : meter?.resetAt)))
301
329
  .filter(Boolean)
302
330
  .sort()[0];
303
331
  const countdown = formatResetCountdown(soonest);
@@ -52,6 +52,11 @@ function shouldUseImmersiveTopLayout() {
52
52
  function syncThemeColor(immersive) {
53
53
  const meta = document.querySelector('meta[name="theme-color"]');
54
54
  if (!meta) return;
55
+ // Day mode keeps the white the head script (and a Theme change) set.
56
+ if (document.documentElement.getAttribute('data-theme') === 'light') {
57
+ meta.setAttribute('content', '#ffffff');
58
+ return;
59
+ }
55
60
  meta.setAttribute('content', immersive ? THEME_COLOR_IMMERSIVE : THEME_COLOR_BASE);
56
61
  }
57
62
 
@@ -39,10 +39,24 @@ const RELAY_ERROR_CTAS = Object.freeze({
39
39
  });
40
40
 
41
41
  const STABLE_CODE_PATTERN = /error code:\s*relay\.([a-z0-9-]+)/i;
42
+ // The shape of a failure note, mirrored from shared/failure-note-text.mjs
43
+ // (the browser cannot import it; failure-note-text.test.mjs keeps the two in
44
+ // step): one paragraph of at most this length leading up to the code, not a
45
+ // quotation, a code span, a table cell, a list item or a heading. A reply
46
+ // that talks about a code gets no button.
47
+ const FAILURE_NOTE_LEAD_MAX = 600;
48
+ const QUOTED_LEAD = /["“”'`|>]\s*$/;
49
+ const MARKUP_LEAD = /^\s*(?:#+|[>|*-]|\d+\.)\s/;
42
50
 
51
+ /** The code of a text that is a failure note of the relay, else ''. */
43
52
  export function relayErrorCodeFromText(text) {
44
- const match = String(text || '').match(STABLE_CODE_PATTERN);
45
- return match ? match[1].toLowerCase() : '';
53
+ const raw = String(text || '').trim();
54
+ const match = raw.match(STABLE_CODE_PATTERN);
55
+ if (!match) return '';
56
+ const lead = raw.slice(0, match.index);
57
+ if (lead.length > FAILURE_NOTE_LEAD_MAX || /[\r\n]/.test(lead)) return '';
58
+ if (QUOTED_LEAD.test(lead) || MARKUP_LEAD.test(lead)) return '';
59
+ return match[1].toLowerCase();
46
60
  }
47
61
 
48
62
  /**
@@ -3,6 +3,12 @@
3
3
  // to the source session) and the "↗ win-test" marker on the sidebar row of a
4
4
  // conversation such an agent created. Both read the `origin` the relay stores
5
5
  // with the message or conversation (docs/plans/2026-09-27-remote-relays.md §4).
6
+ //
7
+ // An agent on THIS relay can start sessions too. Such an origin carries
8
+ // `local: true` and names the conversation the agent runs in: the marker then
9
+ // reads "via agent · “title”" and opens that conversation inside the app
10
+ // (window.openOriginConversation, journal-view.js) instead of linking to
11
+ // another relay. An origin without `local` renders exactly as before.
6
12
 
7
13
  import { remoteConversationUrl } from './remote-relay-shared.mjs';
8
14
 
@@ -19,6 +25,76 @@ function oneLine(value) {
19
25
  return String(value ?? '').replace(/\s+/g, ' ').trim();
20
26
  }
21
27
 
28
+ // How much of the other conversation's title a marker carries; CSS ellipsis
29
+ // takes over where the row is narrower than that.
30
+ export const ORIGIN_TITLE_MAX_SIDEBAR = 28;
31
+ export const ORIGIN_TITLE_MAX = 60;
32
+
33
+ /** "a long conversation tit…": at most `max` characters, one line. */
34
+ export function truncateOriginTitle(value, max = ORIGIN_TITLE_MAX) {
35
+ const text = oneLine(value);
36
+ const limit = Math.max(2, Math.floor(Number(max)) || ORIGIN_TITLE_MAX);
37
+ // By code point, so an emoji is never cut in half.
38
+ const chars = Array.from(text);
39
+ if (chars.length <= limit) return text;
40
+ return `${chars.slice(0, limit - 1).join('').trimEnd()}…`;
41
+ }
42
+
43
+ /** True for the origin of work an agent on this relay started. */
44
+ export function isLocalAgentOrigin(raw) {
45
+ return !!raw && typeof raw === 'object' && !Array.isArray(raw) && raw.local === true;
46
+ }
47
+
48
+ function knownConversation(conversations, id) {
49
+ if (!id || !conversations || typeof conversations !== 'object') return null;
50
+ const found = Object.prototype.hasOwnProperty.call(conversations, id) ? conversations[id] : null;
51
+ return found && typeof found === 'object' ? found : null;
52
+ }
53
+
54
+ /**
55
+ * What a local origin shows, or null when the origin is not a local one.
56
+ * `conversations` is the browser's list by id: the marker is a link only while
57
+ * the orchestrating conversation is in it, and shows that conversation's
58
+ * current title (the origin stores the one it had at creation). `ownId` is the
59
+ * conversation the marker sits on, which never links to itself.
60
+ */
61
+ export function localAgentOriginModel(origin, {
62
+ conversations = null,
63
+ ownId = '',
64
+ linkable = true,
65
+ maxTitleLength = ORIGIN_TITLE_MAX,
66
+ } = {}) {
67
+ if (!isLocalAgentOrigin(origin)) return null;
68
+ const conversationId = oneLine(origin.conversationId);
69
+ const known = conversationId !== oneLine(ownId) ? knownConversation(conversations, conversationId) : null;
70
+ const fullTitle = oneLine(known?.title) || oneLine(origin.conversationTitle);
71
+ const title = truncateOriginTitle(fullTitle, maxTitleLength);
72
+ return {
73
+ conversationId,
74
+ fullTitle,
75
+ title,
76
+ model: oneLine(origin.model),
77
+ text: title ? `via agent · “${title}”` : 'via agent',
78
+ linkable: linkable !== false && !!known,
79
+ };
80
+ }
81
+
82
+ // A button while the orchestrating conversation can be opened, plain text
83
+ // otherwise. `tooltip` is a full sentence.
84
+ function localAgentElementHtml(model, { className, text = model.text, tooltip }) {
85
+ const label = escapeHtml(text);
86
+ if (model.linkable) {
87
+ return `<button type="button" class="${className}" data-origin-conversation-id="${escapeHtml(model.conversationId)}" title="${escapeHtml(`${tooltip} Opens that conversation.`)}" onclick="openOriginConversation(event, this)">${label}</button>`;
88
+ }
89
+ return `<span class="${className}" title="${escapeHtml(tooltip)}">${label}</span>`;
90
+ }
91
+
92
+ function startedByTooltip(model) {
93
+ return model.fullTitle
94
+ ? `Started by an agent in “${model.fullTitle}”.`
95
+ : 'Started by an agent in another conversation on this relay.';
96
+ }
97
+
22
98
  /** The display fields of an `origin`, or null when it names no relay. */
23
99
  export function normalizeRemoteOrigin(raw) {
24
100
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -35,7 +111,9 @@ export function normalizeRemoteOrigin(raw) {
35
111
  };
36
112
  }
37
113
 
38
- export function remoteOriginBadgeText(origin) {
114
+ export function remoteOriginBadgeText(origin, options = {}) {
115
+ const local = localAgentOriginModel(origin, options);
116
+ if (local) return local.model ? `${local.text} · ${local.model}` : local.text;
39
117
  const normalized = normalizeRemoteOrigin(origin);
40
118
  if (!normalized) return '';
41
119
  const parts = [`↗ from ${normalized.relayName}`];
@@ -54,8 +132,21 @@ export function remoteOriginLink(origin) {
54
132
  /**
55
133
  * The badge row that sits above a user bubble's text. `linkable: false` (the
56
134
  * shared read-only view) renders it without the link to the other relay.
135
+ * `conversations` (the browser's list by id) lets a local agent's badge open
136
+ * the conversation that agent runs in.
57
137
  */
58
- export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
138
+ export function renderRemoteOriginBadgeHtml(origin, { linkable = true, conversations = null } = {}) {
139
+ const local = localAgentOriginModel(origin, { conversations, linkable });
140
+ if (local) {
141
+ const badge = localAgentElementHtml(local, {
142
+ className: 'msg-origin-badge msg-origin-agent',
143
+ text: remoteOriginBadgeText(origin, { conversations }),
144
+ tooltip: local.fullTitle
145
+ ? `Sent by an agent in “${local.fullTitle}”.`
146
+ : 'Sent by an agent in another conversation on this relay.',
147
+ });
148
+ return `<div class="msg-origin">${badge}</div>`;
149
+ }
59
150
  const normalized = normalizeRemoteOrigin(origin);
60
151
  if (!normalized) return '';
61
152
  const label = escapeHtml(remoteOriginBadgeText(normalized));
@@ -67,10 +158,35 @@ export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
67
158
  return `<div class="msg-origin"><span class="msg-origin-badge" title="Sent by an agent on relay ${name}.">${label}</span></div>`;
68
159
  }
69
160
 
70
- /** The sidebar marker for a conversation another relay's agent created. */
71
- export function renderConversationOriginMarkerHtml(conversation) {
161
+ /**
162
+ * The sidebar marker for a conversation an agent created: "↗ win-test" for
163
+ * another relay's agent, "via agent · “title”" for one on this relay.
164
+ */
165
+ export function renderConversationOriginMarkerHtml(conversation, { conversations = null, linkable = true } = {}) {
166
+ const local = localAgentOriginModel(conversation?.origin, {
167
+ conversations,
168
+ linkable,
169
+ ownId: conversation?.id,
170
+ maxTitleLength: ORIGIN_TITLE_MAX_SIDEBAR,
171
+ });
172
+ if (local) {
173
+ return localAgentElementHtml(local, {
174
+ className: 'conv-origin-marker conv-origin-agent',
175
+ tooltip: startedByTooltip(local),
176
+ });
177
+ }
72
178
  const normalized = normalizeRemoteOrigin(conversation?.origin);
73
179
  if (!normalized) return '';
74
180
  const name = escapeHtml(normalized.relayName);
75
181
  return `<span class="conv-origin-marker" title="Started by an agent on relay ${name}">↗ ${name}</span>`;
76
182
  }
183
+
184
+ /**
185
+ * The same marker for the conversation header, with room for more of the
186
+ * title. Only a local origin has one; '' for everything else.
187
+ */
188
+ export function renderConversationHeaderOriginHtml(conversation, { conversations = null, linkable = true } = {}) {
189
+ const local = localAgentOriginModel(conversation?.origin, { conversations, linkable, ownId: conversation?.id });
190
+ if (!local) return '';
191
+ return localAgentElementHtml(local, { className: 'conv-origin-agent', tooltip: startedByTooltip(local) });
192
+ }
@@ -6,6 +6,7 @@ import {
6
6
  WORKSPACE_FILE_EXTENSIONS,
7
7
  serverPlatform,
8
8
  } from './store.js';
9
+ import { scanInlineDollars, splitAtLiteralDollars } from './math-dollars.mjs';
9
10
 
10
11
  export function workspaceMentionRegex() {
11
12
  return /(?:[A-Za-z]:[\\/])?(?:\.{1,2}[\\/])?(?:[A-Za-z0-9._-]+[\\/])*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,16}/g;
@@ -397,7 +398,8 @@ function normalizeInlineMathDelimiters(line) {
397
398
  let output = '';
398
399
  let index = 0;
399
400
  let inCode = false;
400
- let inDollarMath = false;
401
+ // Only real dollar maths is skipped over; a price's `$` opens nothing.
402
+ const dollarMathEnds = new Map(scanInlineDollars(line).spans);
401
403
 
402
404
  while (index < line.length) {
403
405
  const char = line[index];
@@ -412,15 +414,10 @@ function normalizeInlineMathDelimiters(line) {
412
414
  index += 1;
413
415
  continue;
414
416
  }
415
- if (char === '$' && line[index - 1] !== '\\') {
416
- inDollarMath = !inDollarMath;
417
- output += char;
418
- index += 1;
419
- continue;
420
- }
421
- if (inDollarMath) {
422
- output += char;
423
- index += 1;
417
+ if (char === '$' && dollarMathEnds.has(index)) {
418
+ const closeIndex = dollarMathEnds.get(index);
419
+ output += line.slice(index, closeIndex + 1);
420
+ index = closeIndex + 1;
424
421
  continue;
425
422
  }
426
423
  if (char === '\\' && line[index + 1] === '(') {
@@ -469,6 +466,7 @@ function protectMarkdownEscapesInMath(source) {
469
466
  let output = '';
470
467
  let index = 0;
471
468
  let inCode = false;
469
+ const dollarMathEnds = new Map(scanInlineDollars(line).spans);
472
470
  while (index < line.length) {
473
471
  if (line[index] === '`') {
474
472
  inCode = !inCode;
@@ -482,7 +480,10 @@ function protectMarkdownEscapesInMath(source) {
482
480
  continue;
483
481
  }
484
482
  const delimiter = line[index + 1] === '$' ? '$$' : '$';
485
- const closeIndex = line.indexOf(delimiter, index + delimiter.length);
483
+ // A single `$` that is a plain dollar sign (a price) opens no formula.
484
+ const closeIndex = delimiter === '$'
485
+ ? (dollarMathEnds.has(index) ? dollarMathEnds.get(index) : -1)
486
+ : line.indexOf(delimiter, index + delimiter.length);
486
487
  if (closeIndex < 0) {
487
488
  output += line[index];
488
489
  index += 1;
@@ -550,6 +551,40 @@ export function normalizeMathDelimiters(source) {
550
551
  return protectMarkdownEscapesInMath(normalized.join('\n'));
551
552
  }
552
553
 
554
+ const MATH_IGNORED_TAGS = ['pre', 'code', 'script', 'style', 'textarea', 'option', 'a'];
555
+ const LITERAL_DOLLAR_CLASS = 'literal-dollar';
556
+
557
+ /**
558
+ * KaTeX's auto-render pairs any two `$` in a block, so "$5 … $9" would become
559
+ * one formula. Each plain dollar sign (see math-dollars.mjs) is moved into a
560
+ * span of its own that auto-render skips; with no partner in its text node,
561
+ * it stays a dollar sign.
562
+ */
563
+ function isolateLiteralDollars(container) {
564
+ const ignored = MATH_IGNORED_TAGS.join(',');
565
+ const walker = document.createTreeWalker(container, 4 /* NodeFilter.SHOW_TEXT */);
566
+ const textNodes = [];
567
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
568
+ if (node.nodeValue.includes('$') && !node.parentElement?.closest(ignored)) textNodes.push(node);
569
+ }
570
+ for (const node of textNodes) {
571
+ const parts = splitAtLiteralDollars(node.nodeValue);
572
+ if (!parts.some((part) => part.literal)) continue;
573
+ const fragment = document.createDocumentFragment();
574
+ for (const part of parts) {
575
+ if (!part.literal) {
576
+ fragment.appendChild(document.createTextNode(part.text));
577
+ continue;
578
+ }
579
+ const span = document.createElement('span');
580
+ span.className = LITERAL_DOLLAR_CLASS;
581
+ span.textContent = part.text;
582
+ fragment.appendChild(span);
583
+ }
584
+ node.parentNode.replaceChild(fragment, node);
585
+ }
586
+ }
587
+
553
588
  function renderMathInHtml(html) {
554
589
  const renderMathInElement = globalThis.renderMathInElement;
555
590
  if (typeof renderMathInElement !== 'function' || !globalThis.document?.createElement) return html;
@@ -557,6 +592,7 @@ function renderMathInHtml(html) {
557
592
  const container = document.createElement('div');
558
593
  container.innerHTML = html;
559
594
  try {
595
+ isolateLiteralDollars(container);
560
596
  renderMathInElement(container, {
561
597
  delimiters: [
562
598
  { left: '$$', right: '$$', display: true },
@@ -564,7 +600,8 @@ function renderMathInHtml(html) {
564
600
  { left: '\\(', right: '\\)', display: false },
565
601
  { left: '$', right: '$', display: false },
566
602
  ],
567
- ignoredTags: ['pre', 'code', 'script', 'style', 'textarea', 'option', 'a'],
603
+ ignoredTags: MATH_IGNORED_TAGS,
604
+ ignoredClasses: [LITERAL_DOLLAR_CLASS],
568
605
  throwOnError: false,
569
606
  strict: 'ignore',
570
607
  trust: false,
@@ -0,0 +1,78 @@
1
+ // Desktop scrollbars are thin and hard to grab. With a mouse, the scrollbar of
2
+ // the scroll area under the pointer widens to twice its width while the
3
+ // pointer is on it, and goes back 2.5 s after the pointer left it. The track
4
+ // always has the wide width (index.html), so nothing in the page moves when
5
+ // the thumb grows.
6
+
7
+ export const SCROLLBAR_WIDE_CLASS = 'scrollbar-wide';
8
+ export const SCROLLBAR_LINGER_MS = 2500;
9
+ // How far left of the scrollbar the pointer already counts as on it.
10
+ const REACH_PX = 4;
11
+
12
+ /**
13
+ * Whether a pointer at `clientX` is on the vertical scrollbar of a scroll
14
+ * area: `rect` is its border box, `barWidth` the room its scrollbar takes
15
+ * (offsetWidth - clientWidth, borders included, which errs on the wide side).
16
+ */
17
+ export function pointerOnScrollbar({ clientX, rect, barWidth, reach = REACH_PX } = {}) {
18
+ if (!rect || !(barWidth > 0)) return false;
19
+ return clientX >= rect.right - barWidth - reach && clientX <= rect.right;
20
+ }
21
+
22
+ function scrollAreaUnderPointer(target, clientX) {
23
+ for (let node = target; node && node.nodeType === 1; node = node.parentElement) {
24
+ if (node.scrollHeight <= node.clientHeight) continue;
25
+ const barWidth = node.offsetWidth - node.clientWidth;
26
+ if (pointerOnScrollbar({ clientX, rect: node.getBoundingClientRect(), barWidth })) return node;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export function initScrollbarHover({ doc = document, win = window, lingerMs = SCROLLBAR_LINGER_MS } = {}) {
32
+ let canHover = false;
33
+ try { canHover = win.matchMedia('(hover: hover) and (pointer: fine)').matches; } catch {}
34
+ if (!canHover) return () => {};
35
+ const timers = new Map();
36
+ const widen = (node) => {
37
+ clearTimeout(timers.get(node));
38
+ timers.delete(node);
39
+ node.classList.add(SCROLLBAR_WIDE_CLASS);
40
+ };
41
+ const release = (node) => {
42
+ if (timers.has(node)) return;
43
+ timers.set(node, setTimeout(() => {
44
+ timers.delete(node);
45
+ node.classList.remove(SCROLLBAR_WIDE_CLASS);
46
+ }, lingerMs));
47
+ };
48
+ const onMove = (event) => {
49
+ const hovered = scrollAreaUnderPointer(event.target, event.clientX);
50
+ if (hovered) widen(hovered);
51
+ for (const node of doc.querySelectorAll(`.${SCROLLBAR_WIDE_CLASS}`)) {
52
+ if (node !== hovered) release(node);
53
+ }
54
+ };
55
+ // Dragging the thumb sends no pointer moves to the page; the scrolling it
56
+ // causes keeps the scrollbar wide for as long as it lasts.
57
+ const onScroll = (event) => {
58
+ const node = event.target;
59
+ if (node?.classList?.contains(SCROLLBAR_WIDE_CLASS) && timers.has(node)) {
60
+ clearTimeout(timers.get(node));
61
+ timers.delete(node);
62
+ release(node);
63
+ }
64
+ };
65
+ const onLeave = () => {
66
+ for (const node of doc.querySelectorAll(`.${SCROLLBAR_WIDE_CLASS}`)) release(node);
67
+ };
68
+ doc.addEventListener('mousemove', onMove, { passive: true });
69
+ doc.addEventListener('scroll', onScroll, { passive: true, capture: true });
70
+ doc.documentElement.addEventListener('mouseleave', onLeave, { passive: true });
71
+ return () => {
72
+ doc.removeEventListener('mousemove', onMove);
73
+ doc.removeEventListener('scroll', onScroll, { capture: true });
74
+ doc.documentElement.removeEventListener('mouseleave', onLeave);
75
+ for (const timer of timers.values()) clearTimeout(timer);
76
+ timers.clear();
77
+ };
78
+ }