@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.
- package/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -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/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- 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 +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- 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 +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- 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
|
-
|
|
3098
|
-
|
|
3099
|
-
//
|
|
3100
|
-
//
|
|
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
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
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
|
-
//
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
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
|
|
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)
|
|
3307
|
+
// stop).
|
|
3263
3308
|
const stored = response.autoCompactWindow ?? null;
|
|
3264
|
-
autoCompactControlStoredWindow = stored;
|
|
3265
|
-
|
|
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
|
-
?
|
|
3268
|
-
: `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|