@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.
- package/CHANGELOG.md +285 -0
- package/README.md +222 -40
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
- package/server/claude-worker/claude-session-process.mjs +225 -24
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +149 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +468 -88
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +145 -11
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +37 -3
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +53 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +564 -20
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +123 -16
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +517 -74
- package/server/server-runtime.mjs +268 -33
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/update-install-service.mjs +9 -3
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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.
|
|
146
|
-
*
|
|
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
|
|
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-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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 =
|
|
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';
|