@oar-sh/oar 0.9.7 → 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 +113 -0
- package/README.md +46 -18
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +6 -2
- package/server/public/app/bootstrap.js +124 -57
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +62 -7
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +2 -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 +183 -13
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/routes/messages-routes.mjs +3 -1
- package/server/routes/sessions-routes.mjs +15 -2
- package/server/server-runtime.mjs +10 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/update-install-service.mjs +9 -3
|
@@ -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,
|
|
@@ -120,6 +121,7 @@ import {
|
|
|
120
121
|
clearImageEditTarget,
|
|
121
122
|
jumpToImageParent,
|
|
122
123
|
flushDeferredMessageRender,
|
|
124
|
+
conversationHasActiveTurn,
|
|
123
125
|
} from './conversation-view.js';
|
|
124
126
|
import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
|
|
125
127
|
import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
|
|
@@ -197,8 +199,11 @@ import {
|
|
|
197
199
|
registerPwaShell,
|
|
198
200
|
updatePwaAppName,
|
|
199
201
|
} from './pwa-install.js';
|
|
202
|
+
import { initScrollbarHover } from './scrollbar-hover.mjs';
|
|
200
203
|
import {
|
|
204
|
+
autoCompactWindowNotes,
|
|
201
205
|
renderAutoCompactControlHtml,
|
|
206
|
+
renderAutoCompactNotesHtml,
|
|
202
207
|
renderContextUsageHtml,
|
|
203
208
|
renderThinkingControlHtml,
|
|
204
209
|
thinkingEnabledFromKey,
|
|
@@ -207,7 +212,6 @@ import {
|
|
|
207
212
|
import { renderAttributionControlHtml } from './attribution-control.mjs';
|
|
208
213
|
import {
|
|
209
214
|
autoCompactWindowFromIndex,
|
|
210
|
-
autoCompactWindowToIndex,
|
|
211
215
|
formatAutoCompactWindowLabel,
|
|
212
216
|
} from './auto-compact-window-options.mjs';
|
|
213
217
|
import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
|
|
@@ -826,7 +830,11 @@ function initNetworkLifecycleHandling() {
|
|
|
826
830
|
window.addEventListener('pageshow', () => {
|
|
827
831
|
handleForegroundTransition('pageshow', { immediate: true });
|
|
828
832
|
});
|
|
829
|
-
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 });
|
|
830
838
|
// The page is going away; no grace period, suspend immediately.
|
|
831
839
|
cancelBackgroundSuspend();
|
|
832
840
|
setForegroundNetworkWorkEnabled(false);
|
|
@@ -3094,18 +3102,37 @@ async function loadUsageSummaryAndRender() {
|
|
|
3094
3102
|
// The context modal is rendered from scratch on every refresh, so the slider's
|
|
3095
3103
|
// conversation binding lives here rather than on the element.
|
|
3096
3104
|
let autoCompactControlConversationId = '';
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
//
|
|
3100
|
-
//
|
|
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.
|
|
3101
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
|
+
}
|
|
3102
3128
|
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
if (slider) slider.value = String(autoCompactWindowToIndex(window));
|
|
3129
|
+
function trackAutoCompactDraft(slider) {
|
|
3130
|
+
const window = autoCompactWindowFromIndex(slider.value);
|
|
3131
|
+
autoCompactDraft = { conversationId: autoCompactControlConversationId, window };
|
|
3107
3132
|
const label = document.getElementById('ctx-autocompact-value');
|
|
3108
3133
|
if (label) label.textContent = formatAutoCompactWindowLabel(window);
|
|
3134
|
+
const notes = document.getElementById('ctx-autocompact-notes');
|
|
3135
|
+
if (notes) notes.innerHTML = renderAutoCompactNotesHtml(autoCompactNotesOptions(window));
|
|
3109
3136
|
}
|
|
3110
3137
|
|
|
3111
3138
|
/**
|
|
@@ -3121,18 +3148,37 @@ function initAutoCompactWindowControl() {
|
|
|
3121
3148
|
const target = event?.target;
|
|
3122
3149
|
return target && target.id === 'ctx-autocompact-slider' ? target : null;
|
|
3123
3150
|
};
|
|
3124
|
-
//
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
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;
|
|
3136
3182
|
});
|
|
3137
3183
|
}
|
|
3138
3184
|
|
|
@@ -3239,41 +3285,32 @@ async function applyThinkingSelection(axis, key) {
|
|
|
3239
3285
|
}
|
|
3240
3286
|
}
|
|
3241
3287
|
|
|
3242
|
-
async function
|
|
3243
|
-
const convId = String(autoCompactControlConversationId || '').trim();
|
|
3244
|
-
const slider = document.getElementById('ctx-autocompact-slider');
|
|
3245
|
-
if (!convId || autoCompactWindowUpdateInFlight) {
|
|
3246
|
-
// Nothing will be written, so leave the control showing what is stored
|
|
3247
|
-
// rather than a position the server never heard about.
|
|
3248
|
-
setAutoCompactControlValue(autoCompactControlStoredWindow);
|
|
3249
|
-
return;
|
|
3250
|
-
}
|
|
3251
|
-
autoCompactWindowUpdateInFlight = true;
|
|
3252
|
-
if (slider) slider.disabled = true;
|
|
3288
|
+
async function saveAutoCompactWindow(convId, autoCompactWindow, pendingNote = '', { pageClosing = false } = {}) {
|
|
3253
3289
|
try {
|
|
3254
3290
|
// Only the window is sent: the route keeps every unmentioned preference as
|
|
3255
3291
|
// stored, so this cannot clobber the composer's model/effort choice.
|
|
3256
3292
|
const response = await updateConversationPreferences(convId, {
|
|
3257
3293
|
clientId: CLIENT_ID,
|
|
3258
3294
|
autoCompactWindow,
|
|
3259
|
-
});
|
|
3295
|
+
}, { keepalive: pageClosing });
|
|
3296
|
+
// Nobody is left to tell on a page that is going away.
|
|
3297
|
+
if (pageClosing) return;
|
|
3260
3298
|
if (!response) throw new Error('Failed to update the auto-compact window');
|
|
3261
3299
|
// The server, not the slider, decides what got stored (values snap to a
|
|
3262
|
-
// stop)
|
|
3300
|
+
// stop).
|
|
3263
3301
|
const stored = response.autoCompactWindow ?? null;
|
|
3264
|
-
autoCompactControlStoredWindow = stored;
|
|
3265
|
-
|
|
3302
|
+
if (convId === autoCompactControlConversationId) autoCompactControlStoredWindow = stored;
|
|
3303
|
+
// No note when the running session already uses this value.
|
|
3304
|
+
const when = pendingNote ? ` ${pendingNote}` : '';
|
|
3266
3305
|
showTransientRelayNotice(stored === null
|
|
3267
|
-
?
|
|
3268
|
-
: `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens
|
|
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
|
+
}
|
|
3269
3312
|
} catch (error) {
|
|
3270
|
-
// The write was refused, so the rejected position is a lie: put the
|
|
3271
|
-
// control back on the last value the server confirmed.
|
|
3272
|
-
setAutoCompactControlValue(autoCompactControlStoredWindow);
|
|
3273
3313
|
alert(error?.message || 'Failed to update the auto-compact window.');
|
|
3274
|
-
} finally {
|
|
3275
|
-
autoCompactWindowUpdateInFlight = false;
|
|
3276
|
-
if (slider) slider.disabled = false;
|
|
3277
3314
|
}
|
|
3278
3315
|
}
|
|
3279
3316
|
|
|
@@ -3294,7 +3331,33 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3294
3331
|
? `${providerLabel} session ${sessionId.slice(0, 8)}`
|
|
3295
3332
|
: (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
|
|
3296
3333
|
|
|
3297
|
-
|
|
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;
|
|
3298
3361
|
// Claude-only: no other provider exposes a compaction window (or any
|
|
3299
3362
|
// session-settings surface) to steer.
|
|
3300
3363
|
const autoCompactHtml = payload.providerType === 'claude'
|
|
@@ -3310,6 +3373,11 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3310
3373
|
? (payload.contextUsage.isAutoCompactEnabled ?? null)
|
|
3311
3374
|
: null,
|
|
3312
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,
|
|
3313
3381
|
})
|
|
3314
3382
|
: '';
|
|
3315
3383
|
const thinkingHtml = payload.providerType === 'claude'
|
|
@@ -3323,14 +3391,6 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3323
3391
|
const attributionHtml = payload.providerType === 'claude' || payload.providerType === 'claude-cloud'
|
|
3324
3392
|
? renderAttributionControlHtml(payload.attribution || null)
|
|
3325
3393
|
: '';
|
|
3326
|
-
// The slider's writes need the conversation id even when the modal was
|
|
3327
|
-
// opened through an sdk_session_id.
|
|
3328
|
-
autoCompactControlConversationId = String(
|
|
3329
|
-
payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
|
|
3330
|
-
).trim();
|
|
3331
|
-
// Baseline for the failure path below: what the server says is stored right
|
|
3332
|
-
// now, refreshed every time the control is re-rendered.
|
|
3333
|
-
autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
|
|
3334
3394
|
thinkingControlStored = {
|
|
3335
3395
|
enabled: payload.thinkingEnabled !== false,
|
|
3336
3396
|
display: payload.thinkingDisplay || 'summarized',
|
|
@@ -3351,6 +3411,7 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3351
3411
|
refresh: () => loadContextSummaryAndRender(refreshLookupId),
|
|
3352
3412
|
kind: 'context',
|
|
3353
3413
|
});
|
|
3414
|
+
setSummaryModalLeaveHandler('context', () => commitAutoCompactDraft());
|
|
3354
3415
|
}
|
|
3355
3416
|
|
|
3356
3417
|
async function showUsage() {
|
|
@@ -4194,11 +4255,15 @@ function refreshClaudeCloudComposerModels() {
|
|
|
4194
4255
|
}
|
|
4195
4256
|
|
|
4196
4257
|
function showAuthGate(error = '') {
|
|
4197
|
-
document.getElementById('startup-loading')?.remove();
|
|
4198
4258
|
document.getElementById('auth-gate').style.display = 'flex';
|
|
4199
4259
|
document.getElementById('app').classList.remove('visible');
|
|
4200
4260
|
showAuthError(error);
|
|
4201
|
-
|
|
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();
|
|
4202
4267
|
}
|
|
4203
4268
|
|
|
4204
4269
|
// Runs before anything reads MODEL_STORAGE_KEY so the first load after an
|
|
@@ -4224,6 +4289,7 @@ async function initApp() {
|
|
|
4224
4289
|
setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
|
|
4225
4290
|
const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
|
|
4226
4291
|
initTheme();
|
|
4292
|
+
initScrollbarHover();
|
|
4227
4293
|
initThemeMenuToggle();
|
|
4228
4294
|
if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
|
|
4229
4295
|
initFontScaling();
|
|
@@ -4256,7 +4322,8 @@ async function initApp() {
|
|
|
4256
4322
|
void flushConversationDraft(currentConvId);
|
|
4257
4323
|
closeTmuxInspectorView();
|
|
4258
4324
|
});
|
|
4259
|
-
|
|
4325
|
+
// The start screen lies over the app and leaves once its animation is done.
|
|
4326
|
+
void window.__startupSplash?.finish?.();
|
|
4260
4327
|
document.getElementById('auth-gate').style.display = 'none';
|
|
4261
4328
|
document.getElementById('app').classList.add('visible');
|
|
4262
4329
|
initSidebarLayout();
|
|
@@ -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
|
|
@@ -88,8 +88,14 @@ import {
|
|
|
88
88
|
normalizeRelayActivityEntry,
|
|
89
89
|
promotedCompactBoundaryEntry,
|
|
90
90
|
relayActivityEntryText,
|
|
91
|
+
visibleActivityEntries,
|
|
91
92
|
} from './activity-replay-state.mjs';
|
|
92
|
-
import {
|
|
93
|
+
import {
|
|
94
|
+
COMPACT_PENDING_STAMP,
|
|
95
|
+
SEPARATOR_CLASS,
|
|
96
|
+
syncSeparatorRail,
|
|
97
|
+
syncTranscriptSeparators,
|
|
98
|
+
} from './transcript-separators.mjs';
|
|
93
99
|
import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
94
100
|
import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
|
|
95
101
|
import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
|
|
@@ -1338,12 +1344,14 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1338
1344
|
// break row immediately above this message (syncTranscriptSeparators reads
|
|
1339
1345
|
// the stamp), so it must not also show up inside the bubble's activity list.
|
|
1340
1346
|
// Only the promoted (last) boundary is hidden — see renderActivityMarkup.
|
|
1347
|
+
// A rendered row is a finished message, so a compaction start without an
|
|
1348
|
+
// end draws no line here (the live bubble owns the running one).
|
|
1341
1349
|
const promotedBoundaryEntry = promotedCompactBoundaryEntry(activities);
|
|
1342
1350
|
const compactBoundary = compactBoundaryFromActivities(activities);
|
|
1343
1351
|
if (compactBoundary) {
|
|
1344
1352
|
div.dataset.compactBoundary = `${compactBoundary.preTokens ?? ''}|${compactBoundary.postTokens ?? ''}`;
|
|
1345
1353
|
}
|
|
1346
|
-
if (activities
|
|
1354
|
+
if (visibleActivityEntries(activities, promotedBoundaryEntry).length) div.classList.add('msg-with-activity');
|
|
1347
1355
|
const thoughts = Array.isArray(msg.thoughts) ? msg.thoughts.filter((t) => t && String(t.text || '').trim()) : [];
|
|
1348
1356
|
const attachmentHtml = attachments.length ? renderAttachmentMarkup(attachments, { messageId: msgId }) : '';
|
|
1349
1357
|
const subagentRuns = Array.isArray(msg.subagentRuns) ? msg.subagentRuns.filter(Boolean) : [];
|
|
@@ -1479,7 +1487,15 @@ function insertMessageNode(node, insertAfterId = null) {
|
|
|
1479
1487
|
const anchorId = String(insertAfterId || '').trim();
|
|
1480
1488
|
const anchor = anchorId ? el.querySelector(`[data-message-id="${anchorId}"]`) : null;
|
|
1481
1489
|
if (anchor && anchor.parentNode === el) {
|
|
1482
|
-
|
|
1490
|
+
let next = anchor.nextSibling;
|
|
1491
|
+
// The line of a compaction that ran in this turn sits between the user
|
|
1492
|
+
// message and its live bubble; the reply lands under it, so the
|
|
1493
|
+
// separator pass relabels that line instead of replacing it.
|
|
1494
|
+
if (next?.nodeType === 1
|
|
1495
|
+
&& next.classList?.contains(SEPARATOR_CLASS)
|
|
1496
|
+
&& next.dataset?.separatorKey === `compact:${anchorId}`) {
|
|
1497
|
+
next = next.nextSibling;
|
|
1498
|
+
}
|
|
1483
1499
|
if (next) el.insertBefore(node, next);
|
|
1484
1500
|
else el.appendChild(node);
|
|
1485
1501
|
} else {
|
|
@@ -1604,11 +1620,12 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
|
|
|
1604
1620
|
// ones stay visible here as prose rather than disappearing from the
|
|
1605
1621
|
// transcript altogether (one break row per message is all the separator
|
|
1606
1622
|
// planner can place).
|
|
1623
|
+
// A compaction's start and cancel steps are never prose either.
|
|
1607
1624
|
const list = Array.isArray(activities) ? activities : [];
|
|
1608
1625
|
const promoted = promotedBoundaryEntry === undefined
|
|
1609
1626
|
? promotedCompactBoundaryEntry(list)
|
|
1610
1627
|
: promotedBoundaryEntry;
|
|
1611
|
-
const visible = list
|
|
1628
|
+
const visible = visibleActivityEntries(list, promoted);
|
|
1612
1629
|
const progress = visible.filter((item) => relayActivityEntryText(item).startsWith('● '));
|
|
1613
1630
|
const tools = visible.filter((item) => !relayActivityEntryText(item).startsWith('● '));
|
|
1614
1631
|
const progressHtml = progress.length
|
|
@@ -1711,10 +1728,16 @@ function anchorThinkingBubble(div, messageId) {
|
|
|
1711
1728
|
const id = String(messageId || '').trim();
|
|
1712
1729
|
const target = id ? el.querySelector(`[data-message-id="${id}"]`) : null;
|
|
1713
1730
|
if (target && target.parentNode === el) {
|
|
1714
|
-
|
|
1731
|
+
// The bubble's own compaction line sits between the two.
|
|
1732
|
+
let follower = target.nextSibling;
|
|
1733
|
+
while (follower && follower !== div && follower.classList?.contains('transcript-separator')) {
|
|
1734
|
+
follower = follower.nextSibling;
|
|
1735
|
+
}
|
|
1736
|
+
if (follower === div) return;
|
|
1715
1737
|
const next = target.nextSibling;
|
|
1716
1738
|
if (next) el.insertBefore(div, next);
|
|
1717
1739
|
else el.appendChild(div);
|
|
1740
|
+
if (div.dataset.compactBoundary) syncSeparatorsNow();
|
|
1718
1741
|
} else if (div.parentNode !== el) {
|
|
1719
1742
|
el.appendChild(div);
|
|
1720
1743
|
}
|
|
@@ -1771,11 +1794,13 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1771
1794
|
<div class="thinking-thoughts-list"></div>
|
|
1772
1795
|
</details>
|
|
1773
1796
|
<div id="thinking-stream" class="thinking-stream" hidden></div>
|
|
1797
|
+
<div class="thinking-compacting-note" hidden>Compacting the conversation…</div>
|
|
1774
1798
|
<div class="dots"><span></span><span></span><span></span></div>
|
|
1775
1799
|
<div id="thinking-activity" class="thinking-activity"></div>
|
|
1776
1800
|
<div class="subagent-bubbles-container" data-subagent-bubbles-root="1"></div>
|
|
1777
1801
|
</div>`;
|
|
1778
1802
|
anchorThinkingBubble(div, nextMessageId);
|
|
1803
|
+
syncThinkingCompaction(div);
|
|
1779
1804
|
renderThinkingThoughts();
|
|
1780
1805
|
renderThinkingStream();
|
|
1781
1806
|
// A board the turn has posted already lives in its live bubble.
|
|
@@ -1788,8 +1813,33 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1788
1813
|
export function removeThinking() {
|
|
1789
1814
|
thinkingMessageId = null;
|
|
1790
1815
|
lastInFlightSnapshotKey = '';
|
|
1816
|
+
const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
|
|
1791
1817
|
unobserveThinkingBubble();
|
|
1792
1818
|
document.getElementById('thinking-indicator')?.remove();
|
|
1819
|
+
// The bubble's compaction line goes with it, but only after the
|
|
1820
|
+
// caller's same-tick work: a reply appended right after this call takes
|
|
1821
|
+
// the line over (relabelled in place) instead of it being dropped first.
|
|
1822
|
+
if (wasCompacting) queueMicrotask(syncSeparatorsNow);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// The live turn's compaction line: the bubble stamps the turn's compaction
|
|
1826
|
+
// for the separator pass — "Compacting context…" while the last compaction
|
|
1827
|
+
// entry is a start without an end, the final label once the boundary is in —
|
|
1828
|
+
// so the line sits above the bubble and the reply takes it over unchanged.
|
|
1829
|
+
// The bubble's own note shows only while it runs.
|
|
1830
|
+
function syncThinkingCompaction(bubble = document.getElementById('thinking-indicator')) {
|
|
1831
|
+
if (!bubble) return;
|
|
1832
|
+
const items = thinkingMessageId ? (relayActivities.get(thinkingMessageId) || []) : [];
|
|
1833
|
+
const boundary = compactBoundaryFromActivities(items, { processing: true });
|
|
1834
|
+
const note = bubble.querySelector('.thinking-compacting-note');
|
|
1835
|
+
if (note) note.hidden = !boundary?.pending;
|
|
1836
|
+
const stamp = !boundary
|
|
1837
|
+
? ''
|
|
1838
|
+
: (boundary.pending ? COMPACT_PENDING_STAMP : `${boundary.preTokens ?? ''}|${boundary.postTokens ?? ''}`);
|
|
1839
|
+
if ((bubble.dataset.compactBoundary || '') === stamp) return;
|
|
1840
|
+
if (stamp) bubble.dataset.compactBoundary = stamp;
|
|
1841
|
+
else delete bubble.dataset.compactBoundary;
|
|
1842
|
+
if (bubble.parentNode) syncSeparatorsNow();
|
|
1793
1843
|
}
|
|
1794
1844
|
|
|
1795
1845
|
// Stop is one tap away on a sticky header, so a stray touch must not kill a
|
|
@@ -1955,6 +2005,7 @@ export function renderThinkingActivities() {
|
|
|
1955
2005
|
if (!selectionIntersectsNode(box)) {
|
|
1956
2006
|
patchActivityList(box, mainExpected, 'thinking-activity-item');
|
|
1957
2007
|
}
|
|
2008
|
+
syncThinkingCompaction();
|
|
1958
2009
|
for (const [subagentRunId, expected] of bySubagentRun) {
|
|
1959
2010
|
const bubble = ensureSubagentBubble(subagentRunId);
|
|
1960
2011
|
const runBox = bubble?.querySelector('.subagent-activity');
|
|
@@ -2060,8 +2111,12 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
|
|
|
2060
2111
|
);
|
|
2061
2112
|
if (!entry) return;
|
|
2062
2113
|
// Same rule as renderThinkingActivities: a boundary becomes a break row on
|
|
2063
|
-
// the persisted message, so it never shows as live prose.
|
|
2064
|
-
|
|
2114
|
+
// the persisted message, so it never shows as live prose. Every
|
|
2115
|
+
// compaction step does move the live turn's compaction line, though.
|
|
2116
|
+
if (isCompactBoundaryActivityEntry(entry)) {
|
|
2117
|
+
syncThinkingCompaction();
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2065
2120
|
const decorated = decorateActivityText(entry.text);
|
|
2066
2121
|
|
|
2067
2122
|
if (subagentRunId) {
|
|
@@ -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
|
|