@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -0,0 +1,58 @@
1
+ // In the installed app, the system back button with nothing left to go back
2
+ // to closes the app. There is no way for a page to send itself to the
3
+ // background instead, but it can make sure that moment never comes: one
4
+ // entry marked as the floor is kept beneath the app's own entry, and when
5
+ // back lands on the floor the app's entry is pushed again at once. Back
6
+ // still closes the file viewer (which pushes its own entry above); only the
7
+ // last step out is swallowed. A browser tab keeps its back button: this is
8
+ // for the installed app alone.
9
+
10
+ export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
11
+ export const BACK_GUARD_APP = 'oarApp';
12
+ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
13
+
14
+ /**
15
+ * Lay the floor at the first touch or key, not at start-up: an entry a page
16
+ * leaves through pushState before the user has done anything is one the
17
+ * browser skips on back (its guard against pages that hijack the button),
18
+ * so a floor laid at start-up would be stepped over. Before the first
19
+ * interaction nothing a page does keeps back from leaving; from then on
20
+ * the floor holds.
21
+ */
22
+ export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
23
+ if (!enabled || !history || !target) return null;
24
+ const state = { guard: null };
25
+ const arm = () => {
26
+ for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
27
+ if (state.guard) return;
28
+ state.guard = installBackGuard({ history, enabled: true, onFloor });
29
+ target.addEventListener('popstate', () => state.guard.onPopState());
30
+ };
31
+ for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
32
+ return { get armed() { return Boolean(state.guard); } };
33
+ }
34
+
35
+ /**
36
+ * `onFloor` is told each time back lands on the floor and is swallowed.
37
+ * The entry pushed again then is pushed without a touch, so the browser
38
+ * lets the next back leave unless the user touches the app in between:
39
+ * one swallowed press, then the app closes — the pattern phones use, and
40
+ * the moment to say "press back again to close".
41
+ */
42
+ export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
43
+ if (!enabled || !history) return null;
44
+ const current = history.state && typeof history.state === 'object' ? history.state : {};
45
+ // After a reload on the app's entry the floor already lies beneath it.
46
+ if (!current[BACK_GUARD_FLOOR] && !current[BACK_GUARD_APP]) {
47
+ history.replaceState({ ...current, [BACK_GUARD_FLOOR]: true }, '');
48
+ history.pushState({ [BACK_GUARD_APP]: true }, '');
49
+ }
50
+ return {
51
+ /** Feed every popstate here. */
52
+ onPopState() {
53
+ if (!history.state?.[BACK_GUARD_FLOOR]) return;
54
+ history.pushState({ [BACK_GUARD_APP]: true }, '');
55
+ onFloor?.();
56
+ },
57
+ };
58
+ }
@@ -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,7 +121,13 @@ import {
120
121
  clearImageEditTarget,
121
122
  jumpToImageParent,
122
123
  flushDeferredMessageRender,
124
+ conversationHasActiveTurn,
125
+ focusConversationMessageById,
126
+ syncRenderedPinState,
127
+ isConversationWindowAtTail,
128
+ getTranscriptJumpEpoch,
123
129
  } from './conversation-view.js';
130
+ import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
124
131
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
125
132
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
126
133
  import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
@@ -196,9 +203,14 @@ import {
196
203
  adoptLegacyPwaAppName,
197
204
  registerPwaShell,
198
205
  updatePwaAppName,
206
+ isInstalledAppMode,
199
207
  } from './pwa-install.js';
208
+ import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
209
+ import { initScrollbarHover } from './scrollbar-hover.mjs';
200
210
  import {
211
+ autoCompactWindowNotes,
201
212
  renderAutoCompactControlHtml,
213
+ renderAutoCompactNotesHtml,
202
214
  renderContextUsageHtml,
203
215
  renderThinkingControlHtml,
204
216
  thinkingEnabledFromKey,
@@ -207,7 +219,6 @@ import {
207
219
  import { renderAttributionControlHtml } from './attribution-control.mjs';
208
220
  import {
209
221
  autoCompactWindowFromIndex,
210
- autoCompactWindowToIndex,
211
222
  formatAutoCompactWindowLabel,
212
223
  } from './auto-compact-window-options.mjs';
213
224
  import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
@@ -826,7 +837,11 @@ function initNetworkLifecycleHandling() {
826
837
  window.addEventListener('pageshow', () => {
827
838
  handleForegroundTransition('pageshow', { immediate: true });
828
839
  });
829
- window.addEventListener('pagehide', () => {
840
+ window.addEventListener('pagehide', (event) => {
841
+ // A page unloaded with the context modal still open (tab closed, app
842
+ // swiped away, reload) saves the window slider's draft like a modal
843
+ // close. A page only put aside (`persisted`) keeps its draft.
844
+ if (!event.persisted) commitAutoCompactDraft({ pageClosing: true });
830
845
  // The page is going away; no grace period, suspend immediately.
831
846
  cancelBackgroundSuspend();
832
847
  setForegroundNetworkWorkEnabled(false);
@@ -3094,18 +3109,37 @@ async function loadUsageSummaryAndRender() {
3094
3109
  // The context modal is rendered from scratch on every refresh, so the slider's
3095
3110
  // conversation binding lives here rather than on the element.
3096
3111
  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.
3112
+ // The save in flight, if any: saves run one after another, never side by side.
3113
+ let autoCompactWindowUpdateInFlight = null;
3114
+ // The server's last known window for the rendered control: what a draft is
3115
+ // compared against.
3101
3116
  let autoCompactControlStoredWindow = null;
3117
+ // What the last payload said about the running session, for the notes.
3118
+ let autoCompactControlSession = { activeWindow: null, deferred: null, usedTokens: null };
3119
+ // The slider is a draft while the modal is open: moving it only updates the
3120
+ // label and the notes, and the value is saved when the modal is left
3121
+ // (setSummaryModalLeaveHandler). `window` undefined = untouched. Kept outside
3122
+ // the DOM so a refresh of the open modal keeps the position.
3123
+ let autoCompactDraft = { conversationId: '', window: undefined };
3124
+
3125
+ function autoCompactNotesOptions(draftWindow, storedWindow = autoCompactControlStoredWindow) {
3126
+ return {
3127
+ draftWindow,
3128
+ storedWindow,
3129
+ activeWindow: autoCompactControlSession.activeWindow,
3130
+ deferred: autoCompactControlSession.deferred,
3131
+ turnRunning: !!autoCompactControlConversationId && conversationHasActiveTurn(autoCompactControlConversationId),
3132
+ usedTokens: autoCompactControlSession.usedTokens,
3133
+ };
3134
+ }
3102
3135
 
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));
3136
+ function trackAutoCompactDraft(slider) {
3137
+ const window = autoCompactWindowFromIndex(slider.value);
3138
+ autoCompactDraft = { conversationId: autoCompactControlConversationId, window };
3107
3139
  const label = document.getElementById('ctx-autocompact-value');
3108
3140
  if (label) label.textContent = formatAutoCompactWindowLabel(window);
3141
+ const notes = document.getElementById('ctx-autocompact-notes');
3142
+ if (notes) notes.innerHTML = renderAutoCompactNotesHtml(autoCompactNotesOptions(window));
3109
3143
  }
3110
3144
 
3111
3145
  /**
@@ -3121,18 +3155,37 @@ function initAutoCompactWindowControl() {
3121
3155
  const target = event?.target;
3122
3156
  return target && target.id === 'ctx-autocompact-slider' ? target : null;
3123
3157
  };
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));
3158
+ // Nothing is written while the modal is open; see commitAutoCompactDraft.
3159
+ for (const type of ['input', 'change']) {
3160
+ body.addEventListener(type, (event) => {
3161
+ const slider = sliderFrom(event);
3162
+ if (slider) trackAutoCompactDraft(slider);
3163
+ });
3164
+ }
3165
+ }
3166
+
3167
+ // Runs when the context modal is left (or re-bound to another conversation):
3168
+ // a draft that differs from the stored value is saved; one set back to the
3169
+ // stored value sends nothing.
3170
+ function commitAutoCompactDraft({ pageClosing = false } = {}) {
3171
+ const { conversationId, window } = autoCompactDraft;
3172
+ autoCompactDraft = { conversationId: '', window: undefined };
3173
+ if (!conversationId || window === undefined) return;
3174
+ if (conversationId === autoCompactControlConversationId && window === autoCompactControlStoredWindow) return;
3175
+ if (pageClosing) {
3176
+ // Sent at once, not queued behind an earlier save: the request has to
3177
+ // leave before the page does.
3178
+ void saveAutoCompactWindow(conversationId, window, '', { pageClosing: true });
3179
+ return;
3180
+ }
3181
+ const notes = autoCompactWindowNotes(autoCompactNotesOptions(window, window));
3182
+ const previous = autoCompactWindowUpdateInFlight || Promise.resolve();
3183
+ const run = previous
3184
+ .catch(() => {})
3185
+ .then(() => saveAutoCompactWindow(conversationId, window, notes.pending));
3186
+ autoCompactWindowUpdateInFlight = run;
3187
+ void run.finally(() => {
3188
+ if (autoCompactWindowUpdateInFlight === run) autoCompactWindowUpdateInFlight = null;
3136
3189
  });
3137
3190
  }
3138
3191
 
@@ -3239,41 +3292,32 @@ async function applyThinkingSelection(axis, key) {
3239
3292
  }
3240
3293
  }
3241
3294
 
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;
3295
+ async function saveAutoCompactWindow(convId, autoCompactWindow, pendingNote = '', { pageClosing = false } = {}) {
3253
3296
  try {
3254
3297
  // Only the window is sent: the route keeps every unmentioned preference as
3255
3298
  // stored, so this cannot clobber the composer's model/effort choice.
3256
3299
  const response = await updateConversationPreferences(convId, {
3257
3300
  clientId: CLIENT_ID,
3258
3301
  autoCompactWindow,
3259
- });
3302
+ }, { keepalive: pageClosing });
3303
+ // Nobody is left to tell on a page that is going away.
3304
+ if (pageClosing) return;
3260
3305
  if (!response) throw new Error('Failed to update the auto-compact window');
3261
3306
  // The server, not the slider, decides what got stored (values snap to a
3262
- // stop), so the control follows the response.
3307
+ // stop).
3263
3308
  const stored = response.autoCompactWindow ?? null;
3264
- autoCompactControlStoredWindow = stored;
3265
- setAutoCompactControlValue(stored);
3309
+ if (convId === autoCompactControlConversationId) autoCompactControlStoredWindow = stored;
3310
+ // No note when the running session already uses this value.
3311
+ const when = pendingNote ? ` ${pendingNote}` : '';
3266
3312
  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.`);
3313
+ ? `Auto-compact window back to the model default.${when}`
3314
+ : `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens.${when}`);
3315
+ // A modal reopened on this conversation meanwhile shows the new value.
3316
+ if (summaryModalState.kind === 'context' && convId === autoCompactControlConversationId) {
3317
+ await refreshSummaryModal();
3318
+ }
3269
3319
  } 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
3320
  alert(error?.message || 'Failed to update the auto-compact window.');
3274
- } finally {
3275
- autoCompactWindowUpdateInFlight = false;
3276
- if (slider) slider.disabled = false;
3277
3321
  }
3278
3322
  }
3279
3323
 
@@ -3294,7 +3338,33 @@ async function loadContextSummaryAndRender(convId) {
3294
3338
  ? `${providerLabel} session ${sessionId.slice(0, 8)}`
3295
3339
  : (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
3296
3340
 
3297
- const usageHtml = renderContextUsageHtml(payload.contextUsage);
3341
+ // The session fields come with the payload when the relay knows them
3342
+ // (older relays send neither, and the notes fall back to draft vs stored).
3343
+ const sessionField = (key) => payload[key] ?? payload.contextUsage?.[key] ?? null;
3344
+ const modelContextLimit = sessionField('modelContextLimit');
3345
+ const usageHtml = renderContextUsageHtml(payload.contextUsage, {
3346
+ compactionWindow: payload.providerType === 'claude',
3347
+ modelContextLimit,
3348
+ });
3349
+ // The slider's writes need the conversation id even when the modal was
3350
+ // opened through an sdk_session_id.
3351
+ const boundConversationId = String(
3352
+ payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
3353
+ ).trim();
3354
+ // A draft for another conversation is saved before this one takes over.
3355
+ if (autoCompactDraft.conversationId && autoCompactDraft.conversationId !== boundConversationId) {
3356
+ commitAutoCompactDraft();
3357
+ }
3358
+ autoCompactControlConversationId = boundConversationId;
3359
+ // What the server says is stored right now, refreshed every time the
3360
+ // control is re-rendered: the draft is compared against it.
3361
+ autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
3362
+ autoCompactControlSession = {
3363
+ activeWindow: sessionField('activeAutoCompactWindow'),
3364
+ deferred: sessionField('autoCompactWindowDeferred'),
3365
+ usedTokens: payload.contextUsage?.totalTokens ?? null,
3366
+ };
3367
+ const draftWindow = autoCompactDraft.conversationId === boundConversationId ? autoCompactDraft.window : undefined;
3298
3368
  // Claude-only: no other provider exposes a compaction window (or any
3299
3369
  // session-settings surface) to steer.
3300
3370
  const autoCompactHtml = payload.providerType === 'claude'
@@ -3310,6 +3380,11 @@ async function loadContextSummaryAndRender(convId) {
3310
3380
  ? (payload.contextUsage.isAutoCompactEnabled ?? null)
3311
3381
  : null,
3312
3382
  maxTokens: payload.contextUsage?.maxTokens ?? null,
3383
+ draftWindow,
3384
+ activeAutoCompactWindow: autoCompactControlSession.activeWindow,
3385
+ autoCompactWindowDeferred: autoCompactControlSession.deferred,
3386
+ turnRunning: autoCompactNotesOptions(draftWindow).turnRunning,
3387
+ usedTokens: autoCompactControlSession.usedTokens,
3313
3388
  })
3314
3389
  : '';
3315
3390
  const thinkingHtml = payload.providerType === 'claude'
@@ -3323,14 +3398,6 @@ async function loadContextSummaryAndRender(convId) {
3323
3398
  const attributionHtml = payload.providerType === 'claude' || payload.providerType === 'claude-cloud'
3324
3399
  ? renderAttributionControlHtml(payload.attribution || null)
3325
3400
  : '';
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
3401
  thinkingControlStored = {
3335
3402
  enabled: payload.thinkingEnabled !== false,
3336
3403
  display: payload.thinkingDisplay || 'summarized',
@@ -3351,6 +3418,7 @@ async function loadContextSummaryAndRender(convId) {
3351
3418
  refresh: () => loadContextSummaryAndRender(refreshLookupId),
3352
3419
  kind: 'context',
3353
3420
  });
3421
+ setSummaryModalLeaveHandler('context', () => commitAutoCompactDraft());
3354
3422
  }
3355
3423
 
3356
3424
  async function showUsage() {
@@ -3531,6 +3599,15 @@ function startSessionWorkerStatusPolling() {
3531
3599
  }, 4000);
3532
3600
  }
3533
3601
 
3602
+ // The live poll and refreshCurrentView both reload the END of the
3603
+ // conversation. After a jump into the history (a search result, a pinned
3604
+ // message) that would put the end back over the window the user asked for,
3605
+ // until they have scrolled down to the newest message again. A message sent
3606
+ // from that window brings the present back at once: its bubble needs the poll.
3607
+ function transcriptIsInHistory(conversationId) {
3608
+ return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
3609
+ }
3610
+
3534
3611
  async function pollAuthenticatedCurrentConversationLive() {
3535
3612
  if (liveConversationPollInFlight) return;
3536
3613
  if (isSharedReaderMode()) return;
@@ -3548,6 +3625,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3548
3625
  // Refreshing while the user selects or drags in the chat would rebuild the
3549
3626
  // DOM under the selection; the guard's release callback re-polls right away.
3550
3627
  if (isChatInteractionHeld()) return;
3628
+ if (transcriptIsInHistory(currentId)) return;
3551
3629
 
3552
3630
  liveConversationPollInFlight = true;
3553
3631
  try {
@@ -3556,9 +3634,14 @@ async function pollAuthenticatedCurrentConversationLive() {
3556
3634
  const savedScrollTop = messagesEl?.scrollTop || 0;
3557
3635
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3558
3636
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3637
+ const jumpEpoch = getTranscriptJumpEpoch();
3559
3638
  const response = await loadConversation(currentId, { limit: requestLimit });
3560
3639
  if (!response) return;
3561
3640
  if (String(currentConvId || '').trim() !== currentId) return;
3641
+ // The user jumped into the history while this request was on its way: the
3642
+ // answer is the end of the conversation as it was asked for before that,
3643
+ // and would overwrite the window they jumped to. The next round asks again.
3644
+ if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3562
3645
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3563
3646
  applyLoadedConversationState(currentId, response, {
3564
3647
  restoreScroll: !preserveBottom,
@@ -3766,6 +3849,7 @@ let refreshViewVersion = 0;
3766
3849
 
3767
3850
  async function refreshCurrentView() {
3768
3851
  const capturedVersion = ++refreshViewVersion;
3852
+ const jumpEpoch = getTranscriptJumpEpoch();
3769
3853
  const messagesEl = document.getElementById('messages');
3770
3854
  const preserveBottom = isMessagesAtBottom();
3771
3855
  const scrollTop = messagesEl?.scrollTop || 0;
@@ -3788,11 +3872,16 @@ async function refreshCurrentView() {
3788
3872
  const r = await loadConversation(currentId, { limit: requestLimit });
3789
3873
  if (capturedVersion < refreshViewVersion) return;
3790
3874
  if (String(currentConvId || '').trim() !== currentId) return;
3875
+ // The user jumped into the history, before this refresh or while it was on
3876
+ // its way: title, tasks, pins and the rest are refreshed, the transcript
3877
+ // and its scroll position are left as they are.
3878
+ const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId);
3791
3879
  if (r) {
3792
3880
  applyLoadedConversationState(currentId, r, {
3793
3881
  restoreScroll: !preserveBottom,
3794
3882
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3795
- followLiveUpdates: preserveBottom,
3883
+ followLiveUpdates: preserveBottom && !keepTranscript,
3884
+ keepTranscript,
3796
3885
  });
3797
3886
  } else {
3798
3887
  setRepoBrowserSessionInfo('', '');
@@ -3801,7 +3890,7 @@ async function refreshCurrentView() {
3801
3890
  await loadRelayQuestions(currentId);
3802
3891
  await loadRelayBoards();
3803
3892
  scheduleContextUsageRefresh(currentId, 0);
3804
- if (messagesEl && String(currentConvId || '').trim() === currentId) {
3893
+ if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
3805
3894
  if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
3806
3895
  messagesEl.scrollTop = stableSavedScrollTop;
3807
3896
  }
@@ -3963,6 +4052,8 @@ function lockChatActionsMenuShield(ms = 300) {
3963
4052
  function syncChatTitleControls() {
3964
4053
  const { title, editBtn, editor, input } = getChatTitleElements();
3965
4054
  const convId = String(currentConvId || '').trim();
4055
+ // No conversation open (deleted, new chat): its 📍 button goes with it.
4056
+ if (!convId) setPinsConversation(null);
3966
4057
  const killBtn = document.getElementById('chat-menu-kill-session');
3967
4058
  const sharedMode = isSharedReaderMode();
3968
4059
  const conversation = convId ? (conversations[convId] || null) : null;
@@ -4194,11 +4285,15 @@ function refreshClaudeCloudComposerModels() {
4194
4285
  }
4195
4286
 
4196
4287
  function showAuthGate(error = '') {
4197
- document.getElementById('startup-loading')?.remove();
4198
4288
  document.getElementById('auth-gate').style.display = 'flex';
4199
4289
  document.getElementById('app').classList.remove('visible');
4200
4290
  showAuthError(error);
4201
- document.getElementById('token-input')?.focus();
4291
+ // The start screen hands its logo over to the box first (installed app);
4292
+ // focusing earlier would open the keyboard and move the box under it.
4293
+ const focusToken = () => document.getElementById('token-input')?.focus();
4294
+ const handover = window.__startupSplash?.toAuth?.();
4295
+ if (handover && window.__startupSplash.animated) void handover.then(focusToken);
4296
+ else focusToken();
4202
4297
  }
4203
4298
 
4204
4299
  // Runs before anything reads MODEL_STORAGE_KEY so the first load after an
@@ -4224,6 +4319,7 @@ async function initApp() {
4224
4319
  setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
4225
4320
  const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
4226
4321
  initTheme();
4322
+ initScrollbarHover();
4227
4323
  initThemeMenuToggle();
4228
4324
  if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
4229
4325
  initFontScaling();
@@ -4256,7 +4352,8 @@ async function initApp() {
4256
4352
  void flushConversationDraft(currentConvId);
4257
4353
  closeTmuxInspectorView();
4258
4354
  });
4259
- document.getElementById('startup-loading')?.remove();
4355
+ // The start screen lies over the app and leaves once its animation is done.
4356
+ void window.__startupSplash?.finish?.();
4260
4357
  document.getElementById('auth-gate').style.display = 'none';
4261
4358
  document.getElementById('app').classList.add('visible');
4262
4359
  initSidebarLayout();
@@ -4504,6 +4601,11 @@ async function initApp() {
4504
4601
  initMessageScrollPersistence();
4505
4602
  initComposerAttachmentInput();
4506
4603
  initMessageSearchView({ openConversation });
4604
+ initPinnedMessagesView({
4605
+ openConversation,
4606
+ focusMessage: focusConversationMessageById,
4607
+ syncRenderedPinState,
4608
+ });
4507
4609
  initGitChangesView();
4508
4610
  syncChatTitleControls();
4509
4611
  connectSocket();
@@ -4560,6 +4662,15 @@ async function bootstrap() {
4560
4662
  }
4561
4663
  const urlToken = getTokenFromUrl();
4562
4664
  if (urlToken) stripTokenFromUrl();
4665
+ // In the installed app the system back button must never close the app;
4666
+ // a tab keeps its own back button. After the token is gone from the
4667
+ // address, so the entries the guard lays down carry the clean one.
4668
+ armBackGuardOnFirstInteraction({
4669
+ history: window.history,
4670
+ enabled: isInstalledAppMode(),
4671
+ target: window,
4672
+ onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
4673
+ });
4563
4674
  const persistedToken = consumeLegacyPersistedAuthToken();
4564
4675
  const bootstrapToken = String(urlToken || persistedToken || '').trim();
4565
4676
  const existingSession = await verifyExistingSession(bootstrapToken);
@@ -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