@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +46 -18
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  6. package/server/claude-worker/claude-session-process.mjs +196 -28
  7. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  8. package/server/public/app/activity-replay-state.mjs +68 -5
  9. package/server/public/app/api-client.js +6 -2
  10. package/server/public/app/bootstrap.js +124 -57
  11. package/server/public/app/context-usage-view.mjs +127 -8
  12. package/server/public/app/conversation-view.js +62 -7
  13. package/server/public/app/pwa-install.js +5 -0
  14. package/server/public/app/scrollbar-hover.mjs +78 -0
  15. package/server/public/app/settings-modal.js +4 -1
  16. package/server/public/app/socket-handlers.js +2 -1
  17. package/server/public/app/store.js +26 -0
  18. package/server/public/app/transcript-separators.mjs +48 -11
  19. package/server/public/app-icon-192.png +0 -0
  20. package/server/public/app-icon-512.png +0 -0
  21. package/server/public/app-icon-light-192.png +0 -0
  22. package/server/public/app-icon-light-512.png +0 -0
  23. package/server/public/app-icon-light-maskable-512.png +0 -0
  24. package/server/public/app-icon-light.svg +4 -0
  25. package/server/public/app-icon-maskable-512.png +0 -0
  26. package/server/public/app-icon.svg +3 -3
  27. package/server/public/favicon.ico +0 -0
  28. package/server/public/index.html +183 -13
  29. package/server/public/manifest.webmanifest +9 -3
  30. package/server/public/sw.js +6 -1
  31. package/server/routes/messages-routes.mjs +3 -1
  32. package/server/routes/sessions-routes.mjs +15 -2
  33. package/server/server-runtime.mjs +10 -8
  34. package/server/services/claude-context-usage.mjs +51 -3
  35. package/server/services/oar-cli-helpers.mjs +67 -1
  36. package/server/services/pwa-manifest-theme.mjs +30 -0
  37. package/server/services/session-worker-registry-service.mjs +18 -1
  38. 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
- let autoCompactWindowUpdateInFlight = false;
3098
- // The server's last known window for the rendered control. Kept so a rejected
3099
- // PATCH can put the slider back instead of leaving the rejected value on
3100
- // screen until the next refresh.
3105
+ // The save in flight, if any: saves run one after another, never side by side.
3106
+ let autoCompactWindowUpdateInFlight = null;
3107
+ // The server's last known window for the rendered control: what a draft is
3108
+ // compared against.
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
- // Snaps both the range input and its label back to `window`.
3104
- function setAutoCompactControlValue(window) {
3105
- const slider = document.getElementById('ctx-autocompact-slider');
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
- // Dragging only previews the label; nothing is persisted until the value
3125
- // settles, matching the settings-modal sliders.
3126
- body.addEventListener('input', (event) => {
3127
- const slider = sliderFrom(event);
3128
- if (!slider) return;
3129
- const label = document.getElementById('ctx-autocompact-value');
3130
- if (label) label.textContent = formatAutoCompactWindowLabel(autoCompactWindowFromIndex(slider.value));
3131
- });
3132
- body.addEventListener('change', (event) => {
3133
- const slider = sliderFrom(event);
3134
- if (!slider) return;
3135
- void applyAutoCompactWindowSelection(autoCompactWindowFromIndex(slider.value));
3151
+ // Nothing is written while the modal is open; see commitAutoCompactDraft.
3152
+ for (const type of ['input', 'change']) {
3153
+ body.addEventListener(type, (event) => {
3154
+ const slider = sliderFrom(event);
3155
+ if (slider) trackAutoCompactDraft(slider);
3156
+ });
3157
+ }
3158
+ }
3159
+
3160
+ // Runs when the context modal is left (or re-bound to another conversation):
3161
+ // a draft that differs from the stored value is saved; one set back to the
3162
+ // stored value sends nothing.
3163
+ function commitAutoCompactDraft({ pageClosing = false } = {}) {
3164
+ const { conversationId, window } = autoCompactDraft;
3165
+ autoCompactDraft = { conversationId: '', window: undefined };
3166
+ if (!conversationId || window === undefined) return;
3167
+ if (conversationId === autoCompactControlConversationId && window === autoCompactControlStoredWindow) return;
3168
+ if (pageClosing) {
3169
+ // Sent at once, not queued behind an earlier save: the request has to
3170
+ // leave before the page does.
3171
+ void saveAutoCompactWindow(conversationId, window, '', { pageClosing: true });
3172
+ return;
3173
+ }
3174
+ const notes = autoCompactWindowNotes(autoCompactNotesOptions(window, window));
3175
+ const previous = autoCompactWindowUpdateInFlight || Promise.resolve();
3176
+ const run = previous
3177
+ .catch(() => {})
3178
+ .then(() => saveAutoCompactWindow(conversationId, window, notes.pending));
3179
+ autoCompactWindowUpdateInFlight = run;
3180
+ void run.finally(() => {
3181
+ if (autoCompactWindowUpdateInFlight === run) autoCompactWindowUpdateInFlight = null;
3136
3182
  });
3137
3183
  }
3138
3184
 
@@ -3239,41 +3285,32 @@ async function applyThinkingSelection(axis, key) {
3239
3285
  }
3240
3286
  }
3241
3287
 
3242
- async function applyAutoCompactWindowSelection(autoCompactWindow) {
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), so the control follows the response.
3300
+ // stop).
3263
3301
  const stored = response.autoCompactWindow ?? null;
3264
- autoCompactControlStoredWindow = stored;
3265
- setAutoCompactControlValue(stored);
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
- ? 'Auto-compact window back to the model default; applies to the next message.'
3268
- : `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens; applies to the next message.`);
3306
+ ? `Auto-compact window back to the model default.${when}`
3307
+ : `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens.${when}`);
3308
+ // A modal reopened on this conversation meanwhile shows the new value.
3309
+ if (summaryModalState.kind === 'context' && convId === autoCompactControlConversationId) {
3310
+ await refreshSummaryModal();
3311
+ }
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
- const usageHtml = renderContextUsageHtml(payload.contextUsage);
3334
+ // The session fields come with the payload when the relay knows them
3335
+ // (older relays send neither, and the notes fall back to draft vs stored).
3336
+ const sessionField = (key) => payload[key] ?? payload.contextUsage?.[key] ?? null;
3337
+ const modelContextLimit = sessionField('modelContextLimit');
3338
+ const usageHtml = renderContextUsageHtml(payload.contextUsage, {
3339
+ compactionWindow: payload.providerType === 'claude',
3340
+ modelContextLimit,
3341
+ });
3342
+ // The slider's writes need the conversation id even when the modal was
3343
+ // opened through an sdk_session_id.
3344
+ const boundConversationId = String(
3345
+ payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
3346
+ ).trim();
3347
+ // A draft for another conversation is saved before this one takes over.
3348
+ if (autoCompactDraft.conversationId && autoCompactDraft.conversationId !== boundConversationId) {
3349
+ commitAutoCompactDraft();
3350
+ }
3351
+ autoCompactControlConversationId = boundConversationId;
3352
+ // What the server says is stored right now, refreshed every time the
3353
+ // control is re-rendered: the draft is compared against it.
3354
+ autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
3355
+ autoCompactControlSession = {
3356
+ activeWindow: sessionField('activeAutoCompactWindow'),
3357
+ deferred: sessionField('autoCompactWindowDeferred'),
3358
+ usedTokens: payload.contextUsage?.totalTokens ?? null,
3359
+ };
3360
+ const draftWindow = autoCompactDraft.conversationId === boundConversationId ? autoCompactDraft.window : undefined;
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
- document.getElementById('token-input')?.focus();
4261
+ // The start screen hands its logo over to the box first (installed app);
4262
+ // focusing earlier would open the keyboard and move the box under it.
4263
+ const focusToken = () => document.getElementById('token-input')?.focus();
4264
+ const handover = window.__startupSplash?.toAuth?.();
4265
+ if (handover && window.__startupSplash.animated) void handover.then(focusToken);
4266
+ else focusToken();
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
- document.getElementById('startup-loading')?.remove();
4325
+ // The start screen lies over the app and leaves once its animation is done.
4326
+ void window.__startupSplash?.finish?.();
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. Applied on the next
146
- * message delivery, which is why there is no live preview of the effect.
212
+ * pinned 100k that every larger stop was pointless.
213
+ *
214
+ * The slider is a draft while the modal is open (`draftWindow`, defaulting to
215
+ * the stored window); the caller saves it when the modal closes.
147
216
  *
148
217
  * `isAutoCompactEnabled` is tri-state on purpose: `null`/`undefined` means the
149
218
  * runtime has not reported yet (no context-usage snapshot exists before the
@@ -158,9 +227,23 @@ export function renderAutoCompactControlHtml({
158
227
  autocompactSource = null,
159
228
  isAutoCompactEnabled = null,
160
229
  maxTokens = null,
230
+ draftWindow = undefined,
231
+ activeAutoCompactWindow = null,
232
+ autoCompactWindowDeferred = null,
233
+ turnRunning = false,
234
+ usedTokens = null,
161
235
  } = {}) {
162
- const window = parseAutoCompactWindow(autoCompactWindow);
236
+ const stored = parseAutoCompactWindow(autoCompactWindow);
237
+ const window = draftWindow === undefined ? stored : parseAutoCompactWindow(draftWindow);
163
238
  const index = autoCompactWindowToIndex(window);
239
+ const notesHtml = renderAutoCompactNotesHtml({
240
+ draftWindow: window,
241
+ storedWindow: stored,
242
+ activeWindow: activeAutoCompactWindow,
243
+ deferred: autoCompactWindowDeferred,
244
+ turnRunning,
245
+ usedTokens,
246
+ });
164
247
 
165
248
  const threshold = toNullableNumber(autoCompactThreshold);
166
249
  const sourceLabel = autocompactSourceLabel(autocompactSource);
@@ -195,7 +278,7 @@ export function renderAutoCompactControlHtml({
195
278
  >
196
279
  <div class="ctx-autocompact-effective">Effective: ${effective}</div>
197
280
  ${disabledNote}
198
- <div class="ctx-autocompact-note">Applied on the next message in this conversation.</div>
281
+ <div id="ctx-autocompact-notes" class="ctx-autocompact-notes">${notesHtml}</div>
199
282
  </div>
200
283
  `;
201
284
  }
@@ -267,20 +350,56 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
267
350
  `;
268
351
  }
269
352
 
353
+ // A model limit is a round figure: 1M rather than 1.0M.
354
+ function formatModelLimit(value) {
355
+ const n = toNullableNumber(value);
356
+ if (n === null) return '—';
357
+ if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
358
+ return formatCompactTokens(n);
359
+ }
360
+
361
+ /**
362
+ * The headline of a Claude session, whose snapshot max is the compaction
363
+ * window rather than the model's limit:
364
+ * `60.9k used · compaction window 100.0k · model limit 1M` (the limit only
365
+ * when known).
366
+ */
367
+ export function formatCompactionWindowHeadline({ totalTokens, maxTokens, modelContextLimit = null } = {}) {
368
+ if (toNullableNumber(totalTokens) === null || toNullableNumber(maxTokens) === null) return '';
369
+ const parts = [
370
+ `${formatCompactTokens(totalTokens)} used`,
371
+ `compaction window ${formatCompactTokens(maxTokens)}`,
372
+ ];
373
+ if (toNullableNumber(modelContextLimit) !== null && Number(modelContextLimit) > 0) {
374
+ parts.push(`model limit ${formatModelLimit(modelContextLimit)}`);
375
+ }
376
+ return parts.join(' · ');
377
+ }
378
+
270
379
  /**
271
380
  * @param {object|null} usage the `contextUsage` field of an /api/context response
381
+ * @param {object} [options]
382
+ * @param {boolean} [options.compactionWindow] the max is a compaction window
383
+ * (Claude): the headline names it, plus `modelContextLimit` when known
272
384
  * @returns {string} modal body HTML, or '' when there is nothing to render
273
385
  */
274
- export function renderContextUsageHtml(usage) {
386
+ export function renderContextUsageHtml(usage, { compactionWindow = false, modelContextLimit = null } = {}) {
275
387
  if (!usage || typeof usage !== 'object') return '';
276
388
  const categories = Array.isArray(usage.categories) ? usage.categories : [];
277
389
  const hasTotals = toNullableNumber(usage.totalTokens) !== null
278
390
  && toNullableNumber(usage.maxTokens) !== null;
279
391
  if (!categories.length && !hasTotals) return '';
280
392
 
281
- const headline = hasTotals
282
- ? `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.maxTokens)} tokens (${formatUsagePercent(usage.percentage, { digits: 0 })})`
283
- : '';
393
+ let headline = '';
394
+ if (hasTotals) {
395
+ headline = compactionWindow
396
+ ? formatCompactionWindowHeadline({
397
+ totalTokens: usage.totalTokens,
398
+ maxTokens: usage.maxTokens,
399
+ modelContextLimit,
400
+ })
401
+ : `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.maxTokens)} tokens (${formatUsagePercent(usage.percentage, { digits: 0 })})`;
402
+ }
284
403
 
285
404
  const rows = categories.map((category) => renderRow(category)).join('');
286
405
  const freeRow = toNullableNumber(usage.freeTokens) !== null
@@ -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 { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
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.some((item) => item !== promotedBoundaryEntry)) div.classList.add('msg-with-activity');
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
- const next = anchor.nextSibling;
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.filter((item) => item !== promoted);
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
- if (target.nextSibling === div) return;
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
- if (isCompactBoundaryActivityEntry(entry)) return;
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