9366 lines
428 KiB
JavaScript
9366 lines
428 KiB
JavaScript
// ── Session action icons (SVG, monochrome, inherit currentColor) ──
|
||
const ICONS={
|
||
stop:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg>',
|
||
pin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
|
||
unpin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
|
||
folder:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
|
||
archive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><line x1="6" y1="8.5" x2="10" y2="8.5"/></svg>',
|
||
unarchive:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="2" width="13" height="3" rx="1"/><path d="M2.5 5v8h11V5"/><polyline points="6.5,7 8,5.5 9.5,7"/></svg>',
|
||
dup:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="4.5" y="4.5" width="8.5" height="8.5" rx="1.5"/><path d="M3 11.5V3h8.5"/></svg>',
|
||
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
|
||
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
|
||
edit:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2L5 13H3v-2z"/><path d="M10 4l2 2"/></svg>',
|
||
spark:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.8l1.1 3.1 3.1 1.1-3.1 1.1L8 10.2 6.9 7.1 3.8 6l3.1-1.1z"/><path d="M12.5 9.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z"/></svg>',
|
||
link:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M6.7 9.3a3 3 0 0 1 0-4.2l1.7-1.7a3 3 0 0 1 4.2 4.2l-1 1"/><path d="M9.3 6.7a3 3 0 0 1 0 4.2l-1.7 1.7a3 3 0 0 1-4.2-4.2l1-1"/></svg>',
|
||
download:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M14 10.5v2.5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2.5"/><polyline points="4.5 7 8 10.5 11.5 7"/><line x1="8" y1="10.5" x2="8" y2="2"/></svg>',
|
||
};
|
||
|
||
// Tracks which session_id is currently being loaded. Used to discard stale
|
||
// responses from in-flight requests when the user switches sessions again
|
||
// before the first request completes (#1060).
|
||
let _loadingSessionId = null;
|
||
// Each loadSession() invocation gets a monotonically increasing generation.
|
||
// `_loadingSessionId` only tracks destination session_id, so same-session
|
||
// concurrent loads can still race and overwrite each other unless we compare
|
||
// the generation token as well.
|
||
let _loadSessionGeneration = 0;
|
||
// #3306: Snapshot of S.messages captured by loadSession() right before it
|
||
// clears them on a force-reload of the active session. Consumed by
|
||
// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
|
||
// ephemeral fields (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
|
||
// _statusCard, _anchor_stream_id) survive the wholesale replace. null when there is nothing
|
||
// to carry forward (initial load, switch-to-different-session, etc.).
|
||
let _pendingCarryForwardSnapshot = null;
|
||
|
||
// ── Composer draft persistence ────────────────────────────────────────────────
|
||
|
||
// Debounced save — prevents hammering the server on every keystroke.
|
||
let _draftSaveTimer = null;
|
||
const _DRAFT_SAVE_DELAY_MS = 400;
|
||
const NEW_CHAT_DRAFT_SESSION_KEY = 'hermes-new-chat-draft-session';
|
||
const _composerDraftKnownPayloadSessions = new Set();
|
||
const _composerDraftRestoreSuppressedUntilBySid = new Map();
|
||
const _COMPOSER_DRAFT_RESTORE_SUPPRESS_MS = 30000;
|
||
|
||
function _composerDraftFileSignature(file) {
|
||
if (typeof file === 'string') return { value: file };
|
||
if (!file || typeof file !== 'object') return { value: String(file || '') };
|
||
return {
|
||
name: String(file.name || file.filename || ''),
|
||
path: String(file.path || ''),
|
||
size: Number.isFinite(Number(file.size)) ? Number(file.size) : null,
|
||
type: String(file.type || file.mime || ''),
|
||
};
|
||
}
|
||
|
||
// A live browser `File` JSON-serializes to `{}`, so a draft persisted to the
|
||
// server loses its name/size/type. Canonicalize files to a plain serializable
|
||
// shape BEFORE both persisting and signing, so the suppression signature of the
|
||
// just-sent payload matches the signature of the same payload after it has
|
||
// round-tripped through the server draft (otherwise a text+attachment send never
|
||
// matches its own suppression and the stale tail can repopulate — #5471).
|
||
function _composerDraftFilesForPersist(files) {
|
||
if (!Array.isArray(files)) return [];
|
||
return files.filter(Boolean).map((file) => {
|
||
if (typeof file === 'string') return file;
|
||
if (!file || typeof file !== 'object') return String(file || '');
|
||
const canon = {
|
||
name: String(file.name || file.filename || ''),
|
||
path: String(file.path || ''),
|
||
size: Number.isFinite(Number(file.size)) ? Number(file.size) : null,
|
||
type: String(file.type || file.mime || ''),
|
||
};
|
||
if (Number.isFinite(Number(file.lastModified))) canon.lastModified = Number(file.lastModified);
|
||
return canon;
|
||
});
|
||
}
|
||
|
||
function _composerDraftPayloadSignature(text, files) {
|
||
const normalizedText = String(text || '');
|
||
const normalizedFiles = _composerDraftFilesForPersist(files).map(_composerDraftFileSignature);
|
||
return JSON.stringify({ text: normalizedText, files: normalizedFiles });
|
||
}
|
||
|
||
function _composerDraftPayloadSignatureForSid(sid) {
|
||
if (typeof S === 'undefined' || !S.session || S.session.session_id !== sid) return null;
|
||
const draft = S.session.composer_draft || null;
|
||
if (!draft) return null;
|
||
return _composerDraftPayloadSignature(draft.text, draft.files);
|
||
}
|
||
|
||
function _suppressComposerDraftRestoreAfterSubmit(sid, text, files) {
|
||
if (!sid) return;
|
||
const previous = _composerDraftRestoreSuppressedUntilBySid.get(sid);
|
||
// Collect EVERY signature a stale poll could legitimately echo back for this
|
||
// just-sent turn, and suppress a restore matching ANY of them (#5471):
|
||
// - the submitted-payload signature (final textarea content on send), AND
|
||
// - the REMEMBERED SERVER DRAFT signature — what was actually persisted last.
|
||
// The two differ in the common Enter-to-send case: `_clearComposerDraft`
|
||
// cancels the pending debounced save, so the server's last draft is often a
|
||
// PREFIX of the submitted text (pause ≥400ms, then send within 400ms of the
|
||
// last keystroke) — an exact submitted-text match would miss that prefix and
|
||
// let it restore. The remembered-server-draft signature must be read BEFORE
|
||
// the `_rememberComposerDraftPayloadState(sid,'',[])` reset below. A genuinely
|
||
// new cross-tab draft matches neither, so it still restores immediately.
|
||
const signatures = [];
|
||
const _addSig = (s) => { if (s && signatures.indexOf(s) === -1) signatures.push(s); };
|
||
_addSig(_composerDraftPayloadSignatureForSid(sid)); // remembered server draft (read first)
|
||
if (arguments.length >= 2) {
|
||
_addSig(_composerDraftPayloadSignature(text, files)); // submitted payload
|
||
} else if (previous && typeof previous === 'object' && Array.isArray(previous.signatures)) {
|
||
previous.signatures.forEach(_addSig);
|
||
}
|
||
_composerDraftRestoreSuppressedUntilBySid.set(
|
||
sid,
|
||
{ until: Date.now() + _COMPOSER_DRAFT_RESTORE_SUPPRESS_MS, signatures },
|
||
);
|
||
// Local state must reflect the submitted/cleared composer immediately. The
|
||
// POST that clears the server-side draft is async; same-session refreshes can
|
||
// otherwise race in with the old draft and repopulate the textarea.
|
||
_rememberComposerDraftPayloadState(sid, '', []);
|
||
}
|
||
|
||
function _clearComposerDraftRestoreSuppression(sid) {
|
||
if (!sid) return;
|
||
_composerDraftRestoreSuppressedUntilBySid.delete(sid);
|
||
}
|
||
|
||
function _isComposerDraftRestoreSuppressed(sid, text, files) {
|
||
if (!sid) return false;
|
||
const suppression = _composerDraftRestoreSuppressedUntilBySid.get(sid);
|
||
if (!suppression) return false;
|
||
const until = (suppression && typeof suppression === 'object') ? suppression.until : suppression;
|
||
if (!until) return false;
|
||
if (Date.now() > until) {
|
||
_composerDraftRestoreSuppressedUntilBySid.delete(sid);
|
||
return false;
|
||
}
|
||
const signatures = (suppression && typeof suppression === 'object' && Array.isArray(suppression.signatures))
|
||
? suppression.signatures
|
||
: null;
|
||
// Legacy/unknown callers still fail closed for the current TTL, but all send
|
||
// paths now pass payload signatures so a different cross-tab draft can restore.
|
||
if (!signatures || !signatures.length) return true;
|
||
if (signatures.indexOf(_composerDraftPayloadSignature(text, files)) !== -1) return true;
|
||
_composerDraftRestoreSuppressedUntilBySid.delete(sid);
|
||
return false;
|
||
}
|
||
|
||
function _profileMatchesActiveProfile(profile, activeProfile){
|
||
const eventName = (typeof profile === 'string' && profile.trim()) ? profile.trim() : 'default';
|
||
const activeName = (typeof activeProfile === 'string' && activeProfile.trim()) ? activeProfile.trim() : 'default';
|
||
if(eventName === activeName) return true;
|
||
return eventName === 'default' && !!S.activeProfileIsDefault;
|
||
}
|
||
|
||
function _sessionEventProfilesMatch(eventProfile, activeProfile){
|
||
if(!(typeof eventProfile === 'string' && eventProfile.trim())) return true;
|
||
return _profileMatchesActiveProfile(eventProfile, activeProfile);
|
||
}
|
||
|
||
function _isRestorableNewChatDraftSession(session, requireDraft=false) {
|
||
if (!session || !session.session_id) return false;
|
||
const messageCount = Number(session.message_count || 0);
|
||
if (messageCount !== 0) return false;
|
||
if (session.active_stream_id || session.pending_user_message || session.worktree_path || session.has_pending_user_message) return false;
|
||
const title = session.title || 'Untitled';
|
||
if (title !== 'Untitled' && title !== 'New Chat') return false;
|
||
const activeProfile = S.activeProfile || 'default';
|
||
const sessionProfile = session.profile || 'default';
|
||
if (!_profileMatchesActiveProfile(sessionProfile, activeProfile)) return false;
|
||
if (!requireDraft) return true;
|
||
const draft = session.composer_draft || {};
|
||
const text = (typeof draft.text === 'string') ? draft.text : '';
|
||
const files = Array.isArray(draft.files) ? draft.files : [];
|
||
return !!(text || files.length);
|
||
}
|
||
|
||
function _rememberNewChatDraftSession(session) {
|
||
if (!_isRestorableNewChatDraftSession(session)) return;
|
||
try { localStorage.setItem(NEW_CHAT_DRAFT_SESSION_KEY, session.session_id); } catch (_) {}
|
||
}
|
||
|
||
function _clearRememberedNewChatDraftSession(sid) {
|
||
if (!sid) return;
|
||
try {
|
||
if (localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) === sid) {
|
||
localStorage.removeItem(NEW_CHAT_DRAFT_SESSION_KEY);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function _restoreRememberedNewChatDraftSession() {
|
||
let sid = '';
|
||
try { sid = localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) || ''; } catch (_) { sid = ''; }
|
||
if (!sid || (S.session && S.session.session_id === sid)) return false;
|
||
try {
|
||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
|
||
const session = data && data.session;
|
||
if (!_isRestorableNewChatDraftSession(session, true)) {
|
||
_clearRememberedNewChatDraftSession(sid);
|
||
return false;
|
||
}
|
||
await loadSession(sid, {skipLineageResolve:true});
|
||
return !!(S.session && S.session.session_id === sid);
|
||
} catch (_) {
|
||
_clearRememberedNewChatDraftSession(sid);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function _saveComposerDraft(sid, text, files) {
|
||
if (!sid) return;
|
||
clearTimeout(_draftSaveTimer);
|
||
const normalizedText = String(text || '');
|
||
const normalizedFiles = _composerDraftFilesForPersist(files);
|
||
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
|
||
_clearComposerDraftRestoreSuppression(sid);
|
||
_composerDraftKnownPayloadSessions.add(sid);
|
||
}
|
||
_draftSaveTimer = setTimeout(() => {
|
||
api('/api/session/draft', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ session_id: sid, text: normalizedText, files: normalizedFiles }),
|
||
}).then(() => {
|
||
_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);
|
||
}).catch(() => {});
|
||
}, _DRAFT_SAVE_DELAY_MS);
|
||
}
|
||
|
||
function _composerDraftHasPayload(text, files) {
|
||
return !!(String(text || '') || (Array.isArray(files) && files.filter(Boolean).length));
|
||
}
|
||
|
||
function _sessionComposerDraftHasPayload(session) {
|
||
const draft = session && session.composer_draft;
|
||
return !!(draft && _composerDraftHasPayload(draft.text, draft.files));
|
||
}
|
||
|
||
function _rememberComposerDraftPayloadState(sid, text, files) {
|
||
if (!sid) return;
|
||
const normalizedText = String(text || '');
|
||
const normalizedFiles = Array.isArray(files) ? files.filter(Boolean) : [];
|
||
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
|
||
_composerDraftKnownPayloadSessions.add(sid);
|
||
} else {
|
||
_composerDraftKnownPayloadSessions.delete(sid);
|
||
}
|
||
if (S.session && S.session.session_id === sid) {
|
||
S.session.composer_draft = { text: normalizedText, files: normalizedFiles };
|
||
}
|
||
}
|
||
|
||
// Immediate save used before session switches.
|
||
function _saveComposerDraftNow(sid, text, files) {
|
||
if (!sid) return Promise.resolve();
|
||
clearTimeout(_draftSaveTimer);
|
||
const normalizedText = String(text || '');
|
||
const normalizedFiles = _composerDraftFilesForPersist(files);
|
||
if (_composerDraftHasPayload(normalizedText, normalizedFiles)) {
|
||
_clearComposerDraftRestoreSuppression(sid);
|
||
}
|
||
// Most chat switches leave an empty composer. Avoid putting the switch path
|
||
// behind a network POST unless there is new local draft content or an existing
|
||
// server draft that must be cleared.
|
||
if (!_composerDraftHasPayload(normalizedText, normalizedFiles)
|
||
&& S.session && S.session.session_id === sid
|
||
&& !_sessionComposerDraftHasPayload(S.session)
|
||
&& !_composerDraftKnownPayloadSessions.has(sid)) {
|
||
return Promise.resolve();
|
||
}
|
||
return api('/api/session/draft', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ session_id: sid, text: normalizedText, files: normalizedFiles }),
|
||
}).then(() => {
|
||
_rememberComposerDraftPayloadState(sid, normalizedText, normalizedFiles);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// Restore composer draft from server onto #msg textarea.
|
||
// Only restores if there's actual text (skip empty/None drafts).
|
||
// Guards against double-restore when rapidly switching sessions.
|
||
function _restoreComposerDraft(draft, targetSid, opts={}) {
|
||
const ta = $('msg');
|
||
if (!ta) return;
|
||
// targetSid is the session that was requested — if it no longer matches
|
||
// _loadingSessionId, a newer session switch has already begun, so skip.
|
||
if (targetSid && _loadingSessionId !== null && _loadingSessionId !== targetSid) return;
|
||
const text = (draft && typeof draft.text === 'string') ? draft.text : '';
|
||
const files = (draft && Array.isArray(draft.files)) ? draft.files : [];
|
||
const current = ta.value || '';
|
||
const preserveActiveInput = !!(opts && opts.preserveActiveInput);
|
||
const restoreSid = targetSid || (S.session && S.session.session_id);
|
||
const hasServerDraftPayload = _composerDraftHasPayload(text, files);
|
||
|
||
if (restoreSid && hasServerDraftPayload && _isComposerDraftRestoreSuppressed(restoreSid, text, files)) return;
|
||
if (restoreSid && !hasServerDraftPayload) _clearComposerDraftRestoreSuppression(restoreSid);
|
||
|
||
// Same-session force refreshes are driven by external state changes and may
|
||
// finish seconds after the user continued typing. In that case the local
|
||
// composer is the authoritative in-progress draft; never replace non-empty
|
||
// local input with an older server draft. Cross-session switches still restore
|
||
// normally so the previous session's composer contents do not leak forward.
|
||
if (preserveActiveInput && current && current !== text) return;
|
||
|
||
// If there's no text and no files, clear the textarea (a previous session's
|
||
// draft may still be sitting there from a cross-session switch).
|
||
if (!text && !files.length) {
|
||
if (current) {
|
||
ta.value = '';
|
||
if (typeof autoResize === 'function') autoResize();
|
||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||
}
|
||
return;
|
||
}
|
||
// Only update if different to avoid cursor jumps on unrelated session switches.
|
||
if (current !== text) {
|
||
ta.value = text;
|
||
if (typeof autoResize === 'function') autoResize();
|
||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||
}
|
||
// Files restoration is skipped for now (requires S.pendingFiles plumbing).
|
||
}
|
||
|
||
// Clear the saved draft for a session (called when message is sent).
|
||
function _clearComposerDraft(sid, text, files) {
|
||
if (!sid) return;
|
||
clearTimeout(_draftSaveTimer);
|
||
_clearRememberedNewChatDraftSession(sid);
|
||
if (arguments.length >= 2) _suppressComposerDraftRestoreAfterSubmit(sid, text, files);
|
||
else _suppressComposerDraftRestoreAfterSubmit(sid);
|
||
return api('/api/session/draft', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ session_id: sid, text: '' }),
|
||
}).then(() => {
|
||
_rememberComposerDraftPayloadState(sid, '', []);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
|
||
// Per-profile session-count cache (issue #4717 / #4662 Phase 1.5). Records how
|
||
// many sessions each profile rendered last time, keyed by profile name, so a
|
||
// profile switch can pick an honest loading skeleton BEFORE the new /api/sessions
|
||
// fetch resolves: a profile we last saw with zero sessions shows an empty-state
|
||
// placeholder instead of a content skeleton that implies data which never arrives.
|
||
// A profile we've never recorded falls back to the normal content skeleton (safe
|
||
// default — never hide a skeleton for a profile that may well have conversations).
|
||
const SESSION_PROFILE_COUNTS_KEY = 'hermes-session-profile-counts';
|
||
let _sessionProfileCounts = null;
|
||
let _sessionViewedCounts = null;
|
||
let _sessionCompletionUnread = null;
|
||
let _sessionObservedStreaming = null;
|
||
const _sessionStreamingById = new Map();
|
||
const _sessionListSnapshotById = new Map();
|
||
const _sessionListSourceById = new Map();
|
||
let _sessionListPointerActive = false;
|
||
let _sessionListLastScrollAt = 0;
|
||
let _pendingSessionListPayload = null;
|
||
let _pendingSessionListApplyTimer = 0;
|
||
let _sessionListLoadError = null;
|
||
let _sessionListHasLoadedOnce = false;
|
||
const _SESSION_LIST_BOOT_TIMEOUT_MS = 90000;
|
||
const SESSION_LIST_INTERACTION_IDLE_MS = 700;
|
||
const SESSION_SWIPE_DURATION_MS = 500;
|
||
const SESSION_SWIPE_REFLOW_LEAD_MS = 220;
|
||
const SESSION_REFLOW_TIMEOUT_MS = 420;
|
||
const SESSION_LIST_FLIP_TIMEOUT_MS = 460;
|
||
const SESSION_LONG_PRESS_DELAY_MS = 400;
|
||
const SESSION_ARCHIVE_SWIPE_THRESHOLD_PX = 128;
|
||
const SESSION_DELETE_SWIPE_THRESHOLD_PX = 128;
|
||
const SESSION_SWIPE_CANCEL_RATIO = 0.75;
|
||
|
||
function _manualTitleAuxConfigFromPayload(auxData){
|
||
if(!auxData||typeof auxData!=='object'||Array.isArray(auxData)) return null;
|
||
if(auxData.title_generation&&typeof auxData.title_generation==='object') return auxData;
|
||
const tasks=Array.isArray(auxData.tasks)?auxData.tasks:null;
|
||
if(!tasks) return null;
|
||
const taskMap={};
|
||
for(const task of tasks){
|
||
if(task&&typeof task==='object'&&typeof task.task==='string'&&task.task){
|
||
taskMap[task.task]=task;
|
||
}
|
||
}
|
||
if(!Object.keys(taskMap).length) return null;
|
||
return taskMap;
|
||
}
|
||
|
||
async function _loadManualTitleAuxConfig(){
|
||
try{
|
||
const auxData=await api('/api/model/auxiliary',{retries:0,timeoutToast:false});
|
||
return _manualTitleAuxConfigFromPayload(auxData);
|
||
}catch(_){
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function _manualTitleRegenerateTimeoutMs(){
|
||
let cfg=null;
|
||
try{
|
||
const auxConfig=await _loadManualTitleAuxConfig();
|
||
cfg=auxConfig&&auxConfig.title_generation;
|
||
}catch(_){
|
||
return null;
|
||
}
|
||
const timeoutSeconds=Number(cfg&&cfg.timeout);
|
||
if(!Number.isFinite(timeoutSeconds)||timeoutSeconds<=0) return null;
|
||
return Math.max(30000,Math.round((timeoutSeconds+5)*1000));
|
||
}
|
||
|
||
function _formatSessionModelWithGateway(s){
|
||
if(!s||!s.model)return'';
|
||
const routing=(typeof _latestGatewayRoutingForSession==='function')?_latestGatewayRoutingForSession(s):(s.gateway_routing||null);
|
||
if(typeof _formatGatewayModelLabel==='function'){
|
||
return _formatGatewayModelLabel(s.model,s.model,routing)||getModelLabel(s.model);
|
||
}
|
||
return s.model;
|
||
}
|
||
|
||
function _getSessionViewedCounts() {
|
||
if (_sessionViewedCounts !== null) return _sessionViewedCounts;
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}');
|
||
_sessionViewedCounts = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch (_){
|
||
_sessionViewedCounts = {};
|
||
}
|
||
return _sessionViewedCounts;
|
||
}
|
||
|
||
// ── Per-profile session-count cache (#4717) ──────────────────────────────────
|
||
function _getSessionProfileCounts() {
|
||
if (_sessionProfileCounts !== null) return _sessionProfileCounts;
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(SESSION_PROFILE_COUNTS_KEY) || '{}');
|
||
_sessionProfileCounts = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch (_){
|
||
_sessionProfileCounts = {};
|
||
}
|
||
return _sessionProfileCounts;
|
||
}
|
||
|
||
// Record how many sessions a profile currently shows, so the NEXT switch into
|
||
// it can pick an honest skeleton. Called after a real list render resolves.
|
||
function _recordSessionProfileCount(profile, count) {
|
||
const name = (profile || '').trim();
|
||
if (!name) return;
|
||
const n = Number(count);
|
||
if (!Number.isFinite(n) || n < 0) return;
|
||
const counts = _getSessionProfileCounts();
|
||
if (counts[name] === n) return; // no-op write avoidance
|
||
counts[name] = n;
|
||
try {
|
||
localStorage.setItem(SESSION_PROFILE_COUNTS_KEY, JSON.stringify(counts));
|
||
} catch (_){
|
||
// Ignore localStorage write failures (private mode / quota).
|
||
}
|
||
}
|
||
|
||
// Return the last-known session count for a profile, or null if we've never
|
||
// recorded one (caller must treat null as "unknown" → keep the content skeleton).
|
||
function _knownSessionProfileCount(profile) {
|
||
const name = (profile || '').trim();
|
||
if (!name) return null;
|
||
const counts = _getSessionProfileCounts();
|
||
const v = counts[name];
|
||
return (typeof v === 'number' && Number.isFinite(v)) ? v : null;
|
||
}
|
||
|
||
function _saveSessionViewedCounts() {
|
||
try {
|
||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
|
||
} catch (_){
|
||
// Ignore localStorage write failures.
|
||
}
|
||
}
|
||
|
||
function _setSessionViewedCount(sid, messageCount = 0) {
|
||
if (!sid) return;
|
||
const counts = _getSessionViewedCounts();
|
||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||
counts[sid] = next;
|
||
_saveSessionViewedCounts();
|
||
// If the viewed count is now current, any prior completion-unread marker is
|
||
// stale — clear it so _hasUnreadForSession doesn't short-circuit (#3020).
|
||
_clearSessionCompletionUnread(sid);
|
||
}
|
||
|
||
function _getSessionCompletionUnread() {
|
||
if (_sessionCompletionUnread !== null) return _sessionCompletionUnread;
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(SESSION_COMPLETION_UNREAD_KEY) || '{}');
|
||
_sessionCompletionUnread = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch (_){
|
||
_sessionCompletionUnread = {};
|
||
}
|
||
return _sessionCompletionUnread;
|
||
}
|
||
|
||
function _saveSessionCompletionUnread() {
|
||
try {
|
||
localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(_getSessionCompletionUnread()));
|
||
} catch (_){
|
||
// Ignore localStorage write failures.
|
||
}
|
||
}
|
||
|
||
function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) {
|
||
if (!sid) return;
|
||
const unread = _getSessionCompletionUnread();
|
||
const count = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||
const entry = {message_count: count, completed_at: Date.now()};
|
||
// Cron markers carry source+profile so profile switches can clear only that
|
||
// cross-profile leak without wiping ordinary chat completion unread (#5960).
|
||
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
|
||
if (meta.source) entry.source = String(meta.source);
|
||
if (typeof meta.profile === 'string' && meta.profile.trim()) {
|
||
entry.profile = meta.profile.trim();
|
||
}
|
||
}
|
||
unread[sid] = entry;
|
||
_saveSessionCompletionUnread();
|
||
}
|
||
|
||
function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta = null) {
|
||
if (!sid) return false;
|
||
let count = Number.isFinite(messageCount) ? Number(messageCount) : NaN;
|
||
if (!Number.isFinite(count)) {
|
||
const snapshot = _sessionListSnapshotById.get(sid)
|
||
|| (_allSessions || []).find(s => s && s.session_id === sid)
|
||
|| null;
|
||
count = Number(snapshot && snapshot.message_count) || 0;
|
||
}
|
||
if (_isSessionActivelyViewedForList(sid)) {
|
||
_setSessionViewedCount(sid, count);
|
||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||
return false;
|
||
}
|
||
_markSessionCompletionUnread(sid, count, meta);
|
||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||
return true;
|
||
}
|
||
|
||
function _clearSessionCompletionUnread(sid) {
|
||
if (!sid) return;
|
||
const unread = _getSessionCompletionUnread();
|
||
if (!Object.prototype.hasOwnProperty.call(unread, sid)) return;
|
||
delete unread[sid];
|
||
_saveSessionCompletionUnread();
|
||
}
|
||
|
||
// True when a session row is a cron-origin session for unread-dot scoping.
|
||
function _isCronSessionForUnread(session) {
|
||
if (!session) return false;
|
||
const key = (typeof _sourceKeyForSession === 'function')
|
||
? _sourceKeyForSession(session)
|
||
: String(
|
||
session.raw_source
|
||
|| session.source_tag
|
||
|| session.source
|
||
|| session.session_source
|
||
|| ''
|
||
).toLowerCase();
|
||
if (key === 'cron') return true;
|
||
return String(session.session_source || '').toLowerCase() === 'cron';
|
||
}
|
||
|
||
// Build {source, profile} for a cron session row; null for ordinary chat.
|
||
function _cronCompletionUnreadMetaForSession(session) {
|
||
if (!_isCronSessionForUnread(session)) return null;
|
||
const fromRow = (session && typeof session.profile === 'string' && session.profile.trim())
|
||
? session.profile.trim()
|
||
: '';
|
||
const active = (typeof S !== 'undefined' && S && typeof S.activeProfile === 'string' && S.activeProfile.trim())
|
||
? S.activeProfile.trim()
|
||
: 'default';
|
||
return {source: 'cron', profile: fromRow || active};
|
||
}
|
||
|
||
// Resolve whether a persisted marker is cron and which profile owns it.
|
||
// Untagged/legacy markers are migrated from the sidebar session row when known.
|
||
function _resolveCronCompletionMarkerOrigin(sid, marker) {
|
||
let isCron = !!(marker && marker.source === 'cron');
|
||
let profile = (marker && typeof marker.profile === 'string' && marker.profile.trim())
|
||
? marker.profile.trim()
|
||
: '';
|
||
let session = null;
|
||
if (Array.isArray(_allSessions)) {
|
||
session = _allSessions.find((s) => s && s.session_id === sid) || null;
|
||
}
|
||
if (!session && typeof _sessionListSnapshotById !== 'undefined'
|
||
&& _sessionListSnapshotById && typeof _sessionListSnapshotById.get === 'function') {
|
||
// Snapshot alone lacks source/profile; keep null.
|
||
session = null;
|
||
}
|
||
if (session) {
|
||
if (!isCron && _isCronSessionForUnread(session)) isCron = true;
|
||
if (!profile) {
|
||
const sp = (typeof session.profile === 'string' && session.profile.trim())
|
||
? session.profile.trim()
|
||
: '';
|
||
if (sp) profile = sp;
|
||
}
|
||
}
|
||
// Persist migration so later switches don't re-resolve from a cleared list.
|
||
if (marker && isCron) {
|
||
if (marker.source !== 'cron') marker.source = 'cron';
|
||
if (profile && marker.profile !== profile) marker.profile = profile;
|
||
}
|
||
return {isCron, profile: profile || ''};
|
||
}
|
||
|
||
// A profile name provably resolving to the root profile: the literal
|
||
// 'default' alias, or a roster entry flagged is_default (renamed root).
|
||
// Unknown names fail closed — exact-name matching still applies to them.
|
||
function _cronProfileNameIsRootAlias(name) {
|
||
if (name === 'default') return true;
|
||
if (typeof _profilesCache !== 'undefined' && _profilesCache
|
||
&& Array.isArray(_profilesCache.profiles)) {
|
||
const entry = _profilesCache.profiles.find((p) => p && p.name === name);
|
||
if (entry && entry.is_default) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// default/renamed-root equivalence for cron-marker ownership (mirrors server
|
||
// _profiles_match enough for the active surface: literal 'default' ↔ root).
|
||
function _cronMarkerProfileMatchesActive(origin, activeProfile) {
|
||
const originName = (typeof origin === 'string' && origin.trim()) ? origin.trim() : '';
|
||
const activeName = (typeof activeProfile === 'string' && activeProfile.trim())
|
||
? activeProfile.trim()
|
||
: 'default';
|
||
if (!originName) return false;
|
||
if (originName === activeName) return true;
|
||
if (typeof _profileMatchesActiveProfile === 'function'
|
||
&& _profileMatchesActiveProfile(originName, activeName)) {
|
||
return true;
|
||
}
|
||
// Reverse alias: marker tagged with the renamed-root name while the active
|
||
// root surface reports a different alias. Match only when the origin name
|
||
// ITSELF provably resolves to the root — never "active is default → match
|
||
// all", which under-cleared other profiles' markers on switch to 'default'.
|
||
if (typeof S !== 'undefined' && S && S.activeProfileIsDefault
|
||
&& typeof _cronProfileNameIsRootAlias === 'function'
|
||
&& _cronProfileNameIsRootAlias(originName)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Drop persisted cron unread dots that belong to inactive profiles. Ordinary
|
||
// (non-cron) completion markers stay put — sticky all-profile sidebars still
|
||
// need those. Called from the shared profile-switch reset in panels.js.
|
||
function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||
const active = (typeof activeProfile === 'string' && activeProfile.trim())
|
||
? activeProfile.trim()
|
||
: 'default';
|
||
const unread = _getSessionCompletionUnread();
|
||
let changed = false;
|
||
for (const sid of Object.keys(unread)) {
|
||
const marker = unread[sid];
|
||
if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue;
|
||
const resolved = _resolveCronCompletionMarkerOrigin(sid, marker);
|
||
if (!resolved.isCron) continue;
|
||
// Only clear when we know the owning profile AND it is not the active one
|
||
// (incl. default/renamed-root equivalence). Untagged + unresolvable stays.
|
||
if (!resolved.profile) continue;
|
||
if (_cronMarkerProfileMatchesActive(resolved.profile, active)) continue;
|
||
delete unread[sid];
|
||
changed = true;
|
||
}
|
||
if (!changed) return false;
|
||
_saveSessionCompletionUnread();
|
||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||
return true;
|
||
}
|
||
|
||
function _clearSessionViewedCount(sid) {
|
||
if (!sid) return;
|
||
const counts = _getSessionViewedCounts();
|
||
if (!Object.prototype.hasOwnProperty.call(counts, sid)) return;
|
||
delete counts[sid];
|
||
_saveSessionViewedCounts();
|
||
}
|
||
|
||
function _hasSessionCompletionUnread(sid) {
|
||
if (!sid) return false;
|
||
return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), sid);
|
||
}
|
||
|
||
function _getSessionObservedStreaming() {
|
||
if (_sessionObservedStreaming !== null) return _sessionObservedStreaming;
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(SESSION_OBSERVED_STREAMING_KEY) || '{}');
|
||
_sessionObservedStreaming = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch (_){
|
||
_sessionObservedStreaming = {};
|
||
}
|
||
return _sessionObservedStreaming;
|
||
}
|
||
|
||
function _saveSessionObservedStreaming() {
|
||
try {
|
||
localStorage.setItem(SESSION_OBSERVED_STREAMING_KEY, JSON.stringify(_getSessionObservedStreaming()));
|
||
} catch (_){
|
||
// Ignore localStorage write failures.
|
||
}
|
||
}
|
||
|
||
function _rememberObservedStreamingSession(s) {
|
||
if (!s || !s.session_id) return;
|
||
const observed = _getSessionObservedStreaming();
|
||
observed[s.session_id] = {
|
||
message_count: Number(s.message_count || 0),
|
||
last_message_at: Number(s.last_message_at || 0),
|
||
observed_at: Date.now(),
|
||
};
|
||
_saveSessionObservedStreaming();
|
||
}
|
||
|
||
function _forgetObservedStreamingSession(sid) {
|
||
if (!sid) return;
|
||
const observed = _getSessionObservedStreaming();
|
||
if (!Object.prototype.hasOwnProperty.call(observed, sid)) return;
|
||
delete observed[sid];
|
||
_saveSessionObservedStreaming();
|
||
}
|
||
|
||
function _hasUnreadForSession(s) {
|
||
if (!s || !s.session_id) return false;
|
||
if (_hasSessionCompletionUnread(s.session_id)) return true;
|
||
const counts = _getSessionViewedCounts();
|
||
if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) {
|
||
_setSessionViewedCount(s.session_id, Number(s.message_count || 0));
|
||
return false;
|
||
}
|
||
if (!Number.isFinite(s.message_count)) return false;
|
||
return s.message_count > Number(counts[s.session_id] || 0);
|
||
}
|
||
|
||
// Keep the sidebar polling snapshot current for a just-visited session so a
|
||
// deferred /api/sessions list refresh landing across the async message-load gap
|
||
// cannot treat the unchanged, already-open session as a fresh background
|
||
// completion and re-flag a stale unread dot (#4946).
|
||
function _syncSessionListSnapshotOnVisit(sid, messageCount, lastMessageAt) {
|
||
if (!sid) return;
|
||
const count = Number(messageCount || 0);
|
||
const last = Number(lastMessageAt || 0);
|
||
_sessionListSnapshotById.set(sid, {message_count: count, last_message_at: last});
|
||
// #5917 gate finding: derive the visited session's streaming state from its
|
||
// OWN (target-owned) metadata, NOT the global S.busy / S.activeStreamId
|
||
// flags. When switching from a BUSY session A to an IDLE session B, those
|
||
// globals can still belong to A at this point in the load, so reading them
|
||
// here would wrongly record idle B as streaming — a later hidden-tab poll
|
||
// would then see a streaming->stopped transition and manufacture a phantom
|
||
// unread completion for B. Only the session object's own is_streaming /
|
||
// active_stream_id / pending-message fields describe THIS session.
|
||
const target = (S.session && S.session.session_id === sid) ? S.session : null;
|
||
const isStreaming = Boolean(
|
||
target && (
|
||
target.is_streaming ||
|
||
target.active_stream_id ||
|
||
target.pending_user_message ||
|
||
target.has_pending_user_message
|
||
)
|
||
);
|
||
_sessionStreamingById.set(sid, isStreaming);
|
||
if (!isStreaming) _forgetObservedStreamingSession(sid);
|
||
}
|
||
|
||
// Acknowledge that the user actually visited/opened `sid`: clear its viewed
|
||
// count (which also clears any stale completion-unread marker, #3020), sync the
|
||
// polling snapshot so a deferred list poll cannot re-flag it, then repaint from
|
||
// cache. Repainting via renderSessionListFromCache() recomputes each row's
|
||
// aggregated unread state (own + children) authoritatively, so a lineage
|
||
// PARENT keeps its own / other children's unread dot instead of being stripped
|
||
// by ad-hoc DOM surgery (Greptile concern (b) on #4946).
|
||
function _acknowledgeSessionVisit(sid, messageCount = 0, lastMessageAt = 0) {
|
||
if (!sid) return;
|
||
_setSessionViewedCount(sid, messageCount);
|
||
_syncSessionListSnapshotOnVisit(sid, messageCount, lastMessageAt);
|
||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||
}
|
||
|
||
// Does the session currently carry any unread state that a visit should clear?
|
||
// Used by the same-session no-op guard so re-selecting the already-open session
|
||
// still clears a stale dot before short-circuiting.
|
||
function _sessionVisitHasUnreadState(sid) {
|
||
if (!sid) return false;
|
||
if (_hasSessionCompletionUnread(sid)) return true;
|
||
if (!S.session || S.session.session_id !== sid) return false;
|
||
return _hasUnreadForSession(S.session);
|
||
}
|
||
|
||
function _isSessionActivelyViewedForList(sid) {
|
||
if (!sid || !S.session || S.session.session_id !== sid) return false;
|
||
if (typeof _loadingSessionId !== 'undefined' && _loadingSessionId && _loadingSessionId !== sid) return false;
|
||
if (typeof document !== 'undefined' && document.visibilityState && document.visibilityState !== 'visible') return false;
|
||
if (typeof document !== 'undefined' && typeof document.hasFocus === 'function' && !document.hasFocus()) return false;
|
||
return true;
|
||
}
|
||
|
||
function _isSessionLocallyStreaming(s) {
|
||
if (!s || !s.session_id) return false;
|
||
const isActive = S.session && s.session_id === S.session.session_id;
|
||
// For the active session, rely on S.busy to indicate an ongoing stream.
|
||
// INFLIGHT entries for non-active sessions are artifacts of interrupted
|
||
// streams (page refresh, network disconnect, gateway restart) where
|
||
// `delete INFLIGHT[sid]` was never reached — they should NOT cause the
|
||
// sidebar spinner to appear on completed sessions. (#2066)
|
||
return isActive && Boolean(S.busy);
|
||
}
|
||
|
||
function _isSessionEffectivelyStreaming(s) {
|
||
return Boolean(s && (
|
||
s.is_streaming ||
|
||
_hasPendingUserMessageSignal(s) ||
|
||
_isSessionLocallyStreaming(s)
|
||
));
|
||
}
|
||
|
||
function _hasPendingUserMessageSignal(s) {
|
||
return Boolean(s && (s.pending_user_message || s.has_pending_user_message));
|
||
}
|
||
|
||
function _isServerIdleSessionRow(s) {
|
||
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message && !s.pending_started_at);
|
||
}
|
||
|
||
function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||
if (!S || !S.session || !S.session.session_id) return false;
|
||
if (!Array.isArray(serverRows)) return false;
|
||
const sid=S.session.session_id;
|
||
// #4354: clear a stuck indicator when the server reports idle — server
|
||
// is_streaming/active_stream_id is authoritative. BUT skip the ONE session
|
||
// that is actively mid-send (#2689 start-race): during the /api/chat/start
|
||
// round-trip the server row is still idle while the client owns the optimistic
|
||
// turn, so reconciling it here would blank the just-sent bubble + queue a
|
||
// spurious force-reload. A long-hung session has _sendInProgress===false, so
|
||
// it still gets unstuck — only the in-flight start window is protected.
|
||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) return false;
|
||
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
|
||
if (!serverRow) return false;
|
||
if (!_isServerIdleSessionRow(serverRow)) return false;
|
||
let changed=false;
|
||
if (S.busy) { S.busy=false; changed=true; }
|
||
if (S.activeStreamId) { S.activeStreamId=null; changed=true; }
|
||
if (INFLIGHT&&INFLIGHT[sid]) {
|
||
delete INFLIGHT[sid];
|
||
if (typeof clearInflightState==='function') clearInflightState(sid);
|
||
changed=true;
|
||
}
|
||
if (S.session) {
|
||
S.session.active_stream_id=null;
|
||
S.session.pending_user_message=null;
|
||
}
|
||
_sessionStreamingById.set(sid, false);
|
||
_forgetObservedStreamingSession(sid);
|
||
if (typeof hideApprovalCard==='function') hideApprovalCard(true);
|
||
if (typeof hideLiveRunStatus==='function') hideLiveRunStatus(sid);
|
||
if (typeof clearLiveToolCards==='function') clearLiveToolCards();
|
||
if (changed&&typeof updateSendBtn==='function') updateSendBtn();
|
||
if (changed&&typeof _scheduleActiveSessionIdleReload==='function') _scheduleActiveSessionIdleReload(sid);
|
||
return changed;
|
||
}
|
||
|
||
function _scheduleActiveSessionIdleReload(sid) {
|
||
if(!sid) return;
|
||
setTimeout(async () => {
|
||
if(!S||!S.session||S.session.session_id !== sid) return;
|
||
// #5409: skip idle reload while any loadSession() is in flight — avoids
|
||
// a race where the idle reload overwrites _loadingSessionId and silently
|
||
// cancels an in-progress session switch (most visible on iOS PWA with
|
||
// large sessions where Phase 1 metadata fetch is slow).
|
||
if(typeof _loadingSessionId !== 'undefined' && _loadingSessionId) return;
|
||
if(S.busy || S.activeStreamId) return;
|
||
if(typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()){
|
||
_deferActiveSessionExternalRefresh('idle-reconcile');
|
||
return;
|
||
}
|
||
try{
|
||
// Avoid an unconditional same-session force reload the moment streaming
|
||
// settles. On mobile PWA this produces a visible end-of-turn flash and can
|
||
// briefly restore the pane with stale layout geometry. Reconcile against
|
||
// server metadata for the just-finished active turn first
|
||
// (ignoreStreamJustFinished bypasses only the post-stream cooldown; the
|
||
// reconcile still reloads ONLY when the message count actually changed).
|
||
// The 'idle-reconcile' reason is non-'poll', so it coexists with the
|
||
// #3916/#4195 poll-only external gate without bypassing it. Preserve the
|
||
// original forced reload as a fallback when the probe request itself fails.
|
||
const outcome = await refreshActiveSessionIfExternallyUpdated('idle-reconcile', {
|
||
ignoreStreamJustFinished: true,
|
||
});
|
||
if(outcome === 'failed'){
|
||
await loadSession(sid, {force:true, externalRefreshReason:'idle-reconcile'});
|
||
}
|
||
}catch(_){}
|
||
},0);
|
||
}
|
||
|
||
function _purgeStaleInflightEntries() {
|
||
// Clean up INFLIGHT entries for sessions the server confirms are NOT
|
||
// streaming. This prevents the in-memory cache from growing unbounded
|
||
// when streams end abnormally. (#2066) Additionally, any INFLIGHT entry
|
||
// whose session id is no longer present in the current _allSessions list
|
||
// (deleted / archived / filtered out) is also removed so that ghost entries
|
||
// from deleted sessions do not accumulate. (#2092)
|
||
if (typeof INFLIGHT !== 'object' || !INFLIGHT) return;
|
||
const sessionsById = new Map();
|
||
if (Array.isArray(_allSessions)) {
|
||
for (const s of _allSessions) {
|
||
if (s && s.session_id) sessionsById.set(s.session_id, s);
|
||
}
|
||
}
|
||
const sourceById = typeof _sessionListSourceById !== 'undefined'
|
||
&& _sessionListSourceById
|
||
&& typeof _sessionListSourceById.get === 'function'
|
||
? _sessionListSourceById
|
||
: null;
|
||
const currentSidebarSource = typeof _allSessionsScope !== 'undefined'
|
||
&& _allSessionsScope
|
||
&& typeof _allSessionsScope.sidebarSource === 'string'
|
||
? _allSessionsScope.sidebarSource
|
||
: null;
|
||
for (const sid of Object.keys(INFLIGHT)) {
|
||
// #4354: purge stale INFLIGHT even for a hung/idle session, BUT skip the one
|
||
// session actively mid-send (#2689 start-race) — during /api/chat/start the
|
||
// server row is briefly idle while the client owns the optimistic INFLIGHT
|
||
// entry; purging it here would drop the in-flight turn's local state.
|
||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
|
||
continue;
|
||
}
|
||
if (!sessionsById.has(sid)) {
|
||
const knownSource = sourceById ? sourceById.get(sid) : null;
|
||
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) {
|
||
continue;
|
||
}
|
||
// Session is absent from _allSessions — it was deleted / archived /
|
||
// filtered and can never stream again, so drop the entry.
|
||
delete INFLIGHT[sid];
|
||
if (typeof clearInflightState === 'function') clearInflightState(sid);
|
||
continue;
|
||
}
|
||
const s = sessionsById.get(sid);
|
||
if (!s.is_streaming) {
|
||
// Session exists but is not streaming — purge it.
|
||
delete INFLIGHT[sid];
|
||
if (typeof clearInflightState === 'function') clearInflightState(sid);
|
||
}
|
||
// Sessions that exist and are still streaming are preserved.
|
||
}
|
||
}
|
||
|
||
function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) {
|
||
const resolvedSid = sid || (s && s.session_id);
|
||
if (!resolvedSid) return;
|
||
let source = null;
|
||
if (s && typeof _isCliSession === 'function') {
|
||
source = _isCliSession(s) ? 'cli' : 'webui';
|
||
}
|
||
if (!source && Array.isArray(_allSessions)) {
|
||
const cached = _allSessions.find(item => item && item.session_id === resolvedSid);
|
||
if (cached && typeof _isCliSession === 'function') {
|
||
source = _isCliSession(cached) ? 'cli' : 'webui';
|
||
}
|
||
}
|
||
if (!source
|
||
&& allowScopeFallback
|
||
&& typeof _allSessionsScope !== 'undefined'
|
||
&& _allSessionsScope
|
||
&& typeof _allSessionsScope.sidebarSource === 'string') {
|
||
source = _allSessionsScope.sidebarSource;
|
||
}
|
||
if (source
|
||
&& typeof _sessionListSourceById !== 'undefined'
|
||
&& _sessionListSourceById
|
||
&& typeof _sessionListSourceById.set === 'function') {
|
||
_sessionListSourceById.set(resolvedSid, source);
|
||
}
|
||
}
|
||
|
||
function _rememberRenderedStreamingState(s, isStreaming) {
|
||
if (!s || !s.session_id || !isStreaming) return;
|
||
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s);
|
||
_sessionStreamingById.set(s.session_id, true);
|
||
_rememberObservedStreamingSession(s);
|
||
}
|
||
|
||
function _inflightHasVisibleLiveState(inflight) {
|
||
if (!inflight || typeof inflight !== 'object') return false;
|
||
if (String(inflight.lastAssistantText || '').trim()) return true;
|
||
if (String(inflight.lastReasoningText || '').trim()) return true;
|
||
if (String(inflight.liveTurnHtml || '').trim()) return true;
|
||
if (Array.isArray(inflight.toolCalls) && inflight.toolCalls.length) return true;
|
||
if (Array.isArray(inflight.activityBurstAnchors) && inflight.activityBurstAnchors.length) return true;
|
||
if (Array.isArray(inflight.messages)) {
|
||
return inflight.messages.some((msg) => {
|
||
if (!msg) return false;
|
||
if (msg.role === 'user') return Boolean(_messageComparableText(msg));
|
||
if (msg.role !== 'assistant') return false;
|
||
const content = msg.content;
|
||
if (typeof content === 'string') return content.trim();
|
||
if (Array.isArray(content)) return content.length > 0;
|
||
return Boolean(content);
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function _serverLiveSnapshotToolId(tc){
|
||
return String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||
}
|
||
|
||
function _serverLiveSnapshotInflight(snapshot, uploaded){
|
||
if(!snapshot||typeof snapshot!=='object') return null;
|
||
const rawMessages=Array.isArray(snapshot.messages)?snapshot.messages:[];
|
||
const messages=rawMessages
|
||
.filter(m=>m&&m.role)
|
||
.map(m=>({...m,_live:m._live!==false,_journal_snapshot:true}));
|
||
const rawToolCalls=Array.isArray(snapshot.tool_calls)?snapshot.tool_calls:[];
|
||
const toolCalls=rawToolCalls
|
||
.filter(tc=>tc&&tc.name)
|
||
.map(tc=>{
|
||
const next={...tc,_live:true,_journal_snapshot:true};
|
||
const tid=_serverLiveSnapshotToolId(next);
|
||
if(tid&&!next.tid) next.tid=tid;
|
||
return next;
|
||
});
|
||
let lastAssistantText=String(snapshot.last_assistant_text||snapshot.lastAssistantText||'');
|
||
let lastReasoningText=String(snapshot.last_reasoning_text||snapshot.lastReasoningText||'');
|
||
const lastLiveAssistant=[...messages].reverse().find(m=>m&&m.role==='assistant'&&m._live);
|
||
if(lastLiveAssistant){
|
||
if(!lastAssistantText&&typeof lastLiveAssistant.content==='string') lastAssistantText=lastLiveAssistant.content;
|
||
if(!lastReasoningText&&typeof lastLiveAssistant.reasoning==='string') lastReasoningText=lastLiveAssistant.reasoning;
|
||
}
|
||
if((lastAssistantText||lastReasoningText)&&!lastLiveAssistant){
|
||
messages.push({
|
||
role:'assistant',
|
||
content:lastAssistantText,
|
||
reasoning:lastReasoningText||undefined,
|
||
_live:true,
|
||
_journal_snapshot:true,
|
||
});
|
||
}
|
||
const replayAfterSeq=Number(snapshot.last_seq||0);
|
||
const activityBurstAnchors=Array.isArray(snapshot.activity_burst_anchors)
|
||
? snapshot.activity_burst_anchors
|
||
: (Array.isArray(snapshot.activityBurstAnchors)?snapshot.activityBurstAnchors:[]);
|
||
const anchorActivityScene=(snapshot.anchor_activity_scene&&snapshot.anchor_activity_scene.version==='activity_scene_v1')
|
||
? snapshot.anchor_activity_scene
|
||
: ((snapshot.anchorActivityScene&&snapshot.anchorActivityScene.version==='activity_scene_v1')?snapshot.anchorActivityScene:null);
|
||
const hasAnchorActivityScene=!!(anchorActivityScene&&Array.isArray(anchorActivityScene.activity_rows)&&anchorActivityScene.activity_rows.length);
|
||
if(!messages.length&&!toolCalls.length&&!lastAssistantText&&!lastReasoningText&&!hasAnchorActivityScene) return null;
|
||
return {
|
||
streamId:String(snapshot.stream_id||snapshot.streamId||''),
|
||
messages,
|
||
uploaded:Array.isArray(uploaded)?[...uploaded]:[],
|
||
toolCalls,
|
||
todos:null,
|
||
todoStateMeta:null,
|
||
reattach:true,
|
||
journalSnapshot:true,
|
||
lastAssistantText,
|
||
lastReasoningText,
|
||
lastRunJournalSeq:Number.isFinite(replayAfterSeq)?Math.max(0,replayAfterSeq):0,
|
||
lastRunJournalEventId:String(snapshot.last_event_id||snapshot.lastEventId||''),
|
||
anchorActivityScene,
|
||
currentActivityBurstId:Number(snapshot.current_activity_burst_id||snapshot.currentActivityBurstId||0)||0,
|
||
currentLiveSegmentSeq:Number(snapshot.current_live_segment_seq||snapshot.currentLiveSegmentSeq||0)||0,
|
||
activityBurstAnchors,
|
||
};
|
||
}
|
||
|
||
function _selectLiveRecoveryInflight(localInflight, serverLiveSnapshot, activeStreamId){
|
||
if(!serverLiveSnapshot) return localInflight||null;
|
||
if(!localInflight||!_inflightHasVisibleLiveState(localInflight)) return serverLiveSnapshot;
|
||
|
||
// The run journal owns the Worklog projection. A same-stream browser tail
|
||
// wins only when it advanced after the metadata snapshot was read.
|
||
const requestedActiveId=String(activeStreamId||'').trim();
|
||
const localId=String(localInflight.streamId||'').trim();
|
||
const serverId=String(serverLiveSnapshot.streamId||'').trim();
|
||
const activeId=requestedActiveId||serverId;
|
||
const selectDurableSnapshot=()=>{
|
||
if(activeId&&localId===activeId&&Array.isArray(localInflight.todos)&&localInflight.todoStateMeta){
|
||
return {...serverLiveSnapshot,todos:localInflight.todos,todoStateMeta:localInflight.todoStateMeta};
|
||
}
|
||
return serverLiveSnapshot;
|
||
};
|
||
if(requestedActiveId&&serverId&&serverId!==requestedActiveId){
|
||
return localId===requestedActiveId?localInflight:null;
|
||
}
|
||
if(activeId&&localId!==activeId) return selectDurableSnapshot();
|
||
|
||
const localSeq=Math.max(0,Number(localInflight.lastRunJournalSeq)||0);
|
||
const serverSeq=Math.max(0,Number(serverLiveSnapshot.lastRunJournalSeq)||0);
|
||
return serverSeq>=localSeq?selectDurableSnapshot():localInflight;
|
||
}
|
||
|
||
function _anchorActivitySceneStreamId(scene){
|
||
if(!scene||typeof scene!=='object') return '';
|
||
const identity=scene.identity&&typeof scene.identity==='object'?scene.identity:null;
|
||
return String(scene.stream_id||scene.streamId||(identity&&(identity.stream_id||identity.streamId))||'').trim();
|
||
}
|
||
|
||
function _anchorActivitySceneMatchesStream(scene, activeStreamId){
|
||
const activeId=String(activeStreamId||'').trim();
|
||
if(!activeId) return true;
|
||
const sceneId=_anchorActivitySceneStreamId(scene);
|
||
return !sceneId||sceneId===activeId;
|
||
}
|
||
|
||
function _runtimeJournalAnchorActivitySceneForSession(sid, activeStreamId){
|
||
const inflight=INFLIGHT&&sid?INFLIGHT[sid]:null;
|
||
if(inflight&&inflight.anchorActivityScene&&inflight.anchorActivityScene.version==='activity_scene_v1'&&_anchorActivitySceneMatchesStream(inflight.anchorActivityScene, activeStreamId)){
|
||
return inflight.anchorActivityScene;
|
||
}
|
||
const snapshot=S.session&&S.session.runtime_journal_snapshot;
|
||
const scene=snapshot&&(snapshot.anchor_activity_scene||snapshot.anchorActivityScene);
|
||
return scene&&scene.version==='activity_scene_v1'&&_anchorActivitySceneMatchesStream(scene, activeStreamId)?scene:null;
|
||
}
|
||
|
||
function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){
|
||
if(!activeStreamId||typeof window==='undefined'||typeof window._renderLiveAnchorActivitySceneSnapshotForStream!=='function') return false;
|
||
const scene=_runtimeJournalAnchorActivitySceneForSession(sid, activeStreamId);
|
||
if(!scene) return false;
|
||
return !!window._renderLiveAnchorActivitySceneSnapshotForStream(activeStreamId, scene, sid);
|
||
}
|
||
|
||
function _rememberRenderedSessionSnapshot(s) {
|
||
if (!s || !s.session_id) return;
|
||
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s);
|
||
const previous = _sessionListSnapshotById.get(s.session_id);
|
||
if (previous) return;
|
||
_sessionListSnapshotById.set(s.session_id, {
|
||
message_count: Number(s.message_count || 0),
|
||
last_message_at: Number(s.last_message_at || 0),
|
||
});
|
||
}
|
||
|
||
function _markSessionCompletedInList(session, previousSid = null) {
|
||
if (!session || !Array.isArray(_allSessions)) return;
|
||
const finalSid = session.session_id || previousSid;
|
||
if (!finalSid) return;
|
||
const finalIdx = _allSessions.findIndex(s => s && s.session_id === finalSid);
|
||
const previousIdx = previousSid ? _allSessions.findIndex(s => s && s.session_id === previousSid) : -1;
|
||
const idx = finalIdx >= 0 ? finalIdx : previousIdx;
|
||
if (idx < 0) return;
|
||
const {messages: _messages, tool_calls: _toolCalls, ...sessionMeta} = session;
|
||
const messageCount = Number(
|
||
session.message_count != null
|
||
? session.message_count
|
||
: (Array.isArray(session.messages) ? session.messages.length : (_allSessions[idx].message_count || 0))
|
||
);
|
||
const lastMessageAt = Number(session.last_message_at || session.updated_at || _allSessions[idx].last_message_at || 0);
|
||
_allSessions[idx] = {
|
||
..._allSessions[idx],
|
||
...sessionMeta,
|
||
session_id: finalSid,
|
||
message_count: messageCount,
|
||
last_message_at: lastMessageAt,
|
||
active_stream_id: null,
|
||
pending_user_message: null,
|
||
pending_started_at: null,
|
||
is_streaming: false,
|
||
};
|
||
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(_allSessions[idx], finalSid);
|
||
_sessionStreamingById.set(finalSid, false);
|
||
_forgetObservedStreamingSession(finalSid);
|
||
if (previousSid && previousSid !== finalSid) {
|
||
for (let i = _allSessions.length - 1; i >= 0; i--) {
|
||
if (i !== idx && _allSessions[i] && _allSessions[i].session_id === previousSid) {
|
||
_allSessions.splice(i, 1);
|
||
}
|
||
}
|
||
_sessionStreamingById.delete(previousSid);
|
||
_forgetObservedStreamingSession(previousSid);
|
||
_sessionListSnapshotById.delete(previousSid);
|
||
_sessionListSourceById.delete(previousSid);
|
||
}
|
||
_sessionListSnapshotById.set(finalSid, {
|
||
message_count: messageCount,
|
||
last_message_at: lastMessageAt,
|
||
});
|
||
renderSessionListFromCache();
|
||
}
|
||
|
||
function _markPollingCompletionUnreadTransitions(sessions) {
|
||
if (!Array.isArray(sessions)) return;
|
||
const seen = new Set();
|
||
const sourceById = typeof _sessionListSourceById !== 'undefined'
|
||
&& _sessionListSourceById
|
||
&& typeof _sessionListSourceById.get === 'function'
|
||
&& typeof _sessionListSourceById.keys === 'function'
|
||
&& typeof _sessionListSourceById.delete === 'function'
|
||
? _sessionListSourceById
|
||
: new Map();
|
||
const currentSidebarSource = typeof _allSessionsScope !== 'undefined'
|
||
&& _allSessionsScope
|
||
&& typeof _allSessionsScope.sidebarSource === 'string'
|
||
? _allSessionsScope.sidebarSource
|
||
: null;
|
||
for (const s of sessions) {
|
||
if (!s || !s.session_id) continue;
|
||
const sid = s.session_id;
|
||
seen.add(sid);
|
||
if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s, sid);
|
||
const wasStreaming = _sessionStreamingById.get(sid);
|
||
const isStreaming = _isSessionEffectivelyStreaming(s);
|
||
const previousSnapshot = _sessionListSnapshotById.get(sid);
|
||
const observedStreaming = _getSessionObservedStreaming()[sid];
|
||
const messageCount = Number(s.message_count || 0);
|
||
const lastMessageAt = Number(s.last_message_at || 0);
|
||
const hasServerRunSignal=Boolean(s.is_streaming||_hasPendingUserMessageSignal(s));
|
||
const canMarkCompletedStream=Boolean(hasServerRunSignal||previousSnapshot||observedStreaming);
|
||
const completedObservedStream = canMarkCompletedStream&&wasStreaming === true && !isStreaming;
|
||
const completedWithNewMessages = Boolean(
|
||
(previousSnapshot || observedStreaming)
|
||
&& !isStreaming
|
||
&& (
|
||
messageCount > Number((previousSnapshot || observedStreaming).message_count || 0)
|
||
|| lastMessageAt > Number((previousSnapshot || observedStreaming).last_message_at || 0)
|
||
)
|
||
);
|
||
const completedPersistedObservedStream = Boolean(observedStreaming && !isStreaming);
|
||
if (completedObservedStream || completedPersistedObservedStream || completedWithNewMessages) {
|
||
if (!_isSessionActivelyViewedForList(sid)) {
|
||
// Tag cron session-list markers with source+profile so profile-switch
|
||
// reset can clear only inactive-profile cron dots (#5960 / #5975 re-gate).
|
||
const meta = (typeof _cronCompletionUnreadMetaForSession === 'function')
|
||
? _cronCompletionUnreadMetaForSession(s)
|
||
: null;
|
||
// Defense: never re-create a cron unread for a non-active profile while
|
||
// the sidebar is single-profile (stale pre-switch payloads).
|
||
const allProfilesOn = (typeof _showAllProfiles !== 'undefined' && !!_showAllProfiles);
|
||
if (
|
||
meta
|
||
&& meta.source === 'cron'
|
||
&& meta.profile
|
||
&& !allProfilesOn
|
||
&& typeof _cronMarkerProfileMatchesActive === 'function'
|
||
&& !_cronMarkerProfileMatchesActive(meta.profile, (typeof S !== 'undefined' && S && S.activeProfile) || 'default')
|
||
) {
|
||
// Skip mark for inactive-profile cron row.
|
||
} else {
|
||
_markSessionCompletionUnread(sid, s.message_count, meta);
|
||
}
|
||
} else {
|
||
// Sync viewed count so we don't flag stale unread on tab switch (#3020)
|
||
_setSessionViewedCount(sid, messageCount);
|
||
}
|
||
}
|
||
_sessionStreamingById.set(sid, isStreaming);
|
||
if (isStreaming) {
|
||
_rememberObservedStreamingSession(s);
|
||
} else {
|
||
_forgetObservedStreamingSession(sid);
|
||
}
|
||
_sessionListSnapshotById.set(sid, {
|
||
message_count: messageCount,
|
||
last_message_at: lastMessageAt,
|
||
});
|
||
}
|
||
const staleRuntimeStateSids = new Set([
|
||
...Array.from(_sessionStreamingById.keys()),
|
||
...Array.from(_sessionListSnapshotById.keys()),
|
||
...Array.from(sourceById.keys()),
|
||
]);
|
||
for (const sid of staleRuntimeStateSids) {
|
||
if (seen.has(sid)) continue;
|
||
const knownSource = sourceById.get(sid);
|
||
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue;
|
||
_sessionStreamingById.delete(sid);
|
||
_sessionListSnapshotById.delete(sid);
|
||
sourceById.delete(sid);
|
||
}
|
||
}
|
||
|
||
let _newSessionInFlight=null;
|
||
const _newSessionPendingText=()=>t('new_session_creating')||'Creating new conversation…';
|
||
const _emptyComposerModelOverrideHost=typeof window!=='undefined'?window:globalThis;
|
||
|
||
function _rememberEmptyComposerModelOverride(model, modelProvider){
|
||
const resolvedModel=String(model||'').trim();
|
||
if(!resolvedModel) return;
|
||
_emptyComposerModelOverrideHost._emptyComposerModelOverride={
|
||
model:resolvedModel,
|
||
model_provider:modelProvider||null,
|
||
saved_at:Date.now(),
|
||
};
|
||
}
|
||
|
||
function _readEmptyComposerModelOverride(){
|
||
const state=_emptyComposerModelOverrideHost._emptyComposerModelOverride;
|
||
if(!state||!state.model) return null;
|
||
return {
|
||
model:String(state.model||''),
|
||
model_provider:state.model_provider||null,
|
||
saved_at:Number(state.saved_at||0)||0,
|
||
};
|
||
}
|
||
|
||
function _clearEmptyComposerModelOverride(){
|
||
_emptyComposerModelOverrideHost._emptyComposerModelOverride=null;
|
||
}
|
||
|
||
let _newSessionWorkspaceAnnouncementClearTimer=null;
|
||
|
||
function _setNewSessionWorkspaceCue(message){
|
||
const announcer=$('a11yAnnouncer');
|
||
const composerCue=$('composerWorkspaceContext');
|
||
const msg=$('msg');
|
||
const cueId='composerWorkspaceContext';
|
||
if(_newSessionWorkspaceAnnouncementClearTimer&&typeof clearTimeout==='function'){
|
||
clearTimeout(_newSessionWorkspaceAnnouncementClearTimer);
|
||
_newSessionWorkspaceAnnouncementClearTimer=null;
|
||
}
|
||
const removeComposerCue=()=>{
|
||
if(composerCue&&composerCue.textContent===message) composerCue.textContent='';
|
||
if(msg){
|
||
const ids=(msg.getAttribute('aria-describedby')||'')
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.filter(id=>id!==cueId);
|
||
if(ids.length) msg.setAttribute('aria-describedby',ids.join(' '));
|
||
else msg.removeAttribute('aria-describedby');
|
||
}
|
||
};
|
||
const clear=()=>{
|
||
if(announcer&&announcer.textContent===message) announcer.textContent='';
|
||
removeComposerCue();
|
||
_newSessionWorkspaceAnnouncementClearTimer=null;
|
||
};
|
||
const announce=()=>{
|
||
if(announcer) announcer.textContent=message;
|
||
if(composerCue&&msg){
|
||
composerCue.textContent=message;
|
||
const ids=(msg.getAttribute('aria-describedby')||'')
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.filter(id=>id!==cueId);
|
||
ids.push(cueId);
|
||
msg.setAttribute('aria-describedby',ids.join(' '));
|
||
}
|
||
if(typeof setTimeout==='function'){
|
||
_newSessionWorkspaceAnnouncementClearTimer=setTimeout(clear,5000);
|
||
}
|
||
};
|
||
if(announcer) announcer.textContent='';
|
||
removeComposerCue();
|
||
if(typeof requestAnimationFrame==='function') requestAnimationFrame(announce);
|
||
else announce();
|
||
}
|
||
|
||
function _announceNewSessionWorkspace(session){
|
||
if(!session||!session.workspace) return;
|
||
const name=(typeof getWorkspaceFriendlyName==='function')
|
||
? getWorkspaceFriendlyName(session.workspace)
|
||
: String(session.workspace).split('/').filter(Boolean).pop()||session.workspace;
|
||
_setNewSessionWorkspaceCue(t('new_session_workspace_announce',name));
|
||
}
|
||
|
||
function _setNewSessionPending(pending){
|
||
const ids=['btnNewChat','btnTitlebarNewChat'];
|
||
for (let i=0;i<ids.length;i++){
|
||
const btn=$(ids[i]);
|
||
if(!btn) continue;
|
||
btn.disabled=!!pending;
|
||
btn.setAttribute('aria-busy',pending?'true':'false');
|
||
}
|
||
const statusEl=$('composerStatus');
|
||
const pendingText=_newSessionPendingText();
|
||
if(pending){
|
||
setComposerStatus(pendingText);
|
||
}else if(statusEl&&statusEl.textContent===pendingText){
|
||
setComposerStatus('');
|
||
}
|
||
}
|
||
|
||
async function newSession(flash, options={}){
|
||
if(_newSessionInFlight){
|
||
if(typeof showToast==='function') showToast(_newSessionPendingText(),1500);
|
||
return _newSessionInFlight;
|
||
}
|
||
_setNewSessionPending(true);
|
||
_newSessionInFlight=(async()=>{
|
||
// Starting a brand-new chat must not carry named context blocks selected in
|
||
// the previous conversation (#2543). loadSession() clears these on a sidebar
|
||
// switch, but the New Chat path replaces S.session here without going through
|
||
// loadSession(), so clear them explicitly before the session is replaced.
|
||
if(typeof window._clearPendingSelections==='function') window._clearPendingSelections();
|
||
updateQueueBadge();
|
||
S.toolCalls=[];
|
||
_messagesTruncated=false;
|
||
_oldestIdx=0;
|
||
clearLiveToolCards();
|
||
// One-shot profile-switch workspace wins first; otherwise prefer the profile default.
|
||
const switchWs=S._profileSwitchWorkspace;
|
||
S._profileSwitchWorkspace=null;
|
||
const inheritWs=switchWs||(S._profileDefaultWorkspace||null)||(S.session?S.session.workspace:null);
|
||
const reqBody={
|
||
workspace:inheritWs,
|
||
profile:S.activeProfile||'default',
|
||
};
|
||
if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id;
|
||
// Three-value worktree contract (#6022): explicit true/false is forwarded
|
||
// verbatim; an ABSENT key lets the server apply the agent's config-level
|
||
// `worktree:` default. Auto-bind paths pass worktree:false explicitly so a
|
||
// config default can never mint a worktree (+ branch) on mere page load.
|
||
if(options&&Object.prototype.hasOwnProperty.call(options,'worktree')) reqBody.worktree=!!options.worktree;
|
||
if(Object.prototype.hasOwnProperty.call(options,'project_id')){
|
||
reqBody.project_id=options.project_id;
|
||
} else if(_activeProject&&_activeProject!==NO_PROJECT_FILTER){
|
||
reqBody.project_id=_activeProject;
|
||
}
|
||
// Forward a pre-session toolset override only from the empty composer (#4490).
|
||
if(!S.session && Array.isArray(S._pendingSessionToolsets)) reqBody.enabled_toolsets=S._pendingSessionToolsets;
|
||
const modelSelForNew=$('modelSelect');
|
||
const explicitModelOverride=(typeof _readEmptyComposerModelOverride==='function')
|
||
? _readEmptyComposerModelOverride()
|
||
: null;
|
||
const hasLoadedSession=!!(S.session&&S.session.session_id);
|
||
let newModelState=null;
|
||
let consumedExplicitModelOverride=false;
|
||
let usingConfiguredDefault=false;
|
||
if(!hasLoadedSession&&explicitModelOverride&&explicitModelOverride.model){
|
||
newModelState=explicitModelOverride;
|
||
consumedExplicitModelOverride=true;
|
||
}else if(window._defaultModel){
|
||
// Configured default wins over stale picker/persisted state even with no
|
||
// loaded session (deleting the last session left S.session null + stale picker) (#4728).
|
||
newModelState={model:window._defaultModel,model_provider:null};
|
||
usingConfiguredDefault=true;
|
||
}else if(modelSelForNew&&modelSelForNew.value&&typeof _modelStateForSelect==='function'){
|
||
newModelState=_modelStateForSelect(modelSelForNew,modelSelForNew.value);
|
||
}else if(typeof _readPersistedModelState==='function'){
|
||
newModelState=_readPersistedModelState();
|
||
}
|
||
if(newModelState&&newModelState.model){
|
||
reqBody.model=newModelState.model;
|
||
// Cold-start / picker-without-provider fallback: when the dropdown option's
|
||
// data-provider is empty/'default' or the persisted state predates provider
|
||
// tracking, newModelState.model_provider is null. POST /api/session/new's
|
||
// fast path in _resolve_compatible_session_model_state requires both model
|
||
// and a truthy model_provider; without it, the request falls into
|
||
// get_available_models() and a 3-4s cold catalog rebuild. window._activeProvider
|
||
// is hydrated at boot (ui.js) and on config refresh (panels.js), so it's a
|
||
// safe default that matches the user's configured route. S.session.model_provider
|
||
// is the previous-session fallback when the dropdown is unhydrated.
|
||
//
|
||
// Guard: a slash-qualified model (e.g. "gemini/gemini-2.5") or an
|
||
// @provider:model string already carries a foreign provider namespace from
|
||
// a previous session that was served by a different backend. Attaching
|
||
// the current _activeProvider to such a slug would let the server's fast
|
||
// path pass it through without consulting the catalog, silently
|
||
// re-pointing the new session at the wrong backend (the very case the
|
||
// slow-path normalization in _resolve_compatible_session_model_state is
|
||
// designed to fix — see routes.py docstring around line 1891-1894). For
|
||
// those models we leave the wire shape with model_provider=null so the
|
||
// slow path's cross-provider repair still runs. Closes the open
|
||
// follow-up from #2518.
|
||
const _bareModel=!/[/]/.test(newModelState.model)&&!newModelState.model.startsWith('@');
|
||
// Second guard (#3410-followup): even a bare model can carry a known
|
||
// family prefix (gpt→openai, claude→anthropic, gemini→google). If that
|
||
// family maps to a DIFFERENT provider than the fallback we'd attach, the
|
||
// server fast path passes the pair through verbatim (no validation) and
|
||
// silently routes to the wrong backend — so leave model_provider=null and
|
||
// let the slow-path family repair run (mirrors routes.py _normalize_provider_id).
|
||
const _fallbackProvider=_bareModel
|
||
? ((usingConfiguredDefault?window._activeProvider:(window._activeProvider||(S.session&&S.session.model_provider)))||'')
|
||
: '';
|
||
const _familyProvider=(m=>{const s=String(m||'').toLowerCase();
|
||
if(s.startsWith('gpt'))return 'openai';if(s.startsWith('claude'))return 'anthropic';
|
||
if(s.startsWith('gemini'))return 'google';return '';})(newModelState.model);
|
||
const _normProv=p=>{const s=String(p||'').toLowerCase();
|
||
if(s.startsWith('openai'))return 'openai';if(s.startsWith('anthropic')||s.startsWith('claude'))return 'anthropic';
|
||
if(s.startsWith('google')||s.startsWith('gemini'))return 'google';return s;};
|
||
const _familyMismatch=_familyProvider&&_fallbackProvider&&_normProv(_fallbackProvider)!==_familyProvider;
|
||
const _fallbackIsNamedCustom=String(_fallbackProvider||'').toLowerCase().startsWith('custom:');
|
||
reqBody.model_provider=newModelState.model_provider
|
||
||((_bareModel&&!_familyMismatch&&!_fallbackIsNamedCustom)?(_fallbackProvider||null):null)
|
||
||null;
|
||
}
|
||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||
if(consumedExplicitModelOverride&&typeof _clearEmptyComposerModelOverride==='function'){
|
||
_clearEmptyComposerModelOverride();
|
||
}
|
||
S.session=data.session;S.messages=data.session.messages||[];
|
||
S._pendingSessionToolsets=null;
|
||
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
|
||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||
S.lastUsage={...(data.session.last_usage||{})};
|
||
if(!(options&&options.worktree)) _rememberNewChatDraftSession(S.session);
|
||
if(flash)S.session._flash=true;
|
||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||
_setActiveSessionUrl(S.session.session_id);
|
||
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
|
||
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
|
||
// Sync chat-header dropdown to the session's model/provider so the UI reflects
|
||
// the default route the server actually used (#872). Compare provider state too:
|
||
// duplicate model ids can exist under several providers, and a stale persisted
|
||
// picker selection with the same model id should not mask the new session's
|
||
// configured default provider.
|
||
const modelSel=$('modelSelect');
|
||
if(S.session.model && modelSel && typeof _applyModelToDropdown==='function'){
|
||
const currentModelState=(typeof _modelStateForSelect==='function')
|
||
? _modelStateForSelect(modelSel,modelSel.value)
|
||
: {model:modelSel.value,model_provider:null};
|
||
const sessionProvider=S.session.model_provider||null;
|
||
const currentProvider=currentModelState.model_provider||null;
|
||
if(S.session.model!==modelSel.value || sessionProvider !== currentProvider){
|
||
let sessionModelApplied=_applyModelToDropdown(S.session.model,modelSel,sessionProvider);
|
||
if(!sessionModelApplied){
|
||
const opt=document.createElement('option');
|
||
opt.value=S.session.model;
|
||
opt.textContent=typeof getModelLabel==='function'?getModelLabel(S.session.model):S.session.model;
|
||
opt.dataset.custom='1';
|
||
opt.dataset.provider=sessionProvider||'';
|
||
modelSel.appendChild(opt);
|
||
sessionModelApplied=_applyModelToDropdown(S.session.model,modelSel,sessionProvider);
|
||
}
|
||
if(sessionModelApplied&&typeof syncModelChip==='function') syncModelChip();
|
||
}
|
||
}
|
||
// Reset per-session visual state: a fresh chat is idle even if another
|
||
// conversation is still streaming in the background.
|
||
S.busy=false;
|
||
S.activeStreamId=null;
|
||
updateSendBtn();
|
||
setStatus('');
|
||
setComposerStatus('');
|
||
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
|
||
if(typeof _syncCtxIndicator==='function'){
|
||
_syncCtxIndicator({
|
||
input_tokens:data.session.input_tokens||0,
|
||
output_tokens:data.session.output_tokens||0,
|
||
estimated_cost:data.session.estimated_cost||0,
|
||
cache_read_tokens:data.session.cache_read_tokens||0,
|
||
cache_write_tokens:data.session.cache_write_tokens||0,
|
||
cache_hit_percent:data.session.cache_hit_percent,
|
||
context_length:data.session.context_length||0,
|
||
last_prompt_tokens:data.session.last_prompt_tokens||0,
|
||
post_compression_context_tokens_estimate:data.session.post_compression_context_tokens_estimate||null,
|
||
threshold_tokens:data.session.threshold_tokens||0,
|
||
});
|
||
}
|
||
updateQueueBadge(S.session.session_id);
|
||
syncTopbar();renderMessages();
|
||
if(typeof _announceNewSessionWorkspace==='function') _announceNewSessionWorkspace(S.session);
|
||
// Keep new-chat first paint instant. The workspace tree / git badge can
|
||
// refresh right after paint unless this caller explicitly needs it loaded
|
||
// before continuing (profile/default-workspace binding path).
|
||
if(options&&options.awaitWorkspaceLoad){
|
||
await loadDir('.');
|
||
}else if(typeof _deferWorkspaceRefreshForSession==='function'){
|
||
_deferWorkspaceRefreshForSession(S.session.session_id);
|
||
}else{
|
||
const _dirP=loadDir('.');
|
||
if(_dirP&&typeof _dirP.catch==='function') _dirP.catch(()=>{});
|
||
}
|
||
// Refresh sidebar to include the newly created session (#3874).
|
||
if(typeof refreshSessionList==='function'){Promise.resolve(refreshSessionList('new-session')).catch(()=>{})}
|
||
})();
|
||
try{
|
||
return await _newSessionInFlight;
|
||
}finally{
|
||
_newSessionInFlight=null;
|
||
_setNewSessionPending(false);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Self-heal: clear the stuck session ID from localStorage and URL when a
|
||
* loadSession() call failed during boot (no currentSid). This prevents the
|
||
* browser from retrying the same dead session on every refresh.
|
||
*
|
||
* Called from loadSession() after 401 redirect (undefined data) or any
|
||
* non-404 error (400, 403, 500, network). The 404 path has its own
|
||
* inline self-heal; this helper consolidates the non-404 cases.
|
||
*
|
||
* Only clears when !currentSid — no session is active on screen, so
|
||
* the stored ID is definitely stale. When currentSid is set (already
|
||
* viewing a session), a non-404 failure could be a transient server error
|
||
* and the session may still exist on the server; wiping localStorage in
|
||
* that case is unnecessarily destructive (#4028 follow-up).
|
||
*
|
||
* A click into a *different* dead session (currentSid && currentSid!==sid)
|
||
* must not run it: localStorage and the URL still point at the live session
|
||
* (both are only updated on a successful load), so wiping them would log
|
||
* the user out of a healthy session (#2782).
|
||
*/
|
||
function _clearStuckSessionOnBoot(sid, currentSid){
|
||
if(!currentSid){
|
||
try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ }
|
||
try{ history.replaceState(null,'',_appRootPath()); }catch(_){ }
|
||
}
|
||
}
|
||
|
||
// #2971 (Greptile P1 r3377162160): loadSession() tears down the live
|
||
// per-session SSE at the top via stopSessionStream() (line ~754), but only the
|
||
// success path re-arms it via startSessionStream() (line ~875). Every
|
||
// early-return exit (fetch error, auth-redirect undefined) — and the
|
||
// same-session no-op guard, which returns BEFORE the teardown — could leave
|
||
// the session the user actually remains on with a permanently null
|
||
// EventSource, silently dropping bg_task_complete delivery until a full page
|
||
// reload or a forced loadSession. This helper re-arms the stream for whatever
|
||
// session is currently on screen (S.session). startSessionStream() is
|
||
// idempotent — it no-ops when already live for that sid (top guard
|
||
// `_sessionStreamSessionId === sid && _sessionEventSource`) — so this never
|
||
// double-arms the success path, which arms the *newly assigned* S.session
|
||
// only after this point.
|
||
function _rearmActiveSessionStream(){
|
||
if(typeof startSessionStream!=='function') return;
|
||
const activeSid = S.session ? S.session.session_id : null;
|
||
if(activeSid) startSessionStream(activeSid);
|
||
}
|
||
|
||
function _sessionProfileMismatchFromError(e){
|
||
if(!e || e.status!==409 || !e.body) return null;
|
||
try{
|
||
const body=JSON.parse(e.body);
|
||
if(body && body.code==='session_profile_mismatch' && body.profile){
|
||
return {profile:String(body.profile), session_id:String(body.session_id||'')};
|
||
}
|
||
}catch(_){ }
|
||
return null;
|
||
}
|
||
|
||
async function _switchProfileForSessionLoad(profile){
|
||
const name=String(profile||'').trim();
|
||
if(!name) throw new Error('missing profile');
|
||
if(name===S.activeProfile) return;
|
||
if(typeof _invalidateSessionListRenders==='function') _invalidateSessionListRenders();
|
||
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(true);
|
||
if(typeof showSessionListSkeleton==='function') showSessionListSkeleton(name);
|
||
try{
|
||
const data=await api('/api/profile/switch',{method:'POST',body:JSON.stringify({name}),timeoutToast:false});
|
||
S.activeProfile=data.active||name;
|
||
S.activeProfileIsDefault=!!data.is_default;
|
||
if(typeof _resetCronUnreadForProfileSwitch==='function'){
|
||
_resetCronUnreadForProfileSwitch();
|
||
}
|
||
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
|
||
else localStorage.removeItem('hermes-webui-model');
|
||
if(data.default_model) window._defaultModel=data.default_model;
|
||
if(data.default_model_provider) window._activeProvider=data.default_model_provider;
|
||
if(typeof refreshProfileTransitionReasoningChip==='function'){
|
||
refreshProfileTransitionReasoningChip(data.default_model,data.default_model_provider);
|
||
}
|
||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||
if(typeof syncTopbar==='function') syncTopbar();
|
||
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(false);
|
||
if(typeof renderSessionList==='function') await renderSessionList();
|
||
}catch(switchErr){
|
||
// The switch POST failed, so we're still on the previous profile and its
|
||
// caches are intact. Clear the up-front skeleton and re-render the real
|
||
// list so the sidebar doesn't strand on the skeleton (the #4671 strand bug
|
||
// — _sessionListSkeletonActive hard-gates renderSessionListFromCache + the
|
||
// SSE/poll repaints until an unrelated full render fires). Mirror the
|
||
// canonical switch's catch in panels.js, then rethrow so loadSession's
|
||
// catch(switchErr) still routes into the generic error handler.
|
||
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(false);
|
||
_sessionListSkeletonActive=false;
|
||
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
|
||
throw switchErr;
|
||
}
|
||
}
|
||
|
||
async function loadSession(sid){
|
||
const opts = arguments[1] || {};
|
||
// Resolve canonical lineage SID BEFORE both the direct and sidebar preload
|
||
// notifications so extensions always see the canonical session id, not the
|
||
// raw sidebar click id (which may differ after lineage folding).
|
||
if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){
|
||
const resolvedSid=_resolveSessionIdFromSidebarLineage(sid);
|
||
if(resolvedSid&&resolvedSid!==sid) sid=resolvedSid;
|
||
}
|
||
// Extension pre-open hook — fires once per sidebar click, not on every call.
|
||
// _openSidebarSession passes _preloadNotified:true so the hook isn't re-fired
|
||
// when loadSession runs the actual navigation inside it.
|
||
if(!opts.skipExtHooks && !opts._preloadNotified && typeof _hermesNotifySessionOpen==='function'){
|
||
var _preResult=_hermesNotifySessionOpen(sid, null, {preload:true, opts:opts});
|
||
if(_preResult&&_preResult.cancel===true){
|
||
return;
|
||
}
|
||
}
|
||
const forceReload = !!opts.force;
|
||
const currentSid = S.session ? S.session.session_id : null;
|
||
const sameSessionForceReload = forceReload && currentSid===sid;
|
||
// Clicking the already-open session in the sidebar is a no-op. Reloading it
|
||
// tears down active pane state and can reset the long-session scroll window
|
||
// to the top even though the user did not navigate anywhere. Explicit
|
||
// refresh paths pass {force:true} when external state.db changes arrive.
|
||
// Do not no-op a same-session click while another load is in flight: the
|
||
// previous transcript may already have been cleared for the pending switch.
|
||
// Static force-reload invariant: if(currentSid===sid && !forceReload) return;
|
||
// #2971: idempotent re-arm before the no-op guard revives a stream a prior
|
||
// failed loadSession killed; no-ops on real switches.
|
||
_rearmActiveSessionStream();
|
||
if(currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)){
|
||
// Re-selecting the already-open session is a no-op for transcript/scroll, but
|
||
// it is still a *visit*: clear a stale sidebar unread dot (e.g. one a
|
||
// background completion left on the open, unfocused pane) before returning.
|
||
if(_sessionVisitHasUnreadState(sid)){
|
||
_acknowledgeSessionVisit(
|
||
sid,
|
||
Number(S.session.message_count || 0),
|
||
Number(S.session.last_message_at || S.session.updated_at || 0)
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
// Mark this session as the in-flight load. Subsequent loadSession() calls
|
||
// will overwrite this; stale awaits use the mismatch to bail out (#1060).
|
||
const _loadGeneration = ++_loadSessionGeneration;
|
||
const _isCurrentLoad = () => _loadingSessionId === sid && _loadSessionGeneration === _loadGeneration;
|
||
_loadingSessionId = sid;
|
||
if(currentSid!==sid&&typeof _uploadPendingFilesSyncProgressForSession==='function')_uploadPendingFilesSyncProgressForSession(sid);
|
||
// Reset scroll state for fresh session navigation — the reader expects to
|
||
// land at the bottom of the new transcript, not wherever a stale unpin flag
|
||
// from a prior session or a stray touch event during loading would place them.
|
||
if (currentSid !== sid && typeof _messageUserUnpinned !== 'undefined') {
|
||
_messageUserUnpinned = false;
|
||
_scrollPinned = true;
|
||
}
|
||
stopApprovalPolling();hideApprovalCard(forceReload);
|
||
if(typeof stopSessionStream==='function') stopSessionStream();
|
||
_yoloEnabled=false;_updateYoloPill();
|
||
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
|
||
if(typeof hideClarifyCard==='function') hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed');
|
||
// Show loading indicator immediately for responsiveness.
|
||
// Cleared by renderMessages() once full session data arrives.
|
||
// Persist the current composer draft before switching away so it can be
|
||
// restored when the user switches back (#1060). Save to server now so the
|
||
// draft survives page refresh and syncs across clients.
|
||
if (currentSid && currentSid !== sid) {
|
||
if(typeof window._clearPendingSelections==='function') window._clearPendingSelections();
|
||
if(typeof _clearQueueCardDisplay==='function') _clearQueueCardDisplay(currentSid);
|
||
await _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []);
|
||
// The awaited draft save above yields the event loop. If another
|
||
// loadSession() started for a different session while we were waiting
|
||
// (rapid switch B→C), _loadingSessionId now points at that newer load —
|
||
// bail out before the destructive state-clearing block below so this stale
|
||
// continuation can't wipe S.messages / write the loading placeholder /
|
||
// close streams for the session the user actually landed on (#1060 guard,
|
||
// extended to cover the new pre-switch await).
|
||
if (!_isCurrentLoad()) return;
|
||
// Snapshot the live turn before msgInner is replaced. Preserves the activity
|
||
// timer, partial response, and tool cards so switching back does not rebuild
|
||
// the stream UI from scratch.
|
||
if(
|
||
(S.busy||S.activeStreamId||(INFLIGHT&&INFLIGHT[currentSid]))&&
|
||
typeof snapshotLiveTurnHtmlForSession==='function'
|
||
){
|
||
if(!INFLIGHT[currentSid]){
|
||
INFLIGHT[currentSid]={
|
||
messages:Array.isArray(S.messages)?[...S.messages]:[],
|
||
uploaded:[],
|
||
toolCalls:Array.isArray(S.toolCalls)?[...S.toolCalls]:[],
|
||
};
|
||
}
|
||
snapshotLiveTurnHtmlForSession(currentSid);
|
||
}
|
||
}
|
||
const _keepStaleUntilLoaded = !!opts.keepStaleUntilLoaded && sameSessionForceReload;
|
||
if (currentSid !== sid || forceReload) {
|
||
// #3306: When force-reloading the currently-active session (e.g. external
|
||
// poll triggering a refresh), snapshot the existing messages BEFORE we
|
||
// clear them. _ensureMessagesLoaded() runs the ephemeral-field
|
||
// carry-forward (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
|
||
// _statusCard, _anchor_stream_id) against S.messages, but by the time the API fetch returns
|
||
// S.messages has already been reset to [] here and the carry-forward is a
|
||
// no-op. The visible symptom is the token-usage badge vanishing ~10s
|
||
// after each assistant turn completes. Stash the snapshot so the
|
||
// carry-forward call can consume it.
|
||
_pendingCarryForwardSnapshot = (currentSid === sid && forceReload)
|
||
? (S.messages || []).slice()
|
||
: null;
|
||
// #3239: also capture a reload-width hint BEFORE clearing so the
|
||
// authoritative reload preserves the already-loaded transcript width
|
||
// instead of collapsing a long session back to the default tail window.
|
||
if (sameSessionForceReload) _captureSameSessionForceReloadHint(sid);
|
||
else _clearSameSessionForceReloadHint();
|
||
// #5177: keep-stale-until-loaded path — defer the destructive
|
||
// S.messages/toolCalls clear so the user does NOT see a transcript-wide
|
||
// blank gap during the metadata + messages round-trip. Only the
|
||
// visibility / focus recovery callers in refreshActiveSessionIfExternallyUpdated
|
||
// request this. The new transcript will be SWAPPED into S.messages by the
|
||
// forced _ensureMessagesLoaded(...{force:true}) call below, producing a
|
||
// single render frame with old DOM directly replaced by new DOM rather
|
||
// than the old → empty → new sequence the default branch produces.
|
||
//
|
||
// The session-switch branch (currentSid !== sid) MUST continue to clear
|
||
// synchronously — leaving a prior session's transcript on screen during a
|
||
// navigation is the original bug this clear was written for. We gate
|
||
// strictly on sameSessionForceReload (computed above as part of
|
||
// _keepStaleUntilLoaded) so cross-session switches keep their existing
|
||
// behaviour.
|
||
if (!_keepStaleUntilLoaded) {
|
||
S.messages = [];
|
||
S.toolCalls = [];
|
||
_messagesTruncated = false;
|
||
_oldestIdx = 0;
|
||
}
|
||
// Close live SSE streams from the session we're leaving. The error
|
||
// handler checks _isSessionActivelyViewed() and won't auto-reconnect
|
||
// for a backgrounded session, preventing leaked connections that would
|
||
// pump token events into an orphaned closure, freezing the main thread.
|
||
if (currentSid && currentSid !== sid && typeof closeOtherLiveStreams === 'function') {
|
||
closeOtherLiveStreams(sid);
|
||
}
|
||
_loadingOlder = false;
|
||
const _msgInner = $('msgInner');
|
||
if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
|
||
}
|
||
// Phase 1: Load metadata only (~1KB) for fast session switching. Keep model
|
||
// resolution out of the first-paint path; old provider-shaped model IDs are
|
||
// repaired by the deferred resolver after S.session is assigned.
|
||
// Guard against network/server failures to prevent a permanently stuck loading state.
|
||
let data;
|
||
try {
|
||
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
|
||
} catch(e) {
|
||
const profileMismatch=_sessionProfileMismatchFromError(e);
|
||
if(profileMismatch && profileMismatch.profile && !opts.skipProfileResolve){
|
||
if (!_isCurrentLoad()) {
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
try{
|
||
if(typeof showToast==='function') showToast(`Switching to ${profileMismatch.profile} profile for this session…`,2200);
|
||
await _switchProfileForSessionLoad(profileMismatch.profile);
|
||
// Post-await stale-load guard (Codex): the profile switch above does a
|
||
// network POST + session-list re-render, during which the user may have
|
||
// navigated to a different session. If we no longer own the load, bail
|
||
// before clearing _loadingSessionId or retrying so the stale
|
||
// continuation can't hijack the UI back to the old target.
|
||
if (!_isCurrentLoad()) {
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||
return loadSession(sid,{...opts,skipProfileResolve:true,force:true,_preloadNotified:true});
|
||
}catch(switchErr){
|
||
e=switchErr;
|
||
}
|
||
}
|
||
const _msgInner = $('msgInner');
|
||
// Stale-load guard (Codex): a newer loadSession() may have started while this
|
||
// request was awaiting (e.g. the user clicked a healthy session during a
|
||
// boot-time restore). currentSid was snapshotted before the await, so without
|
||
// this guard a failed superseded load could self-heal (wipe localStorage/URL)
|
||
// for the session the user actually navigated to. If we no longer own the
|
||
// load, re-arm the active session's stream and bail before any DOM mutation
|
||
// or self-heal.
|
||
if (!_isCurrentLoad()) {
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
if(_msgInner){
|
||
if(e.status===404){
|
||
_msgInner.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Session not available in web UI.</div>';
|
||
// Self-heal (clear saved id + strip /session/<id> URL) only when the
|
||
// 404'd id is the one we are activating: a boot-time restore
|
||
// (!currentSid, #2798) or a mid-session reload of the *current* session
|
||
// whose sidecar was deleted server-side (#2782). A click into a
|
||
// *different* dead session (currentSid && currentSid!==sid) must not run
|
||
// it: localStorage and the URL still point at the live session (both are
|
||
// only updated on a successful load), so wiping them would log the user
|
||
// out of a healthy session. The URL strip is needed in the self-heal
|
||
// case because _sessionIdFromLocation() re-injects the id on reload.
|
||
// Only the rethrow stays gated on !currentSid: boot rethrows to fall
|
||
// through to empty-state; mid-session there is no boot path to reach.
|
||
if(!currentSid || currentSid===sid){
|
||
try{ localStorage.removeItem('hermes-webui-session'); }catch(_){ }
|
||
try{ history.replaceState(null,'',_appRootPath()); }catch(_){ }
|
||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||
if(!currentSid){
|
||
throw e;
|
||
}
|
||
}
|
||
} else {
|
||
// Non-404, non-401 failure (400, 403, 500, network): 401 is handled
|
||
// via the if(!data) guard below since api() returns undefined on 401
|
||
// rather than throwing. Clear the stuck session ID only during boot
|
||
// (!currentSid) so the next boot doesn't retry the same dead session.
|
||
// When currentSid is set, a 500/network error may be transient — the
|
||
// session might still exist on the server (#4028 follow-up).
|
||
_clearStuckSessionOnBoot(sid, currentSid);
|
||
_msgInner.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load session. Try refreshing or switching sessions.</div>';
|
||
if(typeof showToast==='function') showToast('Failed to load session',3000,'error');
|
||
}
|
||
}
|
||
_clearSameSessionForceReloadHint(sid);
|
||
// Capture whether this failure self-healed away the current session (a
|
||
// 404 on the *current* session whose sidecar was deleted server-side).
|
||
// In that case there is no live session left to stream for, so we must
|
||
// NOT restart — doing so would spin the SSE reconnect loop against a dead
|
||
// session_id.
|
||
const _selfHealedCurrent = (e.status===404) && (currentSid===sid);
|
||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||
// The session stream was stopped unconditionally at the top of this load
|
||
// (mirroring stopApprovalPolling). On the happy path it's restarted ~120
|
||
// lines below, but this failure exit never reaches that point — leaving
|
||
// the session still on screen permanently silenced. bg_task_complete
|
||
// events (the new feature's primary delivery path) would be dropped until
|
||
// the user explicitly navigates to a session again. Restart the stream for
|
||
// the session that remains on screen. Skip when a newer load is already in
|
||
// flight (_loadingSessionId !== null after the reset above): that load owns
|
||
// the stream and starts its own. Skip the self-healed-current case (no live
|
||
// session to stream).
|
||
// #2971: this fetch-error path keeps its bespoke guarded restart (rather
|
||
// than the shared _rearmActiveSessionStream helper used on the other
|
||
// early-returns) because only here can the current session have just
|
||
// self-healed away — re-arming a 404'd/deleted session_id would spin the
|
||
// SSE reconnect loop against a dead session.
|
||
if (currentSid && !_selfHealedCurrent && _loadingSessionId === null
|
||
&& typeof startSessionStream === 'function') {
|
||
startSessionStream(currentSid);
|
||
}
|
||
return;
|
||
}
|
||
// Guard: api() may have redirected (401) and returned undefined; in that case
|
||
// the browser is already navigating away, so abort the rest of this flow.
|
||
// No self-heal: 401 is transient auth expiry — the session still exists
|
||
// server-side. Clearing localStorage would wipe the saved session id and
|
||
// send users to empty state after re-login (#4028 follow-up).
|
||
if (!data) {
|
||
_clearSameSessionForceReloadHint(sid);
|
||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||
// #2971: re-arm the still-displayed session's stream (defensive — harmless
|
||
// if the 401 redirect is already tearing the page down). Idempotent.
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
// Stale response? A newer loadSession() call has already started (#1060).
|
||
if (!_isCurrentLoad()) {
|
||
// #2971: a newer in-flight load owns the final stream arming, but until it
|
||
// assigns S.session and reaches startSessionStream() the currently-shown
|
||
// session must not be left stream-dead by our top-of-function teardown.
|
||
// Re-arm the genuinely-displayed S.session (idempotent — no-ops once the
|
||
// newer load arms its own sid).
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
// #2980: if this (current) load resolved a hidden pre-compression snapshot,
|
||
// follow the backend's continuation hint to the visible continuation so a
|
||
// mobile reload mid-compression doesn't strand the user on a hidden snapshot.
|
||
// Do NOT write URL/localStorage here — let the re-entrant loadSession update
|
||
// them only once the continuation actually loads, so a rejected/deleted/
|
||
// cross-profile continuation can't poison restore state with an unusable id.
|
||
const continuationSid=(data.session&&data.session.continuation_session_id)||'';
|
||
if(continuationSid&&continuationSid!==sid&&!opts.skipContinuationResolve){
|
||
_loadingSessionId=null;
|
||
return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true,_preloadNotified:true});
|
||
}
|
||
S.session=data.session;
|
||
if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride();
|
||
// Loading a real existing session abandons any pre-session toolset override
|
||
// staged on the empty composer before any deferred refresh work runs.
|
||
S._pendingSessionToolsets=null;
|
||
if(typeof populateModelDropdown==='function'){
|
||
const modelRefreshSid=sid;
|
||
const isActiveModelRefreshSession=()=>!!(S.session&&S.session.session_id===modelRefreshSid);
|
||
if(!S._bootReady&&typeof window!=='undefined'&&typeof window._startBootModelDropdown==='function'){
|
||
Promise.resolve().then(()=>{
|
||
if(!isActiveModelRefreshSession()) return undefined;
|
||
return window._startBootModelDropdown();
|
||
}).catch(()=>{});
|
||
}else{
|
||
const modelRefreshPromise=_deferSessionSideEffect(modelRefreshSid,()=>{
|
||
if(!isActiveModelRefreshSession()) return undefined;
|
||
return populateModelDropdown({freshness:'session_visit'});
|
||
}).catch(()=>{});
|
||
if(typeof window!=='undefined') window._modelDropdownReady=modelRefreshPromise;
|
||
}
|
||
}
|
||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||
S.session._modelResolutionDeferred=true;
|
||
S.lastUsage={...(data.session.last_usage||{})};
|
||
// Reset scroll-direction tracker only on real session switches so the new
|
||
// chat's first scroll doesn't compare against the previous chat's scrollTop
|
||
// and false-trigger an unpin (#1731 follow-up — Opus stage-302 SHOULD-FIX).
|
||
// Same-session force refreshes reuse the current transcript viewport; clearing
|
||
// the sticky-unpin state here makes preserveScroll treat a reader mid-answer
|
||
// as pinned and snap them back to the bottom on the next render.
|
||
if (currentSid !== sid) {
|
||
_clearDeferredActiveSessionExternalRefresh();
|
||
}
|
||
if (currentSid !== sid && typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') {
|
||
try { window._resetScrollDirectionTracker(); } catch (_) {}
|
||
}
|
||
if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid);
|
||
_resolveSessionModelForDisplaySoon(sid);
|
||
// Sync workspace display immediately so the chip label reflects the new session's workspace
|
||
// before any async message-loading begins (mirrors how model is handled).
|
||
if(typeof syncTopbar==='function') syncTopbar();
|
||
// Acknowledge the visit as soon as the session metadata is accepted for the
|
||
// in-flight load: clears the viewed count + any stale completion-unread marker
|
||
// `let` (not const): re-read below, after the awaited _ensureMessagesLoaded,
|
||
// so a server_turn_started that attaches a live stream MID-RELOAD is honored
|
||
// by the attach/idle decision instead of being clobbered by the stale snapshot.
|
||
let activeStreamId=S.session.active_stream_id||null;
|
||
// If the server says the session is idle, reset browser-side streaming flags
|
||
// NOW — BEFORE _acknowledgeSessionVisit() below (whose sidebar repaint would
|
||
// otherwise inherit the PREVIOUS session's busy/stream state) and before the
|
||
// async _ensureMessagesLoaded gap. Without this, S.busy can remain true from a
|
||
// still-running stream in the PREVIOUS session while S.session.session_id has
|
||
// already advanced to the new one. _isSessionLocallyStreaming() checks
|
||
// (isActive && S.busy), so the new session would appear locally-streaming
|
||
// (sidebar spinner, Stop button, thinking state on an idle chat) and the visit
|
||
// repaint would manufacture a phantom unread. Also clears stale INFLIGHT
|
||
// entries left behind by a crashed/restarted stream. (#5917 gate: reset must
|
||
// precede the acknowledge repaint.)
|
||
if(!activeStreamId){
|
||
S.activeStreamId=null;
|
||
S.busy=false;
|
||
if(INFLIGHT[sid]){
|
||
delete INFLIGHT[sid];
|
||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||
}
|
||
}
|
||
|
||
// and syncs the polling snapshot so a deferred /api/sessions poll landing
|
||
// during the async message-load gap below cannot re-flag a stale unread dot.
|
||
_acknowledgeSessionVisit(
|
||
S.session.session_id,
|
||
Number(data.session.message_count || 0),
|
||
Number(data.session.last_message_at || data.session.updated_at || 0)
|
||
);
|
||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||
_setActiveSessionUrl(S.session.session_id);
|
||
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
|
||
|
||
|
||
function _mergePendingSessionMessage(session,messages){
|
||
if(!Array.isArray(messages)) return false;
|
||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(session,messages):null;
|
||
if(!pendingMsg) return false;
|
||
const liveAssistantIdx=messages.findIndex(m=>m&&m.role==='assistant'&&m._live);
|
||
const currentTurnMessages=liveAssistantIdx>=0?messages.slice(0,liveAssistantIdx):messages;
|
||
if(_hasCurrentTailUserDuplicate(currentTurnMessages,pendingMsg)) return false;
|
||
if(liveAssistantIdx>=0) messages.splice(liveAssistantIdx,0,pendingMsg);
|
||
else messages.push(pendingMsg);
|
||
return true;
|
||
}
|
||
|
||
// Phase 2a: If session is streaming, restore the persisted transcript first,
|
||
// then merge the local INFLIGHT live tail. INFLIGHT is a recovery tail, not a
|
||
// complete transcript; treating it as the full source makes long sessions look
|
||
// like they lost history after switching away and back.
|
||
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
|
||
const stored=loadInflightState(sid, activeStreamId);
|
||
if(stored){
|
||
INFLIGHT[sid]={
|
||
streamId:String(stored.streamId||''),
|
||
messages:Array.isArray(stored.messages)&&stored.messages.length?stored.messages:[],
|
||
uploaded:Array.isArray(stored.uploaded)?stored.uploaded:[],
|
||
toolCalls:Array.isArray(stored.toolCalls)?stored.toolCalls:[],
|
||
// Phase 2: restore the live todo snapshot from persisted INFLIGHT
|
||
// so the panel does not flicker to empty when a mid-stream
|
||
// browser reload reattaches before the next `todo_state` event
|
||
// fires. Both fields are optional; missing values fall back to
|
||
// cold-load via session.todo_state.
|
||
todos:Array.isArray(stored.todos)?stored.todos:null,
|
||
todoStateMeta:stored.todoStateMeta||null,
|
||
reattach:true,
|
||
lastAssistantText:String(stored.lastAssistantText||''),
|
||
lastReasoningText:String(stored.lastReasoningText||''),
|
||
lastRunJournalSeq:Number(stored.lastRunJournalSeq||0)||0,
|
||
lastRunJournalEventId:String(stored.lastRunJournalEventId||''),
|
||
journalReplayFromStart:!!stored.journalReplayFromStart,
|
||
anchorActivityScene:(stored.anchorActivityScene&&stored.anchorActivityScene.version==='activity_scene_v1')?stored.anchorActivityScene:null,
|
||
currentActivityBurstId:Number(stored.currentActivityBurstId||0)||0,
|
||
currentLiveSegmentSeq:Number(stored.currentLiveSegmentSeq||0)||0,
|
||
activityBurstAnchors:Array.isArray(stored.activityBurstAnchors)?stored.activityBurstAnchors:[],
|
||
};
|
||
}
|
||
}
|
||
|
||
if(INFLIGHT[sid]&&INFLIGHT[sid].journalReplayFromStart&&activeStreamId){
|
||
delete INFLIGHT[sid];
|
||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||
}
|
||
|
||
if(activeStreamId&&INFLIGHT[sid]&&!_inflightHasVisibleLiveState(INFLIGHT[sid])){
|
||
// A stale cursor-only INFLIGHT entry is worse than no cache: replay would
|
||
// resume after lastRunJournalSeq while the pane has no prose/tool DOM to
|
||
// preserve, making a session switch look like the live turn vanished.
|
||
delete INFLIGHT[sid];
|
||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||
}
|
||
|
||
const serverLiveSnapshot=activeStreamId
|
||
? _serverLiveSnapshotInflight(S.session.runtime_journal_snapshot, S.session.pending_attachments||[])
|
||
: null;
|
||
const hadLiveRecoveryInflight=!!INFLIGHT[sid];
|
||
const liveRecoveryInflight=_selectLiveRecoveryInflight(INFLIGHT[sid], serverLiveSnapshot, activeStreamId);
|
||
if(liveRecoveryInflight) INFLIGHT[sid]=liveRecoveryInflight;
|
||
else if(hadLiveRecoveryInflight&&activeStreamId){
|
||
delete INFLIGHT[sid];
|
||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||
}
|
||
|
||
if(INFLIGHT[sid]){
|
||
_ensureInflightLiveAssistantMessage(INFLIGHT[sid]);
|
||
const inflightMessages=_projectInflightMessagesForActivityBursts(INFLIGHT[sid]);
|
||
S.toolCalls=[];
|
||
// Switching between active sessions should rebuild the live worklog from
|
||
// this session's INFLIGHT snapshot, not leave prior-session rows in place.
|
||
if(typeof clearLiveToolCards==='function') clearLiveToolCards();
|
||
try {
|
||
await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});
|
||
} catch(e) {
|
||
if (!_isCurrentLoad()) {
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
S.messages=inflightMessages;
|
||
}
|
||
if (!_isCurrentLoad()) {
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
const liveTailPrepared=_prepareRunningLiveTail(S.messages,inflightMessages);
|
||
if(liveTailPrepared){
|
||
S.messages=_dropCurrentTurnAssistantMessages(S.messages);
|
||
}
|
||
S.messages=_mergeInflightTailMessages(S.messages,inflightMessages);
|
||
S.toolCalls=(INFLIGHT[sid].toolCalls||[]);
|
||
if(_mergePendingSessionMessage(S.session,S.messages)&&inflightMessages===(INFLIGHT[sid].messages||[])){
|
||
INFLIGHT[sid].messages=S.messages;
|
||
}
|
||
// Refresh todos from cold-load or persisted INFLIGHT before painting.
|
||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||
S.busy=!!activeStreamId; // #4354: Only assert busy if server confirms active stream.
|
||
// appendLiveToolCard() is guarded by S.activeStreamId; restore it before
|
||
// replaying persisted live tools so the compact Activity count survives
|
||
// switching away from and back to an active chat (#1715).
|
||
S.activeStreamId=activeStreamId;
|
||
const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||
const replayPersistedLiveToolCards=(opts)=>{
|
||
const liveToolCalls=Array.isArray(S.toolCalls)
|
||
? S.toolCalls
|
||
: (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]);
|
||
const skipUnkeyedRestoredDuplicates=!!(opts&&opts.skipUnkeyedRestoredDuplicates);
|
||
const restoredLiveTurn=skipUnkeyedRestoredDuplicates?document.getElementById('liveAssistantTurn'):null;
|
||
const hasRestoredLiveToolRows=!!(restoredLiveTurn&&restoredLiveTurn.querySelector('.tool-card-row'));
|
||
for(const tc of (liveToolCalls||[])){
|
||
if(skipUnkeyedRestoredDuplicates&&hasRestoredLiveToolRows&&!liveToolReplayId(tc)) continue;
|
||
if(tc&&tc.name) appendLiveToolCard(tc,{sessionId:sid,streamId:activeStreamId});
|
||
}
|
||
};
|
||
let didReconnect=false;
|
||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||
INFLIGHT[sid].reattach=false;
|
||
if (!_isCurrentLoad()) return;
|
||
didReconnect=true;
|
||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||
}
|
||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||
const restoredAnchorScene=activeStreamId&&typeof window!=='undefined'
|
||
? ((typeof window._renderLiveAnchorActivitySceneForStream==='function'&&window._renderLiveAnchorActivitySceneForStream(activeStreamId, sid))||
|
||
_renderRuntimeJournalAnchorActivityScene(activeStreamId, sid))
|
||
: false;
|
||
if(typeof ensureRunActivityForCurrentTurn==='function') ensureRunActivityForCurrentTurn();
|
||
const hasStructuredLiveState=!!(INFLIGHT[sid]&&(
|
||
String(INFLIGHT[sid].lastAssistantText||'').trim()||
|
||
String(INFLIGHT[sid].lastReasoningText||'').trim()||
|
||
!!(INFLIGHT[sid].anchorActivityScene&&Array.isArray(INFLIGHT[sid].anchorActivityScene.activity_rows)&&INFLIGHT[sid].anchorActivityScene.activity_rows.length)||
|
||
(Array.isArray(INFLIGHT[sid].activityBurstAnchors)&&INFLIGHT[sid].activityBurstAnchors.length)||
|
||
(Array.isArray(INFLIGHT[sid].toolCalls)&&INFLIGHT[sid].toolCalls.length)
|
||
));
|
||
let restoredLiveTurn=!!restoredAnchorScene;
|
||
if(!restoredLiveTurn&&typeof restoreLiveTurnHtmlForSession==='function'){
|
||
if(!hasStructuredLiveState){
|
||
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||
}else{
|
||
const liveTurn=document.getElementById('liveAssistantTurn');
|
||
const hasCurrentWorklogContent=!!(liveTurn&&liveTurn.querySelector(
|
||
'.live-worklog[data-live-worklog-shell="1"] .tool-card-row,'+
|
||
'.live-worklog[data-live-worklog-shell="1"] .wl-reason,'+
|
||
'.tool-call-group[data-live-tool-worklog-group="1"] .tool-card-row,'+
|
||
'.tool-call-group[data-live-tool-worklog-group="1"] .wl-reason,'+
|
||
'.tool-call-group[data-live-tool-call-group="1"] .tool-card-row,'+
|
||
'.tool-call-group[data-live-tool-call-group="1"] .wl-reason'
|
||
));
|
||
if(hasCurrentWorklogContent) restoredLiveTurn=true;
|
||
else restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||
}
|
||
}
|
||
if(restoredLiveTurn&&didReconnect){
|
||
replayPersistedLiveToolCards({skipUnkeyedRestoredDuplicates:true});
|
||
}
|
||
if(!restoredLiveTurn){
|
||
clearLiveToolCards();
|
||
if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost();
|
||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||
else appendThinking();
|
||
replayPersistedLiveToolCards();
|
||
}
|
||
if(!restoredAnchorScene&&typeof ensureLiveWorklogShell==='function'){
|
||
const liveTurn=document.getElementById('liveAssistantTurn');
|
||
if(!liveTurn||!liveTurn.querySelector('.tool-call-group[data-tool-worklog-group="1"]')) ensureLiveWorklogShell();
|
||
}
|
||
_deferWorkspaceRefreshForSession(sid);
|
||
setBusy(true);setComposerStatus('');
|
||
startApprovalPolling(sid);
|
||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||
if(typeof _fetchYoloState==='function') _fetchYoloState(sid);
|
||
}else{
|
||
// Phase 2b: Idle session — load full messages lazily for rendering.
|
||
// _ensureMessagesLoaded is idempotent; it skips if S.messages already populated.
|
||
// #5177: when the caller asked us to keep stale messages until the new ones
|
||
// arrive (visibility/focus recovery), force the fetch so the
|
||
// "messages already populated" early-return inside _ensureMessagesLoaded
|
||
// does NOT skip the swap to the new transcript.
|
||
try {
|
||
await _ensureMessagesLoaded(sid, {force:_keepStaleUntilLoaded, loadGeneration:_loadGeneration});
|
||
} catch (e) {
|
||
if (!_isCurrentLoad()) {
|
||
_rearmActiveSessionStream();
|
||
return;
|
||
}
|
||
// Network errors, server failures, or SSE drops (Chrome error codes 4/5)
|
||
// can cause _ensureMessagesLoaded to throw. Without a try/catch here the
|
||
// "Loading conversation..." div injected at the top of loadSession would
|
||
// persist forever with no recovery path.
|
||
const _msgInner = $('msgInner');
|
||
if (_msgInner) {
|
||
_msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Failed to load messages. Try switching sessions or refreshing.</div>';
|
||
}
|
||
if (typeof showToast === 'function') showToast('Failed to load conversation messages', 3000, 'error');
|
||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||
return;
|
||
}
|
||
// Stale? A newer loadSession() call has already started (#1060).
|
||
if (!_isCurrentLoad()) return;
|
||
|
||
// Restore any queued message that survived page refresh or tab restore.
|
||
if(typeof queueSessionMessage==='function'){
|
||
try{
|
||
const _entries=typeof _readPersistedSessionQueue==='function'
|
||
? _readPersistedSessionQueue(sid)
|
||
: [];
|
||
if(Array.isArray(_entries)&&_entries.length){
|
||
const _lastMsg=S.messages.slice().reverse()
|
||
.find(m=>m&&m.role==='assistant');
|
||
const _lastAsst=_lastMsg?(_lastMsg.timestamp||_lastMsg._ts||0)*1000:0;
|
||
const _fresh=_entries.filter(e=>!e._queued_at||e._queued_at>_lastAsst);
|
||
if(_fresh.length){
|
||
const _first=_fresh[0];
|
||
const _msg=$&&$('msg');
|
||
if(_msg&&_first.text&&!_msg.value){
|
||
_msg.value=_first.text||'';
|
||
if(typeof autoResize==='function') autoResize();
|
||
if(typeof showToast==='function') showToast((_fresh.length>1?`${_fresh.length} queued messages restored (showing first)`:'Queued message restored')+' — review and send when ready');
|
||
}
|
||
}
|
||
if(typeof _clearPersistedSessionQueue==='function') _clearPersistedSessionQueue(sid);
|
||
}
|
||
}catch(_){if(typeof _clearPersistedSessionQueue==='function') _clearPersistedSessionQueue(sid);}
|
||
}
|
||
|
||
// Reconstruct tool calls from message metadata, or fall back to session-level summary.
|
||
// (hasMessageToolMetadata already computed inside _ensureMessagesLoaded; S.toolCalls set there.)
|
||
updateQueueBadge(sid);
|
||
|
||
// Attach pending user message if one is queued.
|
||
_mergePendingSessionMessage(S.session,S.messages);
|
||
|
||
// Self-heal-vs-live-render race guard (maintainer/Codex-reproduced; verified
|
||
// in an isolated instance). `activeStreamId` was snapshotted BEFORE the
|
||
// awaited _ensureMessagesLoaded above. During a force reload (the
|
||
// `session-updated` self-heal or any keepStaleUntilLoaded recovery), a
|
||
// server-initiated turn can fire `server_turn_started` mid-await and set
|
||
// S.activeStreamId for THIS sid. Without re-reading, the idle branch below
|
||
// would clear S.activeStreamId/S.busy off the stale (null) snapshot and
|
||
// silently kill the live turn's render. Fold a concurrently-attached
|
||
// same-session stream into activeStreamId so the existing attach branch
|
||
// (and all its `attachLiveStream(sid, activeStreamId, ...)` calls) keeps it.
|
||
activeStreamId = activeStreamId || ((S.activeStreamId && S.session && S.session.session_id===sid) ? S.activeStreamId : null);
|
||
|
||
if(activeStreamId){
|
||
S.busy=true;
|
||
S.activeStreamId=activeStreamId;
|
||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||
updateSendBtn();
|
||
setStatus('');
|
||
setComposerStatus('');
|
||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||
const restoredAnchorScene=activeStreamId&&typeof window!=='undefined'
|
||
? ((typeof window._renderLiveAnchorActivitySceneForStream==='function'&&window._renderLiveAnchorActivitySceneForStream(activeStreamId, sid))||
|
||
_renderRuntimeJournalAnchorActivityScene(activeStreamId, sid))
|
||
: false;
|
||
let restoredLiveTurn=!!restoredAnchorScene;
|
||
if(!restoredLiveTurn&&typeof restoreLiveTurnHtmlForSession==='function'){
|
||
restoredLiveTurn=restoreLiveTurnHtmlForSession(sid);
|
||
}
|
||
if(!restoredLiveTurn){
|
||
if(typeof ensureLiveWorklogShell==='function') ensureLiveWorklogShell();
|
||
else appendThinking();
|
||
}
|
||
_deferWorkspaceRefreshForSession(sid);
|
||
updateQueueBadge(sid);
|
||
startApprovalPolling(sid);
|
||
if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
|
||
if(typeof _fetchYoloState==='function') _fetchYoloState(sid);
|
||
}else{
|
||
S.busy=false;
|
||
S.activeStreamId=null;
|
||
updateSendBtn();
|
||
setStatus('');
|
||
setComposerStatus('');
|
||
updateQueueBadge(sid);
|
||
syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
|
||
startApprovalPolling(sid);
|
||
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
|
||
// Workspace refresh is guarded by session id inside loadDir(); keep it
|
||
// after the transcript's first paint so chat switching is not competing
|
||
// with file-tree / git badge IO.
|
||
_deferWorkspaceRefreshForSession(sid);
|
||
}
|
||
}
|
||
|
||
// Sync context usage indicator from session data
|
||
const _s=S.session;
|
||
if(_s&&typeof _syncCtxIndicator==='function'){
|
||
const u=S.lastUsage||{};
|
||
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
|
||
const _pickPositive=(latest,stored,dflt=0)=>Number(latest)>0?latest:(Number(stored)>0?stored:dflt);
|
||
_syncCtxIndicator({
|
||
input_tokens: _pick(u.input_tokens, _s.input_tokens),
|
||
output_tokens: _pick(u.output_tokens, _s.output_tokens),
|
||
estimated_cost: _pick(u.estimated_cost, _s.estimated_cost),
|
||
cache_read_tokens: _pick(u.cache_read_tokens, _s.cache_read_tokens),
|
||
cache_write_tokens:_pick(u.cache_write_tokens,_s.cache_write_tokens),
|
||
cache_hit_percent: _pick(u.cache_hit_percent, _s.cache_hit_percent, null),
|
||
context_length: _pickPositive(u.context_length, _s.context_length),
|
||
last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens),
|
||
post_compression_context_tokens_estimate:_s.post_compression_context_tokens_estimate||null,
|
||
threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens),
|
||
});
|
||
}
|
||
if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession();
|
||
|
||
// Restore server-persisted composer draft (synced across clients + survives refresh).
|
||
// Pass sid so _restoreComposerDraft can skip if this session is mid-load (guards
|
||
// against stale writes from slow responses racing to restore the previous draft).
|
||
const _draft = S.session && S.session.composer_draft;
|
||
if (_draft && (typeof _restoreComposerDraft === 'function')) {
|
||
_restoreComposerDraft(_draft, sid, {preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload)});
|
||
}
|
||
|
||
// Clear the in-flight session marker now that this load has completed (#1060).
|
||
if (_isCurrentLoad()) _loadingSessionId = null;
|
||
|
||
// Re-acknowledge the visit after the async message-load gap. A deferred
|
||
// sidebar /api/sessions poll can land while _ensureMessagesLoaded is in
|
||
// flight and re-mark the open session unread; re-syncing here clears that
|
||
// sticky dot once the transcript is settled (#4946).
|
||
//
|
||
// Gate the final ack on _isSessionActivelyViewedForList(sid): a completion
|
||
// that lands while _ensureMessagesLoaded() is in flight AND the tab then goes
|
||
// hidden is correctly marked unread — an UNCONDITIONAL ack here would wrongly
|
||
// clear that hidden-tab-completion marker. Only clear when the session is
|
||
// still actively viewed. (#5917 gate finding)
|
||
if (
|
||
S.session && S.session.session_id === sid &&
|
||
(typeof _isSessionActivelyViewedForList !== 'function' || _isSessionActivelyViewedForList(sid))
|
||
) {
|
||
_acknowledgeSessionVisit(
|
||
sid,
|
||
Number(S.session.message_count || 0),
|
||
Number(S.session.last_message_at || S.session.updated_at || 0)
|
||
);
|
||
}
|
||
|
||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||
|
||
// ── Cross-channel handoff hint ──
|
||
// After session fully loaded, check if this is a messaging session with
|
||
// enough conversation rounds to warrant a handoff hint bar.
|
||
if (S.session && _isMessagingSession(S.session)) {
|
||
_checkAndShowHandoffHint(sid);
|
||
} else {
|
||
_hideHandoffHint();
|
||
}
|
||
// Extension post-load hook
|
||
if(!opts.skipExtHooks && typeof _hermesNotifySessionOpen==='function'){
|
||
try{ _hermesNotifySessionOpen(sid, S.session, {loaded:true, opts:opts}); }catch(_){}
|
||
}
|
||
}
|
||
|
||
// ── Handoff hint logic ──────────────────────────────────────────────────────
|
||
|
||
const _HANDOFF_THRESHOLD = 10; // conversation rounds
|
||
const _HANDOFF_STORAGE_PREFIX = 'handoff:';
|
||
const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at';
|
||
const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at';
|
||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback']);
|
||
const _MESSAGING_SOURCE_LABELS = {
|
||
weixin: 'WeChat',
|
||
telegram: 'Telegram',
|
||
discord: 'Discord',
|
||
slack: 'Slack',
|
||
email: 'Email',
|
||
wecom: 'WeCom',
|
||
wecom_callback: 'WeCom Callback',
|
||
};
|
||
|
||
function _isMessagingSession(session) {
|
||
if (!session) return false;
|
||
// session_source is set by PR #1294 source normalization
|
||
if (session.session_source === 'messaging') return true;
|
||
// Fallback: check raw_source directly
|
||
const raw = (session.raw_source || session.source_tag || session.source || '').toLowerCase();
|
||
return _MESSAGING_RAW_SOURCES.has(raw);
|
||
}
|
||
|
||
/**
|
||
* Returns true when a session originates from an external channel (CLI bridge,
|
||
* Discord, Telegram, Slack, etc.) and therefore needs a server-side import
|
||
* before the WebUI can read or send messages into it.
|
||
* Covers both legacy CLI sessions and messaging-source sessions.
|
||
*/
|
||
function _isWebUiSourceSession(session) {
|
||
if (!session) return false;
|
||
const source = (
|
||
session.session_source
|
||
|| session.raw_source
|
||
|| session.source_tag
|
||
|| session.source
|
||
|| ''
|
||
).toLowerCase();
|
||
return source === 'webui';
|
||
}
|
||
|
||
function _isExternalSession(session) {
|
||
if (!session || _isWebUiSourceSession(session)) return false;
|
||
return !!(session.is_cli_session || _isMessagingSession(session));
|
||
}
|
||
|
||
function _externalImportPayload(session) {
|
||
const payload = {session_id: session.session_id};
|
||
if (_showAllProfiles && session && typeof session.profile === 'string' && session.profile) {
|
||
payload.all_profiles = true;
|
||
payload.profile = session.profile;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function _sidebarSessionProfileName(session){
|
||
const raw=session&&typeof session.profile==='string'?session.profile.trim():'';
|
||
return raw||'';
|
||
}
|
||
|
||
async function _ensureSidebarSessionProfile(session){
|
||
const targetProfile=_sidebarSessionProfileName(session);
|
||
if(!_showAllProfiles||!targetProfile) return false;
|
||
const activeProfile=S.activeProfile||'default';
|
||
if(_profileMatchesActiveProfile(targetProfile,activeProfile)) return false;
|
||
if(typeof switchToProfile!=='function') return false;
|
||
_profileSwitchOpeningExistingSession=true;
|
||
try{
|
||
await switchToProfile(targetProfile);
|
||
}finally{
|
||
_profileSwitchOpeningExistingSession=false;
|
||
}
|
||
return _profileMatchesActiveProfile(targetProfile,S.activeProfile||'default');
|
||
}
|
||
|
||
async function _openSidebarSession(session, loadOpts={}){
|
||
if(!session||!session.session_id) return;
|
||
// Extension pre-open hook — before any side-effects (external import, profile switching).
|
||
// Handler returns {cancel:true} to prevent the open.
|
||
if(!loadOpts.skipExtHooks && typeof _hermesNotifySessionOpen==='function'){
|
||
var _preResult=_hermesNotifySessionOpen(session.session_id, null, {preload:true, opts:loadOpts});
|
||
if(_preResult&&_preResult.cancel===true) return;
|
||
}
|
||
// #5409: close mobile sidebar AFTER veto guard passes — only close if open proceeds.
|
||
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
|
||
if(_isExternalSession(session)){
|
||
try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(session))});}
|
||
catch(_e){ /* import failed -- fall through to read-only view */ }
|
||
}
|
||
await _ensureSidebarSessionProfile(session);
|
||
// Tell loadSession to skip its pre-hook — we already ran it above.
|
||
await loadSession(session.session_id, Object.assign({}, loadOpts, {_preloadNotified:true}));
|
||
renderSessionListFromCache();
|
||
}
|
||
|
||
function _isReadOnlySession(session) {
|
||
return !!(session && (session.read_only || session.is_read_only));
|
||
}
|
||
|
||
function _isBranchableReadOnlySession(session) {
|
||
if (!_isReadOnlySession(session)) return false;
|
||
const sources = [
|
||
session && session.source_tag,
|
||
session && session.raw_source,
|
||
session && session.source,
|
||
].map(v => String(v || '').trim().toLowerCase());
|
||
return sources.includes('cron');
|
||
}
|
||
|
||
function _sourceKeyForSession(session) {
|
||
return (session && (session.raw_source || session.source_tag || session.source || '') || '').toLowerCase();
|
||
}
|
||
|
||
function _isCliSession(session) {
|
||
if (!session) return false;
|
||
// session_source is set by upstream normalization for CLI sessions as 'cli'
|
||
if (session.session_source === 'cli') return true;
|
||
// Legacy payloads often use raw/source tags to convey the source.
|
||
const raw = (
|
||
session.raw_source
|
||
|| session.source_tag
|
||
|| session.source
|
||
|| session.source_label
|
||
|| ''
|
||
).toLowerCase();
|
||
if (raw === 'cli' || raw === 'tui' || raw === 'acp') return true;
|
||
// If messaging-like, don't classify as legacy CLI even when is_cli_session is true.
|
||
if (_isMessagingSession(session)) return false;
|
||
return session.is_cli_session === true;
|
||
}
|
||
|
||
function _sessionSourceLabel(filter, count) {
|
||
const n = Number(count) || 0;
|
||
return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`;
|
||
}
|
||
|
||
function _clearSessionSourceTabCounts() {
|
||
_serverWebuiSessionCount = null;
|
||
_serverCliSessionCount = null;
|
||
}
|
||
|
||
function _requestedSessionSidebarSource() {
|
||
return window._showCliSessions ? _sessionSourceFilter : 'webui';
|
||
}
|
||
|
||
function _sessionListExcludeHiddenEnabled() {
|
||
return _activeProject===null || _activeProject===NO_PROJECT_FILTER;
|
||
}
|
||
|
||
function _sessionArchivePagingFilterActive() {
|
||
let searchActive=false;
|
||
try{
|
||
const searchEl=typeof $==='function' ? $('sessionSearch') : null;
|
||
searchActive=Boolean(searchEl&&String(searchEl.value||'').trim());
|
||
}catch(_e){ searchActive=false; }
|
||
return Boolean(searchActive||_activeProject);
|
||
}
|
||
|
||
function _sessionListQueryString() {
|
||
const qs = new URLSearchParams();
|
||
qs.set('sidebar_source', _requestedSessionSidebarSource());
|
||
if(_sessionListExcludeHiddenEnabled()) qs.set('exclude_hidden','1');
|
||
if(_showAllProfiles) qs.set('all_profiles','1');
|
||
if(_showArchived){
|
||
qs.set('include_archived','1');
|
||
if(!_sessionArchivePagingFilterActive()){
|
||
const archiveLimit=Math.min(
|
||
SESSION_ARCHIVED_MAX_LOADED_LIMIT,
|
||
Math.max(SESSION_ARCHIVED_PAGE_SIZE, Number(_archivedRowsLoadedLimit)||SESSION_ARCHIVED_PAGE_SIZE)
|
||
);
|
||
qs.set('archived_limit', String(archiveLimit));
|
||
}
|
||
}
|
||
return `?${qs.toString()}`;
|
||
}
|
||
|
||
function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) {
|
||
const serverCount = filter === 'cli' ? _serverCliSessionCount : _serverWebuiSessionCount;
|
||
if (Number.isFinite(serverCount)) return serverCount;
|
||
return filter === 'cli' ? renderedCliSessionCount : renderedWebuiSessionCount;
|
||
}
|
||
|
||
function _setActiveProjectFilter(projectId) {
|
||
const next = projectId === NO_PROJECT_FILTER ? NO_PROJECT_FILTER : (projectId || null);
|
||
if (_activeProject === next) return;
|
||
_activeProject = next;
|
||
renderSessionListFromCache();
|
||
void renderSessionList({deferWhileInteracting:false});
|
||
}
|
||
|
||
function _setSessionSourceFilter(filter) {
|
||
const next = filter === 'cli' ? 'cli' : 'webui';
|
||
if (_sessionSourceFilter === next) return;
|
||
_sessionSourceFilter = next;
|
||
_activeProject = null;
|
||
_selectedSessions.clear();
|
||
_sessionSelectMode = false;
|
||
try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {}
|
||
renderSessionListFromCache();
|
||
void renderSessionList({deferWhileInteracting:false});
|
||
}
|
||
|
||
function _restoreSessionSourceFilter() {
|
||
try {
|
||
const raw = localStorage.getItem('hermes-session-source-filter');
|
||
if (raw === 'cli' || raw === 'webui') _sessionSourceFilter = raw;
|
||
} catch (_e) {}
|
||
}
|
||
|
||
function _normalizeMessageForCliImportComparison(message) {
|
||
if (!message || typeof message !== 'object') return message;
|
||
const clone = { ...message };
|
||
delete clone.timestamp;
|
||
delete clone._ts;
|
||
return clone;
|
||
}
|
||
|
||
function _isCliImportRefreshPrefixMatch(localMessages, freshMessages) {
|
||
if (!Array.isArray(localMessages) || !Array.isArray(freshMessages)) return false;
|
||
if (localMessages.length > freshMessages.length) return false;
|
||
for (let i = 0; i < localMessages.length; i += 1) {
|
||
if (JSON.stringify(_normalizeMessageForCliImportComparison(localMessages[i])) !== JSON.stringify(_normalizeMessageForCliImportComparison(freshMessages[i]))) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function _handoffStorageKey(sid) {
|
||
return `${_HANDOFF_STORAGE_PREFIX}${sid}:`;
|
||
}
|
||
|
||
function _getHandoffStorageValue(sid, suffix) {
|
||
try {
|
||
const raw = localStorage.getItem(_handoffStorageKey(sid) + suffix);
|
||
return raw ? parseFloat(raw) : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function _setHandoffStorageValue(sid, suffix, ts) {
|
||
const key = _handoffStorageKey(sid) + suffix;
|
||
try {
|
||
if (!Number.isFinite(ts)) {
|
||
localStorage.removeItem(key);
|
||
return;
|
||
}
|
||
localStorage.setItem(key, String(ts));
|
||
} catch {}
|
||
}
|
||
|
||
function _clearHandoffStorageForSession(sid) {
|
||
if (!sid) return;
|
||
try {
|
||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null);
|
||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null);
|
||
} catch {}
|
||
// Session deletion should also prune per-session tracking maps. Otherwise
|
||
// heavy users accumulate one localStorage entry per deleted session forever,
|
||
// which increases quota pressure and can make future UI persistence fail.
|
||
try { _clearSessionViewedCount(sid); } catch {}
|
||
try { _clearSessionCompletionUnread(sid); } catch {}
|
||
try { _forgetObservedStreamingSession(sid); } catch {}
|
||
}
|
||
|
||
function _getHandoffDismissedAt(sid) {
|
||
return _getHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT);
|
||
}
|
||
|
||
function _setHandoffDismissedAt(sid, ts) {
|
||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, ts);
|
||
}
|
||
|
||
function _getHandoffSummaryHandledAt(sid) {
|
||
return _getHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT);
|
||
}
|
||
|
||
function _setHandoffSummaryHandledAt(sid, ts) {
|
||
_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, ts);
|
||
}
|
||
|
||
function _getHandoffSince(sid) {
|
||
const dismissedAt = _getHandoffDismissedAt(sid);
|
||
const summaryHandledAt = _getHandoffSummaryHandledAt(sid);
|
||
if (Number.isFinite(dismissedAt) && Number.isFinite(summaryHandledAt)) return Math.max(dismissedAt, summaryHandledAt);
|
||
if (Number.isFinite(dismissedAt)) return dismissedAt;
|
||
if (Number.isFinite(summaryHandledAt)) return summaryHandledAt;
|
||
return null;
|
||
}
|
||
|
||
function _handoffMessagesEl() {
|
||
return document.getElementById('messages');
|
||
}
|
||
|
||
function _handoffIsMessagesNearBottom(el) {
|
||
if (!el) return false;
|
||
return el.scrollHeight - el.scrollTop - el.clientHeight < 150;
|
||
}
|
||
|
||
function _syncHandoffDockSpace(open) {
|
||
const messages = _handoffMessagesEl();
|
||
if (!messages) return;
|
||
const wasNearBottom = _handoffIsMessagesNearBottom(messages);
|
||
if (!open) {
|
||
messages.classList.remove('handoff-dock-visible');
|
||
messages.style.removeProperty('--handoff-dock-height');
|
||
if (wasNearBottom && typeof scrollToBottom === 'function') requestAnimationFrame(scrollToBottom);
|
||
return;
|
||
}
|
||
messages.classList.add('handoff-dock-visible');
|
||
const measure = () => {
|
||
const container = $('handoffHintContainer');
|
||
const h = container && container.getBoundingClientRect().height;
|
||
if (h > 0) messages.style.setProperty('--handoff-dock-height', Math.ceil(h + 24) + 'px');
|
||
if (wasNearBottom && typeof scrollToBottom === 'function') scrollToBottom();
|
||
};
|
||
requestAnimationFrame(measure);
|
||
setTimeout(measure, 360);
|
||
}
|
||
|
||
function _getChannelLabel(session) {
|
||
if (!session) return '';
|
||
// Use source_label from PR #1294 if available
|
||
if (session.source_label) return session.source_label;
|
||
const raw = (session.raw_source || session.source_tag || session.source || '').toLowerCase();
|
||
return _MESSAGING_SOURCE_LABELS[raw] || raw || '';
|
||
}
|
||
|
||
async function _checkAndShowHandoffHint(sid) {
|
||
try {
|
||
const since = _getHandoffSince(sid);
|
||
const body = { session_id: sid };
|
||
if (since != null) body.since = since;
|
||
|
||
const result = await api('/api/session/conversation-rounds', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
// Stale? Session switched while we were fetching.
|
||
if (!S.session || S.session.session_id !== sid) return;
|
||
|
||
if (result && result.ok && result.should_show) {
|
||
_showHandoffHint(sid, result.rounds);
|
||
} else {
|
||
const container = $('handoffHintContainer');
|
||
const isSameVisibleSession = !!(
|
||
container &&
|
||
container.classList.contains('is-visible') &&
|
||
container.dataset.sessionId === String(sid)
|
||
);
|
||
if (!isSameVisibleSession) _hideHandoffHint();
|
||
}
|
||
} catch (e) {
|
||
console.warn('Handoff hint check failed:', e);
|
||
_hideHandoffHint();
|
||
}
|
||
}
|
||
|
||
function _showHandoffHint(sid, rounds) {
|
||
const container = $('handoffHintContainer');
|
||
if (!container) return;
|
||
|
||
// Clear any existing content.
|
||
container.innerHTML = '';
|
||
container.style.display = '';
|
||
container.classList.add('is-visible');
|
||
container.dataset.sessionId = String(sid);
|
||
|
||
const channel = _getChannelLabel(S.session);
|
||
const hintText = channel
|
||
? `${channel} handoff`
|
||
: `Conversation handoff`;
|
||
const hintMeta = `${rounds} new conversation rounds`;
|
||
|
||
const bar = document.createElement('div');
|
||
bar.className = 'handoff-hint-bar';
|
||
bar.id = 'handoffHintBar';
|
||
bar.innerHTML = `
|
||
<div class="handoff-hint-text">
|
||
<span class="handoff-hint-dot" aria-hidden="true"></span>
|
||
<span class="handoff-hint-label">${esc(hintText)}</span>
|
||
<span class="handoff-hint-meta">${esc(hintMeta)}</span>
|
||
</div>
|
||
<div class="handoff-hint-actions">
|
||
<button class="handoff-hint-action" type="button">View summary</button>
|
||
<button class="handoff-hint-dismiss" type="button" onclick="event.stopPropagation(); _dismissHandoffHint('${esc(sid)}')" title="Dismiss">
|
||
Close
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
// Click on the bar (not the explicit close button) triggers summary generation.
|
||
bar.addEventListener('click', (e) => {
|
||
if (e.target.closest('.handoff-hint-dismiss')) return;
|
||
_generateHandoffSummary(sid, rounds);
|
||
});
|
||
|
||
container.appendChild(bar);
|
||
_syncHandoffDockSpace(true);
|
||
}
|
||
|
||
function _hideHandoffHint() {
|
||
const container = $('handoffHintContainer');
|
||
if (container) {
|
||
container.innerHTML = '';
|
||
container.style.display = 'none';
|
||
container.classList.remove('is-visible');
|
||
delete container.dataset.sessionId;
|
||
}
|
||
_syncHandoffDockSpace(false);
|
||
}
|
||
|
||
function _dismissHandoffHint(sid) {
|
||
_setHandoffDismissedAt(sid, Date.now() / 1000);
|
||
_hideHandoffHint();
|
||
}
|
||
|
||
function _buildHandoffSummaryToolMessage(summary, channel, rounds, fallback) {
|
||
const generatedAt = Date.now() / 1000;
|
||
return {
|
||
role: 'tool',
|
||
tool_call_id: '',
|
||
name: 'handoff_summary',
|
||
timestamp: generatedAt,
|
||
_ts: generatedAt,
|
||
content: JSON.stringify({
|
||
_handoff_summary_card: true,
|
||
session_id: sidValue(),
|
||
summary: String(summary || '').trim(),
|
||
channel: (typeof channel === 'string' && channel.trim()) ? channel.trim() : null,
|
||
rounds: Number.isFinite(rounds) ? rounds : null,
|
||
fallback: !!fallback,
|
||
generated_at: generatedAt,
|
||
}),
|
||
};
|
||
}
|
||
|
||
function sidValue() {
|
||
return S && S.session && S.session.session_id ? S.session.session_id : null;
|
||
}
|
||
|
||
function _extractHandoffSummaryPayload(content){
|
||
if(!content) return null;
|
||
if(typeof content!=='string') return null;
|
||
try {
|
||
const parsed=JSON.parse(content);
|
||
return parsed&&typeof parsed==='object'&&parsed._handoff_summary_card===true?parsed:null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function _generateHandoffSummary(sid, rounds) {
|
||
// Treat handoff like a slash-command result: the composer dock entry
|
||
// disappears and the transient summary card renders in the transcript.
|
||
_hideHandoffHint();
|
||
const channel = _getChannelLabel(S.session);
|
||
if (typeof setHandoffUi === 'function') {
|
||
setHandoffUi({
|
||
sessionId: sid,
|
||
phase: 'running',
|
||
channel,
|
||
rounds,
|
||
});
|
||
}
|
||
|
||
try {
|
||
const since = _getHandoffSince(sid);
|
||
const body = { session_id: sid };
|
||
if (since != null) body.since = since;
|
||
|
||
const result = await api('/api/session/handoff-summary', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
const isSuccess = result && result.ok && result.summary;
|
||
if (isSuccess) {
|
||
_setHandoffSummaryHandledAt(sid, Date.now() / 1000);
|
||
_setHandoffDismissedAt(sid, null);
|
||
const marker=_buildHandoffSummaryToolMessage(result.summary, channel, result.rounds || rounds, !!result.fallback);
|
||
if (S.session && S.session.session_id === sid) {
|
||
S.messages = [...S.messages, marker];
|
||
if (typeof renderMessages === 'function') renderMessages();
|
||
}
|
||
if (typeof setHandoffUi === 'function') {
|
||
setHandoffUi(null);
|
||
}
|
||
} else if (S.session && S.session.session_id === sid && typeof setHandoffUi === 'function') {
|
||
// Keep transient card while the user can retry the action.
|
||
setHandoffUi({
|
||
sessionId: sid,
|
||
phase: 'error',
|
||
channel,
|
||
rounds,
|
||
errorText: 'Could not generate summary. Please try again.',
|
||
});
|
||
} else {
|
||
// Stale session response path: only record success baseline.
|
||
}
|
||
} catch (e) {
|
||
console.warn('Handoff summary failed:', e);
|
||
if (S.session && S.session.session_id === sid && typeof setHandoffUi === 'function') {
|
||
setHandoffUi({
|
||
sessionId: sid,
|
||
phase: 'error',
|
||
channel,
|
||
rounds,
|
||
errorText: 'Summary generation failed: ' + e.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
// If generation succeeds, set a baseline so only new activity after that time
|
||
// can re-trigger handoff prompts. Failures keep the hint active so users can
|
||
// retry.
|
||
}
|
||
|
||
function _afterSessionFirstPaint(fn, delayMs=0){
|
||
return new Promise((resolve)=>{
|
||
const invoke=()=>{
|
||
try{ resolve(typeof fn==='function' ? fn() : undefined); }
|
||
catch(_){ resolve(undefined); }
|
||
};
|
||
const run=()=>{
|
||
if(typeof requestIdleCallback==='function'){
|
||
requestIdleCallback(invoke,{timeout:1500});
|
||
}else{
|
||
setTimeout(invoke, delayMs);
|
||
}
|
||
};
|
||
if(typeof requestAnimationFrame==='function'){
|
||
requestAnimationFrame(()=>requestAnimationFrame(run));
|
||
}else{
|
||
setTimeout(run, delayMs);
|
||
}
|
||
});
|
||
}
|
||
|
||
function _deferSessionSideEffect(sid, fn, delayMs=0){
|
||
if(!sid||typeof fn!=='function') return Promise.resolve();
|
||
return _afterSessionFirstPaint(()=>{
|
||
if(!S.session||S.session.session_id!==sid) return undefined;
|
||
return fn();
|
||
},delayMs);
|
||
}
|
||
|
||
function _deferWorkspaceRefreshForSession(sid, opts={}){
|
||
_deferSessionSideEffect(sid,()=>{
|
||
const load=loadDir('.', opts);
|
||
if(load&&typeof load.catch==='function') load.catch(()=>{});
|
||
},150);
|
||
}
|
||
|
||
function _resolveSessionModelForDisplaySoon(sid){
|
||
if(!sid) return;
|
||
_deferSessionSideEffect(sid,async()=>{
|
||
try{
|
||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
|
||
const model=data&&data.session&&data.session.model;
|
||
const provider=data&&data.session&&data.session.model_provider;
|
||
if(!model||!S.session||S.session.session_id!==sid) return;
|
||
S.session.model=model;
|
||
S.session.model_provider=provider||null;
|
||
const resolvedContextLength=data.session.context_length||S.session.context_length||0;
|
||
S.session.context_length=resolvedContextLength;
|
||
S.session.threshold_tokens=data.session.threshold_tokens||0;
|
||
S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
|
||
S.session.post_compression_context_tokens_estimate=data.session.post_compression_context_tokens_estimate||null;
|
||
S.session._modelResolutionDeferred=false;
|
||
syncTopbar();
|
||
if(typeof _syncCtxIndicator==='function'){
|
||
const u=S.lastUsage||{};
|
||
const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
|
||
_syncCtxIndicator({
|
||
input_tokens:_pick(u.input_tokens,S.session.input_tokens),
|
||
output_tokens:_pick(u.output_tokens,S.session.output_tokens),
|
||
estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
|
||
cache_read_tokens:_pick(u.cache_read_tokens,S.session.cache_read_tokens),
|
||
cache_write_tokens:_pick(u.cache_write_tokens,S.session.cache_write_tokens),
|
||
cache_hit_percent:_pick(u.cache_hit_percent,S.session.cache_hit_percent,null),
|
||
context_length:resolvedContextLength||u.context_length||0,
|
||
last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
|
||
post_compression_context_tokens_estimate:S.session.post_compression_context_tokens_estimate,
|
||
threshold_tokens:data.session.threshold_tokens||0,
|
||
});
|
||
}
|
||
}catch(_){
|
||
// Keep session switching non-blocking; the next load can try again.
|
||
}
|
||
},0);
|
||
}
|
||
|
||
// Tracks whether the current session has older messages that were not
|
||
// loaded during the initial paginated fetch (msg_limit window).
|
||
// When true, scrolling to the top triggers _loadOlderMessages().
|
||
let _messagesTruncated = false;
|
||
|
||
// Load session messages if not already present.
|
||
// Called after loadSession fetches metadata (messages=0).
|
||
// Idempotent: if messages are already in S.messages, resolves immediately.
|
||
// Handles streaming sessions specially: restores from INFLIGHT cache or API.
|
||
// msg_limit (default 30): fetch a tail window with roughly N visible
|
||
// user/assistant rows for fast switching. Tool rows inside the window are
|
||
// server-bounded and do not consume the visible-message budget.
|
||
// Older messages are loaded on-demand via _loadOlderMessages().
|
||
const _INITIAL_MSG_LIMIT = 30;
|
||
// ============================================================================
|
||
// COUPLED CONSTANT — keep in sync with api/routes.py:_MAX_MSG_LIMIT.
|
||
// ============================================================================
|
||
// This is a hand-mirrored copy of the backend's GET /api/session ?msg_limit=
|
||
// ceiling. _loadOlderMessages grows its msg_limit tail window by
|
||
// +_INITIAL_MSG_LIMIT each load; once growth would exceed this ceiling the
|
||
// server clamps and the tail stops growing, so we switch to msg_before paging
|
||
// (a fixed-size backward page keyed off _oldestIdx) instead. This const is the
|
||
// static FALLBACK default only — the live ceiling is read from the /api/session
|
||
// `_msg_limit_max` metadata into _msgLimitMax below (#6177), so the two can no
|
||
// longer drift. Keep this fallback value roughly in sync with the backend
|
||
// _MAX_MSG_LIMIT for the mixed-version case where the server omits the field.
|
||
const _MSG_LIMIT_MAX = 500;
|
||
// Live server-advertised msg_limit ceiling. Declared at module scope with the
|
||
// static fallback so the reload-width paths (_ensureMessagesLoaded /
|
||
// _loadOlderMessages) always read a defined value even before the first
|
||
// /api/session response lands; refreshed from `_msg_limit_max` on each load.
|
||
let _msgLimitMax = _MSG_LIMIT_MAX;
|
||
let _sameSessionForceReloadHint = null;
|
||
|
||
function _currentLoadedRenderableMessageCount(){
|
||
if(typeof _messageRenderableMessageCount==='function'){
|
||
try{return Math.max(0,Number(_messageRenderableMessageCount())||0);}
|
||
catch(_){}
|
||
}
|
||
let count=0;
|
||
for(const m of (S.messages||[])){
|
||
if(m&&m.role&&m.role!=='tool') count++;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function _captureSameSessionForceReloadHint(sid){
|
||
const loadedRenderableCount=_currentLoadedRenderableMessageCount();
|
||
const loadedMessageCount=Array.isArray(S.messages)?S.messages.length:0;
|
||
const knownMessageCount=Number(S.session&&S.session.session_id===sid&&S.session.message_count)||loadedMessageCount;
|
||
if(!sid || (loadedRenderableCount<=0 && loadedMessageCount<=0)){
|
||
_sameSessionForceReloadHint=null;
|
||
return;
|
||
}
|
||
_sameSessionForceReloadHint={
|
||
session_id:sid,
|
||
loaded_renderable_count:loadedRenderableCount,
|
||
loaded_message_count:loadedMessageCount,
|
||
message_count:knownMessageCount,
|
||
truncated:!!_messagesTruncated,
|
||
};
|
||
}
|
||
|
||
function _clearSameSessionForceReloadHint(sid){
|
||
if(!_sameSessionForceReloadHint) return;
|
||
if(!sid || _sameSessionForceReloadHint.session_id===sid) _sameSessionForceReloadHint=null;
|
||
}
|
||
|
||
function _messageReloadLimitForSession(sid){
|
||
const hint=_sameSessionForceReloadHint;
|
||
if(hint&&hint.session_id===sid){
|
||
const loadedRenderableCount=Math.max(0,Number(hint.loaded_renderable_count)||0);
|
||
const loadedMessageCount=Math.max(0,Number(hint.loaded_message_count)||0);
|
||
if(loadedRenderableCount>0 || loadedMessageCount>0){
|
||
if(!hint.truncated) return null;
|
||
const previousMessageCount=Math.max(0,Number(hint.message_count)||0);
|
||
const currentMessageCount=Math.max(0,Number(S.session&&S.session.session_id===sid&&S.session.message_count)||0);
|
||
const appendedMessageCount=Math.max(0,currentMessageCount-previousMessageCount);
|
||
return Math.max(_INITIAL_MSG_LIMIT,loadedRenderableCount,loadedMessageCount+appendedMessageCount);
|
||
}
|
||
}
|
||
return _INITIAL_MSG_LIMIT;
|
||
}
|
||
|
||
function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){
|
||
const msgs=Array.isArray(messages)?messages:[];
|
||
// During active streaming, skip — clearing S.toolCalls would lose Activity
|
||
// and the renderMessages fallback is blocked by S.busy=true.
|
||
if(S.busy||S.activeStreamId) return;
|
||
// Persist the loaded compact tool summary onto S.session so the renderMessages
|
||
// derived rebuild can use it as a durable per-tid snippet fallback on cold
|
||
// load (#4927). loadSession keeps the messages=0 session object (tool_calls
|
||
// []), and the messages=1 summary arrives only as this argument — without
|
||
// copying it across, the fallback source is empty exactly on the cold-load
|
||
// path it's meant to repair.
|
||
if(S.session&&Array.isArray(sessionToolCalls)) S.session.tool_calls=sessionToolCalls.map(tc=>({...tc}));
|
||
const hasMessageToolMetadata=msgs.some(m=>{
|
||
if(!m) return false;
|
||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||
// `_partial_tool_calls` are emitted by interrupted/partial turns and must also
|
||
// anchor rendering to the owning assistant message, so we can reconstruct
|
||
// settled tool cards from the message history when available.
|
||
const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;
|
||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||
return hasTc||hasPartialTc||hasTu;
|
||
});
|
||
if(!hasMessageToolMetadata&&Array.isArray(sessionToolCalls)&&sessionToolCalls.length){
|
||
S.toolCalls=sessionToolCalls.map(tc=>({...tc,done:true}));
|
||
}else{
|
||
S.toolCalls=[];
|
||
}
|
||
}
|
||
|
||
async function _ensureMessagesLoaded(sid, opts) {
|
||
// `opts` is an explicit named parameter (vs loadSession's arguments[1]
|
||
// pattern) because _ensureMessagesLoaded is a module-private helper: it is
|
||
// only called from inside loadSession, so the public signature does not need
|
||
// to be preserved. Strict-mode engines optimize named params more reliably
|
||
// than arguments-indexing, and a named opts is self-documenting for static
|
||
// analysis. Callers pass {force:true} when they need to BYPASS the
|
||
// "messages already populated" early-return — currently only the #5177
|
||
// keep-stale-until-loaded path, which intentionally leaves the old messages
|
||
// in place (to avoid a visible disappear/reappear gap) and relies on
|
||
// _ensureMessagesLoaded to fetch and SWAP the new transcript into
|
||
// S.messages in a single frame.
|
||
opts = opts || {};
|
||
const _loadGeneration = Number.isFinite(opts.loadGeneration) ? Number(opts.loadGeneration) : null;
|
||
const _ownsLoad = () => _loadingSessionId === sid && (_loadGeneration === null || _loadSessionGeneration === _loadGeneration);
|
||
if (!_ownsLoad()) return;
|
||
// Already have messages? (e.g. from INFLIGHT restore path, already set)
|
||
if (!opts.force && S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
|
||
_clearSameSessionForceReloadHint(sid);
|
||
return;
|
||
}
|
||
// Fetch session messages with a tail window for fast initial load.
|
||
const reloadLimit = _messageReloadLimitForSession(sid); // defaults to _INITIAL_MSG_LIMIT
|
||
// A reload window above the server's msg_limit ceiling would be clamped by
|
||
// the backend (returning only the last _MSG_LIMIT_MAX rows), which can
|
||
// silently SHRINK an already-loaded transcript that had more than the ceiling
|
||
// of rows visible (rows 400–999 replaced by 500–999). When the requested
|
||
// window exceeds the ceiling, fall back to the bare full-transcript request
|
||
// (no msg_limit / no expand_renderable) so a same-session refresh never drops
|
||
// already-loaded older rows (Codex gate #6154, silent row-loss).
|
||
const boundedReloadLimit = (reloadLimit && reloadLimit <= _msgLimitMax) ? reloadLimit : null;
|
||
const reloadLimitParam = boundedReloadLimit ? `&msg_limit=${boundedReloadLimit}` : '';
|
||
// Older frontends used expand_renderable=1 to request visible-row expansion.
|
||
// The server now counts msg_limit by visible transcript rows by default; keep
|
||
// the flag for compatibility with mixed-version deployments.
|
||
const expandParam = boundedReloadLimit ? '&expand_renderable=1' : '';
|
||
let data;
|
||
try {
|
||
data = await api(
|
||
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0${reloadLimitParam}${expandParam}`,
|
||
{timeoutMs:120000}
|
||
);
|
||
} finally {
|
||
if (_ownsLoad()) _clearSameSessionForceReloadHint(sid);
|
||
}
|
||
if (!_ownsLoad()) return;
|
||
// Guard: api() may have redirected (401) and returned undefined.
|
||
if (!data || !data.session) return;
|
||
_messagesTruncated = !!data.session._messages_truncated;
|
||
_oldestIdx = data.session._messages_offset || 0;
|
||
_msgLimitMax = data.session._msg_limit_max || _MSG_LIMIT_MAX;
|
||
// #3162: `msgs` is reassigned below by the #3018 ephemeral-field carry-forward,
|
||
// so it must be `let`, not `const`. The `const` form threw a TypeError inside
|
||
// _ensureMessagesLoaded() that surfaced as a "Failed to load conversation messages"
|
||
// toast on every mobile message (SSE/visibility events trigger this reload path
|
||
// more aggressively on mobile).
|
||
let msgs = (data.session.messages || []).filter(m => m && m.role);
|
||
// Skip _syncToolCalls when INFLIGHT exists — the INFLIGHT restore path
|
||
// (loadSession line ~871) will overwrite S.toolCalls from INFLIGHT[sid].toolCalls.
|
||
// Clearing here and then overwriting is wasteful, and if S.busy becomes true
|
||
// before the next render, the fallback can't re-derive from messages.
|
||
if(!(typeof INFLIGHT !== 'undefined' && INFLIGHT && INFLIGHT[sid])){
|
||
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
|
||
}
|
||
clearLiveToolCards();
|
||
// #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
|
||
// _turnTps, _gatewayRouting, _statusCard, _anchor_stream_id) across the loadSession replace.
|
||
if(typeof window._carryForwardEphemeralTurnFields==='function'){
|
||
// #3306: Prefer the pre-clear snapshot stashed by loadSession() on a
|
||
// force-reload of the active session; S.messages was reset to [] there
|
||
// and would otherwise yield an empty carry-forward.
|
||
const _prev = (Array.isArray(_pendingCarryForwardSnapshot) && _pendingCarryForwardSnapshot.length)
|
||
? _pendingCarryForwardSnapshot
|
||
: (S.messages || []);
|
||
msgs=window._carryForwardEphemeralTurnFields(_prev, msgs);
|
||
_pendingCarryForwardSnapshot = null;
|
||
}
|
||
if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();
|
||
S.messages = msgs;
|
||
// Expand render window to cover all loaded messages so the next
|
||
// renderMessages() doesn't hide most of them behind a tiny window.
|
||
if(typeof _messageRenderableMessageCount==='function'&&typeof _currentMessageRenderWindowSize==='function'){
|
||
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(), _messageRenderableMessageCount());
|
||
}
|
||
if(S.session&&S.session.session_id===sid){
|
||
S.session.message_count=Number(data.session.message_count || msgs.length);
|
||
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
|
||
// Phase 2: the messages=1 response carries the canonical cold-load
|
||
// `todo_state` snapshot, derived server-side from the FULL untruncated
|
||
// message list (api/routes.py + api/todo_state.py). The earlier
|
||
// messages=0 fetch in loadSession() does not include this field —
|
||
// attach_todo_state is gated on `load_messages`. Without applying it
|
||
// here, long sessions whose latest todo write falls outside the
|
||
// _INITIAL_MSG_LIMIT tail would lose the panel on refresh: the
|
||
// legacy reverse-scan in _legacyTodosFromMessages() can only see the
|
||
// tail S.messages, while the authoritative snapshot was already
|
||
// computed by the server and is sitting in this very response.
|
||
// _hydrateTodosFromSession is idempotent and picks newer of
|
||
// cold-load vs INFLIGHT by timestamp, so calling it again here is
|
||
// safe even when an INFLIGHT snapshot was already restored.
|
||
if(data.session.todo_state !== undefined){
|
||
S.session.todo_state = data.session.todo_state;
|
||
}else{
|
||
delete S.session.todo_state;
|
||
}
|
||
if(typeof _hydrateTodosFromSession === 'function'){
|
||
_hydrateTodosFromSession(S.session);
|
||
}
|
||
if(typeof scheduleTodosRefresh === 'function'){
|
||
scheduleTodosRefresh();
|
||
}
|
||
// Only sync the viewed count (which also clears any completion-unread
|
||
// marker via _setSessionViewedCount -> _clearSessionCompletionUnread)
|
||
// when the session is STILL actively viewed. A hidden-tab completion that
|
||
// lands during the awaited message fetch above must NOT be silently marked
|
||
// read here — mirror the same _isSessionActivelyViewedForList(sid) guard
|
||
// used on the post-load re-ack in loadSession(). (#5917 gate finding)
|
||
if(typeof _isSessionActivelyViewedForList !== 'function' || _isSessionActivelyViewedForList(sid)){
|
||
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
|
||
}
|
||
if(typeof syncTopbar==='function') syncTopbar();
|
||
}
|
||
}
|
||
|
||
function _messageComparableText(m){
|
||
if(!m) return '';
|
||
if(typeof msgContent==='function'){
|
||
try{return String(msgContent(m)||'').trim();}
|
||
catch(_){}
|
||
}
|
||
return String(m.content||'').trim();
|
||
}
|
||
|
||
function _stripAttachedFilesMarker(text){
|
||
return String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim();
|
||
}
|
||
|
||
function _stripForcedSkillEnvelope(text){
|
||
let value=String(text||'').trim();
|
||
// `/use <skill>` augments the model-facing prompt with a directive and a
|
||
// hidden skill-content envelope, while the optimistic UI row keeps the human
|
||
// prompt. Treat them as the same submitted turn for active reload/reconnect
|
||
// dedupe without rewriting the persisted pending prompt.
|
||
value=value.replace(/^\[USER OVERRIDE\][^\n]*\n*/,'').trim();
|
||
value=value.replace(/\[FORCED SKILL CONTEXT:[^\]]+\][\s\S]*?\[\/FORCED SKILL CONTEXT\]\s*/g,'').trim();
|
||
return value;
|
||
}
|
||
|
||
function _normalizeUserTranscriptText(text){
|
||
const value=_stripAttachedFilesMarker(_stripForcedSkillEnvelope(text));
|
||
// ui.js is loaded before sessions.js in index.html and owns the canonical
|
||
// workspace-sentinel parser used by rendering. Keep a small fallback for
|
||
// static/helper tests and defensive partial loads, but prefer the renderer's
|
||
// parser whenever it is available.
|
||
if(typeof _stripWorkspaceDisplayPrefix==='function'){
|
||
return _stripWorkspaceDisplayPrefix(value);
|
||
}
|
||
const raw=String(value||'');
|
||
const strippedV1=raw.replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'');
|
||
if(strippedV1!==raw) return strippedV1.trim();
|
||
return raw.replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
|
||
}
|
||
|
||
function _sameTranscriptMessage(a,b){
|
||
if(!(a&&b)) return false;
|
||
const role=String(a.role||'');
|
||
if(role!==String(b.role||'')) return false;
|
||
const aText=_messageComparableText(a);
|
||
const bText=_messageComparableText(b);
|
||
if(aText===bText) return true;
|
||
if(role==='user'){
|
||
return _normalizeUserTranscriptText(aText)===_normalizeUserTranscriptText(bText);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function _currentTailUserMessage(messages){
|
||
const list=Array.isArray(messages)?messages:[];
|
||
for(let i=list.length-1;i>=0;i--){
|
||
const msg=list[i];
|
||
if(!msg) continue;
|
||
if(String(msg.role||'')==='user'){
|
||
// Compaction rows are synthetic user-role markers, not submitted turns.
|
||
if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) continue;
|
||
return msg;
|
||
}
|
||
if(msg._live||String(msg.role||'')==='tool') continue;
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function _hasCurrentTailUserDuplicate(messages,candidate){
|
||
if(!candidate||String(candidate.role||'')!=='user') return false;
|
||
const existing=_currentTailUserMessage(messages);
|
||
return !!(existing&&_sameTranscriptMessage(existing,candidate));
|
||
}
|
||
|
||
function _currentTurnAssistantText(messages){
|
||
const list=Array.isArray(messages)?messages:[];
|
||
let start=-1;
|
||
for(let i=list.length-1;i>=0;i--){
|
||
if(list[i]&&list[i].role==='user'){start=i;break;}
|
||
}
|
||
const parts=[];
|
||
for(let i=start+1;i<list.length;i++){
|
||
const msg=list[i];
|
||
if(!msg||msg.role!=='assistant'||msg._live) continue;
|
||
const text=_messageComparableText(msg);
|
||
if(text) parts.push(text);
|
||
}
|
||
return parts.join('\n\n').trim();
|
||
}
|
||
|
||
function _compactTranscriptText(text){
|
||
return String(text||'').replace(/\s+/g,' ').trim();
|
||
}
|
||
|
||
function _dropCurrentTurnAssistantMessages(messages){
|
||
const list=Array.isArray(messages)?messages:[];
|
||
let start=-1;
|
||
for(let i=list.length-1;i>=0;i--){
|
||
if(list[i]&&list[i].role==='user'){start=i;break;}
|
||
}
|
||
if(start<0) return list;
|
||
return list.filter((msg,idx)=>idx<=start||!(msg&&msg.role==='assistant'));
|
||
}
|
||
|
||
function _ensureInflightLiveAssistantMessage(inflight){
|
||
if(!inflight) return false;
|
||
const text=String(inflight.lastAssistantText||'').trim();
|
||
const reasoning=String(inflight.lastReasoningText||'').trim();
|
||
if(!text&&!reasoning) return false;
|
||
if(!Array.isArray(inflight.messages)) inflight.messages=[];
|
||
let live=null;
|
||
for(let i=inflight.messages.length-1;i>=0;i--){
|
||
const msg=inflight.messages[i];
|
||
if(msg&&msg.role==='assistant'&&msg._live){live=msg;break;}
|
||
}
|
||
if(live){
|
||
const liveText=_messageComparableText(live);
|
||
if(text&&(!liveText||text.startsWith(liveText)||text.length>liveText.length)){
|
||
live.content=text;
|
||
}
|
||
if(reasoning&&!live.reasoning) live.reasoning=reasoning;
|
||
return true;
|
||
}
|
||
inflight.messages.push({
|
||
role:'assistant',
|
||
content:text,
|
||
reasoning:reasoning||undefined,
|
||
_live:true,
|
||
_ts:Date.now()/1000,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
function _projectInflightMessagesForActivityBursts(inflight){
|
||
const messages=Array.isArray(inflight&&inflight.messages)?inflight.messages:[];
|
||
const anchors=Array.isArray(inflight&&inflight.activityBurstAnchors)?inflight.activityBurstAnchors:[];
|
||
if(!anchors.length) return messages;
|
||
let liveIdx=-1;
|
||
for(let i=messages.length-1;i>=0;i--){
|
||
const msg=messages[i];
|
||
if(msg&&msg.role==='assistant'&&msg._live){liveIdx=i;break;}
|
||
}
|
||
if(liveIdx<0) return messages;
|
||
let liveTailStartIdx=liveIdx;
|
||
while(liveTailStartIdx>0){
|
||
const prev=messages[liveTailStartIdx-1];
|
||
if(!(prev&&prev.role==='assistant'&&prev._live)) break;
|
||
liveTailStartIdx-=1;
|
||
}
|
||
const live=messages[liveIdx];
|
||
const text=_messageComparableText(live);
|
||
if(!text) return messages;
|
||
const priorLiveTexts=messages.slice(liveTailStartIdx,liveIdx)
|
||
.filter(m=>m&&m.role==='assistant'&&m._live)
|
||
.map(m=>_messageComparableText(m))
|
||
.filter(Boolean);
|
||
const liveTailIsAccumulator=priorLiveTexts.length>0&&priorLiveTexts.every(part=>
|
||
_compactTranscriptText(text).includes(_compactTranscriptText(part))
|
||
);
|
||
const replaceStartIdx=liveTailIsAccumulator?liveTailStartIdx:liveIdx;
|
||
if(priorLiveTexts.length&&!liveTailIsAccumulator) return messages;
|
||
const cleanAnchors=anchors
|
||
.map(a=>({id:Number(a&&a.id),textEnd:Number(a&&a.textEnd)}))
|
||
.filter(a=>Number.isFinite(a.id)&&Number.isFinite(a.textEnd)&&a.textEnd>0)
|
||
.sort((a,b)=>a.textEnd-b.textEnd||a.id-b.id);
|
||
const aliasBurstIds=new Map();
|
||
const fallbackBurstId = Number(inflight.currentActivityBurstId||0)||0;
|
||
aliasBurstIds.set(0,fallbackBurstId);
|
||
let lastVisibleBurstId=null;
|
||
let lastVisibleTextEnd=0;
|
||
const visibleAnchors=[];
|
||
for(const anchor of cleanAnchors){
|
||
const end=Math.min(text.length,anchor.textEnd);
|
||
if(end<=lastVisibleTextEnd){
|
||
if(lastVisibleBurstId!==null) aliasBurstIds.set(anchor.id,lastVisibleBurstId);
|
||
continue;
|
||
}
|
||
visibleAnchors.push(anchor);
|
||
lastVisibleBurstId=anchor.id;
|
||
lastVisibleTextEnd=end;
|
||
}
|
||
|
||
if(visibleAnchors.length&&Number.isFinite(visibleAnchors[0].id)) aliasBurstIds.set(0,visibleAnchors[0].id);
|
||
if(!visibleAnchors.length){
|
||
const firstVisibleBurstId=Number(cleanAnchors[0]&&cleanAnchors[0].id);
|
||
const fallbackAnchorId=Number.isFinite(firstVisibleBurstId)?firstVisibleBurstId:fallbackBurstId;
|
||
if(fallbackAnchorId!==fallbackBurstId) aliasBurstIds.set(0,fallbackAnchorId);
|
||
const projected=[{...live,content:text,_activityBurstId:fallbackAnchorId}];
|
||
|
||
const baselineSeq=Number(inflight.currentLiveSegmentSeq);
|
||
const existingSeqs=messages
|
||
.filter(m=>m&&m._live&&Number.isFinite(Number(m._liveSegmentSeq)))
|
||
.map(m=>Number(m._liveSegmentSeq));
|
||
const baseFromMessages=existingSeqs.length
|
||
? existingSeqs.reduce((acc,n)=>Math.max(acc,n),-Infinity)
|
||
: 0;
|
||
const firstSeq=(Number.isFinite(baselineSeq)&&baselineSeq>0)
|
||
? baselineSeq
|
||
: (Number.isFinite(baseFromMessages)&&baseFromMessages>0)
|
||
? baseFromMessages
|
||
: 1;
|
||
projected.forEach((seg,i)=>{
|
||
seg._liveSegmentSeq=i===0?firstSeq:1+i;
|
||
});
|
||
if(Array.isArray(inflight.toolCalls)){
|
||
const segmentSeqByBurstId=new Map();
|
||
segmentSeqByBurstId.set(String(fallbackAnchorId),firstSeq);
|
||
projected.forEach(seg=>{
|
||
const bid=Number(seg&&seg._activityBurstId);
|
||
const seq=Number(seg&&seg._liveSegmentSeq);
|
||
if(!Number.isFinite(bid)||!Number.isFinite(seq)) return;
|
||
const key=String(bid);
|
||
const current=segmentSeqByBurstId.get(key);
|
||
if(current===undefined||seq>current) segmentSeqByBurstId.set(key,seq);
|
||
});
|
||
|
||
const validSeqs=new Set(segmentSeqByBurstId.values());
|
||
const canonicalBurstId=(value)=>{
|
||
const bid=Number(value);
|
||
if(!Number.isFinite(bid)) return null;
|
||
if(aliasBurstIds.has(bid)) return aliasBurstIds.get(bid);
|
||
return bid;
|
||
};
|
||
|
||
inflight.toolCalls.forEach(tc=>{
|
||
if(!tc) return;
|
||
if(tc.activityBurstId!==undefined&&tc.activityBurstId!==null){
|
||
const current=Number(tc.activityBurstId);
|
||
if(aliasBurstIds.has(current)) tc.activityBurstId=aliasBurstIds.get(current);
|
||
}
|
||
const segSeq=Number(tc.activitySegmentSeq);
|
||
if(Number.isFinite(segSeq)&&validSeqs.has(segSeq)) return;
|
||
const canonical=canonicalBurstId(tc.activityBurstId);
|
||
if(!Number.isFinite(canonical)){
|
||
if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||
return;
|
||
}
|
||
const mappedSeq=segmentSeqByBurstId.get(String(canonical));
|
||
if(Number.isFinite(mappedSeq)) tc.activitySegmentSeq=mappedSeq;
|
||
else if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||
});
|
||
}
|
||
return [...messages.slice(0,replaceStartIdx),...projected,...messages.slice(liveIdx+1)];
|
||
}
|
||
const projected=[];
|
||
let prev=0;
|
||
for(let i=0;i<visibleAnchors.length;i++){
|
||
const anchor=visibleAnchors[i];
|
||
const end=Math.max(prev,Math.min(text.length,anchor.textEnd));
|
||
const part=text.slice(prev,end).trim();
|
||
if(part) projected.push({...live,content:part,_activityBurstId:anchor.id});
|
||
else{
|
||
const fallbackAnchor = visibleAnchors[i+1] || visibleAnchors[i-1];
|
||
if(fallbackAnchor && Number.isFinite(anchor.id)&&Number.isFinite(fallbackAnchor.id)){
|
||
aliasBurstIds.set(anchor.id,fallbackAnchor.id);
|
||
}
|
||
}
|
||
prev=end;
|
||
}
|
||
const tail=text.slice(prev).trim();
|
||
if(tail) projected.push({...live,content:tail,_activityBurstId:Number(inflight.currentActivityBurstId||0)||0});
|
||
if(!projected.length) return messages;
|
||
|
||
const baselineSeq=Number(inflight.currentLiveSegmentSeq);
|
||
const existingSeqs=messages
|
||
.filter(m=>m&&m._live&&Number.isFinite(Number(m._liveSegmentSeq)))
|
||
.map(m=>Number(m._liveSegmentSeq));
|
||
const baseFromMessages=existingSeqs.length
|
||
? existingSeqs.reduce((acc,n)=>Math.max(acc,n),-Infinity)
|
||
: 0;
|
||
const endSeq=(Number.isFinite(baselineSeq)&&baselineSeq>0)
|
||
? baselineSeq
|
||
: (Number.isFinite(baseFromMessages)&&baseFromMessages>0)
|
||
? baseFromMessages
|
||
: projected.length;
|
||
let firstSeq=endSeq-projected.length+1;
|
||
if(!Number.isFinite(firstSeq)||firstSeq<1) firstSeq=1;
|
||
projected.forEach((seg,i)=>{
|
||
const seq=firstSeq+i;
|
||
seg._liveSegmentSeq=seq;
|
||
});
|
||
if(Number.isFinite(firstSeq) && projected.length){
|
||
inflight.currentLiveSegmentSeq=projected[projected.length-1]._liveSegmentSeq;
|
||
}
|
||
|
||
const segmentSeqByBurstId=new Map();
|
||
projected.forEach(seg=>{
|
||
const bid=Number(seg&&seg._activityBurstId);
|
||
const seq=Number(seg&&seg._liveSegmentSeq);
|
||
if(!Number.isFinite(bid)||!Number.isFinite(seq)) return;
|
||
const key=String(bid);
|
||
const current=segmentSeqByBurstId.get(key);
|
||
if(current===undefined||seq>current) segmentSeqByBurstId.set(key,seq);
|
||
});
|
||
|
||
const canonicalBurstId = (value)=>{
|
||
const bid=Number(value);
|
||
if(!Number.isFinite(bid)) return null;
|
||
if(aliasBurstIds.has(bid)) return aliasBurstIds.get(bid);
|
||
return bid;
|
||
};
|
||
|
||
const validSeqs=new Set(segmentSeqByBurstId.values());
|
||
|
||
if(Array.isArray(inflight.toolCalls)){
|
||
inflight.toolCalls.forEach(tc=>{
|
||
if(!tc) return;
|
||
if(tc.activityBurstId!==undefined&&tc.activityBurstId!==null){
|
||
const current=Number(tc.activityBurstId);
|
||
if(aliasBurstIds.has(current)) tc.activityBurstId=aliasBurstIds.get(current);
|
||
}
|
||
const segSeq=Number(tc.activitySegmentSeq);
|
||
if(Number.isFinite(segSeq)&&validSeqs.has(segSeq)) return;
|
||
const canonical=canonicalBurstId(tc.activityBurstId);
|
||
if(!Number.isFinite(canonical)){
|
||
if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||
return;
|
||
}
|
||
const mappedSeq=segmentSeqByBurstId.get(String(canonical));
|
||
if(Number.isFinite(mappedSeq)) tc.activitySegmentSeq=mappedSeq;
|
||
else if(Number.isFinite(segSeq)) tc.activitySegmentSeq=undefined;
|
||
});
|
||
}
|
||
return [...messages.slice(0,replaceStartIdx),...projected,...messages.slice(liveIdx+1)];
|
||
}
|
||
|
||
function _prepareRunningLiveTail(baseMessages,inflightMessages){
|
||
const inflight=Array.isArray(inflightMessages)?inflightMessages:[];
|
||
const liveMessages=inflight.filter(m=>m&&m.role==='assistant'&&m._live);
|
||
if(liveMessages.length>1) return liveMessages.some(m=>!!_messageComparableText(m));
|
||
const live=liveMessages[0]||null;
|
||
if(!live) return false;
|
||
const liveText=_messageComparableText(live);
|
||
const persistedText=_currentTurnAssistantText(baseMessages);
|
||
if(persistedText){
|
||
const compactPersisted=_compactTranscriptText(persistedText);
|
||
const compactLive=_compactTranscriptText(liveText);
|
||
if(!liveText || persistedText.startsWith(liveText)){
|
||
live.content=persistedText;
|
||
}else if(liveText.startsWith(persistedText)){
|
||
const extra=liveText.slice(persistedText.length).trim();
|
||
if(extra&&compactPersisted.includes(_compactTranscriptText(extra))){
|
||
live.content=persistedText;
|
||
}
|
||
}else if(compactPersisted===compactLive){
|
||
live.content=persistedText;
|
||
}
|
||
}
|
||
return !!_messageComparableText(live);
|
||
}
|
||
|
||
function _mergeInflightTailMessages(baseMessages, inflightMessages){
|
||
const base=Array.isArray(baseMessages)?baseMessages:[];
|
||
const inflight=Array.isArray(inflightMessages)?inflightMessages:[];
|
||
let firstLiveIdx=-1;
|
||
for(let i=0;i<inflight.length;i++){
|
||
if(inflight[i]&&inflight[i]._live){firstLiveIdx=i;break;}
|
||
}
|
||
if(firstLiveIdx<0) return base;
|
||
let start=firstLiveIdx;
|
||
if(firstLiveIdx>0&&inflight[firstLiveIdx-1]&&inflight[firstLiveIdx-1].role==='user') start=firstLiveIdx-1;
|
||
const tail=inflight.slice(start).filter(m=>m&&m.role);
|
||
const merged=[...base];
|
||
for(const msg of tail){
|
||
let candidate=msg;
|
||
if(!candidate) continue;
|
||
const duplicate=String(candidate.role||'')==='user'
|
||
? _hasCurrentTailUserDuplicate(merged,candidate)
|
||
: merged.slice(-Math.max(5,tail.length+2)).some(existing=>_sameTranscriptMessage(existing,candidate));
|
||
if(!duplicate) merged.push(candidate);
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
// Load older messages when the user scrolls to the top of the conversation.
|
||
// Prepends them to S.messages and re-renders, preserving scroll position.
|
||
let _loadingOlder = false;
|
||
// _oldestIdx tracks the index (in the server's full message array) of the
|
||
// oldest message currently loaded in S.messages. Starts at 0 when all
|
||
// messages are loaded, or > 0 when truncated by msg_limit.
|
||
let _oldestIdx = 0;
|
||
// Generation token bumped every time S.messages is wholesale-replaced
|
||
// (rather than incrementally extended). _loadOlderMessages snapshots it
|
||
// before its `await` and re-checks after, so a late-resolving prefetch
|
||
// does not prepend onto a transcript that was rebuilt under it
|
||
// (e.g. by _ensureAllMessagesLoaded after a Start-jump). See #1937.
|
||
let _messagesGeneration = 0;
|
||
function _bumpMessagesGeneration() {
|
||
// Wrap to keep the counter bounded; the only operation that matters is
|
||
// strict inequality between the snapshot and the post-await read, so any
|
||
// monotonic bump is sufficient.
|
||
_messagesGeneration = (_messagesGeneration + 1) | 0;
|
||
return _messagesGeneration;
|
||
}
|
||
|
||
async function _loadOlderMessages() {
|
||
if (_loadingOlder || !_messagesTruncated) return;
|
||
const sid = S.session ? S.session.session_id : null;
|
||
if (!sid || !S.messages.length) return;
|
||
if (_oldestIdx <= 0) { _messagesTruncated = false; return; }
|
||
_loadingOlder = true;
|
||
// Snapshot the generation BEFORE we await. If S.messages is wholesale
|
||
// replaced while the request is in flight, the post-await check below
|
||
// bails out so we never prepend stale older messages onto a freshly
|
||
// rebuilt transcript (#1937).
|
||
const startGeneration = _messagesGeneration;
|
||
try {
|
||
// Two strategies, chosen by whether the growing tail window still fits under
|
||
// the server's msg_limit ceiling (_MSG_LIMIT_MAX, mirroring backend
|
||
// _MAX_MSG_LIMIT):
|
||
//
|
||
// - Below the ceiling: ask for a larger authoritative tail window
|
||
// (currentLoaded + _INITIAL_MSG_LIMIT). Post-#2716 the backend runs the
|
||
// full append-only merge, so a larger msg_limit produces the same merged
|
||
// transcript we'd get by stitching pages, without client-side index
|
||
// bookkeeping. The newly exposed head is what we expose to the user.
|
||
//
|
||
// - At/above the ceiling: the server clamps msg_limit, so the tail window
|
||
// stops growing and this strategy would stall (the same clamped tail is
|
||
// returned, olderMsgs -> 0). Switch to msg_before paging — a fixed
|
||
// _INITIAL_MSG_LIMIT backward page keyed off _oldestIdx — which is
|
||
// bounded and never hits the ceiling, so the head stays reachable for
|
||
// arbitrarily long transcripts. (This is the same paging request the
|
||
// race-fallback below uses, proven correct there.)
|
||
const requestedLimit = Math.max(_INITIAL_MSG_LIMIT, (S.messages || []).length + _INITIAL_MSG_LIMIT);
|
||
const useBeforePaging = requestedLimit >= _msgLimitMax;
|
||
const data = useBeforePaging
|
||
? await api(
|
||
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,
|
||
{timeoutMs:120000}
|
||
)
|
||
: await api(
|
||
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${requestedLimit}`,
|
||
{timeoutMs:120000}
|
||
);
|
||
// Guard: api() may have redirected (401) and returned undefined.
|
||
if (!data || !data.session) { _loadingOlder = false; return; }
|
||
// - response shape sane
|
||
// - the active session is still the one we issued the request for.
|
||
// Compare against S.session.session_id, NOT _loadingSessionId — the
|
||
// latter is null between session loads, leaving a window where a
|
||
// stale response could prepend onto the new session's S.messages.
|
||
if (!data || !data.session) return;
|
||
if (!S.session || S.session.session_id !== sid) return;
|
||
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
|
||
// Generation guard: another code path (typically jumpToSessionStart →
|
||
// _ensureAllMessagesLoaded) may have replaced S.messages while we were
|
||
// awaiting. Prepending older messages onto that replacement would
|
||
// duplicate the head of the transcript. Detect via the generation
|
||
// counter and abort cleanly. _oldestIdx and _messagesTruncated were
|
||
// already reset by the wholesale-replace path, so no rollback needed.
|
||
if (_messagesGeneration !== startGeneration) return;
|
||
let responseSession = data.session;
|
||
let expandedMsgs = (responseSession.messages || []).filter(m => m && m.role);
|
||
const currentMsgs = (S.messages || []).filter(m => m && m.role);
|
||
const currentLen = currentMsgs.length;
|
||
// Suffix-continuity check: the cumulative tail is only safe to wholesale-
|
||
// replace when our currently-displayed messages are still its suffix. If
|
||
// the server appended new messages (or merge filtered something) while we
|
||
// were awaiting, the suffix won't line up — fall back to the legacy
|
||
// msg_before page so we never drop visible older messages on the floor.
|
||
// When useBeforePaging is true, `data` is a bounded msg_before OLDER page,
|
||
// not a cumulative tail. A raw-row-heavy older page whose visible text
|
||
// repeats the current tail could otherwise pass the suffix check below and
|
||
// be wholesale-replaced AS IF it were the full tail — silently discarding
|
||
// the current (newer) rows and marking history complete. Gate the suffix
|
||
// heuristic on !useBeforePaging so every msg_before page is always treated
|
||
// as an older page and prepended (Codex gate #6154, silent row-loss).
|
||
let tailMatches = !useBeforePaging && expandedMsgs.length >= currentLen;
|
||
if (tailMatches && currentLen > 0) {
|
||
const start = expandedMsgs.length - currentLen;
|
||
for (let i = 0; i < currentLen; i++) {
|
||
if (!_sameTranscriptMessage(expandedMsgs[start + i], currentMsgs[i])) {
|
||
tailMatches = false;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
let olderCount = Math.max(0, expandedMsgs.length - currentLen);
|
||
let olderMsgs = expandedMsgs.slice(0, olderCount);
|
||
let nextMessages = expandedMsgs;
|
||
if (!tailMatches) {
|
||
// Race fallback (or the over-ceiling msg_before primary path): keep the
|
||
// legacy index-page request as the correctness-preserving alternative.
|
||
// When useBeforePaging is true we already fetched a msg_before page as
|
||
// the primary `data`, so reuse it instead of re-fetching. Same guards
|
||
// reapplied because we just awaited again (skipped for the reuse case).
|
||
if (!useBeforePaging) {
|
||
const fallback = await api(
|
||
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,
|
||
{timeoutMs:120000}
|
||
);
|
||
if (!fallback || !fallback.session) { _loadingOlder = false; return; }
|
||
if (!S.session || S.session.session_id !== sid) return;
|
||
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
|
||
if (_messagesGeneration !== startGeneration) return;
|
||
responseSession = fallback.session;
|
||
}
|
||
olderMsgs = (responseSession.messages || []).filter(m => m && m.role);
|
||
nextMessages = [...olderMsgs, ...S.messages];
|
||
}
|
||
if (!olderMsgs.length) { _messagesTruncated = !!responseSession._messages_truncated; return; }
|
||
// Replace with the larger tail window and preserve scroll as if older
|
||
// messages were prepended. When the suffix check fails, nextMessages
|
||
// already encodes the legacy prepend fallback so the visible behavior
|
||
// matches the old msg_before page path exactly.
|
||
// Use $('messages') — the scrollable container (#msgInner is not scrollable).
|
||
const container = $('messages');
|
||
const prevScrollH = container ? container.scrollHeight : 0;
|
||
const oldTop = container ? container.scrollTop : 0;
|
||
const viewportAnchor = (container && typeof _captureMessageViewportAnchor === 'function')
|
||
? _captureMessageViewportAnchor()
|
||
: null;
|
||
// Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/
|
||
// _gatewayRouting/_statusCard/_anchor_stream_id) before the wholesale replace so the badge
|
||
// does not briefly appear and disappear during older-message expansion.
|
||
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
|
||
nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages);
|
||
}
|
||
S.messages = nextMessages;
|
||
_syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls);
|
||
// renderMessages() windows long transcripts from the end. If we do not
|
||
// expand that window before rendering, the newly prepended page stays
|
||
// hidden and the "hidden" counter rises while the viewport appears stuck.
|
||
// Count by the same visible-message rules used by renderMessages(); the
|
||
// virtual fallback below uses this as a pixel-height prefix length.
|
||
const addedRenderable = olderMsgs.filter(m=>{
|
||
if(typeof _messageIsRenderable==='function') return _messageIsRenderable(m);
|
||
if(!m||!m.role||m.role==='tool') return false;
|
||
if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m)) return false;
|
||
if(typeof _isPreservedCompressionTaskListMessage==='function'&&_isPreservedCompressionTaskListMessage(m)) return false;
|
||
if(typeof _isRecoveryControlMessage==='function'&&_isRecoveryControlMessage(m)) return false;
|
||
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
|
||
const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;
|
||
return !!(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||hasPartialTc||(typeof _messageHasReasoningPayload==='function'&&_messageHasReasoningPayload(m))||(typeof _assistantMessageHasVisibleContent==='function'&&_assistantMessageHasVisibleContent(m)))));
|
||
}).length;
|
||
_messageRenderWindowSize=_currentMessageRenderWindowSize()+Math.max(addedRenderable, MESSAGE_RENDER_WINDOW_DEFAULT);
|
||
_messagesTruncated = !!responseSession._messages_truncated;
|
||
_oldestIdx = responseSession._messages_offset || 0;
|
||
renderMessages({ preserveScroll: true });
|
||
if (container) {
|
||
// Prepending older messages must not teleport the reader. Anchor to the
|
||
// first visible rendered row and restore that row's top offset after the
|
||
// prepend so synthetic virtual spacer heights cannot skew the delta.
|
||
const restoredViaAnchor = (viewportAnchor && typeof _restoreMessageViewportAnchor === 'function')
|
||
? _restoreMessageViewportAnchor(viewportAnchor, olderMsgs.length)
|
||
: false;
|
||
if (!restoredViaAnchor) {
|
||
const virtualAddedHeight = (typeof _messageVirtualPrependedHeightDelta === 'function')
|
||
? _messageVirtualPrependedHeightDelta(addedRenderable)
|
||
: null;
|
||
const newScrollH = container.scrollHeight;
|
||
const addedHeight = Number.isFinite(virtualAddedHeight)
|
||
? virtualAddedHeight
|
||
: Math.max(0, newScrollH - prevScrollH);
|
||
_programmaticScroll = true;
|
||
container.scrollTop = oldTop + addedHeight;
|
||
requestAnimationFrame(()=>{ _programmaticScroll = false; });
|
||
}
|
||
}
|
||
_scrollPinned = false;
|
||
} catch(e) {
|
||
console.warn('_loadOlderMessages failed:', e);
|
||
} finally {
|
||
// Always clear the loading lock. If the user switched sessions while
|
||
// this request was in flight, loadSession() already set _loadingOlder=false
|
||
// (see line ~122), so this is a harmless double-reset.
|
||
_loadingOlder = false;
|
||
}
|
||
}
|
||
|
||
// Ensure the full message history is loaded (for undo, export, etc).
|
||
// If the session was loaded with msg_limit, this fetches all messages.
|
||
//
|
||
// Race-safety (#1937): with the endless-scroll opt-in, _loadOlderMessages
|
||
// may be in flight when this runs (e.g. user scrolled near the top, then
|
||
// hit the Start jump pill). Two coordinated guards prevent the prefetch
|
||
// from prepending duplicate messages onto our wholesale replacement:
|
||
// 1. Hold the _loadingOlder mutex around the body so a NEW prefetch
|
||
// cannot start mid-replace (entry-gate check at line ~1003 returns
|
||
// early). The mutex is also self-protecting against concurrent
|
||
// ensure-all calls from rapid double-clicks on Start.
|
||
// 2. Bump _messagesGeneration before mutating S.messages so any
|
||
// in-flight prefetch's post-await generation check bails out.
|
||
async function _ensureAllMessagesLoaded() {
|
||
if (!_messagesTruncated || !S.session) return;
|
||
if (_loadingOlder) {
|
||
// A prefetch is mid-flight (between the `_loadingOlder = true` line
|
||
// and its post-await guards). Bumping the generation token now
|
||
// poisons that prefetch's continuation, but we still need to claim
|
||
// the mutex AFTER it releases. Yield until the prefetch finishes
|
||
// (its finally-block clears _loadingOlder) before fetching the full
|
||
// history ourselves. The generation bump below ensures any other
|
||
// future race against this same continuation also fails closed.
|
||
_bumpMessagesGeneration();
|
||
while (_loadingOlder) {
|
||
await new Promise(resolve => setTimeout(resolve, 16));
|
||
}
|
||
if (!_messagesTruncated || !S.session) return;
|
||
}
|
||
_loadingOlder = true;
|
||
try {
|
||
const sid = S.session.session_id;
|
||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`, {timeoutMs:120000});
|
||
// Guard: api() may have redirected (401) and returned undefined.
|
||
if (!data || !data.session) return;
|
||
// Session may have been switched while we awaited. Bail rather than
|
||
// overwrite the new session's messages.
|
||
if (!S.session || S.session.session_id !== sid) return;
|
||
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
|
||
const msgs = (data.session.messages || []).filter(m => m && m.role);
|
||
// Bump the generation BEFORE the wholesale replace so any racing
|
||
// prefetch (whose snapshot was taken before this call's mutex
|
||
// acquisition) sees the new value and aborts.
|
||
_bumpMessagesGeneration();
|
||
// #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded.
|
||
// Loading older messages also does a wholesale replace of S.messages
|
||
// and would otherwise drop _turnUsage/_turnDuration/_turnTps/
|
||
// _gatewayRouting/_statusCard/_anchor_stream_id on the existing turns.
|
||
let _msgsToAssign = msgs;
|
||
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
|
||
_msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs);
|
||
}
|
||
S.messages = _msgsToAssign;
|
||
_messagesTruncated = false;
|
||
_oldestIdx = 0;
|
||
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
|
||
if (S.session && S.session.session_id === sid) {
|
||
S.session.message_count = Number(data.session.message_count || msgs.length);
|
||
}
|
||
} finally {
|
||
_loadingOlder = false;
|
||
}
|
||
}
|
||
|
||
const SESSION_ARCHIVED_PAGE_SIZE = 100;
|
||
const SESSION_ARCHIVED_MAX_LOADED_LIMIT = 2000;
|
||
let _allSessions = []; // cached for search filter
|
||
let _sidebarReferenceSessions = []; // hidden archived ancestor rows used only for nesting/suppression
|
||
let _allSessionsScope = null; // {profile, allProfiles} the cache was loaded under (#4167)
|
||
let _sessionAttentionSoundPrimed = false;
|
||
const _sessionAttentionSoundState = new Map();
|
||
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
|
||
let _showArchived = false; // toggle to show archived sessions
|
||
let _sessionSelectMode = false; // batch select mode
|
||
const _selectedSessions = new Set(); // selected session IDs
|
||
let _allProjects = []; // cached project list
|
||
// Sentinel value for the _activeProject state when filtering to sessions
|
||
// that have no project_id assigned. Distinct from real project IDs so the
|
||
// equality check below can branch cleanly on it. The literal string is
|
||
// not user-visible (the chip renders the localized label) — it just has
|
||
// to be something a user-created project_id can never collide with, which
|
||
// double-underscore prefixes provide.
|
||
const NO_PROJECT_FILTER = '__none__';
|
||
let _activeProject = null; // project_id filter (null = show all, NO_PROJECT_FILTER = unassigned only)
|
||
const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes-show-all-profiles';
|
||
let _showAllProfiles = false; // false = filter to active profile only
|
||
let _profileSwitchOpeningExistingSession = false; // true while cross-profile sidebar click switches profile before loadSession()
|
||
let _otherProfileCount = 0; // count of sessions from other profiles (server-reported)
|
||
let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested
|
||
let _archivedCliCount = 0; // archived non-WebUI sessions not fetched until requested
|
||
let _archivedRowsLoadedLimit = SESSION_ARCHIVED_PAGE_SIZE;
|
||
let _serverWebuiSessionCount = null; // explicit server count for WebUI sessions
|
||
let _serverCliSessionCount = null; // explicit server count for CLI sessions
|
||
let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions
|
||
|
||
function _restoreShowAllProfiles(){
|
||
try{
|
||
const raw=localStorage.getItem(SHOW_ALL_PROFILES_STORAGE_KEY);
|
||
_showAllProfiles = raw === '1' || raw === 'true';
|
||
}catch(_e){ _showAllProfiles = false; }
|
||
}
|
||
|
||
function _setShowAllProfiles(enabled){
|
||
_showAllProfiles=!!enabled;
|
||
try{ localStorage.setItem(SHOW_ALL_PROFILES_STORAGE_KEY,_showAllProfiles?'1':'0'); }catch(_e){}
|
||
}
|
||
|
||
_restoreShowAllProfiles();
|
||
_restoreSessionSourceFilter();
|
||
let _sessionActionMenu = null;
|
||
let _sessionActionAnchor = null;
|
||
let _sessionActionSessionId = null;
|
||
let _sessionActionMenuId = 0;
|
||
let _sessionActionPreviousFocus = null;
|
||
const _expandedChildSessionKeys = new Set();
|
||
const _expandedLineageKeys = new Set();
|
||
const _lineageReportCache = new Map();
|
||
const _lineageReportInflight = new Map();
|
||
let _lineageReportCacheGeneration = 0;
|
||
let _sessionVisibleSidebarIds = [];
|
||
let _pendingSessionReflowPositions = null;
|
||
const _optimisticallyRemovedSessionIds = new Set();
|
||
const _sessionSwipeReturnOffsets = new Map();
|
||
|
||
function _captureSessionReflowPositions(){
|
||
const list=$('sessionList');
|
||
if(!list) return null;
|
||
const positions=new Map();
|
||
list.querySelectorAll('.session-item[data-sid]').forEach(row=>{
|
||
positions.set(row.dataset.sid,row.getBoundingClientRect().top);
|
||
});
|
||
return positions;
|
||
}
|
||
|
||
function _waitForSessionMotion(ms){
|
||
return new Promise(resolve=>setTimeout(resolve,ms));
|
||
}
|
||
|
||
function _playSessionRowsReflowFromPositions(before, timeoutMs, prefersReducedMotion){
|
||
if(!before||!before.size) return;
|
||
if(prefersReducedMotion&&prefersReducedMotion()) return;
|
||
const list=$('sessionList');
|
||
if(!list) return;
|
||
const movingRows=[];
|
||
list.querySelectorAll('.session-item[data-sid]').forEach(row=>{
|
||
const oldTop=before.get(row.dataset.sid);
|
||
if(oldTop===undefined) return;
|
||
const delta=oldTop-row.getBoundingClientRect().top;
|
||
if(Math.abs(delta)<1) return;
|
||
movingRows.push({row,delta});
|
||
});
|
||
if(!movingRows.length) return;
|
||
movingRows.forEach(({row,delta})=>{
|
||
row.style.transition='none';
|
||
row.style.setProperty('--session-reflow-offset',delta+'px');
|
||
row.classList.add('session-reflowing');
|
||
});
|
||
list.getBoundingClientRect();
|
||
movingRows.forEach(({row})=>{
|
||
let reflowCleared=false;
|
||
const clearReflow=()=>{
|
||
if(reflowCleared) return;
|
||
reflowCleared=true;
|
||
row.classList.remove('session-reflowing');
|
||
row.style.removeProperty('--session-reflow-offset');
|
||
row.removeEventListener('transitionend',onReflowEnd);
|
||
};
|
||
const onReflowEnd=(event)=>{
|
||
if(event.propertyName==='transform') clearReflow();
|
||
};
|
||
row.addEventListener('transitionend',onReflowEnd);
|
||
row.style.removeProperty('transition');
|
||
requestAnimationFrame(()=>requestAnimationFrame(()=>{
|
||
if(!reflowCleared) row.style.setProperty('--session-reflow-offset','0px');
|
||
}));
|
||
setTimeout(clearReflow,timeoutMs);
|
||
});
|
||
}
|
||
|
||
function _sessionPrefersReducedMotion(){
|
||
try{
|
||
return Boolean(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||
}catch(_){
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function _makeSessionSwipeAffordance(side, icon, label){
|
||
const affordance=document.createElement('div');
|
||
affordance.className='session-swipe-affordance session-swipe-affordance-'+side;
|
||
affordance.setAttribute('aria-hidden','true');
|
||
const stack=document.createElement('span');
|
||
stack.className='session-swipe-action-stack';
|
||
const badge=document.createElement('span');
|
||
badge.className='session-swipe-badge';
|
||
badge.innerHTML=li(icon,18);
|
||
const text=document.createElement('span');
|
||
text.className='session-swipe-label';
|
||
text.textContent=label;
|
||
stack.append(badge,text);
|
||
affordance.append(stack);
|
||
return affordance;
|
||
}
|
||
const SESSION_VIRTUAL_ROW_HEIGHT = 52;
|
||
const SESSION_VIRTUAL_BUFFER_ROWS = 12;
|
||
const SESSION_VIRTUAL_THRESHOLD_ROWS = 80;
|
||
let _sessionVirtualScrollList = null;
|
||
let _sessionVirtualScrollRaf = 0;
|
||
|
||
function _sessionSnapshotById(sid){
|
||
if(!sid)return null;
|
||
if(S.session&&S.session.session_id===sid) return S.session;
|
||
return (_allSessions||[]).find(s=>s&&s.session_id===sid)||null;
|
||
}
|
||
function _pinnedSessionCount(){
|
||
return (_allSessions||[]).filter(s=>s&&s.pinned&&!s.archived).length;
|
||
}
|
||
function _getPinnedSessionsLimit(){
|
||
const limit=parseInt(window._pinnedSessionsLimit||3,10);
|
||
return (Number.isFinite(limit)&&limit>0)?limit:3;
|
||
}
|
||
function _pinnedSessionsLimitMessage(){
|
||
const limit=_getPinnedSessionsLimit();
|
||
return `Only ${limit} conversations can be pinned. Unpin one before pinning another.`;
|
||
}
|
||
function _worktreeSessionCount(ids){
|
||
return (ids||[]).reduce((count,sid)=>{
|
||
const session=_sessionSnapshotById(sid);
|
||
return count+(session&&session.worktree_path?1:0);
|
||
},0);
|
||
}
|
||
function _sessionResponseRetainsWorktree(response, session){
|
||
if(response&&typeof response.worktree_retained==='boolean') return response.worktree_retained;
|
||
return !!(session&&session.worktree_path);
|
||
}
|
||
function _worktreeResponseCount(results){
|
||
return (results||[]).reduce((count,result)=>{
|
||
return count+(_sessionResponseRetainsWorktree(result&&result.response,result&&result.session)?1:0);
|
||
},0);
|
||
}
|
||
function _sessionArchiveDescription(session){
|
||
return session&&session.worktree_path?t('session_archive_worktree_desc'):t('session_archive_desc');
|
||
}
|
||
function _sessionArchiveToast(response, session){
|
||
return _sessionResponseRetainsWorktree(response,session)?t('session_archived_worktree'):t('session_archived');
|
||
}
|
||
function _sessionDeleteDescription(session){
|
||
return session&&session.worktree_path?t('session_delete_worktree_desc'):t('session_delete_desc');
|
||
}
|
||
function _optimisticallyArchiveSessionInList(sid, archived){
|
||
if(!sid||!Array.isArray(_allSessions)) return;
|
||
let changed=false;
|
||
_allSessions=_allSessions.map(s=>{
|
||
if(!s||s.session_id!==sid) return s;
|
||
changed=true;
|
||
return {...s,archived:!!archived};
|
||
});
|
||
if(changed) renderSessionListFromCache();
|
||
}
|
||
function _optimisticallyRemoveSessionFromList(sid){
|
||
if(!sid||!Array.isArray(_allSessions)) return;
|
||
const before=_allSessions.length;
|
||
_allSessions=_allSessions.filter(s=>!s||s.session_id!==sid);
|
||
if(_selectedSessions&&_selectedSessions.has(sid)) _selectedSessions.delete(sid);
|
||
if(typeof _dropStaleOptimisticSessionRow==='function') _dropStaleOptimisticSessionRow(sid);
|
||
if(_allSessions.length!==before) renderSessionListFromCache();
|
||
}
|
||
|
||
function _sessionIdFromLocation(){
|
||
if(typeof window==='undefined'||!window.location) return null;
|
||
const marker='/session/';
|
||
const path=window.location.pathname||'';
|
||
const idx=path.indexOf(marker);
|
||
if(idx>=0){
|
||
const raw=path.slice(idx+marker.length).split('/')[0];
|
||
if(raw){try{return decodeURIComponent(raw);}catch(_e){return raw;}}
|
||
}
|
||
try{
|
||
const qs=new URLSearchParams(window.location.search||'');
|
||
return qs.get('session')||qs.get('session_id')||null;
|
||
}catch(_e){return null;}
|
||
}
|
||
function _composerPrefillIntentFromLocation(){
|
||
const empty={hasParams:false,hasText:false,text:'',autoSend:false};
|
||
if(typeof window==='undefined'||!window.location) return empty;
|
||
try{
|
||
const qs=new URLSearchParams(window.location.search||'');
|
||
const hasQ=qs.has('q');
|
||
const hasPrompt=qs.has('prompt');
|
||
const hasSend=qs.has('send');
|
||
if(!hasQ&&!hasPrompt&&!hasSend) return empty;
|
||
const text=hasQ?(qs.get('q')||''):(hasPrompt?(qs.get('prompt')||''):'');
|
||
return {
|
||
hasParams:true,
|
||
hasText:!!String(text).trim(),
|
||
text,
|
||
autoSend:false
|
||
};
|
||
}catch(_e){return empty;}
|
||
}
|
||
function _profileQueryIntentFromLocation(){
|
||
const empty={hasParam:false,valid:false,name:''};
|
||
if(typeof window==='undefined'||!window.location) return empty;
|
||
try{
|
||
const qs=new URLSearchParams(window.location.search||'');
|
||
if(!qs.has('profile')) return empty;
|
||
const name=String(qs.get('profile')||'');
|
||
return {
|
||
hasParam:true,
|
||
valid:/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name),
|
||
name
|
||
};
|
||
}catch(_e){return empty;}
|
||
}
|
||
function _consumeProfileQueryParamFromLocation(){
|
||
if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return;
|
||
try{
|
||
const current=new URL(window.location.href);
|
||
const before=current.searchParams.toString();
|
||
current.searchParams.delete('profile');
|
||
const after=current.searchParams.toString();
|
||
if(after===before) return;
|
||
const next=current.pathname+(after?`?${after}`:'')+(current.hash||'');
|
||
window.history.replaceState(window.history.state||null,'',next);
|
||
}catch(_e){}
|
||
}
|
||
function _consumeComposerPrefillParamsFromLocation(){
|
||
if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return;
|
||
try{
|
||
const current=new URL(window.location.href);
|
||
const before=current.searchParams.toString();
|
||
current.searchParams.delete('q');
|
||
current.searchParams.delete('prompt');
|
||
current.searchParams.delete('send');
|
||
const after=current.searchParams.toString();
|
||
if(after===before) return;
|
||
const next=current.pathname+(after?`?${after}`:'')+(current.hash||'');
|
||
window.history.replaceState(window.history.state||null,'',next);
|
||
}catch(_e){}
|
||
}
|
||
function _appRootPath(){
|
||
try{
|
||
const base = new URL(document.baseURI||window.location.origin+'/', window.location.origin);
|
||
return base.pathname || '/';
|
||
}catch(_e){return '/';}
|
||
}
|
||
function _sessionUrlForSid(sid){
|
||
const encoded=encodeURIComponent(sid);
|
||
let base;
|
||
try{base=new URL(`session/${encoded}`, document.baseURI||window.location.origin+'/');}
|
||
catch(_e){base=new URL(`/session/${encoded}`, window.location.origin);}
|
||
try{
|
||
const current=new URL(window.location.href);
|
||
current.searchParams.delete('session');
|
||
current.searchParams.delete('session_id');
|
||
current.searchParams.delete('q');
|
||
current.searchParams.delete('prompt');
|
||
current.searchParams.delete('send');
|
||
base.search=current.searchParams.toString();
|
||
base.hash=current.hash;
|
||
}catch(_e){}
|
||
return base.pathname+base.search+base.hash;
|
||
}
|
||
function _setActiveSessionUrl(sid){
|
||
if(typeof window==='undefined'||!window.history||!sid) return;
|
||
const next=_sessionUrlForSid(sid);
|
||
if(next && next!==(window.location.pathname+window.location.search+window.location.hash)){
|
||
window.history.pushState({session_id:sid},'',next);
|
||
}
|
||
}
|
||
|
||
// ── Batch select mode ──
|
||
function toggleSessionSelectMode(){
|
||
_sessionSelectMode=!_sessionSelectMode;
|
||
_selectedSessions.clear();
|
||
renderSessionListFromCache();
|
||
}
|
||
function exitSessionSelectMode(){
|
||
_sessionSelectMode=false;
|
||
_selectedSessions.clear();
|
||
const bar=$('batchActionBar');
|
||
if(bar) bar.style.display='none';
|
||
renderSessionListFromCache();
|
||
}
|
||
function toggleSessionSelect(sid){
|
||
if(_selectedSessions.has(sid)) _selectedSessions.delete(sid);
|
||
else _selectedSessions.add(sid);
|
||
_updateBatchActionBar();
|
||
const cb=document.querySelector('.session-select-cb[data-sid="'+sid+'"]');
|
||
const item=cb?cb.closest('.session-item,.session-child-session-fork'):null;
|
||
if(item){item.classList.toggle('selected',_selectedSessions.has(sid));if(cb)cb.checked=_selectedSessions.has(sid);}
|
||
}
|
||
function setSessionSelected(sid, selected){
|
||
if(selected) _selectedSessions.add(sid);
|
||
else _selectedSessions.delete(sid);
|
||
_updateBatchActionBar();
|
||
const cb=document.querySelector('.session-select-cb[data-sid="'+sid+'"]');
|
||
const item=cb?cb.closest('.session-item,.session-child-session-fork'):null;
|
||
if(item){item.classList.toggle('selected',_selectedSessions.has(sid));if(cb)cb.checked=_selectedSessions.has(sid);}
|
||
}
|
||
function selectAllSessions(){
|
||
_selectedSessions.clear();
|
||
const ids=Array.isArray(_sessionVisibleSidebarIds)&&_sessionVisibleSidebarIds.length
|
||
? _sessionVisibleSidebarIds
|
||
: Array.from(document.querySelectorAll('.session-select-cb')).map(cb=>cb.dataset.sid).filter(Boolean);
|
||
ids.forEach(sid=>_selectedSessions.add(sid));
|
||
document.querySelectorAll('.session-select-cb').forEach(cb=>{
|
||
const sid=cb.dataset.sid;
|
||
if(sid){cb.checked=_selectedSessions.has(sid);const item=cb.closest('.session-item,.session-child-session-fork');if(item)item.classList.toggle('selected',_selectedSessions.has(sid));}
|
||
});
|
||
_updateBatchActionBar();
|
||
}
|
||
function deselectAllSessions(){
|
||
_selectedSessions.clear();
|
||
document.querySelectorAll('.session-select-cb').forEach(cb=>{cb.checked=false;const item=cb.closest('.session-item,.session-child-session-fork');if(item)item.classList.remove('selected');});
|
||
_updateBatchActionBar();
|
||
}
|
||
function _updateBatchActionBar(){
|
||
const bar=$('batchActionBar');if(!bar)return;
|
||
const count=_selectedSessions.size;
|
||
if(count>0){_renderBatchActionBar();}
|
||
else{bar.style.display='none';}
|
||
}
|
||
function _renderBatchActionBar(){
|
||
const bar=$('batchActionBar');if(!bar)return;
|
||
bar.innerHTML='';bar.style.display=_selectedSessions.size>0?'flex':'none';
|
||
const countBadge=document.createElement('span');countBadge.className='batch-count';
|
||
countBadge.textContent=t('session_selected_count',_selectedSessions.size);bar.appendChild(countBadge);
|
||
// Archive
|
||
const archiveBtn=document.createElement('button');archiveBtn.className='batch-action-btn';
|
||
archiveBtn.textContent=t('session_batch_archive');
|
||
archiveBtn.onclick=async()=>{
|
||
const ids=[..._selectedSessions];
|
||
const wtCount=_worktreeSessionCount(ids);
|
||
const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)]));
|
||
const ok=await showConfirmDialog({
|
||
message:wtCount?t('session_batch_archive_worktree_confirm',ids.length,wtCount):t('session_batch_archive_confirm',ids.length),
|
||
confirmLabel:t('session_batch_archive'),
|
||
danger:true
|
||
});
|
||
if(!ok)return;
|
||
try{
|
||
const results=await Promise.all(ids.map(async sid=>{
|
||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:sid,archived:true})});
|
||
return {response,session:sessionsById.get(sid)||null};
|
||
}));
|
||
const retainedCount=_worktreeResponseCount(results);
|
||
showToast(retainedCount?t('session_archived_worktree'):t('session_archived'));exitSessionSelectMode();await renderSessionList();
|
||
}catch(e){showToast('Archive failed: '+(e.message||e));}
|
||
};bar.appendChild(archiveBtn);
|
||
// Move
|
||
const moveBtn=document.createElement('button');moveBtn.className='batch-action-btn';
|
||
moveBtn.textContent=t('session_batch_move');
|
||
moveBtn.onclick=(e)=>{e.stopPropagation();_showBatchProjectPicker();};bar.appendChild(moveBtn);
|
||
// Delete
|
||
const deleteBtn=document.createElement('button');deleteBtn.className='batch-action-btn batch-action-btn-danger';
|
||
deleteBtn.textContent=t('session_batch_delete');
|
||
deleteBtn.onclick=async()=>{
|
||
const ids=[..._selectedSessions];
|
||
const wtCount=_worktreeSessionCount(ids);
|
||
const sessionsById=new Map(ids.map(sid=>[sid,_sessionSnapshotById(sid)]));
|
||
const ok=await showConfirmDialog({
|
||
message:wtCount?t('session_batch_delete_worktree_confirm',ids.length,wtCount):t('session_batch_delete_confirm',ids.length),
|
||
confirmLabel:t('delete_title'),
|
||
danger:true
|
||
});
|
||
if(!ok)return;
|
||
try{
|
||
const results=await Promise.all(ids.map(async sid=>{
|
||
const response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||
return {response,session:sessionsById.get(sid)||null};
|
||
}));
|
||
const retainedCount=_worktreeResponseCount(results);
|
||
const cleanupFailedCount=results.filter(result=>result.response&&result.response.state_db_cleanup_failed).length;
|
||
ids.forEach(_clearHandoffStorageForSession);
|
||
if(S.session&&ids.includes(S.session.session_id)){
|
||
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
|
||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null);
|
||
const remaining=await api('/api/sessions'+_sessionListQueryString());
|
||
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
|
||
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
|
||
}
|
||
if(cleanupFailedCount) showToast(t('delete_failed')+' ('+cleanupFailedCount+'/'+ids.length+')',0,'error');
|
||
else showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');
|
||
exitSessionSelectMode();await renderSessionList();
|
||
}catch(e){showToast('Delete failed: '+(e.message||e));}
|
||
};bar.appendChild(deleteBtn);
|
||
}
|
||
function _showBatchProjectPicker(){
|
||
const ids=[..._selectedSessions];if(!ids.length)return;
|
||
const bar=$('batchActionBar');if(!bar)return;
|
||
bar.querySelectorAll('.batch-project-picker').forEach(p=>p.remove());
|
||
const picker=document.createElement('div');picker.className='project-picker batch-project-picker';
|
||
const none=document.createElement('div');none.className='project-picker-item';none.textContent='No project';
|
||
none.onclick=async()=>{picker.remove();
|
||
try{await Promise.all(ids.map(sid=>api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:null})})));
|
||
showToast('Removed from project');exitSessionSelectMode();await renderSessionList();
|
||
}catch(e){showToast('Move failed: '+(e.message||e));}
|
||
};picker.appendChild(none);
|
||
for(const p of(_allProjects||[])){
|
||
const item=document.createElement('div');item.className='project-picker-item';
|
||
if(p.color){const dot=document.createElement('span');dot.className='color-dot';
|
||
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';item.appendChild(dot);}
|
||
const name=document.createElement('span');name.textContent=p.name;item.appendChild(name);
|
||
item.onclick=async()=>{picker.remove();
|
||
try{await Promise.all(ids.map(sid=>api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:sid,project_id:p.project_id})})));
|
||
showToast('Moved to '+p.name);exitSessionSelectMode();await renderSessionList();
|
||
}catch(e){showToast('Move failed: '+(e.message||e));}
|
||
};picker.appendChild(item);
|
||
}
|
||
bar.appendChild(picker);
|
||
const close=(e)=>{if(!picker.contains(e.target)){picker.remove();document.removeEventListener('click',close);}};
|
||
setTimeout(()=>document.addEventListener('click',close),0);
|
||
}
|
||
|
||
function _focusSessionActionMenuRestoreTarget(target){
|
||
if(!target||!target.isConnected||typeof target.focus!=='function') return false;
|
||
try{target.focus({preventScroll:true});}catch(_){target.focus();}
|
||
return document.activeElement===target;
|
||
}
|
||
|
||
function closeSessionActionMenu({restoreFocus=false}={}){
|
||
const focusTarget=restoreFocus?_sessionActionAnchor:null;
|
||
const fallbackFocusTarget=restoreFocus?_sessionActionPreviousFocus:null;
|
||
if(_sessionActionMenu){
|
||
_sessionActionMenu.remove();
|
||
_sessionActionMenu = null;
|
||
}
|
||
if(_sessionActionAnchor){
|
||
if(_sessionActionAnchor.classList&&_sessionActionAnchor.classList.contains('session-actions-trigger')){
|
||
_sessionActionAnchor.classList.remove('active');
|
||
_sessionActionAnchor.setAttribute('aria-expanded','false');
|
||
_sessionActionAnchor.removeAttribute('aria-controls');
|
||
}
|
||
const row=_sessionActionAnchor.closest('.session-item,.session-child-session');
|
||
if(row) row.classList.remove('menu-open','long-pressing');
|
||
_sessionActionAnchor = null;
|
||
}
|
||
_sessionActionSessionId = null;
|
||
_sessionActionPreviousFocus = null;
|
||
if(!_focusSessionActionMenuRestoreTarget(focusTarget)) _focusSessionActionMenuRestoreTarget(fallbackFocusTarget);
|
||
}
|
||
|
||
function _sessionActionMenuShouldIgnoreScrollTarget(target){
|
||
if(!target || typeof target.closest !== 'function') return false;
|
||
// #5347: active-chat auto-scroll / manual wheel must not dismiss the sidebar menu.
|
||
return Boolean(target.closest('#messages, #msgInner, .messages-inner'));
|
||
}
|
||
|
||
function _sessionActionMenuShouldRepositionOnScroll(target){
|
||
if(!target || typeof target.closest !== 'function') return false;
|
||
return Boolean(target.closest('#sessionList, .session-list'));
|
||
}
|
||
|
||
function _positionSessionActionMenu(anchorEl){
|
||
if(!_sessionActionMenu || !anchorEl) return;
|
||
const rect=anchorEl.getBoundingClientRect();
|
||
const menuW=Math.min(280, Math.max(220, _sessionActionMenu.scrollWidth || 220));
|
||
let left=rect.right-menuW;
|
||
if(left<8) left=8;
|
||
if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
|
||
_sessionActionMenu.style.left=left+'px';
|
||
_sessionActionMenu.style.top='8px';
|
||
// Reset any prior clamp so we measure the menu's natural height.
|
||
_sessionActionMenu.style.maxHeight='';
|
||
const menuH=_sessionActionMenu.offsetHeight || 0;
|
||
const margin=8;
|
||
const maxAvail=window.innerHeight-margin*2;
|
||
let top=rect.bottom+6;
|
||
// Prefer flipping above the row when the menu would overflow the bottom and
|
||
// there's room above.
|
||
if(top+menuH>window.innerHeight-margin && rect.top>menuH+12){
|
||
top=rect.top-menuH-6;
|
||
}
|
||
// If the menu is taller than the viewport, or still overflows after the flip
|
||
// attempt (e.g. a top-anchored row with a tall menu and no room above), cap
|
||
// its height to the viewport and let it scroll instead of clipping off-screen.
|
||
if(menuH>maxAvail){
|
||
_sessionActionMenu.style.maxHeight=maxAvail+'px';
|
||
top=margin;
|
||
} else {
|
||
// Clamp vertically so the whole menu stays on-screen at both edges.
|
||
if(top+menuH>window.innerHeight-margin) top=window.innerHeight-margin-menuH;
|
||
if(top<margin) top=margin;
|
||
}
|
||
_sessionActionMenu.style.top=top+'px';
|
||
}
|
||
|
||
function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
|
||
const opt=document.createElement('button');
|
||
opt.type='button';
|
||
opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
|
||
opt.setAttribute('role','menuitem');
|
||
// Compact context-menu shape (#3223 redesign, Nathan 2026-06-01): show only
|
||
// icon + label, matching VS Code / browser / ChatGPT conversation menus. The
|
||
// descriptive `meta` is preserved as a hover tooltip (title=) so the
|
||
// information stays discoverable without consuming permanent vertical space —
|
||
// this also keeps the menu short enough to avoid viewport clipping.
|
||
if(meta) opt.title=meta;
|
||
opt.innerHTML=
|
||
`<span class="ws-opt-action">`
|
||
+ `<span class="ws-opt-icon">${icon}</span>`
|
||
+ `<span class="session-action-copy">`
|
||
+ `<span class="ws-opt-name">${esc(label)}</span>`
|
||
+ `</span>`
|
||
+ `</span>`;
|
||
opt.onclick=async(e)=>{
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
await onSelect();
|
||
};
|
||
return opt;
|
||
}
|
||
|
||
function _sessionMarkdownLabel(session){
|
||
const sid=session&&session.session_id?String(session.session_id):'';
|
||
const title=String((session&&(session.title||session.name))||'Conversation').replace(/\s+/g,' ').trim()||'Conversation';
|
||
const shortSid=sid?sid.slice(0,12):'';
|
||
const label=shortSid?`${title} (${shortSid})`:title;
|
||
return label.replace(/([\\\[\]])/g,'\\$1').slice(0,120);
|
||
}
|
||
|
||
function _sessionMarkdownUrlSid(sid){
|
||
return encodeURIComponent(String(sid||'')).replace(/[()]/g, ch => ch==='('?'%28':'%29');
|
||
}
|
||
|
||
function _sessionInternalReferenceForSession(session){
|
||
const sid=session&&session.session_id;
|
||
if(!sid) return '';
|
||
return `[${_sessionMarkdownLabel(session)}](session://${_sessionMarkdownUrlSid(sid)})`;
|
||
}
|
||
|
||
async function _copyTextToClipboard(text){
|
||
if(navigator&&navigator.clipboard&&typeof navigator.clipboard.writeText==='function'){
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
}
|
||
const ta=document.createElement('textarea');
|
||
ta.value=text;
|
||
ta.setAttribute('readonly','');
|
||
ta.style.position='fixed';
|
||
ta.style.left='-9999px';
|
||
ta.style.top='0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try{return document.execCommand('copy');}
|
||
finally{ta.remove();}
|
||
}
|
||
|
||
async function _copySessionLink(session){
|
||
const sid=session&&session.session_id;
|
||
if(!sid) return;
|
||
const ref=(window.location.origin||'')+_sessionUrlForSid(sid);
|
||
try{
|
||
await _copyTextToClipboard(ref);
|
||
showToast(t('session_link_copied'));
|
||
}catch(err){
|
||
showToast(t('session_link_copy_failed')+(err&&err.message?err.message:err));
|
||
}
|
||
}
|
||
|
||
function _mountSessionActionMenu(menu, session, anchorEl){
|
||
_sessionActionPreviousFocus=document.activeElement;
|
||
document.body.appendChild(menu);
|
||
_sessionActionMenu = menu;
|
||
_sessionActionAnchor = anchorEl;
|
||
_sessionActionSessionId = session.session_id;
|
||
if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')){
|
||
anchorEl.classList.add('active');
|
||
anchorEl.setAttribute('aria-expanded','true');
|
||
anchorEl.setAttribute('aria-controls',menu.id);
|
||
}
|
||
const row=anchorEl.closest('.session-item,.session-child-session');
|
||
if(row) row.classList.add('menu-open');
|
||
_positionSessionActionMenu(anchorEl);
|
||
_playSessionActionMenuEntrance(menu);
|
||
const menuItems=()=>Array.from(menu.querySelectorAll('.session-action-opt:not([disabled])'));
|
||
menu.addEventListener('keydown',e=>{
|
||
const items=menuItems();
|
||
if(e.key==='Escape'){
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
closeSessionActionMenu({restoreFocus:true});
|
||
return;
|
||
}
|
||
if(!items.length) return;
|
||
const currentIndex=Math.max(0,items.indexOf(document.activeElement));
|
||
let nextIndex=null;
|
||
if(e.key==='ArrowDown') nextIndex=(currentIndex+1)%items.length;
|
||
else if(e.key==='ArrowUp') nextIndex=(currentIndex-1+items.length)%items.length;
|
||
else if(e.key==='Home') nextIndex=0;
|
||
else if(e.key==='End') nextIndex=items.length-1;
|
||
if(nextIndex===null) return;
|
||
e.preventDefault();
|
||
try{items[nextIndex].focus({preventScroll:true});}catch(_){items[nextIndex].focus();}
|
||
});
|
||
const firstAction=menuItems()[0];
|
||
if(firstAction){
|
||
try{firstAction.focus({preventScroll:true});}catch(_){firstAction.focus();}
|
||
}
|
||
}
|
||
|
||
function _findSessionRenameRow(sessionId){
|
||
const sid=String(sessionId||'');
|
||
if(!sid) return null;
|
||
return document.querySelector('.session-item[data-sid="'+sid+'"], .session-child-session[data-sid="'+sid+'"]');
|
||
}
|
||
|
||
function _buildSessionRenameStarter(session, displayEl, renderDisplay){
|
||
return ()=>{
|
||
if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be renamed.',3000); return; }
|
||
if(_loadingSessionId&&_loadingSessionId!==session.session_id) return;
|
||
|
||
closeSessionActionMenu();
|
||
_renamingSid=session.session_id;
|
||
const oldTitle=_sessionDisplayTitle(session)||'Untitled';
|
||
const inp=document.createElement('input');
|
||
inp.className='session-title-input';
|
||
inp.value=oldTitle;
|
||
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
|
||
inp.addEventListener(ev, e2=>e2.stopPropagation())
|
||
);
|
||
const applyLocalTitle=(target, nextTitle)=>{
|
||
if(!target) return;
|
||
target.title=nextTitle;
|
||
target.display_title=nextTitle;
|
||
target._state_db_title=nextTitle;
|
||
};
|
||
const applyTitle=(nextTitle, updateDom=true)=>{
|
||
applyLocalTitle(session, nextTitle);
|
||
const cached=_allSessions.find(item=>item&&item.session_id===session.session_id);
|
||
applyLocalTitle(cached, nextTitle);
|
||
if(S.session&&S.session.session_id===session.session_id){applyLocalTitle(S.session, nextTitle);syncTopbar();}
|
||
if(updateDom) renderDisplay(_sessionDisplayTitle(session), session);
|
||
};
|
||
let finishDone=false;
|
||
const finish=async(save)=>{
|
||
if(finishDone) return;
|
||
finishDone=true;
|
||
const releaseRename=()=>{
|
||
_renamingSid=null;
|
||
if(inp.isConnected) inp.replaceWith(displayEl);
|
||
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
|
||
};
|
||
if(!save){
|
||
applyTitle(oldTitle,false);
|
||
releaseRename();
|
||
return;
|
||
}
|
||
const newTitle=inp.value.trim()||'Untitled';
|
||
try{
|
||
if(newTitle!==oldTitle){
|
||
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:session.session_id,title:newTitle})});
|
||
}
|
||
applyTitle(newTitle);
|
||
}catch(err){
|
||
applyTitle(oldTitle,false);
|
||
const msg='Rename failed: '+(err&&err.message?err.message:String(err));
|
||
setStatus(msg);
|
||
if(typeof showToast==='function') showToast(msg,3000,'error');
|
||
}finally{
|
||
releaseRename();
|
||
}
|
||
};
|
||
inp.onkeydown=e2=>{
|
||
if(e2.key==='Enter'){
|
||
if(window._isImeEnter&&window._isImeEnter(e2)){return;}
|
||
e2.preventDefault();
|
||
e2.stopPropagation();
|
||
finish(true);
|
||
}
|
||
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
|
||
};
|
||
inp.onblur=()=>{ if(_renamingSid===session.session_id) finish(true); };
|
||
displayEl.replaceWith(inp);
|
||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||
};
|
||
}
|
||
|
||
function _appendSessionCopyLinkAction(menu, session){
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_copy_link'),
|
||
t('session_copy_link_desc'),
|
||
ICONS.link,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
await _copySessionLink(session);
|
||
}
|
||
));
|
||
}
|
||
|
||
function _sessionPublicShareUrl(session){
|
||
const token=session&&session.share_token?String(session.share_token).trim():'';
|
||
if(!token) return '';
|
||
return new URL(`/share/${encodeURIComponent(token)}`,location.origin).href;
|
||
}
|
||
|
||
function _syncSessionShareState(session, nextSession){
|
||
if(!session||!nextSession) return;
|
||
session.share_token=nextSession.share_token||null;
|
||
session.share_created_at=nextSession.share_created_at||null;
|
||
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
|
||
if(cached){
|
||
cached.share_token=session.share_token;
|
||
cached.share_created_at=session.share_created_at;
|
||
}
|
||
if(S.session&&S.session.session_id===session.session_id){
|
||
S.session.share_token=session.share_token;
|
||
S.session.share_created_at=session.share_created_at;
|
||
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
||
}
|
||
renderSessionListFromCache();
|
||
void renderSessionList();
|
||
}
|
||
|
||
async function _createOrRefreshSessionShare(session){
|
||
if(!session||!session.session_id) return;
|
||
const existing=_sessionPublicShareUrl(session);
|
||
if(existing){
|
||
const reuse=await showConfirmDialog({
|
||
title:t('share_session'),
|
||
message:t('share_session_existing_confirm'),
|
||
confirmLabel:t('share_session_copy_existing'),
|
||
cancelLabel:t('share_session_refresh_snapshot'),
|
||
});
|
||
if(reuse){
|
||
let copied=true;
|
||
try{ await _copyTextToClipboard(existing); }catch(_){ copied=false; }
|
||
showToast(copied?t('share_session_link_copied'):(t('share_session_status_active')+' — '+existing),copied?2500:6000);
|
||
window.open(existing,'_blank','noopener');
|
||
return;
|
||
}
|
||
}
|
||
const res=await api('/api/share/create',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
|
||
if(res&&res.session) _syncSessionShareState(session,res.session);
|
||
const href=new URL(String(res&&res.share&&res.share.url||''),location.origin).href;
|
||
// The share is now created server-side. A clipboard-copy failure (permissions,
|
||
// focus, non-secure context) must NOT be reported as "Share failed" — the link
|
||
// exists and we still open it. Only surface the copied-vs-not-copied distinction.
|
||
let copied=true;
|
||
try{ await _copyTextToClipboard(href); }catch(_){ copied=false; }
|
||
if(copied){
|
||
showToast(existing?t('share_session_link_copied'):t('share_session_created'));
|
||
}else{
|
||
showToast((existing?t('share_session_created'):t('share_session_created'))+' — '+href,6000);
|
||
}
|
||
window.open(href,'_blank','noopener');
|
||
}
|
||
|
||
async function _revokeSessionShare(session){
|
||
if(!session||!session.session_id||!session.share_token) return;
|
||
const ok=await showConfirmDialog({
|
||
title:t('stop_sharing_session'),
|
||
message:t('stop_sharing_session_confirm'),
|
||
confirmLabel:t('stop_sharing_session'),
|
||
danger:true,
|
||
});
|
||
if(!ok) return;
|
||
const res=await api('/api/share/revoke',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
|
||
if(res&&res.session) _syncSessionShareState(session,res.session);
|
||
showToast(t('share_session_revoked'));
|
||
}
|
||
|
||
function _appendSessionShareActions(menu, session){
|
||
const hasMessages=Number(session&&session.message_count||0)>0;
|
||
if(!hasMessages) return;
|
||
menu.appendChild(_buildSessionAction(
|
||
t('share_session'),
|
||
session&&session.share_token?t('share_session_status_active'):t('share_session_tooltip'),
|
||
ICONS.link,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
try{
|
||
await _createOrRefreshSessionShare(session);
|
||
}catch(err){
|
||
showToast(t('share_session_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
|
||
}
|
||
},
|
||
session&&session.share_token?'is-active':''
|
||
));
|
||
if(!(session&&session.share_token)) return;
|
||
menu.appendChild(_buildSessionAction(
|
||
t('share_session_copy_existing'),
|
||
t('share_session_tooltip'),
|
||
ICONS.link,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
try{
|
||
const href=_sessionPublicShareUrl(session);
|
||
if(!href) return;
|
||
await _copyTextToClipboard(href);
|
||
showToast(t('share_session_link_copied'));
|
||
}catch(err){
|
||
showToast(t('share_session_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
|
||
}
|
||
}
|
||
));
|
||
menu.appendChild(_buildSessionAction(
|
||
t('stop_sharing_session'),
|
||
t('stop_sharing_session_tooltip'),
|
||
ICONS.trash,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
try{
|
||
await _revokeSessionShare(session);
|
||
}catch(err){
|
||
showToast(t('share_session_revoke_failed')+(err&&err.message?err.message:String(err||'')),4000,'error');
|
||
}
|
||
},
|
||
'danger'
|
||
));
|
||
}
|
||
|
||
function _appendSessionDuplicateAction(menu, session){
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_duplicate'),
|
||
t('session_duplicate_desc'),
|
||
ICONS.dup,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
try{
|
||
const res=await api('/api/session/duplicate',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
|
||
if(res.session){
|
||
await loadSession(res.session.session_id);
|
||
await renderSessionList();
|
||
showToast(t('session_duplicated'));
|
||
}
|
||
}catch(err){showToast(t('session_duplicate_failed')+err.message);}
|
||
}
|
||
));
|
||
}
|
||
|
||
function _appendSessionExportHtmlAction(menu, session){
|
||
// Per-conversation "Export as HTML" — the sidebar ⋮ menu is the app's uniform
|
||
// home for per-conversation actions (matches ChatGPT / Open WebUI). Operates
|
||
// on THIS row's session, not just the active one; the export endpoint accepts
|
||
// any session_id in the active profile and is non-mutating, so it's offered
|
||
// for read-only/imported sessions too. exportSessionHTML(session) is a global
|
||
// defined in boot.js (loaded after sessions.js under defer, so it's bound by
|
||
// the time this click can fire).
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_export_html'),
|
||
t('session_export_html_desc'),
|
||
ICONS.download,
|
||
()=>{
|
||
closeSessionActionMenu();
|
||
if(typeof exportSessionHTML==='function') exportSessionHTML(session);
|
||
}
|
||
));
|
||
}
|
||
|
||
function _playSessionActionMenuEntrance(menu){
|
||
if(!menu) return;
|
||
const reduce=_sessionPrefersReducedMotion();
|
||
if(reduce) return;
|
||
if(typeof menu.animate==='function'){
|
||
try{
|
||
const anim=menu.animate(
|
||
[
|
||
{opacity:0, transform:'translate3d(0,-4px,0) scale(.985)'},
|
||
{opacity:1, transform:'translate3d(0,0,0) scale(1)'}
|
||
],
|
||
{duration:450, easing:'cubic-bezier(.2,.8,.2,1)'}
|
||
);
|
||
if(anim&&anim.finished) anim.finished.catch(()=>{});
|
||
return;
|
||
}catch(_){}
|
||
}
|
||
menu.classList.add('open-animated');
|
||
}
|
||
|
||
async function _archiveSession(session, archived=true, beforeListRender=null){
|
||
if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return false; }
|
||
const reflowPositions=_captureSessionReflowPositions();
|
||
const renderHold=beforeListRender?Promise.resolve().then(beforeListRender):null;
|
||
try{
|
||
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived})});
|
||
session.archived=archived;
|
||
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
|
||
if(cached) cached.archived=archived;
|
||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived;
|
||
try{ if(archived&&session.session_id&&localStorage.getItem('hermes-webui-session')===session.session_id) localStorage.removeItem('hermes-webui-session'); }catch(_){ }
|
||
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
|
||
if(renderHold) await renderHold;
|
||
if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px');
|
||
_pendingSessionReflowPositions=reflowPositions;
|
||
renderSessionListFromCache();
|
||
void renderSessionList();
|
||
return true;
|
||
}catch(err){if(renderHold) await renderHold.catch(()=>{});_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;}
|
||
}
|
||
|
||
function _openSessionActionMenu(session, anchorEl){
|
||
const isReadOnly = _isReadOnlySession(session);
|
||
if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){
|
||
closeSessionActionMenu();
|
||
return;
|
||
}
|
||
closeSessionActionMenu();
|
||
const isMessagingSession = _isMessagingSession(session);
|
||
const isCliSession = _isCliSession(session);
|
||
const isExternalSession = isMessagingSession || isCliSession;
|
||
const menu=document.createElement('div');
|
||
menu.className='session-action-menu';
|
||
menu.id='sessionActionMenu-'+(++_sessionActionMenuId);
|
||
menu.setAttribute('role','menu');
|
||
menu.setAttribute('aria-label', 'Conversation actions');
|
||
_appendSessionCopyLinkAction(menu, session);
|
||
if(isReadOnly){
|
||
_appendSessionExportHtmlAction(menu, session);
|
||
_mountSessionActionMenu(menu, session, anchorEl);
|
||
return;
|
||
}
|
||
// Rename — first menu item by request (#1764). Double-click rename is
|
||
// timing-sensitive: the first click frequently registers as "open the
|
||
// chat" before the second click arrives, so users open the conversation
|
||
// when they meant to rename it. Putting Rename in the menu eliminates
|
||
// the timing entirely. Only shown for sessions that support rename
|
||
// (read-only imported sessions skip it; same gate as startRename's
|
||
// _isReadOnlySession check).
|
||
if(!_isReadOnlySession(session)){
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_rename'),
|
||
t('session_rename_desc'),
|
||
ICONS.edit,
|
||
()=>{
|
||
closeSessionActionMenu();
|
||
// Find the row for this session and call its attached startRename.
|
||
// Falls back to a no-op toast if the row isn't currently rendered
|
||
// (e.g. archived-and-hidden) — extremely rare since the menu only
|
||
// opens from a visible row's three-dot button.
|
||
const row=_findSessionRenameRow(session.session_id);
|
||
if(row && typeof row._startRename === 'function'){
|
||
row._startRename();
|
||
} else if(typeof showToast==='function'){
|
||
showToast(t('session_rename_failed_no_row')||'Could not start rename — row not found.', 3000, 'error');
|
||
}
|
||
}
|
||
));
|
||
}
|
||
_appendSessionShareActions(menu, session);
|
||
menu.appendChild(_buildSessionAction(
|
||
session.pinned?t('session_unpin'):t('session_pin'),
|
||
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
|
||
session.pinned?ICONS.pin:ICONS.unpin,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
const newPinned=!session.pinned;
|
||
try{
|
||
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
|
||
session.pinned=newPinned;
|
||
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
|
||
if(cached) cached.pinned=newPinned;
|
||
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
|
||
renderSessionListFromCache();
|
||
void renderSessionList();
|
||
}catch(err){
|
||
showToast(t('session_pin_failed')+err.message);
|
||
await renderSessionList();
|
||
}
|
||
},
|
||
session.pinned?'is-active':''
|
||
));
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_move_project'),
|
||
session.project_id?t('session_move_project_desc_has'):t('session_move_project_desc_none'),
|
||
ICONS.folder,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
_showProjectPicker(session, anchorEl);
|
||
}
|
||
));
|
||
menu.appendChild(_buildSessionAction(
|
||
session.archived?t('session_restore'):t('session_archive'),
|
||
session.archived?t('session_restore_desc'):_sessionArchiveDescription(session),
|
||
session.archived?ICONS.unarchive:ICONS.archive,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
await _archiveSession(session,!session.archived);
|
||
}
|
||
));
|
||
if(isExternalSession && !session.archived){
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_hide_external'),
|
||
t('session_hide_external_desc'),
|
||
ICONS.archive,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
try{
|
||
await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:true})});
|
||
_optimisticallyArchiveSessionInList(session.session_id,true);
|
||
session.archived=true;
|
||
if(S.session&&S.session.session_id===session.session_id) S.session.archived=true;
|
||
void renderSessionList();
|
||
showToast(t('session_hidden'));
|
||
}catch(err){showToast(t('session_archive_failed')+err.message);}
|
||
}
|
||
));
|
||
}
|
||
if(!isExternalSession){
|
||
_appendSessionDuplicateAction(menu, session);
|
||
}
|
||
_appendSessionExportHtmlAction(menu, session);
|
||
if(session.active_stream_id){
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_stop_response'),
|
||
t('session_stop_response_desc'),
|
||
ICONS.stop,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
if(await cancelSessionStream(session)) showToast(t('stream_stopped'));
|
||
else showToast(t('cancel_failed'));
|
||
}
|
||
));
|
||
}
|
||
// Title regeneration stays available for writable imported sessions.
|
||
// Read-only sessions return earlier through the shared action-menu guard.
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_title_regenerate'),
|
||
t('session_title_regenerate_desc'),
|
||
ICONS.spark,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
try{
|
||
if(typeof showToast==='function') showToast(t('session_title_regenerating'), 1600);
|
||
const requestOpts={method:'POST',body:JSON.stringify({session_id:session.session_id})};
|
||
const timeoutMs=await _manualTitleRegenerateTimeoutMs();
|
||
if(timeoutMs) requestOpts.timeoutMs=timeoutMs;
|
||
const response=await api('/api/session/title/regenerate',requestOpts);
|
||
const nextTitle=(response&&response.title)||(response&&response.session&&response.session.title)||'';
|
||
if(nextTitle){
|
||
session.title=nextTitle;
|
||
const cached=(_allSessions||[]).find(item=>item&&item.session_id===session.session_id);
|
||
if(cached) cached.title=nextTitle;
|
||
if(S.session&&S.session.session_id===session.session_id){S.session.title=nextTitle;syncTopbar();}
|
||
renderSessionListFromCache();
|
||
}
|
||
if(typeof showToast==='function') showToast(t('session_title_regenerated', nextTitle||t('untitled')), 2400);
|
||
}catch(err){
|
||
const msg=t('session_title_regenerate_failed')+(err&&err.message?err.message:String(err));
|
||
setStatus(msg);
|
||
if(typeof showToast==='function') showToast(msg,3000,'error');
|
||
}
|
||
}
|
||
));
|
||
if(!isExternalSession){
|
||
if(session.worktree_path){
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_worktree_remove'),
|
||
t('session_worktree_remove_desc', session.worktree_path),
|
||
ICONS.trash,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
await removeWorktree(session);
|
||
},
|
||
'danger'
|
||
));
|
||
}
|
||
menu.appendChild(_buildSessionAction(
|
||
t('session_delete'),
|
||
_sessionDeleteDescription(session),
|
||
ICONS.trash,
|
||
async()=>{
|
||
closeSessionActionMenu();
|
||
// Menu Delete has no swipe/removal animation to wait for. Pass an
|
||
// immediate beforeDelete hook so deleteSession() removes the sidebar row
|
||
// optimistically while slow backend cleanup (/api/session/delete,
|
||
// state.db/FTS/journal cleanup) continues.
|
||
await deleteSession(session.session_id,()=>Promise.resolve());
|
||
},
|
||
'danger'
|
||
));
|
||
}
|
||
_mountSessionActionMenu(menu, session, anchorEl);
|
||
}
|
||
|
||
document.addEventListener('click',e=>{
|
||
if(!_sessionActionMenu) return;
|
||
if(_sessionActionMenu.contains(e.target)) return;
|
||
if(_sessionActionAnchor && _sessionActionAnchor.contains(e.target)) return;
|
||
closeSessionActionMenu();
|
||
});
|
||
document.addEventListener('scroll',e=>{
|
||
if(!_sessionActionMenu) return;
|
||
if(_sessionActionMenu.contains(e.target)) return;
|
||
if(_sessionActionMenuShouldIgnoreScrollTarget(e.target)) return;
|
||
if(_sessionActionMenuShouldRepositionOnScroll(e.target) && _sessionActionAnchor){
|
||
if(!_sessionActionAnchor.isConnected){
|
||
closeSessionActionMenu();
|
||
return;
|
||
}
|
||
_positionSessionActionMenu(_sessionActionAnchor);
|
||
return;
|
||
}
|
||
closeSessionActionMenu();
|
||
}, true);
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu({restoreFocus:true});
|
||
});
|
||
window.addEventListener('resize',()=>{
|
||
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
|
||
});
|
||
|
||
// Generation counter to discard stale API responses (issue #1430).
|
||
// Multiple callers (message send, rename, session switch) fire renderSessionList()
|
||
// concurrently. Without this guard, a slower older response can overwrite _allSessions
|
||
// with stale data, causing sessions to vanish from the sidebar.
|
||
let _renderSessionListGen = 0;
|
||
let _renderSessionListInFlight = null;
|
||
let _renderSessionListQueuedRequest = null;
|
||
let _sessionListRefreshAnimationPending = false;
|
||
let _sessionListFirstRenderAnimated = false;
|
||
let _sessionListEnterAllAnimationPending = false;
|
||
|
||
// #4671: invalidate any session-list render that is in flight or queued. Called at
|
||
// profile-switch start (with showSessionListSkeleton) so a pre-switch /api/sessions
|
||
// response — which carries the OLD profile's rows but was issued before the switch
|
||
// bumped the generation, so it would otherwise pass the _renderSessionListGen guard,
|
||
// clear the skeleton flag, and paint stale rows over the skeleton — is discarded.
|
||
// Bumping the generation makes every outstanding response stale; clearing the
|
||
// pending/queued payloads drops a deferred apply that would do the same.
|
||
function _invalidateSessionListRenders(){
|
||
_renderSessionListGen++;
|
||
_pendingSessionListPayload = null;
|
||
_renderSessionListQueuedRequest = null;
|
||
// A retry whose fetch is invalidated here (e.g. a profile switch mid-retry)
|
||
// would otherwise leave the error note stuck as an inert "Retrying…" button
|
||
// with no request in flight — the stale fetch returns before
|
||
// _showSessionListLoadError and the .finally() bails when the old button was
|
||
// removed. Clear the pending retry markers so the next repaint shows an
|
||
// actionable idle Retry again.
|
||
if(_sessionListLoadError && (_sessionListLoadError.retrying || _sessionListLoadError._retryFailedFocus)){
|
||
_sessionListLoadError = {..._sessionListLoadError};
|
||
delete _sessionListLoadError.retrying;
|
||
delete _sessionListLoadError._retryFailedFocus;
|
||
}
|
||
}
|
||
if(typeof window!=='undefined') window._invalidateSessionListRenders = _invalidateSessionListRenders;
|
||
|
||
// #4671: profile-switch session-list EMBARGO. Point-in-time invalidation isn't enough —
|
||
// a renderSessionList() can START after the skeleton is shown but BEFORE /api/profile/switch
|
||
// returns (the profile cookie is only set by the switch response), so that GET fetches the
|
||
// OLD profile's rows, passes the generation guard, and clobbers the skeleton. While the
|
||
// embargo is on, _runRenderSessionListRefresh drops ALL payloads (none may paint), so only
|
||
// the switch-owned render — which runs after the switch clears the embargo — replaces the
|
||
// skeleton. The switch sets it before showSessionListSkeleton() and clears it immediately
|
||
// before its own renderSessionList() (and in the failure-restore path).
|
||
let _profileSwitchListEmbargo = false;
|
||
function _setProfileSwitchListEmbargo(on){ _profileSwitchListEmbargo = !!on; }
|
||
if(typeof window!=='undefined') window._setProfileSwitchListEmbargo = _setProfileSwitchListEmbargo;
|
||
|
||
function animateNextSessionListRefresh(options={}){
|
||
_sessionListRefreshAnimationPending = true;
|
||
if(options&&options.enterAll) _sessionListEnterAllAnimationPending = true;
|
||
}
|
||
|
||
// ── Loading skeletons (#4662 Phase 1) ───────────────────────────────────────
|
||
// Tracks whether the session list is currently showing a skeleton so a
|
||
// resolving render knows to replace it (and so we don't stack skeletons).
|
||
let _sessionListSkeletonActive = false;
|
||
|
||
// Skeleton structure mirrors a real sidebar: a couple of group headers
|
||
// (Pinned / Today / Last week) with single-line rows under each. Title widths
|
||
// vary so it reads as real conversations. `stamp:false` omits the timestamp bar
|
||
// on the occasional row (a real list mixes rows with/without a visible time).
|
||
const _SESSION_SKELETON_GROUPS = [
|
||
{rows: [{title: 70}]},
|
||
{rows: [{title: 84}, {title: 58}, {title: 76}]},
|
||
{rows: [{title: 64}, {title: 90}, {title: 52}, {title: 72}]},
|
||
];
|
||
|
||
// Render a skeleton placeholder into #sessionList that mirrors the real row
|
||
// anatomy (group labels + single-line title bars with a short timestamp bar).
|
||
// Called the instant a profile switch begins so the user never sees the
|
||
// previous profile's conversations.
|
||
function showSessionListSkeleton(targetProfile){
|
||
const list = $('sessionList');
|
||
if(!list) return;
|
||
// Tear down any active virtual-scroll state up front so a pending scroll-driven
|
||
// render can't repaint the previous profile's cached rows over the skeleton
|
||
// (#4662 Codex gate). Cancel the queued RAF and drop the data-session-virtual-*
|
||
// window markers; the real render rebuilds them from the new payload. Done once
|
||
// here so it applies to BOTH the content and empty-state skeleton branches.
|
||
if(typeof _sessionVirtualScrollRaf!=='undefined'&&_sessionVirtualScrollRaf){
|
||
cancelAnimationFrame(_sessionVirtualScrollRaf);
|
||
_sessionVirtualScrollRaf=0;
|
||
}
|
||
delete list.dataset.sessionVirtualTotal;
|
||
delete list.dataset.sessionVirtualStart;
|
||
delete list.dataset.sessionVirtualEnd;
|
||
delete list.dataset.sessionVirtualFilter;
|
||
delete list.dataset.sessionVirtualActiveAnchor;
|
||
// #4717: if we already know (from a prior render) the profile we're switching
|
||
// INTO has zero conversations, a full content skeleton (group labels + 8 rows)
|
||
// is misleading — it implies data that will never arrive, then resolves to an
|
||
// empty list. Render a quiet empty-state placeholder instead. Only when the
|
||
// count is KNOWN to be 0; an unknown profile (null) keeps the content skeleton
|
||
// (safe default — never hide a skeleton for a profile that may have sessions).
|
||
// Skip the empty branch while a project/source filter is active, since the
|
||
// per-profile count is an unfiltered total and could be non-zero overall yet
|
||
// empty under the filter (or vice-versa) — the content skeleton is the safe
|
||
// choice there. typeof guards keep this safe if the helper isn't in scope.
|
||
const knownCount = (typeof targetProfile === 'string' && targetProfile
|
||
&& typeof _knownSessionProfileCount === 'function')
|
||
? _knownSessionProfileCount(targetProfile) : null;
|
||
const filterActive = (typeof _activeProject !== 'undefined' && _activeProject)
|
||
|| (typeof _sessionSourceFilter !== 'undefined' && _sessionSourceFilter === 'cli');
|
||
const wrap = document.createElement('div');
|
||
wrap.setAttribute('aria-hidden', 'true');
|
||
if(knownCount === 0 && !filterActive){
|
||
// A single faint placeholder bar rather than a "no conversations" text — the
|
||
// real empty-state note paints the instant the (fast, empty) fetch resolves,
|
||
// so we just hold a calm, content-free space in the meantime (no flash of a
|
||
// fake list, no premature wording).
|
||
wrap.className = 'skeleton-list skeleton-list-empty';
|
||
const bar = document.createElement('div');
|
||
bar.className = 'skeleton-empty-hint';
|
||
wrap.appendChild(bar);
|
||
} else {
|
||
wrap.className = 'skeleton-list';
|
||
let rowIndex = 0;
|
||
for(const group of _SESSION_SKELETON_GROUPS){
|
||
const label = document.createElement('div');
|
||
label.className = 'skeleton-group-label';
|
||
wrap.appendChild(label);
|
||
for(const spec of group.rows){
|
||
const row = document.createElement('div');
|
||
row.className = 'skeleton-row';
|
||
// Stagger the fade-in per row. Set inline (not via CSS :nth-child) because
|
||
// group-label siblings are interleaved with rows, so a :nth-child stagger
|
||
// would skip most rows. Cap so the longest list doesn't feel laggy.
|
||
row.style.animationDelay = Math.min(rowIndex * 0.025, 0.2) + 's';
|
||
rowIndex++;
|
||
const title = document.createElement('div');
|
||
title.className = 'skeleton-bar skeleton-title';
|
||
title.style.width = spec.title + '%';
|
||
const stamp = document.createElement('div');
|
||
stamp.className = 'skeleton-bar skeleton-stamp';
|
||
row.appendChild(title);
|
||
row.appendChild(stamp);
|
||
wrap.appendChild(row);
|
||
}
|
||
}
|
||
}
|
||
list.innerHTML = '';
|
||
list.appendChild(wrap);
|
||
list.scrollTop = 0;
|
||
_sessionListSkeletonActive = true;
|
||
}
|
||
|
||
function _isOptimisticFirstTurnSessionRow(s){
|
||
if(!s||!s.session_id||s.archived) return false;
|
||
const messageCount=Number(s.message_count||0);
|
||
if(messageCount<=0&&!(s.pending_user_message||s.has_pending_user_message)) return false;
|
||
return Boolean(
|
||
s.is_streaming||
|
||
s.active_stream_id||
|
||
s.pending_user_message||
|
||
s.has_pending_user_message||
|
||
s.pending_started_at||
|
||
_isSessionLocallyStreaming(s)||
|
||
_sessionStreamingById.get(s.session_id)===true
|
||
);
|
||
}
|
||
|
||
function _shouldKeepLocalOnlyOptimisticSessionRow(local){
|
||
if(!_isOptimisticFirstTurnSessionRow(local)) return false;
|
||
const sid=local.session_id;
|
||
if(typeof _sendInProgress!=='undefined'&&_sendInProgress&&sid===_sendInProgressSid) return true;
|
||
const activeSid=S&&S.session&&S.session.session_id;
|
||
const isActive=Boolean(activeSid&&activeSid===sid);
|
||
const hasRuntimeConfirmation=Boolean(local.active_stream_id||local.pending_user_message||local.has_pending_user_message||local.pending_started_at);
|
||
if(isActive&&S.busy&&hasRuntimeConfirmation) return true;
|
||
const localTs=Number(local.last_message_at||local.updated_at||0);
|
||
const ageMs=localTs>0?Date.now()-(localTs*1000):Infinity;
|
||
return Boolean(isActive&&S.busy&&ageMs>=0&&ageMs<5000);
|
||
}
|
||
|
||
function _dropStaleOptimisticSessionRow(sid){
|
||
if(!sid) return;
|
||
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
|
||
if(INFLIGHT&&INFLIGHT[sid]){
|
||
delete INFLIGHT[sid];
|
||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||
}
|
||
if(typeof _sessionStreamingById!=='undefined'&&_sessionStreamingById&&typeof _sessionStreamingById.set==='function'){
|
||
_sessionStreamingById.set(sid,false);
|
||
}
|
||
if(typeof _forgetObservedStreamingSession==='function') _forgetObservedStreamingSession(sid);
|
||
}
|
||
|
||
function _mergeOptimisticFirstTurnSessions(fetchedSessions){
|
||
const merged=Array.isArray(fetchedSessions)?[...fetchedSessions]:[];
|
||
const bySid=new Map();
|
||
merged.forEach((s,idx)=>{if(s&&s.session_id) bySid.set(s.session_id,idx);});
|
||
for(const local of Array.isArray(_allSessions)?_allSessions:[]){
|
||
if(!_isOptimisticFirstTurnSessionRow(local)) continue;
|
||
const sid=local.session_id;
|
||
const idx=bySid.has(sid)?bySid.get(sid):-1;
|
||
if(idx>=0){
|
||
const fetched=merged[idx]||{};
|
||
const fetchedIsServerIdle=_isServerIdleSessionRow(fetched);
|
||
const keepLocalOptimistic=fetchedIsServerIdle?false:_shouldKeepLocalOnlyOptimisticSessionRow(local);
|
||
const localCount=Number(local.message_count||0);
|
||
const fetchedCount=Number(fetched.message_count||0);
|
||
const localTs=Number(local.last_message_at||local.updated_at||0);
|
||
const fetchedTs=Number(fetched.last_message_at||fetched.updated_at||0);
|
||
if(!keepLocalOptimistic&&typeof _dropStaleOptimisticSessionRow==='function') _dropStaleOptimisticSessionRow(sid);
|
||
merged[idx]={
|
||
...local,
|
||
...fetched,
|
||
title:keepLocalOptimistic?(local.title||fetched.title):fetched.title,
|
||
message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount,
|
||
last_message_at:keepLocalOptimistic?Math.max(localTs,fetchedTs):fetchedTs,
|
||
updated_at:keepLocalOptimistic?Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs):Number(fetched.updated_at||fetchedTs||0),
|
||
active_stream_id:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.active_stream_id||local.active_stream_id||null):null),
|
||
pending_user_message:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.pending_user_message||local.pending_user_message||null):null),
|
||
pending_started_at:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.pending_started_at||local.pending_started_at||null):null),
|
||
is_streaming:fetchedIsServerIdle?false:Boolean(fetched.is_streaming||(keepLocalOptimistic&&(local.is_streaming||_isSessionLocallyStreaming(local)))),
|
||
};
|
||
}else{
|
||
if(_shouldKeepLocalOnlyOptimisticSessionRow(local)){
|
||
merged.push({...local,is_streaming:true});
|
||
bySid.set(sid,merged.length-1);
|
||
}else{
|
||
_dropStaleOptimisticSessionRow(sid);
|
||
}
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function _isSessionListUserInteracting(){
|
||
const now=Date.now();
|
||
const list=$('sessionList');
|
||
const pointerOverList=Boolean(list&&(list.matches(':hover')||list.matches(':focus-within')));
|
||
return Boolean(
|
||
_sessionListPointerActive ||
|
||
pointerOverList ||
|
||
(_sessionListLastScrollAt && now-_sessionListLastScrollAt<SESSION_LIST_INTERACTION_IDLE_MS)
|
||
);
|
||
}
|
||
|
||
function _schedulePendingSessionListApply(){
|
||
if(_pendingSessionListApplyTimer) clearTimeout(_pendingSessionListApplyTimer);
|
||
_pendingSessionListApplyTimer=setTimeout(()=>{
|
||
_pendingSessionListApplyTimer=0;
|
||
if(!_pendingSessionListPayload) return;
|
||
if(_isSessionListUserInteracting()){
|
||
_schedulePendingSessionListApply();
|
||
return;
|
||
}
|
||
const payload=_pendingSessionListPayload;
|
||
_pendingSessionListPayload=null;
|
||
if(payload.gen!==_renderSessionListGen) return;
|
||
// Profile switch may have bumped unread gen after the list gen check
|
||
// window; still drop completion-marking for the stale pre-switch payload.
|
||
_applySessionListPayload(payload.sessData,payload.projData,{
|
||
unreadGen:payload.unreadGen,
|
||
});
|
||
}, Math.max(120, SESSION_LIST_INTERACTION_IDLE_MS));
|
||
}
|
||
|
||
|
||
function _sessionAttentionSoundSignature(s){
|
||
const attention=s&&s.attention&&typeof s.attention==='object'?s.attention:null;
|
||
const count=Number(attention&&attention.count);
|
||
if(!attention||!attention.kind||!Number.isFinite(count)||count<=0)return null;
|
||
const kind=String(attention.kind)==='approval'?'approval':(String(attention.kind)==='clarify'?'clarify':'attention');
|
||
return `${kind}:${Math.max(1,count||1)}`;
|
||
}
|
||
|
||
function _syncSessionAttentionSoundState(sessions){
|
||
const next=new Map();
|
||
for(const s of Array.isArray(sessions)?sessions:[]){
|
||
if(!s||!s.session_id)continue;
|
||
const sig=_sessionAttentionSoundSignature(s);
|
||
if(sig) next.set(s.session_id,sig);
|
||
}
|
||
if(!_sessionAttentionSoundPrimed){
|
||
_sessionAttentionSoundPrimed=true;
|
||
_sessionAttentionSoundState.clear();
|
||
next.forEach((sig,sid)=>_sessionAttentionSoundState.set(sid,sig));
|
||
return;
|
||
}
|
||
next.forEach((sig,sid)=>{
|
||
const prev=_sessionAttentionSoundState.get(sid);
|
||
if(prev!==sig){
|
||
const [kind,countRaw]=String(sig).split(':');
|
||
const count=Number(countRaw)||1;
|
||
const s=(Array.isArray(sessions)?sessions:[]).find(item=>item&&item.session_id===sid)||{session_id:sid};
|
||
const playKey=typeof _attentionSoundKey==='function'?_attentionSoundKey(s.session_id,kind,count):`${s.session_id}:${sig}`;
|
||
if(playKey&&typeof playAttentionSound==='function') playAttentionSound(playKey);
|
||
}
|
||
});
|
||
_sessionAttentionSoundState.clear();
|
||
next.forEach((sig,sid)=>_sessionAttentionSoundState.set(sid,sig));
|
||
}
|
||
|
||
// Signature of everything the sidebar render reads. Used to skip the full DOM
|
||
// rebuild when a poll returns data identical to what is already on screen (the
|
||
// common idle case). We serialize the FULL applied row objects (not a curated
|
||
// field subset) plus the reference/nesting rows and the coarse display state, so
|
||
// ANY server- or client-visible field the render helpers read (streaming/pending
|
||
// state, attention dots, source/read-only/worktree/lineage/child/model/profile
|
||
// meta, etc.) is covered — a narrow allowlist silently false-skips the moment a
|
||
// new rendered field is added (Codex #5467 gate: it omitted pending/running,
|
||
// attention, and the source/lineage cluster). A streaming/pending row's fields
|
||
// advance each poll so its signature changes and it still renders. Serialization
|
||
// failure returns null → never skip (fail-open). (#5455 WS2.4)
|
||
let _lastSessionListRenderSig = null;
|
||
function _sessionListRenderSignature(){
|
||
try{
|
||
const search=($('sessionSearch')&&$('sessionSearch').value)||'';
|
||
return JSON.stringify([
|
||
_allSessions,
|
||
_sidebarReferenceSessions,
|
||
_allProjects,
|
||
_activeSessionIdForSidebar(),
|
||
search,
|
||
_sessionSourceFilter,
|
||
!!_sessionSelectMode,
|
||
(window._sidebarDensity==='detailed'?'d':'c'),
|
||
!!_showAllProfiles,
|
||
_otherProfileCount,_archivedWebuiCount,_archivedCliCount,
|
||
_serverWebuiSessionCount,_serverCliSessionCount,
|
||
]);
|
||
}catch(_){ return null; }
|
||
}
|
||
function _applySessionListPayload(sessData, projData, opts){
|
||
// Server's other_profile_count tells us how many sessions exist outside the
|
||
// active profile so the "Show N from other profiles" toggle can render
|
||
// without a second round-trip. Stashed on the module for renderSessionListFromCache.
|
||
const applyOpts = (opts && typeof opts === 'object') ? opts : {};
|
||
_otherProfileCount = sessData.other_profile_count || 0;
|
||
_archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0);
|
||
_archivedCliCount = Number(sessData.archived_cli_count ?? 0);
|
||
_serverWebuiSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'webui_session_count')
|
||
? Number(sessData.webui_session_count)
|
||
: null;
|
||
_serverCliSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'cli_session_count')
|
||
? Number(sessData.cli_session_count)
|
||
: null;
|
||
if (!Number.isFinite(_serverWebuiSessionCount)) _serverWebuiSessionCount = null;
|
||
if (!Number.isFinite(_serverCliSessionCount)) _serverCliSessionCount = null;
|
||
// Capture server clock for clock-skew compensation (issue #1144).
|
||
// server_time is epoch seconds from the server's time.time().
|
||
// _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta)
|
||
// gives an approximation of the current server time.
|
||
if (typeof sessData.server_time === 'number' && sessData.server_time > 0) {
|
||
_serverTimeDelta = Date.now() - (sessData.server_time * 1000);
|
||
}
|
||
if (typeof sessData.server_tz === 'string') {
|
||
_serverTz = sessData.server_tz;
|
||
}
|
||
const serverSessions=_optimisticallyRemovedSessionIds.size
|
||
? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id))
|
||
: (sessData.sessions||[]);
|
||
_sidebarReferenceSessions = Array.isArray(sessData.sidebar_reference_sessions)
|
||
? sessData.sidebar_reference_sessions
|
||
: [];
|
||
_reconcileActiveSessionIdleStateFromList(serverSessions);
|
||
_allSessions = _mergeOptimisticFirstTurnSessions(serverSessions);
|
||
// Tag the cache with the scope it was loaded under (active profile +
|
||
// all-profiles flag). If a later /api/sessions fails right after a profile
|
||
// switch, the catch path checks this so it won't re-render the PRIOR
|
||
// profile's rows as if they were current (#4167 review item 3).
|
||
_allSessionsScope = {
|
||
profile: (typeof sessData.active_profile === 'string' && sessData.active_profile)
|
||
? sessData.active_profile
|
||
: (S.activeProfile || 'default'),
|
||
allProfiles: !!_showAllProfiles,
|
||
sidebarSource: _requestedSessionSidebarSource(),
|
||
excludeHidden: _sessionListExcludeHiddenEnabled(),
|
||
};
|
||
// Record this profile's session count so the NEXT switch into it can pick an
|
||
// honest skeleton (empty-state vs content) before its fetch resolves (#4717).
|
||
// Only record an UNFILTERED total: skip all-profiles (conflates profiles), and
|
||
// skip while a project or CLI-source filter is active (those record a filtered
|
||
// subset that could cache a misleading 0 for a profile that has sessions under
|
||
// a different filter). This mirrors the read-side `filterActive` gate in
|
||
// showSessionListSkeleton so the write and read agree on what the count means.
|
||
const _recordFilterActive = (typeof _activeProject !== 'undefined' && _activeProject)
|
||
|| (typeof _sessionSourceFilter !== 'undefined' && _sessionSourceFilter === 'cli');
|
||
if (!_showAllProfiles && !_recordFilterActive) {
|
||
_recordSessionProfileCount(_allSessionsScope.profile, _allSessions.length);
|
||
}
|
||
_syncSessionAttentionSoundState(_allSessions);
|
||
_pruneLineageReportCacheToVisibleSessions(_allSessions);
|
||
_allProjects = projData.projects||[];
|
||
// Capture the recovering-from-error state BEFORE clearing it: the error banner
|
||
// DOM was rendered outside the signature path, so if this payload heals with
|
||
// rows identical to the last render, the identical-signature skip below would
|
||
// leave the stale "Could not load conversations" banner on screen. (Codex #5467)
|
||
const _hadSessionListLoadError = !!_sessionListLoadError;
|
||
_sessionListLoadError = null;
|
||
_sessionListHasLoadedOnce = true;
|
||
// Greptile #5975 P1: a /api/sessions request started under profile A can
|
||
// finish after a switch to B already cleared A's cron markers. The list gen
|
||
// check can already have passed (TOCTOU) or a deferred apply can land later.
|
||
// Re-validate the profile-switch unread generation (shared with cron poll
|
||
// reset via _cronPollGeneration) immediately before marking completions.
|
||
const expectedUnreadGen = applyOpts.unreadGen;
|
||
const currentUnreadGen = (typeof _cronPollGeneration === 'number') ? _cronPollGeneration : 0;
|
||
if (typeof expectedUnreadGen !== 'number' || expectedUnreadGen === currentUnreadGen) {
|
||
_markPollingCompletionUnreadTransitions(_allSessions);
|
||
}
|
||
const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s));
|
||
if (isStreaming) {
|
||
startStreamingPoll();
|
||
} else {
|
||
stopStreamingPoll();
|
||
}
|
||
ensureSessionTimeRefreshPoll();
|
||
ensureActiveSessionExternalRefreshPoll();
|
||
if(!_sessionListFirstRenderAnimated&&Array.isArray(_allSessions)&&_allSessions.length){
|
||
animateNextSessionListRefresh({enterAll:true});
|
||
_sessionListFirstRenderAnimated=true;
|
||
}
|
||
ensureSessionEventsSSE();
|
||
// #4671: this payload is the freshly-resolved /api/sessions response (and a superseded
|
||
// response was already discarded by the generation guard upstream), so _allSessions now
|
||
// holds the CURRENT profile's rows. Clear the skeleton flag right before painting so this
|
||
// authoritative render replaces the profile-switch skeleton — while unrelated renders that
|
||
// fire before this point stay blocked by the guard in renderSessionListFromCache().
|
||
const _hadSessionListSkeleton = _sessionListSkeletonActive;
|
||
_sessionListSkeletonActive = false;
|
||
// No-op fast path: if this payload renders identically to what is already on
|
||
// screen (the common case for idle polls) and no entrance animation is
|
||
// pending, skip the full DOM rebuild. Only applies here in the fetch/apply
|
||
// path; the 60s relative-time refresh and every other render trigger call
|
||
// renderSessionListFromCache directly and are unaffected. Guarded by the same
|
||
// conditions renderSessionListFromCache bails on, so a bailed render never
|
||
// caches a signature that would suppress the next real repaint. (#5455 WS2.4)
|
||
// NEVER skip when recovering from a skeleton or error-banner DOM state: those
|
||
// are rendered outside the signature path, so an identical-signature match
|
||
// would leave the skeleton/error on screen instead of the real list. (Codex #5467)
|
||
const _canRenderNow = !_renamingSid && !_sessionActionMenu;
|
||
const _mustForceRender = _hadSessionListSkeleton || _hadSessionListLoadError;
|
||
const _renderSig = _sessionListRenderSignature();
|
||
if(_canRenderNow && !_mustForceRender && !_sessionListRefreshAnimationPending && _renderSig && _renderSig===_lastSessionListRenderSig){
|
||
// Preserve the per-refresh INFLIGHT cleanup that renderSessionListFromCache
|
||
// would otherwise perform, then skip only the DOM rebuild.
|
||
if(typeof _purgeStaleInflightEntries==='function') _purgeStaleInflightEntries();
|
||
return;
|
||
}
|
||
if(_canRenderNow) _lastSessionListRenderSig = _renderSig;
|
||
renderSessionListFromCache(); // no-ops if rename is in progress
|
||
}
|
||
|
||
function _mergeRenderSessionListOptions(prev, next){
|
||
const merged={...(prev||{}),...(next||{})};
|
||
// Immediate refreshes must not be downgraded by a later passive polling tick.
|
||
if((prev&&prev.deferWhileInteracting===false)||(next&&next.deferWhileInteracting===false)){
|
||
merged.deferWhileInteracting=false;
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
function _showSessionListLoadError(error){
|
||
console.warn('renderSessionList',error);
|
||
const isTimeout=Boolean(error&&(error.timeout===true||error.name==='TimeoutError'));
|
||
// If this error is landing while a retry was in flight, flag the fresh Retry
|
||
// button (rebuilt by the repaint) to reclaim keyboard focus so keyboard users
|
||
// aren't dropped to <body> on a failed retry.
|
||
const wasRetrying=Boolean(_sessionListLoadError&&_sessionListLoadError.retrying);
|
||
_sessionListLoadError={
|
||
message:isTimeout
|
||
? 'Session list is taking longer than expected.'
|
||
: 'Could not load conversations.',
|
||
detail:isTimeout
|
||
? 'The backend may still be scanning a very large session history.'
|
||
: String(error&&error.message?error.message:''),
|
||
_retryFailedFocus:wasRetrying,
|
||
};
|
||
}
|
||
|
||
function _renderSessionListLoadErrorNote(){
|
||
if(!_sessionListLoadError) return null;
|
||
const note=document.createElement('div');
|
||
note.className='session-list-error session-empty-note';
|
||
// a11y: announce load-error / retry-failure transitions to screen readers
|
||
// (the note is re-rendered on both the pending click and the failure repaint).
|
||
note.setAttribute('role','status');
|
||
note.setAttribute('aria-live','polite');
|
||
const title=document.createElement('div');
|
||
title.textContent=_sessionListLoadError.message||'Could not load conversations.';
|
||
note.appendChild(title);
|
||
if(_sessionListLoadError.detail){
|
||
const detail=document.createElement('div');
|
||
detail.className='session-list-error-detail';
|
||
detail.textContent=_sessionListLoadError.detail;
|
||
note.appendChild(detail);
|
||
}
|
||
const retry=document.createElement('button');
|
||
retry.type='button';
|
||
retry.className='session-list-error-retry';
|
||
const retrying=Boolean(_sessionListLoadError.retrying);
|
||
// Use aria-disabled (not the disabled property) for the pending state so the
|
||
// button can keep keyboard focus across the sidebar rebuild; the click/keydown
|
||
// guards below make it inert while busy.
|
||
const setPending=()=>{
|
||
retry.textContent='Retrying…';
|
||
retry.setAttribute('aria-disabled','true');
|
||
retry.setAttribute('aria-busy','true');
|
||
retry.onclick=null;
|
||
};
|
||
const bindRetry=()=>{
|
||
retry.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
if(!_sessionListLoadError||_sessionListLoadError.retrying) return;
|
||
if(retry.getAttribute('aria-disabled')==='true') return;
|
||
setPending();
|
||
_sessionListLoadError={..._sessionListLoadError,retrying:true};
|
||
renderSessionListFromCache();
|
||
void renderSessionList({deferWhileInteracting:false}).finally(()=>{
|
||
if(!retry.parentNode||(_sessionListLoadError&&_sessionListLoadError.retrying)) return;
|
||
retry.textContent='Retry';
|
||
retry.removeAttribute('aria-disabled');
|
||
retry.removeAttribute('aria-busy');
|
||
bindRetry();
|
||
});
|
||
};
|
||
};
|
||
if(retrying){
|
||
setPending();
|
||
}else{
|
||
retry.textContent='Retry';
|
||
retry.removeAttribute('aria-disabled');
|
||
bindRetry();
|
||
// On a failure repaint that replaces a pending button, restore keyboard
|
||
// focus to the fresh Retry button so keyboard users aren't dropped to body.
|
||
if(_sessionListLoadError._retryFailedFocus){
|
||
delete _sessionListLoadError._retryFailedFocus;
|
||
const _refocus=()=>{ try{ if(typeof retry.focus==='function') retry.focus(); }catch(_e){} };
|
||
if(typeof requestAnimationFrame==='function') requestAnimationFrame(_refocus); else _refocus();
|
||
}
|
||
}
|
||
note.appendChild(retry);
|
||
return note;
|
||
}
|
||
|
||
async function _runRenderSessionListRefresh(opts, _gen){
|
||
const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);
|
||
if(!deferWhileInteracting) _pendingSessionListPayload=null;
|
||
// Capture profile-switch unread generation BEFORE the await so a switch
|
||
// mid-flight (which increments _cronPollGeneration) invalidates completion
|
||
// marking for this response even if list gen checks already passed.
|
||
const unreadGen = (typeof _cronPollGeneration === 'number') ? _cronPollGeneration : 0;
|
||
try{
|
||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||
const sessionListQS = _sessionListQueryString();
|
||
// #5394: the sidebar session-list GET is idempotent, so 502/503/504 retry
|
||
// must be unconditional. Previously retries/retryStatuses were boot-gated, so
|
||
// a transient 502 during an nginx->backend restart on a warm refresh (profile
|
||
// switch, focus/visible/reconnect) failed on the first attempt and left the
|
||
// sidebar stale until a hard reload. Boot still keeps the larger timeout +
|
||
// timeout retry; every refresh now retries the transient upstream statuses.
|
||
const sessionRequestOpts={
|
||
timeoutToast:false,
|
||
retries:1,
|
||
retryStatuses:[502,503,504],
|
||
};
|
||
if(!_sessionListHasLoadedOnce){
|
||
sessionRequestOpts.timeoutMs=_SESSION_LIST_BOOT_TIMEOUT_MS;
|
||
sessionRequestOpts.retryTimeouts=true;
|
||
}
|
||
const {sessData, projData}=await _loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts);
|
||
// Discard stale response — a newer renderSessionList() call superseded us.
|
||
if (_gen !== _renderSessionListGen) return;
|
||
// #4671: while a profile switch is mid-flight, drop ANY payload — even one whose
|
||
// generation still matches — because a render that STARTED after the skeleton showed
|
||
// but before the switch response set the new-profile cookie fetched the OLD profile's
|
||
// rows. The switch clears the embargo immediately before its own (authoritative)
|
||
// renderSessionList(), so that render's payload is the first allowed to paint.
|
||
if (_profileSwitchListEmbargo) return;
|
||
if(deferWhileInteracting&&_isSessionListUserInteracting()){
|
||
_pendingSessionListPayload={gen:_gen,sessData,projData,unreadGen};
|
||
_schedulePendingSessionListApply();
|
||
return;
|
||
}
|
||
_applySessionListPayload(sessData,projData,{unreadGen});
|
||
}catch(e){
|
||
if (_gen !== _renderSessionListGen) return;
|
||
// #4671: same embargo guard as the success path — a mid-switch /api/sessions that
|
||
// FAILS must not clear the skeleton flag or render the old-profile cache either. The
|
||
// switch-owned render (after the embargo lifts) is the only one allowed to resolve the
|
||
// skeleton; if the switch itself fails, its catch clears the skeleton + embargo.
|
||
if (_profileSwitchListEmbargo) return;
|
||
_showSessionListLoadError(e);
|
||
// Only fall back to the cached rows if they were loaded under the SAME
|
||
// scope we're requesting now. After a profile switch the cache holds the
|
||
// PRIOR profile's sessions; re-rendering them would falsely show another
|
||
// profile's conversations, so render the error state with no rows instead
|
||
// (#4167 review item 3).
|
||
const _curScope = {
|
||
profile: S.activeProfile || 'default',
|
||
allProfiles: !!_showAllProfiles,
|
||
sidebarSource: _requestedSessionSidebarSource(),
|
||
excludeHidden: _sessionListExcludeHiddenEnabled(),
|
||
};
|
||
const _scopeMatches = _allSessionsScope
|
||
&& _allSessionsScope.profile === _curScope.profile
|
||
&& _allSessionsScope.allProfiles === _curScope.allProfiles
|
||
&& _allSessionsScope.sidebarSource === _curScope.sidebarSource
|
||
&& _allSessionsScope.excludeHidden === _curScope.excludeHidden;
|
||
// #4671: the /api/sessions fetch failed — clear the skeleton flag so this error
|
||
// render (matched cache, or empty rows for a mismatched scope) replaces the
|
||
// up-front profile-switch skeleton instead of stranding it.
|
||
_sessionListSkeletonActive = false;
|
||
if (_scopeMatches) {
|
||
renderSessionListFromCache();
|
||
} else {
|
||
_allSessions = [];
|
||
_sidebarReferenceSessions = [];
|
||
_allSessionsScope = _curScope;
|
||
_clearSessionSourceTabCounts();
|
||
renderSessionListFromCache();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _loadSidebarSessionListPayload(sessionListQS, sessionRequestOpts){
|
||
const projectPromise = (async() => {
|
||
try{
|
||
const projectQS = _showAllProfiles ? '?all_profiles=1' : '';
|
||
return await api('/api/projects' + projectQS,{timeoutToast:false});
|
||
}catch(projectError){
|
||
console.warn('renderProjectsList',projectError);
|
||
return {projects:_allProjects||[]};
|
||
}
|
||
})();
|
||
|
||
const sessData = await api('/api/sessions' + sessionListQS,sessionRequestOpts);
|
||
const projData = await projectPromise;
|
||
|
||
return {sessData,projData};
|
||
}
|
||
|
||
async function _drainRenderSessionListQueue(initialRequest){
|
||
let request=initialRequest;
|
||
try{
|
||
while(request){
|
||
await _runRenderSessionListRefresh(request.opts, request.gen);
|
||
request=_renderSessionListQueuedRequest;
|
||
_renderSessionListQueuedRequest=null;
|
||
}
|
||
}finally{
|
||
_renderSessionListInFlight=null;
|
||
if(_renderSessionListQueuedRequest){
|
||
const next=_renderSessionListQueuedRequest;
|
||
_renderSessionListQueuedRequest=null;
|
||
_renderSessionListInFlight=_drainRenderSessionListQueue(next);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renderSessionList(opts={}){
|
||
const request={opts:opts||{},gen:++_renderSessionListGen};
|
||
if(_renderSessionListInFlight){
|
||
_renderSessionListQueuedRequest={
|
||
opts:_mergeRenderSessionListOptions(_renderSessionListQueuedRequest&&_renderSessionListQueuedRequest.opts, request.opts),
|
||
gen:request.gen,
|
||
};
|
||
return _renderSessionListInFlight;
|
||
}
|
||
_renderSessionListInFlight=_drainRenderSessionListQueue(request);
|
||
return _renderSessionListInFlight;
|
||
}
|
||
|
||
// ── Gateway session SSE (real-time sync for agent sessions) ──
|
||
let _gatewaySSE = null;
|
||
let _gatewayPollTimer = null;
|
||
let _gatewayProbeInFlight = false;
|
||
let _gatewaySSEWarningShown = false;
|
||
const _gatewayFallbackPollMs = 30000;
|
||
const _streamingPollMs = 30000;
|
||
const _sessionTimeRefreshMs = 60000;
|
||
// #3107: the active-session "is it externally updated?" poll used to fire
|
||
// every 5 s. On long sessions this caused visible scroll jitter and a
|
||
// noticeable network/CPU floor because the SSE session-events stream
|
||
// already pushes invalidations in real time; this poll exists only as a
|
||
// fallback for the case where SSE is broken/unavailable. Bump to 30 s
|
||
// to keep the safety net without turning it into a primary refresh path.
|
||
const _activeSessionExternalRefreshMs = 30000;
|
||
let _streamingPollTimer = null;
|
||
let _sessionTimeRefreshTimer = null;
|
||
let _streamingPollVisibilityHandler = null;
|
||
let _sessionTimeRefreshVisibilityHandler = null;
|
||
let _activeSessionExternalRefreshTimer = null;
|
||
let _activeSessionExternalRefreshInFlight = false;
|
||
let _deferredActiveSessionExternalRefreshReason = '';
|
||
let _sessionEventsSSE = null;
|
||
let _sessionEventsRefreshTimer = 0;
|
||
let _sessionEventsRefreshPendingRequest = null;
|
||
let _sessionEventsReconnectTimer = 0;
|
||
let _sessionEventsNeedsRefreshOnOpen = false;
|
||
let _sessionEventsReconnectAttempt = 0;
|
||
const _sessionEventsReconnectBaseMs = 5000;
|
||
const _sessionEventsReconnectMaxMs = 30000;
|
||
|
||
function _sessionEventsReconnectDelayMs(){
|
||
const attempt = Math.max(0, Number(_sessionEventsReconnectAttempt || 0));
|
||
const base = Math.min(_sessionEventsReconnectMaxMs, _sessionEventsReconnectBaseMs * Math.pow(2, attempt));
|
||
const jitter = Math.floor(Math.random() * Math.max(1, Math.floor(base * 0.35)));
|
||
return Math.min(_sessionEventsReconnectMaxMs, Math.floor(base * 0.75) + jitter);
|
||
}
|
||
let _sessionListRefreshInFlight = false;
|
||
let _sessionListRefreshPendingRequest = null;
|
||
|
||
function _mergeSessionListRefreshOptions(prev, next){
|
||
const merged = {...(prev||{}), ...(next||{})};
|
||
if((prev&&prev.force===true)||(next&&next.force===true)) merged.force = true;
|
||
if((prev&&prev.refreshActive===true)||(next&&next.refreshActive===true)) merged.refreshActive = true;
|
||
return merged;
|
||
}
|
||
|
||
function _refreshSessionListAfterSidebarResume(reason){
|
||
// A direct resume refresh satisfies any pending onopen catch-up from the same close.
|
||
_sessionEventsNeedsRefreshOnOpen = false;
|
||
void refreshSessionList(reason, {force:true});
|
||
}
|
||
|
||
function startStreamingPoll(){
|
||
if(_streamingPollTimer) return;
|
||
_streamingPollTimer = setInterval(() => {
|
||
// Skip while the tab is hidden: this poll fetches /api/sessions and rebuilds
|
||
// the sidebar, work the user cannot see. The visibilitychange handler below
|
||
// brings the list current the moment the tab is shown again, so no update is
|
||
// lost — the background tab just stops burning network + DOM churn.
|
||
if(typeof document !== 'undefined' && document.hidden) return;
|
||
void renderSessionList({deferWhileInteracting:true});
|
||
}, _streamingPollMs);
|
||
if(typeof document !== 'undefined' && !_streamingPollVisibilityHandler){
|
||
_streamingPollVisibilityHandler = () => {
|
||
if(!document.hidden) void renderSessionList({deferWhileInteracting:true});
|
||
};
|
||
document.addEventListener('visibilitychange', _streamingPollVisibilityHandler);
|
||
}
|
||
}
|
||
|
||
function stopStreamingPoll(){
|
||
if(_streamingPollVisibilityHandler && typeof document !== 'undefined'){
|
||
document.removeEventListener('visibilitychange', _streamingPollVisibilityHandler);
|
||
_streamingPollVisibilityHandler = null;
|
||
}
|
||
if(!_streamingPollTimer) return;
|
||
clearInterval(_streamingPollTimer);
|
||
_streamingPollTimer = null;
|
||
}
|
||
|
||
function ensureSessionTimeRefreshPoll(){
|
||
if(_sessionTimeRefreshTimer) return;
|
||
_sessionTimeRefreshTimer = setInterval(() => {
|
||
// Relative-time labels only matter when visible; the visibilitychange
|
||
// handler below refreshes timestamps immediately when the tab is shown.
|
||
if(typeof document !== 'undefined' && document.hidden) return;
|
||
renderSessionListFromCache();
|
||
}, _sessionTimeRefreshMs);
|
||
if(typeof document !== 'undefined' && !_sessionTimeRefreshVisibilityHandler){
|
||
_sessionTimeRefreshVisibilityHandler = () => {
|
||
if(!document.hidden) renderSessionListFromCache();
|
||
};
|
||
document.addEventListener('visibilitychange', _sessionTimeRefreshVisibilityHandler);
|
||
}
|
||
}
|
||
|
||
function _deferActiveSessionExternalRefresh(reason){
|
||
const nextReason = reason || 'poll';
|
||
if(_deferredActiveSessionExternalRefreshReason==='idle-reconcile'&&nextReason==='poll') return;
|
||
_deferredActiveSessionExternalRefreshReason = nextReason;
|
||
}
|
||
|
||
function _clearDeferredActiveSessionExternalRefresh(){
|
||
_deferredActiveSessionExternalRefreshReason = '';
|
||
}
|
||
|
||
function _flushDeferredActiveSessionExternalRefresh(){
|
||
const reason = _deferredActiveSessionExternalRefreshReason;
|
||
if(!reason) return;
|
||
_deferredActiveSessionExternalRefreshReason = '';
|
||
void refreshActiveSessionIfExternallyUpdated(reason);
|
||
}
|
||
|
||
// Reconcile the active session against server-side metadata. Returns a status
|
||
// string so callers (notably the post-stream idle reconcile) can decide how to
|
||
// react:
|
||
// 'skipped' — a guard short-circuited before any network probe ran
|
||
// 'unchanged' — server metadata matched local (or only a non-transcript bump)
|
||
// 'reloaded' — the transcript was force-reloaded to re-sync
|
||
// 'failed' — the probe request threw (transient); caller may fall back
|
||
//
|
||
// opts.ignoreStreamJustFinished — bypass the post-stream cooldown. Only the
|
||
// idle-reconcile path sets this: it runs once for the just-finished active
|
||
// turn and probes server metadata FIRST (reloading only on an actual count
|
||
// change), so it is safe to look even right after the "done" event without
|
||
// the unconditional force reload that produced the mobile-PWA end-of-turn
|
||
// flash (#3976). This intentionally COEXISTS with the #3916/#4195 poll-only
|
||
// external gate below, which is untouched.
|
||
async function refreshActiveSessionIfExternallyUpdated(reason){
|
||
// opts read via arguments[1] (same pattern as loadSession) so the public
|
||
// signature stays (reason) — callers like the poll/focus/visibility hooks and
|
||
// refreshSessionList keep passing a single reason. Only the post-stream idle
|
||
// reconcile passes opts (see _scheduleActiveSessionIdleReload).
|
||
const opts = arguments[1] || {};
|
||
if(_activeSessionExternalRefreshInFlight) return 'skipped';
|
||
if(!S.session || !S.session.session_id) return 'skipped';
|
||
if(S.busy || S.activeStreamId) return 'skipped';
|
||
if(typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()){
|
||
_deferActiveSessionExternalRefresh(reason||'poll');
|
||
return 'skipped';
|
||
}
|
||
// #3916/#4195: the 30s timer is only a fallback for imported/external sessions.
|
||
// WebUI-native sessions should not keep probing forever when the sidebar SSE
|
||
// is healthy, but they still must reconcile when an actual sessions_changed
|
||
// event, focus, or visibility recovery says another client/process mutated
|
||
// the active transcript (#4205 follow-up shape). The idle-reconcile path uses
|
||
// a non-'poll' reason, so it already sails through this gate untouched.
|
||
if((reason||'poll')==='poll' && !_isExternalSession(S.session)) return 'skipped';
|
||
// Cooldown: don't force-reload immediately after streaming ends — the
|
||
// "done" event already delivered the final messages. Reloading here would
|
||
// clear S.toolCalls and lose Activity. The idle-reconcile path may bypass
|
||
// this guard (opts.ignoreStreamJustFinished) because it probes server
|
||
// metadata first and only reloads when the count actually changed (#3976).
|
||
if(!opts.ignoreStreamJustFinished && typeof window !== 'undefined' && window._streamJustFinished) return 'skipped';
|
||
if(typeof document !== 'undefined' && document.hidden) return 'skipped';
|
||
const sid = S.session.session_id;
|
||
const localCount = Number(S.session.message_count || (Array.isArray(S.messages)?S.messages.length:0) || 0);
|
||
const localLast = Number(S.session.last_message_at || S.session.updated_at || 0);
|
||
_activeSessionExternalRefreshInFlight = true;
|
||
try{
|
||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`,{timeoutToast:false});
|
||
if(!data || !data.session) return 'unchanged';
|
||
if(!S.session || S.session.session_id !== sid) return 'skipped';
|
||
if(S.busy || S.activeStreamId) return 'skipped';
|
||
const remoteCount = Number(data.session.message_count || 0);
|
||
const remoteLast = Number(data.session.last_message_at || data.session.updated_at || 0);
|
||
// Force-reload the whole transcript whenever the visible conversation's
|
||
// message count CHANGED in either direction. A higher count means new
|
||
// messages; a LOWER count means another tab/client truncated, undid,
|
||
// retried, or regenerated the transcript (/api/session/truncate, /retry,
|
||
// /undo all shrink s.messages and write a lower message_count) — both must
|
||
// re-sync or this tab silently keeps a stale transcript.
|
||
//
|
||
// A bump in last_message_at WITHOUT a count change means a non-transcript
|
||
// write touched the session — most commonly the post-turn background
|
||
// skill/memory review, which rewrites memory/skills and advances updated_at
|
||
// but adds no chat messages. Reloading on that bump tears down and re-fetches
|
||
// the transcript: loadSession(force) clears S.messages and awaits a
|
||
// round-trip before re-rendering, so the whole conversation visibly
|
||
// disappears and "reappears a moment later" with no new content. Skip the
|
||
// destructive reload in that case and just refresh the lightweight sidebar
|
||
// list metadata, advancing the local last-seen marker so the same metadata
|
||
// bump doesn't re-trigger on every subsequent poll.
|
||
if(remoteCount !== localCount){
|
||
// Hidden-tab return / visibility / focus recovery commonly trips
|
||
// remoteCount !== localCount when the post-turn bg-review thread or a
|
||
// sibling tab persisted messages while the tab was hidden. The default
|
||
// loadSession(force) path clears S.messages synchronously and waits for
|
||
// the full transcript round-trip before re-rendering, producing the
|
||
// user-visible "everything disappears, then reappears after a moment"
|
||
// gap that #5061 (metadata-only) and #5122 (SSE 4-probe) DO NOT cover
|
||
// (#5177). Pass keepStaleUntilLoaded so the destructive clear is
|
||
// deferred to swap-in-place when the new transcript actually arrives.
|
||
// Restrict to the recovery reasons that produced the field repro; the
|
||
// post-stream idle reconcile and external/imported-session polls keep
|
||
// the original behaviour (no DOM is on-screen long enough for the gap
|
||
// to matter, and any change there would have to re-verify their own
|
||
// tradeoffs).
|
||
const _recoveryReasons = {visible:true, focus:true};
|
||
const _keepStaleUntilLoaded = !!_recoveryReasons[String(reason||'')];
|
||
// #5409: skip force-reload while a different session's loadSession()
|
||
// is in flight — avoids overwriting _loadingSessionId and silently
|
||
// cancelling an in-progress session switch. All four call paths
|
||
// (idle-reconcile, poll, visibility, focus) funnel through here.
|
||
if(typeof _loadingSessionId !== 'undefined' && _loadingSessionId && _loadingSessionId !== sid) return 'skipped';
|
||
await loadSession(sid, {force:true, externalRefreshReason:reason||'poll', keepStaleUntilLoaded:_keepStaleUntilLoaded});
|
||
if(typeof renderSessionList==='function') void renderSessionList();
|
||
return 'reloaded';
|
||
}else if(remoteLast > localLast){
|
||
if(S.session && S.session.session_id === sid){
|
||
S.session.last_message_at = remoteLast;
|
||
if(data.session.updated_at) S.session.updated_at = data.session.updated_at;
|
||
}
|
||
if(typeof renderSessionList==='function') void renderSessionList();
|
||
}
|
||
return 'unchanged';
|
||
}catch(e){
|
||
// Ignore transient refresh failures; the next poll/focus event will retry.
|
||
return 'failed';
|
||
}finally{
|
||
_activeSessionExternalRefreshInFlight = false;
|
||
}
|
||
}
|
||
|
||
function ensureActiveSessionExternalRefreshPoll(){
|
||
if(_activeSessionExternalRefreshTimer) return;
|
||
_activeSessionExternalRefreshTimer = setInterval(() => {
|
||
void refreshActiveSessionIfExternallyUpdated('poll');
|
||
}, _activeSessionExternalRefreshMs);
|
||
if(typeof document !== 'undefined' && !document._hermesExternalRefreshVisibilityHook){
|
||
document.addEventListener('visibilitychange', () => {
|
||
if(!document.hidden) void refreshActiveSessionIfExternallyUpdated('visible');
|
||
});
|
||
document._hermesExternalRefreshVisibilityHook = true;
|
||
}
|
||
if(typeof window !== 'undefined' && !window._hermesExternalRefreshFocusHook){
|
||
window.addEventListener('focus', () => { void refreshActiveSessionIfExternallyUpdated('focus'); });
|
||
window._hermesExternalRefreshFocusHook = true;
|
||
}
|
||
}
|
||
|
||
async function refreshSessionList(reason='manual', opts={}){
|
||
const force = !!(opts && opts.force);
|
||
const refreshActive = !!(opts && opts.refreshActive);
|
||
if(!force && typeof document !== 'undefined' && document.hidden) return;
|
||
if(_sessionListRefreshInFlight){
|
||
_sessionListRefreshPendingRequest = {
|
||
reason: reason || 'session-list',
|
||
opts:_mergeSessionListRefreshOptions(_sessionListRefreshPendingRequest && _sessionListRefreshPendingRequest.opts, opts),
|
||
};
|
||
return;
|
||
}
|
||
_sessionListRefreshInFlight = true;
|
||
try{
|
||
await renderSessionList({deferWhileInteracting:!force});
|
||
if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list');
|
||
}finally{
|
||
_sessionListRefreshInFlight = false;
|
||
const pendingRequest = _sessionListRefreshPendingRequest;
|
||
_sessionListRefreshPendingRequest = null;
|
||
if(pendingRequest) _scheduleSessionEventsRefresh(pendingRequest.reason, pendingRequest.opts);
|
||
}
|
||
}
|
||
|
||
function _scheduleSessionEventsRefresh(reason, opts={}){
|
||
_sessionEventsRefreshPendingRequest = {
|
||
reason: reason || (_sessionEventsRefreshPendingRequest && _sessionEventsRefreshPendingRequest.reason) || 'event',
|
||
opts:_mergeSessionListRefreshOptions(_sessionEventsRefreshPendingRequest && _sessionEventsRefreshPendingRequest.opts, opts),
|
||
};
|
||
if(_sessionEventsRefreshTimer) return;
|
||
_sessionEventsRefreshTimer = setTimeout(() => {
|
||
_sessionEventsRefreshTimer = 0;
|
||
const request = _sessionEventsRefreshPendingRequest || {reason:'event', opts:{}};
|
||
_sessionEventsRefreshPendingRequest = null;
|
||
void refreshSessionList(request.reason||'event', request.opts);
|
||
}, 300);
|
||
}
|
||
|
||
function _sessionEventTargetsActiveSession(payload){
|
||
const eventSessionId = payload && typeof payload.session_id === 'string' ? payload.session_id : '';
|
||
if(!eventSessionId) return false;
|
||
return !!(S.session && S.session.session_id && S.session.session_id === eventSessionId);
|
||
}
|
||
|
||
// ── #4151: focus-aware close for the two GLOBAL sidebar SSE streams ──────────
|
||
// Each WebUI window holds up to three persistent SSE connections (session-events
|
||
// + gateway + the per-session stream). #3992/#3996 close them on the Page
|
||
// Visibility API (`visibilitychange` / `document.hidden`) so a hidden tab frees
|
||
// HTTP/1.1 pool slots. But a PWA *standalone* window does NOT reliably fire
|
||
// `visibilitychange` when it merely loses focus to another window of the same
|
||
// app — `document.hidden` only flips on minimize. So two side-by-side PWA windows
|
||
// both stay `visibilityState==='visible'`, each keeps its sidebar streams open,
|
||
// and 2x3 = 6 = the per-origin HTTP/1.1 connection limit; every later fetch()
|
||
// (the 30s polls) queues behind the saturated pool and times out (#4151).
|
||
// `document.hasFocus()` is the signal `visibilitychange` misses — only one window
|
||
// holds focus at a time.
|
||
//
|
||
// Scope: ONLY the two global sidebar streams (session-events + gateway). The
|
||
// per-session live stream (messages.js `startSessionStream`) deliberately stays
|
||
// visibility-only — it carries live `bg_task_complete` toasts and
|
||
// `server_turn_started` live-view that an unfocused-but-VISIBLE window must still
|
||
// receive (the OS-notification path is gated on `document.hidden`, so the in-app
|
||
// toast is the only completion signal a visible-unfocused window gets). Closing
|
||
// it on blur would regress the multi-window live-view UX.
|
||
function _sidebarSseBackgrounded(){
|
||
if(typeof document === 'undefined') return false;
|
||
if(document.hidden) return true;
|
||
if(typeof document.hasFocus === 'function' && !document.hasFocus()) return true;
|
||
return false;
|
||
}
|
||
|
||
let _sidebarSseBlurCloseTimer = 0;
|
||
// Debounce the blur-close so a transient blur (native dialog, quick alt-tab and
|
||
// back) doesn't thrash the streams; a sustained blur frees the pool slots.
|
||
const _SIDEBAR_SSE_BLUR_CLOSE_MS = 1000;
|
||
|
||
function _installSidebarSseFocusHook(){
|
||
if(typeof window === 'undefined' || typeof document === 'undefined') return;
|
||
if(document._hermesSidebarSseFocusHook) return;
|
||
document._hermesSidebarSseFocusHook = true;
|
||
window.addEventListener('blur', () => {
|
||
if(_sidebarSseBlurCloseTimer) return;
|
||
_sidebarSseBlurCloseTimer = setTimeout(() => {
|
||
_sidebarSseBlurCloseTimer = 0;
|
||
// Re-check at fire time — focus may have returned during the debounce.
|
||
if(_sidebarSseBackgrounded()){
|
||
_closeSessionEventsSSE();
|
||
stopGatewaySSE();
|
||
}
|
||
}, _SIDEBAR_SSE_BLUR_CLOSE_MS);
|
||
});
|
||
window.addEventListener('focus', () => {
|
||
if(_sidebarSseBlurCloseTimer){ clearTimeout(_sidebarSseBlurCloseTimer); _sidebarSseBlurCloseTimer = 0; }
|
||
// Reopen and catch up on anything missed while blurred. ensureSessionEventsSSE()
|
||
// is idempotent (`if(_sessionEventsSSE) return`), but startGatewaySSE() is NOT — it
|
||
// begins with an unconditional stopGatewaySSE(). So only reopen the gateway when it
|
||
// was actually closed; otherwise a transient blur shorter than the debounce (where
|
||
// the blur-close timer was cleared and the stream was never torn down) would
|
||
// drop+reconnect the live gateway on every window switch, cancelling its poll
|
||
// fallback and resetting probe/warning state — the exact thrash the debounce exists
|
||
// to prevent, in the multi-window scenario this fix targets (#4151).
|
||
ensureSessionEventsSSE();
|
||
if(!_gatewaySSE) startGatewaySSE();
|
||
void _refreshSessionListAfterSidebarResume('focus');
|
||
});
|
||
}
|
||
|
||
function _closeSessionEventsSSE(){
|
||
if(_sessionEventsSSE){
|
||
try{if(_sessionEventsSSE.readyState!==2)_sessionEventsSSE.close();}catch(_){ }
|
||
_sessionEventsSSE = null;
|
||
_sessionEventsNeedsRefreshOnOpen = true;
|
||
}
|
||
}
|
||
|
||
function ensureSessionEventsSSE(){
|
||
if(typeof document !== 'undefined' && !document._hermesSessionEventsVisibilityHook){
|
||
document.addEventListener('visibilitychange', () => {
|
||
if(document.hidden){
|
||
_closeSessionEventsSSE();
|
||
}else{
|
||
ensureSessionEventsSSE();
|
||
void _refreshSessionListAfterSidebarResume('visible');
|
||
}
|
||
});
|
||
document._hermesSessionEventsVisibilityHook = true;
|
||
}
|
||
_installSidebarSseFocusHook();
|
||
if(typeof EventSource==='undefined') return;
|
||
if(_sidebarSseBackgrounded()) return;
|
||
if(_sessionEventsSSE) return;
|
||
try{
|
||
// Same-origin relative URL preserves subpath mounts and normal WebUI cookies.
|
||
_sessionEventsSSE = new EventSource('api/sessions/events');
|
||
_sessionEventsSSE.onopen = () => {
|
||
_sessionEventsReconnectAttempt = 0;
|
||
if(!_sessionEventsNeedsRefreshOnOpen) return;
|
||
_sessionEventsNeedsRefreshOnOpen = false;
|
||
void _refreshSessionListAfterSidebarResume('reconnect');
|
||
};
|
||
_sessionEventsSSE.addEventListener('sessions_changed', (ev) => {
|
||
const activeProfile = S.activeProfile || 'default';
|
||
let eventTargetsActiveSession = false;
|
||
try {
|
||
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data) : {};
|
||
const eventProfile = payload && typeof payload.profile === 'string' ? payload.profile : '';
|
||
if (!_sessionEventProfilesMatch(eventProfile, activeProfile)) {
|
||
return;
|
||
}
|
||
eventTargetsActiveSession = _sessionEventTargetsActiveSession(payload);
|
||
} catch (_err) {
|
||
// Non-JSON payload (or transient malformed event). Keep legacy behavior:
|
||
// refresh once event was seen.
|
||
}
|
||
_scheduleSessionEventsRefresh(eventTargetsActiveSession?'event-active-session':'event', {force:true, refreshActive:true});
|
||
});
|
||
_sessionEventsSSE.onerror = () => {
|
||
_sessionEventsNeedsRefreshOnOpen = true;
|
||
_closeSessionEventsSSE();
|
||
if(_sessionEventsReconnectTimer) return;
|
||
const delayMs = _sessionEventsReconnectDelayMs();
|
||
_sessionEventsReconnectAttempt = Math.min(_sessionEventsReconnectAttempt + 1, 6);
|
||
_sessionEventsReconnectTimer = setTimeout(() => {
|
||
_sessionEventsReconnectTimer = 0;
|
||
ensureSessionEventsSSE();
|
||
}, delayMs);
|
||
};
|
||
}catch(e){
|
||
_closeSessionEventsSSE();
|
||
}
|
||
}
|
||
|
||
if(typeof window!=='undefined') window.refreshSessionList = refreshSessionList;
|
||
|
||
let _gatewayPollVisibilityHandler = null; // saved so stopGatewayPollFallback can remove it
|
||
|
||
function startGatewayPollFallback(ms){
|
||
const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs);
|
||
if(_gatewayPollTimer) clearInterval(_gatewayPollTimer);
|
||
_gatewayPollTimer = setInterval(() => {
|
||
// Skip poll when tab is hidden or a stream is active — saves CPU
|
||
// and avoids redundant DOM renders during active streaming (#4704).
|
||
if(typeof document !== 'undefined' && document.hidden) return;
|
||
if(typeof S !== 'undefined' && (S.busy || S.activeStreamId)) return;
|
||
renderSessionList({deferWhileInteracting:true});
|
||
}, intervalMs);
|
||
// Visibility catch-up: refresh immediately when tab re-gains focus,
|
||
// so no gateway updates are dropped during hidden-skip periods.
|
||
// Save the handler so stopGatewayPollFallback can removeEventListener it (#4730 review).
|
||
if(typeof document !== 'undefined' && !_gatewayPollVisibilityHandler){
|
||
_gatewayPollVisibilityHandler = () => {
|
||
if(!document.hidden && typeof renderSessionList === 'function'){
|
||
void renderSessionList({deferWhileInteracting:false});
|
||
}
|
||
};
|
||
document.addEventListener('visibilitychange', _gatewayPollVisibilityHandler);
|
||
}
|
||
}
|
||
|
||
function stopGatewayPollFallback(){
|
||
if(_gatewayPollTimer){
|
||
clearInterval(_gatewayPollTimer);
|
||
_gatewayPollTimer = null;
|
||
}
|
||
if(_gatewayPollVisibilityHandler && typeof document !== 'undefined'){
|
||
document.removeEventListener('visibilitychange', _gatewayPollVisibilityHandler);
|
||
_gatewayPollVisibilityHandler = null;
|
||
}
|
||
}
|
||
|
||
function _gatewaySessionSnapshotKey(sessions){
|
||
return (Array.isArray(sessions)?sessions:[])
|
||
.filter(s=>s&&s.session_id)
|
||
.map(s=>`${s.session_id}:${s.updated_at||0}:${s.message_count||0}`)
|
||
.sort()
|
||
.join('|');
|
||
}
|
||
|
||
function _isGatewaySessionForSnapshot(session){
|
||
if(!session) return false;
|
||
if(typeof _isCliSession==='function'&&_isCliSession(session)) return true;
|
||
if(typeof _isMessagingSession==='function'&&_isMessagingSession(session)) return true;
|
||
const source=String(session.session_source||session.raw_source||session.source_tag||session.source||'').toLowerCase();
|
||
return !!source&&source!=='webui';
|
||
}
|
||
|
||
function _isDuplicateGatewaySessionSnapshot(sessions){
|
||
const incoming=(Array.isArray(sessions)?sessions:[]).filter(_isGatewaySessionForSnapshot);
|
||
const currentGatewaySessions=(Array.isArray(_allSessions)?_allSessions:[]).filter(_isGatewaySessionForSnapshot);
|
||
if(!incoming.length&&!currentGatewaySessions.length) return true;
|
||
return _gatewaySessionSnapshotKey(incoming)===_gatewaySessionSnapshotKey(currentGatewaySessions);
|
||
}
|
||
|
||
async function probeGatewaySSEStatus(){
|
||
if(_gatewayProbeInFlight || !window._showCliSessions) return;
|
||
_gatewayProbeInFlight = true;
|
||
try{
|
||
const resp = await fetch(new URL('api/sessions/gateway/stream?probe=1', document.baseURI || location.href).href, { credentials:'same-origin' });
|
||
const data = await resp.json().catch(() => ({}));
|
||
if(resp.ok && data.watcher_running){
|
||
stopGatewayPollFallback();
|
||
_gatewaySSEWarningShown = false;
|
||
if(!_gatewaySSE && typeof EventSource!=='undefined' && !(document&&document.hidden)) startGatewaySSE();
|
||
return;
|
||
}
|
||
if(resp.status === 503 || data.watcher_running === false){
|
||
startGatewayPollFallback(data.fallback_poll_ms || _gatewayFallbackPollMs);
|
||
renderSessionList({deferWhileInteracting:true});
|
||
if(!_gatewaySSEWarningShown && typeof showToast === 'function'){
|
||
showToast('Gateway sync unavailable — falling back to periodic refresh.', 5000);
|
||
_gatewaySSEWarningShown = true;
|
||
}
|
||
}
|
||
}catch(e){
|
||
// Network error during probe — server may be unreachable.
|
||
// Start fallback polling as a safe default; it will self-cancel
|
||
// when the SSE connection recovers and sessions_changed fires.
|
||
startGatewayPollFallback(_gatewayFallbackPollMs);
|
||
renderSessionList({deferWhileInteracting:true});
|
||
}finally{
|
||
_gatewayProbeInFlight = false;
|
||
}
|
||
}
|
||
|
||
function startGatewaySSE(){
|
||
stopGatewaySSE();
|
||
if(!window._showCliSessions) return;
|
||
// Visibility hook (install once) — mirror ensureSessionEventsSSE() pattern
|
||
if(typeof document !== 'undefined' && !document._hermesGatewaySSEVisibilityHook){
|
||
document.addEventListener('visibilitychange', () => {
|
||
if(document.hidden){
|
||
stopGatewaySSE();
|
||
}else{
|
||
void startGatewaySSE();
|
||
}
|
||
});
|
||
document._hermesGatewaySSEVisibilityHook = true;
|
||
}
|
||
_installSidebarSseFocusHook();
|
||
// Don't open when tab is hidden OR the window has lost focus (PWA blur) —
|
||
// saves connection pool slots (#4151).
|
||
if(_sidebarSseBackgrounded()) return;
|
||
try{
|
||
_gatewaySSE = new EventSource('api/sessions/gateway/stream');
|
||
_gatewaySSE.addEventListener('sessions_changed', (ev) => {
|
||
try{
|
||
const data = JSON.parse(ev.data);
|
||
if(data.sessions){
|
||
stopGatewayPollFallback();
|
||
_gatewaySSEWarningShown = false;
|
||
if(!_isDuplicateGatewaySessionSnapshot(data.sessions)){
|
||
renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render
|
||
}
|
||
// If the active session received new gateway messages, refresh the conversation view.
|
||
// S.busy check prevents stomping on an in-progress WebUI response.
|
||
// _isExternalSession covers CLI-originated and messaging-source sessions
|
||
// that need a server-side import before WebUI can read them.
|
||
if(S.session && !S.busy && _isExternalSession(S.session)){
|
||
const changedIds = new Set((data.sessions||[]).map(s=>s.session_id));
|
||
if(changedIds.has(S.session.session_id)){
|
||
// Capture active session ID before async fetch — race guard.
|
||
// If the user switches sessions while the fetch is in-flight, discard the result.
|
||
const activeSid = S.session.session_id;
|
||
api('/api/session/import_cli',{method:'POST',body:JSON.stringify(_externalImportPayload(S.session))})
|
||
.then(res=>{
|
||
if(!S.session || S.session.session_id !== activeSid) return;
|
||
if(res && res.session && Array.isArray(res.session.messages)){
|
||
const prev = S.messages.length;
|
||
const next = res.session.messages.filter(m => m && m.role);
|
||
if (next.length < prev) return;
|
||
if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return;
|
||
// Carry forward ephemeral turn fields (_turnUsage/
|
||
// _turnDuration/_turnTps/_gatewayRouting/_statusCard/
|
||
// _anchor_stream_id) so
|
||
// gateway-driven CLI refreshes do not drop the badge.
|
||
let _nextToAssign = next;
|
||
if (typeof window._carryForwardEphemeralTurnFields === 'function') {
|
||
_nextToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], next);
|
||
}
|
||
S.messages = _nextToAssign;
|
||
if(S.session && S.session.session_id === activeSid){
|
||
S.session.message_count = next.length;
|
||
const newest = next.length ? next[next.length - 1] : null;
|
||
const newestTs = Number((newest && (newest.timestamp || newest._ts)) || 0);
|
||
if(newestTs){
|
||
S.session.last_message_at = newestTs;
|
||
S.session.updated_at = newestTs;
|
||
}
|
||
}
|
||
if(S.messages.length !== prev){
|
||
renderMessages({preserveScroll:true});
|
||
if(typeof highlightCode==='function') highlightCode();
|
||
}
|
||
}
|
||
})
|
||
.catch(()=>{ /* ignore — next poll will retry */ });
|
||
}
|
||
}
|
||
}
|
||
}catch(e){ /* ignore parse errors */ }
|
||
});
|
||
_gatewaySSE.onerror = () => {
|
||
if(typeof recordClientSSEError==='function') recordClientSSEError('gateway-sessions',{ready_state:_gatewaySSE?_gatewaySSE.readyState:null,reason:'gateway EventSource.onerror'});
|
||
if(_gatewaySSE){
|
||
try{if(_gatewaySSE.readyState!==2)_gatewaySSE.close();}catch(_){ }
|
||
_gatewaySSE = null;
|
||
}
|
||
void probeGatewaySSEStatus();
|
||
};
|
||
}catch(e){
|
||
void probeGatewaySSEStatus();
|
||
}
|
||
}
|
||
|
||
function stopGatewaySSE(){
|
||
if(_gatewaySSE){
|
||
try{if(_gatewaySSE.readyState!==2)_gatewaySSE.close();}catch(_){ }
|
||
_gatewaySSE = null;
|
||
}
|
||
stopGatewayPollFallback();
|
||
_gatewayProbeInFlight = false;
|
||
_gatewaySSEWarningShown = false;
|
||
}
|
||
|
||
let _searchDebounceTimer = null;
|
||
let _contentSearchResults = []; // results from /api/sessions/search content scan
|
||
let _lastSessionSearchQuery = '';
|
||
let _hideSearchPreviewsAfterSelect = false;
|
||
let _archivedSearchPagingQueryActive = false;
|
||
let _serverTimeDelta = 0; // ms offset: client clock - server clock (for clock-skew compensation)
|
||
let _serverTz = ''; // server timezone offset string (e.g. "+0800", "+0000", "-0500")
|
||
|
||
function _sessionSearchRanges(text, query){
|
||
const source=String(text||'');
|
||
const q=String(query||'').trim();
|
||
if(!source||!q) return [];
|
||
const lower=source.toLowerCase();
|
||
const full=q.toLowerCase();
|
||
const ranges=[];
|
||
const collect=(needle)=>{
|
||
if(!needle) return;
|
||
let from=0;
|
||
while(from<lower.length){
|
||
const idx=lower.indexOf(needle,from);
|
||
if(idx<0) break;
|
||
const end=idx+needle.length;
|
||
if(!ranges.some(r=>idx<r.end&&end>r.start)) ranges.push({start:idx,end});
|
||
from=Math.max(end,idx+1);
|
||
}
|
||
};
|
||
collect(full);
|
||
if(!ranges.length&&/\s/.test(full)){
|
||
const seen=new Set();
|
||
full.split(/\s+/).filter(Boolean).sort((a,b)=>b.length-a.length).forEach(token=>{
|
||
if(seen.has(token)) return;
|
||
seen.add(token);
|
||
collect(token);
|
||
});
|
||
}
|
||
return ranges.sort((a,b)=>a.start-b.start);
|
||
}
|
||
|
||
function _appendHighlightedText(parent, text, query, highlightClass){
|
||
const source=String(text||'');
|
||
const ranges=_sessionSearchRanges(source,query);
|
||
if(!ranges.length){
|
||
parent.appendChild(document.createTextNode(source));
|
||
return ranges;
|
||
}
|
||
let pos=0;
|
||
for(const r of ranges){
|
||
if(r.start>pos) parent.appendChild(document.createTextNode(source.slice(pos,r.start)));
|
||
const mark=document.createElement('span');
|
||
mark.className=highlightClass||'session-search-hit';
|
||
mark.textContent=source.slice(r.start,r.end);
|
||
parent.appendChild(mark);
|
||
pos=r.end;
|
||
}
|
||
if(pos<source.length) parent.appendChild(document.createTextNode(source.slice(pos)));
|
||
return ranges;
|
||
}
|
||
|
||
function _sessionSearchContentPreview(session, query){
|
||
if(!session||!query||_hideSearchPreviewsAfterSelect) return '';
|
||
if(session.match_type!=='content') return '';
|
||
const preview=String(session.match_preview||'').replace(/\s+/g,' ').trim();
|
||
return preview||'';
|
||
}
|
||
|
||
function _sessionSearchAddIdCandidate(candidates, seen, value){
|
||
const raw=String(value||'').trim();
|
||
if(!raw) return;
|
||
const add=(candidate)=>{
|
||
const sid=String(candidate||'').trim();
|
||
if(!sid||seen.has(sid)) return;
|
||
seen.add(sid);
|
||
candidates.push(sid);
|
||
};
|
||
add(raw);
|
||
try{add(decodeURIComponent(raw));}catch(_e){}
|
||
}
|
||
|
||
function _sessionSearchCleanUrlToken(token){
|
||
let value=String(token||'').trim();
|
||
value=value.replace(/[\],.;]+$/g,'');
|
||
while(value.endsWith(')')&&value.indexOf('(')<0) value=value.slice(0,-1);
|
||
return value;
|
||
}
|
||
|
||
function _sessionSearchSessionIdCandidates(query){
|
||
const source=String(query||'').trim();
|
||
const candidates=[];
|
||
const seen=new Set();
|
||
if(!source) return candidates;
|
||
_sessionSearchAddIdCandidate(candidates,seen,source);
|
||
|
||
const inspectUrl=(token)=>{
|
||
const cleaned=_sessionSearchCleanUrlToken(token);
|
||
if(!cleaned) return;
|
||
try{
|
||
const url=new URL(cleaned,'http://webui.local');
|
||
const parts=url.pathname.split('/').filter(Boolean);
|
||
const sessionIdx=parts.findIndex(p=>p.toLowerCase()==='session');
|
||
if(sessionIdx>=0&&parts[sessionIdx+1]) _sessionSearchAddIdCandidate(candidates,seen,parts[sessionIdx+1]);
|
||
for(const key of ['session_id','session','sid']){
|
||
const value=url.searchParams.get(key);
|
||
if(value) _sessionSearchAddIdCandidate(candidates,seen,value);
|
||
}
|
||
}catch(_e){}
|
||
};
|
||
|
||
const markdownLinkRe=/\]\(([^\s)]+)\)/g;
|
||
let match;
|
||
while((match=markdownLinkRe.exec(source))) inspectUrl(match[1]);
|
||
|
||
const sessionSchemeRe=/session:\/\/([^\s)>\]]+)/gi;
|
||
while((match=sessionSchemeRe.exec(source))) _sessionSearchAddIdCandidate(candidates,seen,match[1]);
|
||
|
||
const urlRe=/(?:https?:\/\/[^\s<>\]]+|\/session\/[^\s<>\]]+|\?[^\s<>\]]+)/gi;
|
||
while((match=urlRe.exec(source))) inspectUrl(match[0]);
|
||
|
||
const queryParamRe=/(?:^|[?&\s])(session_id|session|sid)=([^&#\s)]+)/gi;
|
||
while((match=queryParamRe.exec(source))) _sessionSearchAddIdCandidate(candidates,seen,match[2]);
|
||
return candidates;
|
||
}
|
||
|
||
function _sessionSearchDirectSessionMatches(sessions, query){
|
||
const candidates=_sessionSearchSessionIdCandidates(query);
|
||
if(!candidates.length) return [];
|
||
const candidateIds=new Set(candidates.map(s=>String(s)));
|
||
return (sessions||[]).filter(s=>s&&candidateIds.has(String(s.session_id||'')));
|
||
}
|
||
|
||
function _sessionSearchDirectAndTitleMatches(sessions, query){
|
||
const source=String(query||'').trim();
|
||
if(!source) return sessions||[];
|
||
const q=source.toLowerCase();
|
||
const titleMatches=(sessions||[]).filter(s=>_sessionDisplayTitle(s).toLowerCase().includes(q));
|
||
const directSessionMatches=_sessionSearchDirectSessionMatches(sessions,source);
|
||
const directSessionIds=new Set(directSessionMatches.map(s=>s.session_id));
|
||
return [...directSessionMatches,...titleMatches.filter(s=>!directSessionIds.has(s.session_id))];
|
||
}
|
||
|
||
function _sessionSearchMergeMatches(sessions, query, contentResults){
|
||
const source=String(query||'').trim();
|
||
if(!source) return sessions||[];
|
||
const directAndTitleMatches=_sessionSearchDirectAndTitleMatches(sessions,source);
|
||
const directOrTitleIds=new Set(directAndTitleMatches.map(s=>s.session_id));
|
||
return [
|
||
...directAndTitleMatches,
|
||
...(contentResults||[]).filter(s=>s&&s.match_type==='content'&&!directOrTitleIds.has(s.session_id))
|
||
];
|
||
}
|
||
|
||
function syncSessionSearchClear(){
|
||
const input=$('sessionSearch');
|
||
const clear=$('sessionSearchClear');
|
||
if(!input||!clear) return;
|
||
clear.hidden=!Boolean(input.value);
|
||
}
|
||
|
||
function clearSessionSearch(focusInput=true){
|
||
const input=$('sessionSearch');
|
||
if(!input) return;
|
||
if(input.value){
|
||
input.value='';
|
||
filterSessions();
|
||
}else{
|
||
syncSessionSearchClear();
|
||
}
|
||
if(focusInput) input.focus();
|
||
}
|
||
|
||
function _syncArchivedSearchPagingRefresh(query){
|
||
const queryActive=Boolean(String(query||'').trim());
|
||
const previous=_archivedSearchPagingQueryActive;
|
||
_archivedSearchPagingQueryActive=queryActive;
|
||
if(!_showArchived||queryActive===previous) return;
|
||
// Archived title/id filtering is client-side. When search becomes active,
|
||
// refetch without archived_limit so matches beyond the first archived page are
|
||
// reachable; when search clears, refetch again to restore normal archive paging.
|
||
if(typeof renderSessionList==='function') void renderSessionList({deferWhileInteracting:false});
|
||
}
|
||
|
||
function filterSessions(){
|
||
// Immediate client-side title filter (no flicker)
|
||
// Debounced content search via API for message text
|
||
syncSessionSearchClear();
|
||
const q = ($('sessionSearch').value || '').trim();
|
||
_syncArchivedSearchPagingRefresh(q);
|
||
if(q!==_lastSessionSearchQuery){
|
||
_lastSessionSearchQuery=q;
|
||
_hideSearchPreviewsAfterSelect=false;
|
||
}
|
||
renderSessionListFromCache();
|
||
clearTimeout(_searchDebounceTimer);
|
||
if (!q) { _contentSearchResults = []; return; }
|
||
_searchDebounceTimer = setTimeout(async () => {
|
||
const requestedQ = q;
|
||
try {
|
||
const data = await api(`/api/sessions/search?q=${encodeURIComponent(requestedQ)}&content=1&depth=5`);
|
||
const currentQ = ($('sessionSearch').value || '').trim();
|
||
if(currentQ!==requestedQ) return;
|
||
const directAndTitleMatches=_sessionSearchDirectAndTitleMatches(_allSessions,currentQ);
|
||
const directOrTitleIds=new Set(directAndTitleMatches.map(s=>s.session_id));
|
||
_contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !directOrTitleIds.has(s.session_id));
|
||
renderSessionListFromCache();
|
||
} catch(e) { /* ignore */ }
|
||
}, 350);
|
||
}
|
||
|
||
function _sessionTimestampMs(session) {
|
||
const raw = Number(session && (session._sidebar_activity_at || session.last_message_at || session.updated_at || session.created_at || 0));
|
||
return Number.isFinite(raw) ? raw * 1000 : 0;
|
||
}
|
||
|
||
function _sessionSortTimestampMs(session) {
|
||
const base = _sessionTimestampMs(session);
|
||
const pending = Number(session && session.pending_started_at);
|
||
const pendingMs = Number.isFinite(pending) ? pending * 1000 : 0;
|
||
return Math.max(base, pendingMs);
|
||
}
|
||
|
||
function _sessionRunningSortRank(session) {
|
||
if(_isSessionEffectivelyStreaming(session)) return 1;
|
||
return session && session.active_stream_id && session.has_pending_user_message ? 1 : 0;
|
||
}
|
||
|
||
function _sessionSidebarSortCompare(a, b) {
|
||
const activeDelta = _sessionRunningSortRank(b) - _sessionRunningSortRank(a);
|
||
if(activeDelta) return activeDelta;
|
||
return _sessionSortTimestampMs(b) - _sessionSortTimestampMs(a);
|
||
}
|
||
|
||
function _serverNowMs() {
|
||
// Compensate for clock skew between client and server (issue #1144).
|
||
// Returns an approximation of the current server time in ms.
|
||
return Date.now() - _serverTimeDelta;
|
||
}
|
||
|
||
function _serverTzOptions() {
|
||
// Build a timeZone option from _serverTz (e.g. "+0800" → "Etc/GMT-8").
|
||
// Falls back to undefined (uses browser timezone) when:
|
||
// - _serverTz is not set or is UTC (no offset to apply)
|
||
// - _serverTz is malformed
|
||
// - _serverTz has a fractional-hour component (India +0530, Iran +0330,
|
||
// Newfoundland -0330, Nepal +0545, etc.) — IANA Etc/GMT zones cannot
|
||
// express half/quarter-hour offsets; use _formatInServerTz() instead
|
||
// for correct fractional-offset formatting.
|
||
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') return undefined;
|
||
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
|
||
if (!m) return undefined;
|
||
if (m[3] !== '00') return undefined; // fractional offset — caller must use _formatInServerTz
|
||
// IANA Etc/GMT uses inverted sign: UTC+8 → "Etc/GMT-8"
|
||
const sign = m[1] === '+' ? '-' : '+';
|
||
return { timeZone: `Etc/GMT${sign}${parseInt(m[2])}` };
|
||
}
|
||
|
||
function _formatInServerTz(date, options) {
|
||
// Format `date` in the server's wall-clock timezone, including correct
|
||
// handling of fractional-hour offsets that Etc/GMT cannot express.
|
||
//
|
||
// Strategy: shift the timestamp by the server's offset, then format with
|
||
// timeZone:'UTC' so no further conversion is applied — the formatted
|
||
// output reads as the wall-clock time in the server's timezone.
|
||
//
|
||
// Falls back to plain `date.toLocaleString(undefined, options)` (browser
|
||
// timezone) when _serverTz is absent, UTC, or malformed.
|
||
if (!_serverTz || _serverTz === '+0000' || _serverTz === '-0000') {
|
||
return date.toLocaleString(undefined, options);
|
||
}
|
||
const m = _serverTz.match(/^([+-])(\d{2})(\d{2})$/);
|
||
if (!m) return date.toLocaleString(undefined, options);
|
||
const sign = m[1] === '+' ? 1 : -1;
|
||
const offsetMin = sign * (parseInt(m[2]) * 60 + parseInt(m[3]));
|
||
const adjusted = new Date(date.getTime() + offsetMin * 60 * 1000);
|
||
return adjusted.toLocaleString(undefined, { ...options, timeZone: 'UTC' });
|
||
}
|
||
|
||
function _localDayOrdinal(timestampMs) {
|
||
const date = new Date(timestampMs);
|
||
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
|
||
}
|
||
|
||
function _sessionCalendarBoundaries(nowMs) {
|
||
nowMs = nowMs || _serverNowMs();
|
||
const now = new Date(nowMs);
|
||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||
const startOfWeek = new Date(startOfToday);
|
||
startOfWeek.setDate(startOfWeek.getDate() - ((startOfWeek.getDay() + 6) % 7));
|
||
const startOfLastWeek = new Date(startOfWeek);
|
||
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
||
return {
|
||
startOfToday: startOfToday.getTime(),
|
||
startOfYesterday: startOfYesterday.getTime(),
|
||
startOfWeek: startOfWeek.getTime(),
|
||
startOfLastWeek: startOfLastWeek.getTime(),
|
||
};
|
||
}
|
||
|
||
function _formatSessionDate(timestampMs, nowMs) {
|
||
nowMs = nowMs || _serverNowMs();
|
||
const date = new Date(timestampMs);
|
||
const now = new Date(nowMs);
|
||
const options = {month:'short', day:'numeric'};
|
||
if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
|
||
return date.toLocaleDateString(undefined, options);
|
||
}
|
||
|
||
function _formatRelativeSessionTime(timestampMs, nowMs) {
|
||
if (!timestampMs) return t('session_time_unknown');
|
||
nowMs = nowMs || _serverNowMs();
|
||
const diffMs = Math.max(0, nowMs - timestampMs);
|
||
const minute = 60 * 1000;
|
||
const hour = 60 * minute;
|
||
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
|
||
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
|
||
if (timestampMs >= startOfToday) {
|
||
if (diffMs < minute) return t('session_time_minutes_ago', 1);
|
||
if (diffMs < hour) {
|
||
const minutes = Math.floor(diffMs / minute);
|
||
return t('session_time_minutes_ago', minutes);
|
||
}
|
||
const hours = Math.floor(diffMs / hour);
|
||
return t('session_time_hours_ago', hours);
|
||
}
|
||
if (timestampMs >= startOfYesterday) return t('session_time_days_ago', 1);
|
||
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
|
||
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
|
||
return _formatSessionDate(timestampMs, nowMs);
|
||
}
|
||
|
||
function _sessionTimeBucketLabel(timestampMs, nowMs) {
|
||
if (!timestampMs) return t('session_time_bucket_older');
|
||
nowMs = nowMs || _serverNowMs();
|
||
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
|
||
if (timestampMs >= startOfToday) return t('session_time_bucket_today');
|
||
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
|
||
if (timestampMs >= startOfWeek) return t('session_time_bucket_this_week');
|
||
if (timestampMs >= startOfLastWeek) return t('session_time_bucket_last_week');
|
||
return t('session_time_bucket_older');
|
||
}
|
||
|
||
function _isChildSession(s){
|
||
return !!(s&&s.parent_session_id&&s.relationship_type==='child_session');
|
||
}
|
||
|
||
function _isForkWithResolvableParent(s, sessionIdsInList){
|
||
return !!(s&&s.session_source==='fork'&&s.parent_session_id&&sessionIdsInList&&sessionIdsInList.has(s.parent_session_id));
|
||
}
|
||
|
||
function _sessionLineageKey(s, sessionIdsInList, sessionsById){
|
||
if(!s||!s.session_id) return null;
|
||
if(_isChildSession(s)) return null;
|
||
if(s.session_source==='fork') return null;
|
||
const lineageKey=s._lineage_root_id||s.lineage_root_id||null;
|
||
if(lineageKey) return lineageKey;
|
||
// WebUI-native context compression may only persist parent_session_id:
|
||
// the preserved parent snapshot is marked pre_compression_snapshot while
|
||
// the new continuation points at it. When both rows are in the sidebar
|
||
// payload, still collapse them into one conversation (#2489).
|
||
const parent=s.parent_session_id&&sessionsById?sessionsById.get(s.parent_session_id):null;
|
||
if(s.pre_compression_snapshot||parent&&parent.pre_compression_snapshot){
|
||
let root=s;
|
||
const seen=new Set();
|
||
while(root&&root.parent_session_id&&sessionsById&&sessionsById.has(root.parent_session_id)&&!seen.has(root.parent_session_id)){
|
||
const next=sessionsById.get(root.parent_session_id);
|
||
if(!next||_isChildSession(next)||next.session_source==='fork'||!(root.pre_compression_snapshot||next.pre_compression_snapshot)) break;
|
||
seen.add(root.session_id);
|
||
root=next;
|
||
}
|
||
return root&&root.session_id||s.parent_session_id||s.session_id;
|
||
}
|
||
// If parent_session_id points to another session in the current list,
|
||
// this is a subagent/fork child without compression metadata — don't
|
||
// collapse it into lineage (#494).
|
||
if(s.parent_session_id && sessionIdsInList && sessionIdsInList.has(s.parent_session_id)){
|
||
return null;
|
||
}
|
||
return s.parent_session_id || null;
|
||
}
|
||
|
||
function _sessionLineageContainsSession(s, sid){
|
||
if(!s||!sid) return false;
|
||
if(s.session_id===sid) return true;
|
||
if(Array.isArray(s._lineage_segments)&&s._lineage_segments.some(seg=>seg&&seg.session_id===sid)) return true;
|
||
if(Array.isArray(s._child_sessions)&&s._child_sessions.some(child=>child&&child.session_id===sid)) return true;
|
||
return false;
|
||
}
|
||
|
||
function _authoritativeLineageTipId(s){
|
||
if(!s) return null;
|
||
return s._lineage_tip_id||s._parent_lineage_tip_id||null;
|
||
}
|
||
|
||
function _resolveSessionIdFromSidebarLineage(sid){
|
||
sid=String(sid||'').trim();
|
||
if(!sid||!Array.isArray(_allSessions)||!_allSessions.length) return sid||null;
|
||
const visibleRows=_collapseSessionLineageForSidebar(_allSessions).filter(row=>row&&!_isChildSession(row));
|
||
if(visibleRows.some(row=>row&&row.session_id===sid)) return sid;
|
||
const candidates=[];
|
||
for(const row of visibleRows){
|
||
if(!row||!row.session_id) continue;
|
||
if(row.relationship_type==='child_session') continue;
|
||
const lineageLike=!!(
|
||
row._lineage_key||row._lineage_root_id||row.lineage_root_id||
|
||
row._compression_segment_count||row.pre_compression_snapshot||
|
||
(Array.isArray(row._lineage_segments)&&row._lineage_segments.length>1)
|
||
);
|
||
if(!lineageLike) continue;
|
||
const key=_sidebarLineageKeyForRow(row);
|
||
if(key===sid||row.parent_session_id===sid||row._lineage_root_id===sid||row.lineage_root_id===sid||_sessionLineageContainsSession(row,sid)){
|
||
candidates.push(row);
|
||
}
|
||
}
|
||
if(!candidates.length) return sid;
|
||
candidates.sort((a,b)=>{
|
||
const bSeg=Number(b&&b._compression_segment_count||b&&b._lineage_collapsed_count||0);
|
||
const aSeg=Number(a&&a._compression_segment_count||a&&a._lineage_collapsed_count||0);
|
||
if(bSeg!==aSeg) return bSeg-aSeg;
|
||
const bSnapshot=!!(b&&b.pre_compression_snapshot);
|
||
const aSnapshot=!!(a&&a.pre_compression_snapshot);
|
||
if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
|
||
return _sessionTimestampMs(b)-_sessionTimestampMs(a);
|
||
});
|
||
return candidates[0].session_id||sid;
|
||
}
|
||
|
||
function _sessionSegmentCount(s){
|
||
if(!s) return 0;
|
||
const counts=[];
|
||
if(typeof s._lineage_collapsed_count==='number') counts.push(s._lineage_collapsed_count);
|
||
if(typeof s._compression_segment_count==='number') counts.push(s._compression_segment_count);
|
||
if(Array.isArray(s._lineage_segments)) counts.push(s._lineage_segments.length);
|
||
const count=Math.max(0,...counts.map(n=>Number.isFinite(n)?n:0));
|
||
return count>1?count:0;
|
||
}
|
||
|
||
function _clearLineageReportCache(){
|
||
_lineageReportCache.clear();
|
||
_lineageReportInflight.clear();
|
||
_lineageReportCacheGeneration++;
|
||
}
|
||
|
||
function _pruneLineageReportCacheToVisibleSessions(sessions){
|
||
const visibleKeys=new Set();
|
||
const rows=Array.isArray(sessions)?sessions:[];
|
||
for(const s of rows){
|
||
const key=_sidebarLineageKeyForRow(s);
|
||
if(key) visibleKeys.add(key);
|
||
}
|
||
// Also retain the cache keys derived from the COLLAPSED/rendered rows. The
|
||
// render loop keys the lineage-report cache by _sidebarLineageKeyForRow on
|
||
// the COLLAPSED row (see renderSessionListFromCache), and collapse can merge
|
||
// segments so a collapsed row's key differs from any single raw input row's
|
||
// key. Mirroring the precedent in _resolveSessionIdFromSidebarLineage, fold
|
||
// the collapsed rows' keys in so a still-visible expanded row is never evicted
|
||
// (and re-fetched every payload) on a chain the raw keys alone wouldn't cover.
|
||
try{
|
||
for(const row of _collapseSessionLineageForSidebar(rows)){
|
||
if(!row||_isChildSession(row)) continue;
|
||
const key=_lineageReportCacheKey(row,_sidebarLineageKeyForRow(row));
|
||
if(key) visibleKeys.add(key);
|
||
}
|
||
}catch(_){ /* defensive: never let a prune-key derivation break list apply */ }
|
||
for(const key of Array.from(_lineageReportCache.keys())){
|
||
if(!visibleKeys.has(key)) _lineageReportCache.delete(key);
|
||
}
|
||
for(const key of Array.from(_lineageReportInflight.keys())){
|
||
if(!visibleKeys.has(key)) _lineageReportInflight.delete(key);
|
||
}
|
||
}
|
||
|
||
function _lineageReportCacheKey(s,lineageKey){
|
||
const key=lineageKey||_sidebarLineageKeyForRow(s)||null;
|
||
const tip=typeof _authoritativeLineageTipId==='function'
|
||
? _authoritativeLineageTipId(s)
|
||
: s&&(s._lineage_tip_id||s._parent_lineage_tip_id)||null;
|
||
return key&&tip&&tip!==key?`${key}::${tip}`:key;
|
||
}
|
||
|
||
function _lineageLocalSegmentCount(s){
|
||
if(!s) return 0;
|
||
if(Array.isArray(s._lineage_segments)) return s._lineage_segments.length;
|
||
return s.session_id?1:0;
|
||
}
|
||
|
||
function _lineageReportNeedsFetch(s,lineageKey,segmentCount){
|
||
const key=_lineageReportCacheKey(s,lineageKey);
|
||
if(!s||!s.session_id||!key) return false;
|
||
const cached=_lineageReportCache.get(key);
|
||
const expectedCount=Number(segmentCount||0);
|
||
if(cached){
|
||
const cachedCount=Array.isArray(cached.segments)?cached.segments.length:0;
|
||
if(!cached.error&&expectedCount>0&&cachedCount>0&&cachedCount!==expectedCount){
|
||
_lineageReportCache.delete(key);
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
if(_lineageReportInflight.has(key)) return false;
|
||
return Number(segmentCount||0)>_lineageLocalSegmentCount(s);
|
||
}
|
||
|
||
function _lineageSegmentsForRender(s,lineageKey,skipCached){
|
||
const segments=[];
|
||
const seen=new Set();
|
||
const currentSid=s&&s.session_id;
|
||
const addSegment=(seg)=>{
|
||
if(!seg||!seg.session_id||seg.session_id===currentSid||seen.has(seg.session_id)) return;
|
||
if(seg.role==='child_session') return;
|
||
seen.add(seg.session_id);
|
||
segments.push({...seg});
|
||
};
|
||
for(const seg of (Array.isArray(s&&s._lineage_segments)?s._lineage_segments:[])) addSegment(seg);
|
||
if(!skipCached){
|
||
const cached=_lineageReportCache.get(_lineageReportCacheKey(s,lineageKey));
|
||
if(cached&&Array.isArray(cached.segments)){
|
||
for(const seg of cached.segments) addSegment(seg);
|
||
}
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
function _fetchLineageReportForRow(s,lineageKey){
|
||
const key=_lineageReportCacheKey(s,lineageKey);
|
||
if(!s||!s.session_id||!key) return Promise.resolve(null);
|
||
if(_lineageReportCache.has(key)) return Promise.resolve(_lineageReportCache.get(key));
|
||
if(_lineageReportInflight.has(key)) return _lineageReportInflight.get(key);
|
||
const generation=_lineageReportCacheGeneration;
|
||
let request;
|
||
request=api('/api/session/lineage/report?session_id='+encodeURIComponent(s.session_id))
|
||
.then(report=>{
|
||
if(generation===_lineageReportCacheGeneration&&_lineageReportInflight.get(key)===request){
|
||
_lineageReportCache.set(key,(report&&report.found!==false)?report:{error:true});
|
||
}
|
||
return report;
|
||
})
|
||
.catch(err=>{
|
||
console.warn('lineage report',err);
|
||
if(generation===_lineageReportCacheGeneration&&_lineageReportInflight.get(key)===request){
|
||
_lineageReportCache.set(key,{error:true});
|
||
}
|
||
return null;
|
||
})
|
||
.finally(()=>{
|
||
if(_lineageReportInflight.get(key)===request) _lineageReportInflight.delete(key);
|
||
});
|
||
_lineageReportInflight.set(key,request);
|
||
return request;
|
||
}
|
||
|
||
function _sidebarLineageKeyForRow(s){
|
||
if(!s) return null;
|
||
if(s.session_source==='fork') return s.session_id||s.parent_session_id||null;
|
||
return s._lineage_key||s._lineage_root_id||s.lineage_root_id||s.parent_session_id||s.session_id||null;
|
||
}
|
||
|
||
function _truncatedSessionId(sid){
|
||
sid=String(sid||'').trim();
|
||
if(!sid) return '';
|
||
if(sid.length<=16) return sid;
|
||
return sid.slice(0,12)+'...';
|
||
}
|
||
|
||
function _sessionTitleForForkParent(parentSid){
|
||
if(!parentSid||!Array.isArray(_allSessions)) return '';
|
||
const parent=_allSessions.find(item=>item&&item.session_id===parentSid);
|
||
const title=parent&&String(parent.title||'').trim();
|
||
if(!title||title==='Untitled') return '';
|
||
return title;
|
||
}
|
||
|
||
function _sessionFullTitleTooltip(rawTitle, cleanTitle, session){
|
||
const fallback=String(cleanTitle||'Untitled').trim()||'Untitled';
|
||
const full=String(rawTitle||fallback).trim()||fallback;
|
||
const title=full.startsWith('[SYSTEM:') ? fallback : full;
|
||
if(typeof t==='function'&&_isReadOnlySession(session)) return t('session_readonly_title_hint', title);
|
||
return title;
|
||
}
|
||
|
||
function _sessionForkTooltip(parentLabel){
|
||
const parent=String(parentLabel||'').trim()||'unknown parent';
|
||
// Preserve the localized "Forked from" base (the catalog key exists in all
|
||
// locales) rather than hardcoding English — the only regression risk in the
|
||
// tooltip rework was dropping t('forked_from') here.
|
||
const prefix=(typeof t==='function'?t('forked_from'):'Forked from');
|
||
return `${prefix}: ${parent}`;
|
||
}
|
||
|
||
function _sessionLineageBadgeTooltip(label, canExpand){
|
||
const base=String(label||'Prior turns').trim()||'Prior turns';
|
||
if(typeof t==='function'){
|
||
return canExpand
|
||
? t('session_lineage_toggle_hint', base)
|
||
: t('session_lineage_static_hint', base);
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function _sessionChildBadgeTooltip(label){
|
||
const base=String(label||'Child sessions').trim()||'Child sessions';
|
||
if(typeof t==='function') return t('session_child_toggle_hint', base);
|
||
return base;
|
||
}
|
||
|
||
function _sessionStateTooltip({isStreaming=false,hasUnread=false}={}){
|
||
if(isStreaming) return 'Conversation is running';
|
||
if(hasUnread) return 'Unread completion';
|
||
return '';
|
||
}
|
||
|
||
function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawReferenceSessions){
|
||
const referenceSessions=Array.isArray(rawReferenceSessions)?rawReferenceSessions:(rawSessions||[]);
|
||
const sessionIdsInList=new Set(referenceSessions.map(s=>s&&s.session_id).filter(Boolean));
|
||
const rawSessionsById=new Map(referenceSessions.filter(s=>s&&s.session_id).map(s=>[s.session_id,s]));
|
||
const cleanSidebarRow=(s)=>{
|
||
const row={...s};
|
||
// Child-session decoration is render-derived. Drop stale copies so an
|
||
// archived child disappears immediately on the next list rebuild instead of
|
||
// lingering under a copied parent row (#4293).
|
||
delete row._child_sessions;
|
||
delete row._child_session_count;
|
||
delete row._child_session_streaming;
|
||
delete row._child_session_has_unread;
|
||
delete row._child_session_attention;
|
||
delete row._child_session_latest_at;
|
||
delete row._sidebar_activity_at;
|
||
return row;
|
||
};
|
||
const rows=(collapsedRows||[])
|
||
.filter(s=>!_isChildSession(s)&&((s&&s.pinned)||!_isForkWithResolvableParent(s, sessionIdsInList)))
|
||
.map(cleanSidebarRow);
|
||
const isChildStreaming=(childRow)=>typeof _isSessionEffectivelyStreaming==='function'
|
||
? _isSessionEffectivelyStreaming(childRow)
|
||
: !!(childRow&&(childRow.active_stream_id||childRow.pending_user_message));
|
||
const childHasUnread=(childRow)=>typeof _hasUnreadForSession==='function'
|
||
? _hasUnreadForSession(childRow)
|
||
: !!(childRow&&childRow.has_unread);
|
||
const bubbleSidebarState=(parentRow, childRow)=>{
|
||
if(isChildStreaming(childRow)) parentRow._child_session_streaming=true;
|
||
if(childHasUnread(childRow)) parentRow._child_session_has_unread=true;
|
||
const childActivityRaw=childRow
|
||
? (childRow._sidebar_activity_at??childRow.last_message_at??childRow.updated_at??childRow.created_at??0)
|
||
: 0;
|
||
const childActivitySec=Number(childActivityRaw);
|
||
if(Number.isFinite(childActivitySec)&&childActivitySec>Number(parentRow._child_session_latest_at||0)){
|
||
parentRow._child_session_latest_at=childActivitySec;
|
||
}
|
||
const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null;
|
||
if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return;
|
||
const priorityFor=(kind)=>kind==='approval'?3:(kind==='clarify'?2:1);
|
||
const current=parentRow._child_session_attention&&typeof parentRow._child_session_attention==='object'
|
||
? parentRow._child_session_attention
|
||
: null;
|
||
const nextPriority=priorityFor(String(childAttention.kind));
|
||
const currentPriority=current?priorityFor(String(current.kind)):0;
|
||
if(!current||nextPriority>currentPriority||(nextPriority===currentPriority&&Number(childAttention.count||0)>Number(current.count||0))){
|
||
parentRow._child_session_attention={...childAttention};
|
||
}
|
||
};
|
||
const visibleBySid=new Map();
|
||
const visibleBySegmentSid=new Map();
|
||
const visibleByLineageKey=new Map();
|
||
const attachDepthCache=new Map();
|
||
const attachDepthFor=(session, seen=new Set())=>{
|
||
if(!session||!session.session_id) return 0;
|
||
if(attachDepthCache.has(session.session_id)) return attachDepthCache.get(session.session_id);
|
||
if(seen.has(session.session_id)) return 0;
|
||
seen.add(session.session_id);
|
||
const parent=session.parent_session_id&&rawSessionsById.get(session.parent_session_id);
|
||
let depth=0;
|
||
if(parent&&(_isChildSession(session)||(_isForkWithResolvableParent(session, sessionIdsInList)&&!(session&&session.pinned)))){
|
||
depth=1+attachDepthFor(parent, seen);
|
||
}
|
||
attachDepthCache.set(session.session_id, depth);
|
||
return depth;
|
||
};
|
||
for(const row of rows){
|
||
if(row&&row.session_id) visibleBySid.set(row.session_id,row);
|
||
const lineageKey=_sidebarLineageKeyForRow(row);
|
||
if(lineageKey&&!visibleByLineageKey.has(lineageKey)) visibleByLineageKey.set(lineageKey,row);
|
||
for(const seg of (Array.isArray(row._lineage_segments)?row._lineage_segments:[])){
|
||
if(seg&&seg.session_id) visibleBySegmentSid.set(seg.session_id,{row,seg});
|
||
}
|
||
}
|
||
const hiddenArchivedChildTree=new Set();
|
||
const archivedRowsVisible=typeof _showArchived!=='undefined'&&!!_showArchived;
|
||
const hasHiddenArchivedAncestor=(session)=>{
|
||
if(!session||!session.session_id||archivedRowsVisible) return false;
|
||
const seen=new Set();
|
||
let parentSid=session.parent_session_id;
|
||
while(parentSid){
|
||
if(hiddenArchivedChildTree.has(parentSid)) return true;
|
||
if(seen.has(parentSid)) break;
|
||
seen.add(parentSid);
|
||
const rawParent=rawSessionsById.get(parentSid);
|
||
if(!rawParent) break;
|
||
if(rawParent.archived) return true;
|
||
parentSid=rawParent.parent_session_id;
|
||
}
|
||
return false;
|
||
};
|
||
const orphans=[];
|
||
const renderableChildIds=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean));
|
||
const attachQueueById=new Map();
|
||
for(const candidate of [...(rawSessions||[]),...(referenceSessions||[])]){
|
||
if(candidate&&candidate.session_id&&!attachQueueById.has(candidate.session_id)) attachQueueById.set(candidate.session_id,candidate);
|
||
}
|
||
const attachQueue=[...attachQueueById.values()].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b));
|
||
for(const child of attachQueue){
|
||
const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id));
|
||
if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue;
|
||
const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned);
|
||
const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id);
|
||
const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable);
|
||
if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue;
|
||
const parentSid=child.parent_session_id;
|
||
let parentRow=visibleBySid.get(parentSid);
|
||
let parentSegment=null;
|
||
if(!parentRow&&visibleBySegmentSid.has(parentSid)){
|
||
const resolved=visibleBySegmentSid.get(parentSid);
|
||
parentRow=resolved.row;
|
||
parentSegment=resolved.seg;
|
||
}
|
||
if(!parentRow&&child._parent_lineage_tip_id){
|
||
parentRow=visibleBySid.get(child._parent_lineage_tip_id)||null;
|
||
}
|
||
if(!parentRow&&child._parent_lineage_root_id){
|
||
parentRow=visibleByLineageKey.get(child._parent_lineage_root_id)||null;
|
||
}
|
||
if(!parentRow){
|
||
parentRow=visibleByLineageKey.get(childLineageKey||parentSid)||null;
|
||
}
|
||
if(!parentRow&&hasHiddenArchivedAncestor(child)){
|
||
hiddenArchivedChildTree.add(child.session_id);
|
||
continue;
|
||
}
|
||
// Cross-surface rows (for example a WebUI continuation from a Telegram
|
||
// conversation) should remain top-level when there is no WebUI-owned parent
|
||
// row to stack under. But if the parent is visible in this same sidebar
|
||
// render, attach normally — delegated subagent rows are also cross-source
|
||
// relative to their WebUI parent and should not be forced into orphans.
|
||
const parentSourceMarker=String(parentRow&&(
|
||
parentRow.session_source||parentRow.raw_source||parentRow.source_tag||parentRow.source
|
||
)||'').toLowerCase();
|
||
const parentIsExternal=parentRow&&(
|
||
(typeof _isExternalSession==='function'&&_isExternalSession(parentRow))||
|
||
(typeof _isMessagingSession==='function'&&_isMessagingSession(parentRow))||
|
||
parentRow.is_cli_session===true||
|
||
parentRow.session_source==='messaging'||
|
||
(parentSourceMarker&&parentSourceMarker!=='webui'&&parentSourceMarker!=='subagent'&&parentSourceMarker!=='other'&&parentSourceMarker!=='fork')
|
||
);
|
||
if(parentRow&&child._cross_surface_child_session&&parentIsExternal){
|
||
if(childRenderable) orphans.push({...child,_orphan_child_session:true});
|
||
continue;
|
||
}
|
||
if(parentRow){
|
||
const childCopy={...child};
|
||
if(parentSegment){
|
||
childCopy._parent_segment_id=parentSegment.session_id;
|
||
childCopy._parent_segment_title=_sessionDisplayTitle(parentSegment)||child.parent_title||'Untitled';
|
||
}
|
||
if(childRenderable&&!isHiddenLineageReferenceChild){
|
||
if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[];
|
||
parentRow._child_sessions.push(childCopy);
|
||
parentRow._child_session_count=parentRow._child_sessions.length;
|
||
}
|
||
bubbleSidebarState(parentRow, childCopy);
|
||
visibleBySegmentSid.set(childCopy.session_id,{row: parentRow, seg: childCopy});
|
||
} else if(childRenderable) {
|
||
// #5305: a delegated subagent child whose WebUI parent is NOT a visible
|
||
// row in this render (filtered out by the active project / profile / source
|
||
// scope, or otherwise absent) must NOT be promoted to a contextless
|
||
// top-level "Subagent Session" orphan — that is the confusing orphan #5244
|
||
// set out to remove for the common case. The parent still exists; it is
|
||
// simply out of the current view, so the child follows its parent's scope
|
||
// and is suppressed here (it re-stacks under the parent once that scope is
|
||
// active). This mirrors the archived-hidden-parent suppression above
|
||
// (hasHiddenArchivedAncestor / #4293), generalizing the "parent hidden"
|
||
// trigger from archived to filtered-out. A cross-surface WebUI child of a
|
||
// genuinely external (messaging/CLI) parent is handled by the parentIsExternal
|
||
// branch above and still orphans as before.
|
||
if(child&&child._cross_surface_child_session&&_isChildSession(child)) continue;
|
||
orphans.push({...child,_orphan_child_session:true});
|
||
}
|
||
}
|
||
return [...rows,...orphans];
|
||
}
|
||
|
||
function _syncSidebarExpansionForActiveSession(rows, activeSid){
|
||
if(!activeSid) return;
|
||
for(const row of rows||[]){
|
||
const key=_sidebarLineageKeyForRow(row);
|
||
if(!key) continue;
|
||
if(Array.isArray(row._child_sessions)&&row._child_sessions.some(child=>child&&child.session_id===activeSid)){
|
||
_expandedChildSessionKeys.add(key);
|
||
}
|
||
if(Array.isArray(row._lineage_segments)&&row._lineage_segments.some(seg=>seg&&seg.session_id===activeSid&&seg.session_id!==row.session_id)){
|
||
_expandedLineageKeys.add(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
function _collapseSessionLineageForSidebar(sessions){
|
||
const result=[];
|
||
const sessionIdsInList=new Set((sessions||[]).map(s=>s.session_id));
|
||
const sessionsById=new Map((sessions||[]).filter(s=>s&&s.session_id).map(s=>[s.session_id,s]));
|
||
const groups=new Map();
|
||
for(const s of sessions||[]){
|
||
const key=_sessionLineageKey(s, sessionIdsInList, sessionsById);
|
||
if(!key){result.push(s);continue;}
|
||
if(!groups.has(key)) groups.set(key,[]);
|
||
groups.get(key).push(s);
|
||
}
|
||
for(const [key,items] of groups.entries()){
|
||
if(items.length<=1){result.push(items[0]);continue;}
|
||
const sorted=[...items].sort((a,b)=>{
|
||
const bSeg=Number(b&&b._compression_segment_count||0);
|
||
const aSeg=Number(a&&a._compression_segment_count||0);
|
||
if(bSeg||aSeg){
|
||
if(bSeg!==aSeg) return bSeg-aSeg;
|
||
}
|
||
// Preserved pre-compression parents can share the same backend segment
|
||
// count as the continuation. Prefer the non-snapshot tip before falling
|
||
// back to timestamps, otherwise a recently-polled parent reopens the
|
||
// older transcript and makes the active continuation look lost.
|
||
const bSnapshot=!!(b&&b.pre_compression_snapshot);
|
||
const aSnapshot=!!(a&&a.pre_compression_snapshot);
|
||
if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot;
|
||
return _sessionTimestampMs(b)-_sessionTimestampMs(a);
|
||
});
|
||
const tipIds=new Set(items.map(item=>typeof _authoritativeLineageTipId==='function'
|
||
? _authoritativeLineageTipId(item)
|
||
: item&&(item._lineage_tip_id||item._parent_lineage_tip_id)||null).filter(Boolean));
|
||
const chosen=sorted.find(item=>tipIds.has(item&&item.session_id))||sorted[0];
|
||
result.push({...chosen,_lineage_key:key,_lineage_collapsed_count:items.length,_lineage_segments:sorted});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function _sessionDisplayTitle(s){
|
||
const rawTitle=String((s&&(s.display_title||s._state_db_title||s.title))||'Untitled').trim();
|
||
const strip=(typeof _stripAttachedFilesMarker==='function')
|
||
? _stripAttachedFilesMarker
|
||
: (text)=>String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim();
|
||
const title=strip(rawTitle);
|
||
return title||'Untitled';
|
||
}
|
||
|
||
function _sessionTitleIsDefaultWebUI(rawTitle){
|
||
const title=String(rawTitle||'').replace(/\s+/g,' ').trim();
|
||
return title==='Hermes WebUI'||/^Hermes WebUI #\d+$/.test(title);
|
||
}
|
||
|
||
function _sessionTitleTags(rawTitle){
|
||
if(_sessionTitleIsDefaultWebUI(rawTitle)) return [];
|
||
return String(rawTitle||'').match(/#(?!\d+\b)[\w-]+/g)||[];
|
||
}
|
||
|
||
function _activeSessionIdForSidebar(){
|
||
if(S.session&&S.session.session_id) return S.session.session_id;
|
||
if(typeof _sessionIdFromLocation==='function') return _sessionIdFromLocation();
|
||
return null;
|
||
}
|
||
|
||
function upsertActiveSessionForLocalTurn({title='', messageCount=0, timestampMs=Date.now()}={}){
|
||
if(!S.session||!S.session.session_id) return;
|
||
const sid=S.session.session_id;
|
||
const nowSec=Math.floor((Number(timestampMs)||Date.now())/1000);
|
||
const localCount=Array.isArray(S.messages)?S.messages.length:0;
|
||
const count=Math.max(Number(S.session.message_count||0),Number(messageCount||0),localCount,1);
|
||
S.session.message_count=count;
|
||
S.session.last_message_at=nowSec;
|
||
S.session.updated_at=nowSec;
|
||
if((S.session.title==='Untitled'||!S.session.title)&&title){
|
||
S.session.title=title;
|
||
}
|
||
const existingIdx=_allSessions.findIndex(s=>s&&s.session_id===sid);
|
||
const row={
|
||
...S.session,
|
||
session_id:sid,
|
||
title:S.session.title||title||'New chat',
|
||
message_count:count,
|
||
last_message_at:nowSec,
|
||
updated_at:nowSec,
|
||
profile:S.session.profile||S.activeProfile||'default',
|
||
is_streaming:true,
|
||
};
|
||
if(existingIdx>=0) _allSessions[existingIdx]={..._allSessions[existingIdx],...row};
|
||
else _allSessions.unshift(row);
|
||
renderSessionListFromCache();
|
||
}
|
||
|
||
function _sessionRowsWithActiveEphemeralSession(rows){
|
||
rows=Array.isArray(rows)?rows:[];
|
||
if(!S.session||!S.session.session_id) return rows;
|
||
const sid=S.session.session_id;
|
||
if(rows.some(s=>s&&s.session_id===sid)) return rows;
|
||
const nowSec=Math.floor(Date.now()/1000);
|
||
const activeRow={
|
||
...S.session,
|
||
session_id:sid,
|
||
title:S.session.title||'New Chat',
|
||
display_title:S.session.display_title||S.session.title||'New Chat',
|
||
message_count:0,
|
||
last_message_at:S.session.last_message_at||S.session.updated_at||nowSec,
|
||
updated_at:S.session.updated_at||S.session.last_message_at||nowSec,
|
||
profile:S.session.profile||S.activeProfile||'default',
|
||
is_streaming:false,
|
||
};
|
||
return [activeRow,...rows];
|
||
}
|
||
|
||
function _ensureActiveSessionRowPresent(rows, sourceRows){
|
||
rows=Array.isArray(rows)?rows:[];
|
||
const activeSid=_activeSessionIdForSidebar();
|
||
if(!activeSid||rows.some(s=>s&&s.session_id===activeSid)) return rows;
|
||
const activeRow=(Array.isArray(sourceRows)?sourceRows:[]).find(s=>s&&s.session_id===activeSid);
|
||
// Only re-inject the active FRESHLY-CREATED 0-message ephemeral chat. An active
|
||
// conversation that already has messages and was filtered out by the search
|
||
// query must stay filtered — re-adding it here would pollute unrelated search
|
||
// results with the current chat (#3408 review, Codex).
|
||
if(activeRow && Number(activeRow.message_count||0)<=0){
|
||
return [activeRow,...rows];
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function clearOptimisticSessionStreaming(sid){
|
||
sid=sid||(S.session&&S.session.session_id)||'';
|
||
if(!sid) return;
|
||
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
|
||
if(S.session&&S.session.session_id===sid){
|
||
S.session.active_stream_id=null;
|
||
S.activeStreamId=null;
|
||
}
|
||
if(Array.isArray(_allSessions)){
|
||
const idx=_allSessions.findIndex(s=>s&&s.session_id===sid);
|
||
if(idx>=0){
|
||
_allSessions[idx]={
|
||
..._allSessions[idx],
|
||
active_stream_id:null,
|
||
pending_user_message:null,
|
||
pending_started_at:null,
|
||
is_streaming:false,
|
||
};
|
||
}
|
||
}
|
||
if(typeof _sessionStreamingById!=='undefined'&&_sessionStreamingById&&typeof _sessionStreamingById.set==='function'){
|
||
_sessionStreamingById.set(sid,false);
|
||
}
|
||
if(typeof _forgetObservedStreamingSession==='function') _forgetObservedStreamingSession(sid);
|
||
renderSessionListFromCache();
|
||
}
|
||
|
||
|
||
function _sessionVirtualWindow(opts){
|
||
const total=Math.max(0, Number(opts&&opts.total)||0);
|
||
const threshold=Math.max(1, Number(opts&&opts.threshold)||SESSION_VIRTUAL_THRESHOLD_ROWS);
|
||
const itemHeight=Math.max(1, Number(opts&&opts.itemHeight)||SESSION_VIRTUAL_ROW_HEIGHT);
|
||
const buffer=Math.max(0, Number(opts&&opts.buffer)||SESSION_VIRTUAL_BUFFER_ROWS);
|
||
const viewportHeight=Math.max(itemHeight, Number(opts&&opts.viewportHeight)||itemHeight*10);
|
||
const visibleRows=Math.max(1, Math.ceil(viewportHeight/itemHeight));
|
||
if(total<=threshold){
|
||
return {virtualized:false,start:0,end:total,topPad:0,bottomPad:0,itemHeight,total};
|
||
}
|
||
let start=Math.floor((Number(opts&&opts.scrollTop)||0)/itemHeight)-buffer;
|
||
start=Math.max(0, Math.min(start, Math.max(0,total-visibleRows)));
|
||
let end=Math.min(total, start+visibleRows+(buffer*2));
|
||
const activeIndex=Number.isFinite(Number(opts&&opts.activeIndex))?Number(opts.activeIndex):-1;
|
||
if(activeIndex>=0&&activeIndex<total&&(activeIndex<start||activeIndex>=end)){
|
||
start=Math.max(0, Math.min(activeIndex-buffer, Math.max(0,total-visibleRows-(buffer*2))));
|
||
end=Math.min(total, start+visibleRows+(buffer*2));
|
||
}
|
||
return {
|
||
virtualized:true,
|
||
start,
|
||
end,
|
||
topPad:start*itemHeight,
|
||
bottomPad:Math.max(0,(total-end)*itemHeight),
|
||
itemHeight,
|
||
total,
|
||
};
|
||
}
|
||
|
||
function _sessionVirtualSpacer(height, where){
|
||
const spacer=document.createElement('div');
|
||
spacer.className='session-virtual-spacer';
|
||
spacer.dataset.virtualSpacer=where||'gap';
|
||
spacer.setAttribute('aria-hidden','true');
|
||
spacer.style.height=Math.max(0,Math.round(height||0))+'px';
|
||
spacer.style.flex='0 0 auto';
|
||
return spacer;
|
||
}
|
||
|
||
function _scheduleSessionVirtualizedRender(){
|
||
_sessionListLastScrollAt=Date.now();
|
||
// While a profile-switch skeleton is up, ignore virtual-scroll events: the
|
||
// cached rows are the PREVIOUS profile's, and repainting them here would
|
||
// clobber the skeleton before the new /api/sessions response lands (#4662
|
||
// Codex gate). The real render clears _sessionListSkeletonActive.
|
||
if(_sessionListSkeletonActive) return;
|
||
if(_renamingSid||_sessionVirtualScrollRaf) return;
|
||
const list=_sessionVirtualScrollList;
|
||
const total=Number(list&&list.dataset&&list.dataset.sessionVirtualTotal||0);
|
||
// Skip the re-render if the list is below the virtualization threshold —
|
||
// there's no virtual window to recompute, and re-rendering would just
|
||
// rebuild the whole DOM on every scroll tick. Without this guard, the
|
||
// unconditional scroll listener (attached for any list) caused
|
||
// user-facing scroll jumps on small lists. (#1669 follow-up)
|
||
if(total>0&&total<=SESSION_VIRTUAL_THRESHOLD_ROWS) return;
|
||
_sessionVirtualScrollRaf=requestAnimationFrame(()=>{
|
||
_sessionVirtualScrollRaf=0;
|
||
const liveList=_sessionVirtualScrollList;
|
||
const liveTotal=Number(liveList&&liveList.dataset&&liveList.dataset.sessionVirtualTotal||0);
|
||
if(liveList&&liveTotal>SESSION_VIRTUAL_THRESHOLD_ROWS){
|
||
const nextWindow=_sessionVirtualWindow({
|
||
total:liveTotal,
|
||
scrollTop:liveList.scrollTop||0,
|
||
viewportHeight:liveList.clientHeight||520,
|
||
itemHeight:SESSION_VIRTUAL_ROW_HEIGHT,
|
||
buffer:SESSION_VIRTUAL_BUFFER_ROWS,
|
||
threshold:SESSION_VIRTUAL_THRESHOLD_ROWS,
|
||
activeIndex:-1,
|
||
});
|
||
const currentStart=Number(liveList.dataset.sessionVirtualStart||0);
|
||
const currentEnd=Number(liveList.dataset.sessionVirtualEnd||0);
|
||
if(nextWindow.virtualized&&nextWindow.start===currentStart&&nextWindow.end===currentEnd) return;
|
||
}
|
||
renderSessionListFromCache();
|
||
});
|
||
}
|
||
|
||
function _ensureSessionVirtualScrollHandler(list){
|
||
if(!list) return;
|
||
if(_sessionVirtualScrollList===list) return;
|
||
if(_sessionVirtualScrollList){
|
||
_sessionVirtualScrollList.removeEventListener('scroll', _scheduleSessionVirtualizedRender);
|
||
_sessionVirtualScrollList.removeEventListener('pointerdown', _markSessionListPointerDown);
|
||
_sessionVirtualScrollList.removeEventListener('pointerup', _markSessionListPointerUp);
|
||
_sessionVirtualScrollList.removeEventListener('pointercancel', _markSessionListPointerUp);
|
||
_sessionVirtualScrollList.removeEventListener('pointerleave', _markSessionListPointerUp);
|
||
}
|
||
_sessionVirtualScrollList=list;
|
||
list.addEventListener('scroll', _scheduleSessionVirtualizedRender, {passive:true});
|
||
list.addEventListener('pointerdown', _markSessionListPointerDown, {passive:true});
|
||
list.addEventListener('pointerup', _markSessionListPointerUp, {passive:true});
|
||
list.addEventListener('pointercancel', _markSessionListPointerUp, {passive:true});
|
||
list.addEventListener('pointerleave', _markSessionListPointerUp, {passive:true});
|
||
}
|
||
|
||
function _markSessionListPointerDown(){
|
||
_sessionListPointerActive=true;
|
||
_sessionListLastScrollAt=Date.now();
|
||
}
|
||
|
||
function _markSessionListPointerUp(){
|
||
_sessionListPointerActive=false;
|
||
_sessionListLastScrollAt=Date.now();
|
||
if(_pendingSessionListPayload) _schedulePendingSessionListApply();
|
||
}
|
||
|
||
let _sessionVirtualResyncRaf = 0;
|
||
function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtualWindow){
|
||
if(!list||!virtualWindow||!virtualWindow.virtualized) return;
|
||
expectedScrollTop=Number(expectedScrollTop)||0;
|
||
if(expectedScrollTop<=0) return;
|
||
if(_sessionVirtualResyncRaf) cancelAnimationFrame(_sessionVirtualResyncRaf);
|
||
_sessionVirtualResyncRaf=requestAnimationFrame(()=>{
|
||
_sessionVirtualResyncRaf=0;
|
||
if(_renamingSid) return;
|
||
const actualScrollTop=Number(list.scrollTop)||0;
|
||
const tolerance=Math.max(2, Number(virtualWindow.itemHeight||SESSION_VIRTUAL_ROW_HEIGHT)/2);
|
||
if(Math.abs(actualScrollTop-expectedScrollTop)<=tolerance) return;
|
||
renderSessionListFromCache();
|
||
});
|
||
}
|
||
|
||
// Top-level so BOTH the sidebar visibility predicate (_sidebarRowHasVisibleMessages,
|
||
// reached via renderSessionListFromCache -> _partitionSidebarSessionRows) and the
|
||
// per-row renderer (_renderOneSession, nested in renderSessionListFromCache) can call
|
||
// it. It was previously declared INSIDE renderSessionListFromCache and relied on
|
||
// function hoisting — but hoisting is scoped to the enclosing function, so the
|
||
// top-level _sidebarRowHasVisibleMessages threw "ReferenceError: _sessionAttentionState
|
||
// is not defined" on every cache render, crashing the sidebar (#3696, regressed in
|
||
// #3672 when _sidebarRowHasVisibleMessages was extracted to top level). Pure function
|
||
// (only its arg `s` plus the i18n global `t`), so hoisting it is safe.
|
||
function _sessionAttentionState(s){
|
||
const attention=s&&s.attention&&typeof s.attention==='object'?s.attention:null;
|
||
if(!attention||!attention.kind||!Number.isFinite(Number(attention.count))||Number(attention.count)<=0)return null;
|
||
const kind=String(attention.kind)==='approval'?'approval':(String(attention.kind)==='clarify'?'clarify':'attention');
|
||
const count=Math.max(1,Number(attention.count)||1);
|
||
const labelKey=kind==='approval'?'session_attention_approval':(kind==='clarify'?'session_attention_clarify':'session_attention_generic');
|
||
const titleKey=kind==='approval'?'session_attention_approval_title':(kind==='clarify'?'session_attention_clarify_title':'session_attention_generic_title');
|
||
const fallback=kind==='approval'?(count===1?'Approval':`${count} approvals`):(kind==='clarify'?(count===1?'Question':`${count} questions`):(count===1?'Attention':`${count} items`));
|
||
const titleFallback=kind==='approval'?'Waiting for permission decision':(kind==='clarify'?'Waiting for your answer':'Waiting for user action');
|
||
const label=(typeof t==='function')?t(labelKey,count):fallback;
|
||
const title=(typeof t==='function')?t(titleKey,count):titleFallback;
|
||
return {kind,count,severity:String(attention.severity||''),label,title};
|
||
}
|
||
|
||
function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
|
||
return (s.message_count||0)>0 ||
|
||
_sessionAttentionState(s) ||
|
||
_isSessionEffectivelyStreaming(s) ||
|
||
!!s.active_stream_id ||
|
||
!!s.pending_user_message ||
|
||
!!s.has_pending_user_message ||
|
||
(activeSidForSidebar&&s.session_id===activeSidForSidebar) ||
|
||
// #5306: a linked delegate child of the currently-active/streaming parent
|
||
// must stay rendered for the duration of the parent's turn. A subagent child
|
||
// that transiently reports message_count===0 between /api/sessions polls would
|
||
// otherwise be dropped HERE (before _attachChildSessionsToSidebarRows ever sees
|
||
// it), so it never reaches sessionsRaw, vanishes from the sidebar, then
|
||
// reappears on the next refresh once its list metadata catches up — the flicker.
|
||
// Scoped to children of the ACTIVE parent, mirroring the active-session
|
||
// exception above, so unrelated truly-empty sessions are still hidden.
|
||
(activeSidForSidebar&&s.parent_session_id===activeSidForSidebar&&_isChildSession(s)) ||
|
||
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
|
||
}
|
||
|
||
function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||
let cliSessionCount=0;
|
||
const webuiProfileFiltered=[];
|
||
const cliProfileFiltered=[];
|
||
const webuiReferenceRaw=[];
|
||
const cliReferenceRaw=[];
|
||
const webuiSessionsRaw=[];
|
||
const cliSessionsRaw=[];
|
||
let webuiArchivedCount=0;
|
||
let cliArchivedCount=0;
|
||
for(const s of allMatched){
|
||
if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue;
|
||
const isCli=_isCliSession(s);
|
||
if(isCli) cliSessionCount++;
|
||
if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue;
|
||
const profileFiltered=isCli ? cliProfileFiltered : webuiProfileFiltered;
|
||
const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw;
|
||
const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw;
|
||
profileFiltered.push(s);
|
||
if(_activeProject===NO_PROJECT_FILTER){
|
||
if(s.project_id) continue;
|
||
} else if(_activeProject){
|
||
if(s.project_id!==_activeProject) continue;
|
||
}
|
||
referenceRaw.push(s);
|
||
if(s.archived){
|
||
if(isCli) cliArchivedCount++;
|
||
else webuiArchivedCount++;
|
||
}
|
||
if(!_showArchived&&s.archived) continue;
|
||
sessionsRaw.push(s);
|
||
}
|
||
if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){
|
||
_sessionSourceFilter='webui';
|
||
}
|
||
const showCliOnly=_sessionSourceFilter==='cli';
|
||
const serverArchivedCount=showCliOnly?_archivedCliCount:_archivedWebuiCount;
|
||
return {
|
||
cliSessionCount,
|
||
profileFiltered: showCliOnly ? cliProfileFiltered : webuiProfileFiltered,
|
||
sessionsRaw: showCliOnly ? cliSessionsRaw : webuiSessionsRaw,
|
||
archivedCount: Math.max(showCliOnly ? cliArchivedCount : webuiArchivedCount, Number(serverArchivedCount||0)),
|
||
webuiReferenceRaw,
|
||
cliReferenceRaw,
|
||
webuiSessionsRaw,
|
||
cliSessionsRaw,
|
||
};
|
||
}
|
||
|
||
// Hidden archived-ancestor reference rows (sidebar_reference_sessions) arrive
|
||
// from /api/sessions WITHOUT the client-side project/source scoping that
|
||
// _partitionSidebarSessionRows applies to the visible rows. Appending them to
|
||
// EVERY render unconditionally let an archived parent from a DIFFERENT project
|
||
// (or the other source bucket) enter a project/source-filtered render's
|
||
// suppression context — silently hiding a visible child/fork whose archived
|
||
// ancestor lives outside the current view. Scope the references to the same
|
||
// project + source bucket as the render they feed before using them.
|
||
function _scopedSidebarReferenceRows(isCli){
|
||
if(typeof _sidebarReferenceSessions==='undefined'||!Array.isArray(_sidebarReferenceSessions)||!_sidebarReferenceSessions.length) return [];
|
||
return _sidebarReferenceSessions.filter(s=>{
|
||
if(!s) return false;
|
||
// Source scope: only references in the same webui/cli bucket as this render.
|
||
if(_isCliSession(s)!==!!isCli) return false;
|
||
// Project scope: mirror _partitionSidebarSessionRows exactly.
|
||
if(_activeProject===NO_PROJECT_FILTER){ if(s.project_id) return false; }
|
||
else if(_activeProject){ if(s.project_id!==_activeProject) return false; }
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){
|
||
const referenceRows=Array.isArray(referenceSessionsRaw)?referenceSessionsRaw:sessionsRaw;
|
||
return _attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows);
|
||
}
|
||
|
||
function _attachProjectQuickCreateButton(chip, project){
|
||
const btn=document.createElement('button');
|
||
btn.type='button';
|
||
btn.className='project-chip-quick-create';
|
||
btn.textContent='+';
|
||
btn.title='New conversation in this project';
|
||
btn.setAttribute('aria-label','New conversation in this project');
|
||
const stop=function(e){
|
||
if(!e) return;
|
||
if(typeof e.preventDefault==='function') e.preventDefault();
|
||
if(typeof e.stopPropagation==='function') e.stopPropagation();
|
||
if(typeof e.stopImmediatePropagation==='function') e.stopImmediatePropagation();
|
||
};
|
||
const stopTouchBubble=function(e){
|
||
if(!e) return;
|
||
if(typeof e.stopPropagation==='function') e.stopPropagation();
|
||
if(typeof e.stopImmediatePropagation==='function') e.stopImmediatePropagation();
|
||
};
|
||
btn.onclick=async(e)=>{
|
||
stop(e);
|
||
if(_newSessionInFlight){
|
||
// The initiating tap already owns the filter change and rollback path.
|
||
try{
|
||
await newSession(false,{project_id:project.project_id});
|
||
}catch(_){
|
||
// The initiating tap already owns the visible failure path.
|
||
}
|
||
return;
|
||
}
|
||
const previousProject=(typeof _activeProject!=='undefined')?_activeProject:NO_PROJECT_FILTER;
|
||
_setActiveProjectFilter(project.project_id);
|
||
try{
|
||
await newSession(false,{project_id:project.project_id});
|
||
// newSession() does not repaint the sidebar (callers own that — see the
|
||
// newSession contract). Repaint from the post-create state so the new
|
||
// project-assigned session appears deterministically.
|
||
try{ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); }catch(_){}
|
||
try{ if(typeof renderSessionList==='function') void renderSessionList({deferWhileInteracting:false}); }catch(_){}
|
||
}catch(err){
|
||
_setActiveProjectFilter(previousProject);
|
||
if(typeof showToast==='function') showToast('New conversation failed: '+(err&&err.message||err));
|
||
}
|
||
};
|
||
btn.ondblclick=(e)=>{stop(e);};
|
||
btn.oncontextmenu=(e)=>{stop(e);};
|
||
btn.ontouchstart=(e)=>{stopTouchBubble(e);};
|
||
btn.ontouchend=(e)=>{stopTouchBubble(e);};
|
||
chip.appendChild(btn);
|
||
}
|
||
|
||
|
||
function renderSessionListFromCache(){
|
||
// #4671: while a profile-switch skeleton is up, bail — _allSessions still holds the
|
||
// PREVIOUS profile's rows until /api/sessions resolves, so any unrelated caller
|
||
// (sidebar SSE syncs, stream/unread updates, gateway-poll timers, panel-resync
|
||
// repairs) hitting this mid-switch would repaint the wrong profile's rows over the
|
||
// skeleton. The authoritative switch render clears the flag from inside
|
||
// _applySessionListPayload — once _allSessions is fresh — so only a render backed by
|
||
// up-to-date data replaces the skeleton. The failure-restore path clears it too.
|
||
if(_sessionListSkeletonActive) return;
|
||
// Don't re-render while user is actively renaming a session (would destroy the input)
|
||
if(_renamingSid) return;
|
||
// Keep the per-conversation actions menu stable while the user is trying to
|
||
// click it. Sidebar syncs, stream/unread updates, and panel-resync repairs can
|
||
// all call this while the fixed-position menu is open; rebuilding the row DOM
|
||
// here removes the anchor and makes the menu feel unclickable.
|
||
if(_sessionActionMenu) return;
|
||
closeSessionActionMenu();
|
||
// Purge stale INFLIGHT entries for sessions the server confirms are NOT
|
||
// streaming. This runs on every list refresh to prevent memory leaks from
|
||
// interrupted streams. (#2066)
|
||
_purgeStaleInflightEntries();
|
||
const searchQueryRaw=($('sessionSearch').value||'').trim();
|
||
const q=searchQueryRaw.toLowerCase();
|
||
const activeSidForSidebar=_activeSessionIdForSidebar();
|
||
const sidebarRows=_sessionRowsWithActiveEphemeralSession(_allSessions);
|
||
// Merge direct session-id/link matches, title matches, then content matches (deduped).
|
||
// Direct matches must not disable content search: if a user pasted the same
|
||
// session id into another conversation, that content hit should still appear.
|
||
const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults);
|
||
const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows);
|
||
const {
|
||
cliSessionCount,
|
||
profileFiltered,
|
||
sessionsRaw,
|
||
archivedCount,
|
||
webuiReferenceRaw,
|
||
cliReferenceRaw,
|
||
webuiSessionsRaw,
|
||
cliSessionsRaw,
|
||
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
|
||
const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw;
|
||
const isCliView=_sessionSourceFilter==='cli';
|
||
const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)]);
|
||
// Server-provided source bucket counts are authoritative for the current
|
||
// payload. When present, skip the expensive cross-bucket render/count pass;
|
||
// null is a deliberate "not computed" sentinel consumed only by
|
||
// _sessionSourceTabCount's fallback path below.
|
||
const renderedWebuiSessionCount=_serverWebuiSessionCount===null
|
||
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length
|
||
: null;
|
||
const renderedCliSessionCount=_serverCliSessionCount===null
|
||
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length
|
||
: null;
|
||
const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);
|
||
const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);
|
||
_syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar);
|
||
const list=$('sessionList');
|
||
const animateRefresh=_sessionListRefreshAnimationPending;
|
||
_sessionListRefreshAnimationPending=false;
|
||
const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending;
|
||
_sessionListEnterAllAnimationPending=false;
|
||
const flipBefore=animateRefresh?_captureSessionReflowPositions():null;
|
||
const committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;
|
||
const committedSwipeReflowDelay=Math.max(0,committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);
|
||
const listScrollTopBeforeRender=list.scrollTop||0;
|
||
list.innerHTML='';
|
||
// #4671: belt-and-suspenders. The authoritative skeleton-clear happens in
|
||
// _applySessionListPayload (once fresh data is in hand) BEFORE this function is
|
||
// reached, and the guard at the top of renderSessionListFromCache bails while the
|
||
// flag is still true — so by the time we paint here the flag is already false. Keep
|
||
// this assignment as a defensive backstop for any future non-switch caller that
|
||
// reaches a real paint with the flag somehow still set.
|
||
_sessionListSkeletonActive=false;
|
||
// Batch select bar (when in select mode)
|
||
if(_sessionSelectMode){
|
||
const selectBar=document.createElement('div');selectBar.className='session-select-bar';
|
||
const exitBtn=document.createElement('button');exitBtn.className='batch-exit-btn';
|
||
exitBtn.textContent='\u2715';exitBtn.title='Exit select mode';
|
||
exitBtn.onclick=(e)=>{e.stopPropagation();exitSessionSelectMode();};
|
||
selectBar.appendChild(exitBtn);
|
||
const selectAllBtn=document.createElement('button');selectAllBtn.className='batch-select-all-btn';
|
||
selectAllBtn.textContent=t('session_select_all');
|
||
selectAllBtn.onclick=(e)=>{e.stopPropagation();selectAllSessions();};
|
||
selectBar.appendChild(selectAllBtn);
|
||
list.appendChild(selectBar);
|
||
}
|
||
// Ensure batch action bar exists in DOM
|
||
let batchBar=$('batchActionBar');
|
||
if(!batchBar){batchBar=document.createElement('div');batchBar.id='batchActionBar';batchBar.className='batch-action-bar';}
|
||
list.appendChild(batchBar);
|
||
if(_sessionSelectMode&&_selectedSessions.size>0){batchBar.style.display='flex';_renderBatchActionBar();}
|
||
else{batchBar.style.display='none';}
|
||
if(_sessionListLoadError){
|
||
const note=_renderSessionListLoadErrorNote();
|
||
if(note) list.appendChild(note);
|
||
}
|
||
if(window._showCliSessions || cliSessionCount>0){
|
||
const sourceTabs=document.createElement('div');
|
||
sourceTabs.className='session-source-tabs';
|
||
for(const filter of ['webui','cli']){
|
||
const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount;
|
||
const btn=document.createElement('button');
|
||
btn.type='button';
|
||
btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':'');
|
||
btn.textContent=_sessionSourceLabel(filter,count);
|
||
btn.setAttribute('aria-pressed', _sessionSourceFilter===filter?'true':'false');
|
||
btn.onclick=()=>_setSessionSourceFilter(filter);
|
||
sourceTabs.appendChild(btn);
|
||
}
|
||
list.appendChild(sourceTabs);
|
||
}
|
||
// Project filter bar — show when there are real projects OR there are
|
||
// unassigned sessions (so the Unassigned chip has something to filter to).
|
||
const hasUnprojected=profileFiltered.some(s=>!s.project_id);
|
||
if(_allProjects.length>0||hasUnprojected){
|
||
const bar=document.createElement('div');
|
||
bar.className='project-bar';
|
||
// "All" chip
|
||
const allChip=document.createElement('span');
|
||
allChip.className='project-chip'+(!_activeProject?' active':'');
|
||
allChip.textContent='All';
|
||
allChip.onclick=()=>{_setActiveProjectFilter(null);};
|
||
bar.appendChild(allChip);
|
||
// "Unassigned" chip — only when there are sessions with no project to
|
||
// filter to. Hidden in the common case where every session is already
|
||
// organized, to keep the chip bar uncluttered.
|
||
if(hasUnprojected){
|
||
const noneChip=document.createElement('span');
|
||
noneChip.className='project-chip no-project'+(_activeProject===NO_PROJECT_FILTER?' active':'');
|
||
noneChip.textContent='Unassigned';
|
||
noneChip.title='Show conversations not yet assigned to a project';
|
||
noneChip.onclick=()=>{_setActiveProjectFilter(NO_PROJECT_FILTER);};
|
||
bar.appendChild(noneChip);
|
||
}
|
||
// Project chips
|
||
for(const p of _allProjects){
|
||
const chip=document.createElement('span');
|
||
chip.className='project-chip'+(p.project_id===_activeProject?' active':'');
|
||
if(p.color){
|
||
const dot=document.createElement('span');
|
||
dot.className='color-dot';
|
||
dot.style.background=p.color;
|
||
chip.appendChild(dot);
|
||
}
|
||
const nameSpan=document.createElement('span');
|
||
nameSpan.textContent=p.name;
|
||
chip.appendChild(nameSpan);
|
||
let _pClickTimer=null;
|
||
chip.onclick=(e)=>{
|
||
clearTimeout(_pClickTimer);
|
||
_pClickTimer=setTimeout(()=>{_pClickTimer=null;_setActiveProjectFilter(p.project_id);},220);
|
||
};
|
||
chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);};
|
||
chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);};
|
||
// Touch long-press → context menu (mobile UX: project chips can only be
|
||
// deleted via the right-click menu, which has no touch equivalent).
|
||
let _lpTimer=null;
|
||
let _lpHandled=false;
|
||
let _lpStartX=0,_lpStartY=0;
|
||
chip.addEventListener('touchstart',(e)=>{
|
||
const t=e.changedTouches&&e.changedTouches[0];
|
||
if(!t) return;
|
||
// Clear any in-flight timer before scheduling a new one, mirroring the
|
||
// session-item long-press path (_clearLongPressTimer). Without this a
|
||
// second finger / stray touchstart orphans the prior timer, which then
|
||
// fires unsuppressed ~500ms later and pops the menu after the gesture
|
||
// was cancelled.
|
||
if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}
|
||
_lpHandled=false;_lpStartX=t.clientX;_lpStartY=t.clientY;
|
||
chip.classList.add('long-pressing');
|
||
_lpTimer=setTimeout(()=>{
|
||
_lpTimer=null;
|
||
if(_lpHandled) return; // already consumed by another gesture — stale fire is a no-op
|
||
_lpHandled=true;
|
||
chip.classList.remove('long-pressing');
|
||
clearTimeout(_pClickTimer);_pClickTimer=null;
|
||
const syn={clientX:t.clientX,clientY:t.clientY,preventDefault:()=>{}};
|
||
_showProjectContextMenu(syn,p,chip);
|
||
},500);
|
||
},{passive:true});
|
||
chip.addEventListener('touchmove',(e)=>{
|
||
if(!_lpTimer) return;
|
||
const t=e.changedTouches&&e.changedTouches[0];
|
||
if(!t) return;
|
||
if(Math.abs(t.clientX-_lpStartX)>10||Math.abs(t.clientY-_lpStartY)>10){
|
||
clearTimeout(_lpTimer);_lpTimer=null;
|
||
chip.classList.remove('long-pressing');
|
||
}
|
||
},{passive:true});
|
||
chip.addEventListener('touchend',(e)=>{
|
||
clearTimeout(_lpTimer);_lpTimer=null;
|
||
chip.classList.remove('long-pressing');
|
||
if(_lpHandled){e.preventDefault();e.stopPropagation();}
|
||
},{passive:false});
|
||
chip.addEventListener('touchcancel',()=>{
|
||
clearTimeout(_lpTimer);_lpTimer=null;_lpHandled=false;
|
||
chip.classList.remove('long-pressing');
|
||
},{passive:true});
|
||
if(window._projectQuickCreate) _attachProjectQuickCreateButton(chip,p);
|
||
bar.appendChild(chip);
|
||
}
|
||
// Create button
|
||
const addBtn=document.createElement('button');
|
||
addBtn.className='project-create-btn';
|
||
addBtn.textContent='+';
|
||
addBtn.title='New project';
|
||
addBtn.onclick=(e)=>{e.stopPropagation();_startProjectCreate(bar,addBtn);};
|
||
bar.appendChild(addBtn);
|
||
list.appendChild(bar);
|
||
}
|
||
// Profile filter toggle (show sessions from other profiles).
|
||
// Cross-profile rows live SERVER-SIDE behind ?all_profiles=1, so the toggle
|
||
// must trigger a refetch — there's no client-cached aggregate to slice through.
|
||
// The server is authoritative for the count (renamed-root cross-alias is
|
||
// server-side). A naive strict-equality client fallback would mis-count.
|
||
const otherProfileCount = _otherProfileCount;
|
||
if(otherProfileCount>0&&!_showAllProfiles){
|
||
const pfToggle=document.createElement('div');
|
||
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||
pfToggle.textContent='Show '+otherProfileCount+' from other profiles';
|
||
pfToggle.onclick=()=>{_setShowAllProfiles(true);renderSessionList({deferWhileInteracting:false});};
|
||
list.appendChild(pfToggle);
|
||
} else if(_showAllProfiles){
|
||
const pfToggle=document.createElement('div');
|
||
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||
pfToggle.textContent='Show active profile only';
|
||
pfToggle.onclick=()=>{_setShowAllProfiles(false);renderSessionList({deferWhileInteracting:false});};
|
||
list.appendChild(pfToggle);
|
||
}
|
||
// Show/hide archived toggle if there are archived sessions. Archived rows
|
||
// are fetched on demand so large histories do not bloat every sidebar poll.
|
||
if(archivedCount>0||_showArchived){
|
||
const toggle=document.createElement('div');
|
||
toggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||
toggle.textContent=_showArchived?'Hide archived':'Show '+archivedCount+' archived';
|
||
toggle.onclick=()=>{
|
||
_showArchived=!_showArchived;
|
||
if(_showArchived) _archivedRowsLoadedLimit=SESSION_ARCHIVED_PAGE_SIZE;
|
||
renderSessionList();
|
||
};
|
||
list.appendChild(toggle);
|
||
}
|
||
// Empty state for active project filter
|
||
if(_sessionSourceFilter==='cli'&&sessions.length===0){
|
||
const empty=document.createElement('div');
|
||
empty.className='session-empty-note';
|
||
empty.textContent=window._showCliSessions?'No CLI sessions found.':'Enable Show agent sessions in Settings to list CLI sessions here.';
|
||
list.appendChild(empty);
|
||
} else if(_activeProject&&sessions.length===0){
|
||
const empty=document.createElement('div');
|
||
empty.className='session-empty-note';
|
||
empty.textContent=_activeProject===NO_PROJECT_FILTER?'No unassigned sessions.':'No sessions in this project yet.';
|
||
list.appendChild(empty);
|
||
}
|
||
const orderedSessions=[...sessions].sort(_sessionSidebarSortCompare);
|
||
// Separate pinned from unpinned
|
||
const pinned=orderedSessions.filter(s=>s.pinned);
|
||
const unpinned=orderedSessions.filter(s=>!s.pinned);
|
||
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
|
||
const now=_serverNowMs();
|
||
// Collapse state persisted in localStorage
|
||
let _groupCollapsed={};
|
||
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
|
||
const _saveCollapsed=()=>{try{localStorage.setItem('hermes-date-groups-collapsed',JSON.stringify(_groupCollapsed));}catch(e){}};
|
||
// Group sessions by date
|
||
const groups=[];
|
||
let curLabel=null,curItems=[];
|
||
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
|
||
for(const s of unpinned){
|
||
const ts=_sessionSortTimestampMs(s);
|
||
const label=_sessionTimeBucketLabel(ts, now);
|
||
if(label!==curLabel){
|
||
if(curItems.length) groups.push({label:curLabel,items:curItems});
|
||
curLabel=label;curItems=[s];
|
||
} else { curItems.push(s); }
|
||
}
|
||
if(curItems.length) groups.push({label:curLabel,items:curItems});
|
||
const flatSessionRows=[];
|
||
for(const g of groups){
|
||
if(_groupCollapsed[g.label]) continue;
|
||
for(const s of g.items){ flatSessionRows.push({group:g,session:s}); }
|
||
}
|
||
_sessionVisibleSidebarIds=flatSessionRows.map(row=>row.session&&row.session.session_id).filter(Boolean);
|
||
for(const row of flatSessionRows){
|
||
const s=row.session;
|
||
if(!s||!Array.isArray(s._child_sessions)) continue;
|
||
const key=_sidebarLineageKeyForRow(s);
|
||
if(!_expandedChildSessionKeys.has(key)&&!searchQueryRaw) continue;
|
||
for(const child of s._child_sessions){
|
||
if(child&&child.session_source==='fork'&&child.session_id&&!_isReadOnlySession(child)){
|
||
_sessionVisibleSidebarIds.push(child.session_id);
|
||
}
|
||
}
|
||
}
|
||
_ensureSessionVirtualScrollHandler(list);
|
||
const activeIndex=flatSessionRows.findIndex(row=>_sessionLineageContainsSession(row.session,activeSidForSidebar));
|
||
const shouldAnchorActive=activeSidForSidebar&&activeIndex>=0&&(
|
||
list.dataset.sessionVirtualActiveAnchor!==activeSidForSidebar||
|
||
list.dataset.sessionVirtualFilter!==q
|
||
);
|
||
const virtualWindowBeforeActiveAnchor=_sessionVirtualWindow({
|
||
total:flatSessionRows.length,
|
||
scrollTop:listScrollTopBeforeRender,
|
||
viewportHeight:list.clientHeight||520,
|
||
itemHeight:SESSION_VIRTUAL_ROW_HEIGHT,
|
||
buffer:SESSION_VIRTUAL_BUFFER_ROWS,
|
||
threshold:SESSION_VIRTUAL_THRESHOLD_ROWS,
|
||
activeIndex:-1,
|
||
});
|
||
const activeWasAlreadyVisible=activeIndex>=virtualWindowBeforeActiveAnchor.start&&activeIndex<virtualWindowBeforeActiveAnchor.end;
|
||
const shouldMoveSidebarToActive=shouldAnchorActive&&!activeWasAlreadyVisible;
|
||
let virtualWindow=_sessionVirtualWindow({
|
||
total:flatSessionRows.length,
|
||
scrollTop:listScrollTopBeforeRender,
|
||
viewportHeight:list.clientHeight||520,
|
||
itemHeight:SESSION_VIRTUAL_ROW_HEIGHT,
|
||
buffer:SESSION_VIRTUAL_BUFFER_ROWS,
|
||
threshold:SESSION_VIRTUAL_THRESHOLD_ROWS,
|
||
activeIndex:shouldMoveSidebarToActive?activeIndex:-1,
|
||
});
|
||
let virtualAnchorScrollTop=null;
|
||
if(shouldMoveSidebarToActive&&virtualWindow.virtualized){
|
||
list.dataset.sessionVirtualActiveAnchor=activeSidForSidebar;
|
||
virtualAnchorScrollTop=virtualWindow.topPad;
|
||
}else if(activeSidForSidebar){
|
||
list.dataset.sessionVirtualActiveAnchor=activeSidForSidebar;
|
||
}else{
|
||
delete list.dataset.sessionVirtualActiveAnchor;
|
||
}
|
||
list.dataset.sessionVirtualTotal=String(flatSessionRows.length);
|
||
list.dataset.sessionVirtualFilter=q;
|
||
list.dataset.sessionVirtualStart=String(virtualWindow.start);
|
||
list.dataset.sessionVirtualEnd=String(virtualWindow.end);
|
||
// Render groups with collapsible headers. Large sidebars render only the
|
||
// current session-row window plus top/bottom spacers inside each group body;
|
||
// headers remain real DOM so pin/archive/date grouping and clicks survive.
|
||
let globalSessionRowIndex=0;
|
||
for(const g of groups){
|
||
const wrapper=document.createElement('div');
|
||
wrapper.className='session-date-group';
|
||
const hdr=document.createElement('div');
|
||
hdr.className='session-date-header'+(g.isPinned?' pinned':'');
|
||
const caret=document.createElement('span');
|
||
caret.className='session-date-caret';
|
||
caret.textContent='\u25BE'; // down when expanded; rotated right when collapsed
|
||
const label=document.createElement('span');
|
||
label.textContent=g.label;
|
||
hdr.appendChild(caret);hdr.appendChild(label);
|
||
const body=document.createElement('div');
|
||
body.className='session-date-body';
|
||
const isGroupCollapsed=Boolean(_groupCollapsed[g.label]);
|
||
if(isGroupCollapsed){body.style.display='none';caret.classList.add('collapsed');}
|
||
hdr.onclick=()=>{
|
||
const isCollapsed=body.style.display==='none';
|
||
body.style.display=isCollapsed?'':'none';
|
||
caret.classList.toggle('collapsed',!isCollapsed);
|
||
_groupCollapsed[g.label]=!isCollapsed;
|
||
_saveCollapsed();
|
||
renderSessionListFromCache();
|
||
};
|
||
wrapper.appendChild(hdr);
|
||
let groupTopPad=0;
|
||
let groupBottomPad=0;
|
||
for(const s of g.items){
|
||
if(isGroupCollapsed) continue;
|
||
const rowIndex=globalSessionRowIndex++;
|
||
const inWindow=!virtualWindow.virtualized||(rowIndex>=virtualWindow.start&&rowIndex<virtualWindow.end);
|
||
if(inWindow){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
|
||
else if(rowIndex<virtualWindow.start){ groupTopPad+=virtualWindow.itemHeight; }
|
||
else { groupBottomPad+=virtualWindow.itemHeight; }
|
||
}
|
||
if(groupTopPad>0){ body.insertBefore(_sessionVirtualSpacer(groupTopPad,'before'), body.firstChild); }
|
||
if(groupBottomPad>0){ body.appendChild(_sessionVirtualSpacer(groupBottomPad,'after')); }
|
||
wrapper.appendChild(body);
|
||
list.appendChild(wrapper);
|
||
}
|
||
if(virtualAnchorScrollTop!==null){
|
||
list.scrollTop=virtualAnchorScrollTop;
|
||
}else if(listScrollTopBeforeRender>0){
|
||
// Always restore the user's scroll position after re-render, regardless
|
||
// of whether the virtualization window applies. Lists below the
|
||
// virtualization threshold (≤80 rows) still have their DOM rebuilt by
|
||
// every renderSessionListFromCache() call, and without this restore the
|
||
// scrollTop drops to 0 — producing a "scroll keeps jumping back" feel
|
||
// when the list scrolls naturally. Fixed for #1669 follow-up.
|
||
list.scrollTop=listScrollTopBeforeRender;
|
||
_resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow);
|
||
}
|
||
const archivePagingFilterActive=_sessionArchivePagingFilterActive();
|
||
if(_showArchived&&!archivePagingFilterActive){
|
||
const activeArchivedTotal=_sessionSourceFilter==='cli'?_archivedCliCount:_archivedWebuiCount;
|
||
const loadedArchivedCount=sidebarRows.filter(s=>s&&s.archived&&(_sessionSourceFilter==='cli'?_isCliSession(s):!_isCliSession(s))).length;
|
||
const archiveLoadCapReached=Number(_archivedRowsLoadedLimit||0)>=SESSION_ARCHIVED_MAX_LOADED_LIMIT;
|
||
const remainingArchived=archiveLoadCapReached?0:Math.max(0, Number(activeArchivedTotal||0)-loadedArchivedCount);
|
||
if(remainingArchived>0){
|
||
const more=document.createElement('div');
|
||
more.className='session-archive-more';
|
||
more.style.cssText='font-size:10px;padding:6px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.8;';
|
||
more.textContent='Load '+Math.min(SESSION_ARCHIVED_PAGE_SIZE, remainingArchived)+' more archived ('+remainingArchived+' remaining)';
|
||
more.onclick=()=>{
|
||
_archivedRowsLoadedLimit=Math.min(
|
||
SESSION_ARCHIVED_MAX_LOADED_LIMIT,
|
||
Math.max(SESSION_ARCHIVED_PAGE_SIZE, Number(_archivedRowsLoadedLimit)||SESSION_ARCHIVED_PAGE_SIZE)+SESSION_ARCHIVED_PAGE_SIZE
|
||
);
|
||
renderSessionList();
|
||
};
|
||
list.appendChild(more);
|
||
}
|
||
}
|
||
// Select mode toggle button (only when NOT in select mode)
|
||
if(!_sessionSelectMode){
|
||
const toggleBtn=document.createElement('div');toggleBtn.className='session-select-toggle';
|
||
toggleBtn.textContent=t('session_select_mode');
|
||
toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();};
|
||
list.appendChild(toggleBtn);
|
||
}
|
||
// Refresh FLIP and queued archive/delete reflow both drive
|
||
// --session-reflow-offset. Refresh wins so one render has one transform writer.
|
||
const reflowBefore=animateRefresh?flipBefore:_pendingSessionReflowPositions;
|
||
const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS;
|
||
_pendingSessionReflowPositions=null;
|
||
_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);
|
||
|
||
function _renderOneSession(s, isPinnedGroup=false){
|
||
const el=document.createElement('div');
|
||
const isActive=_sessionLineageContainsSession(s,activeSidForSidebar);
|
||
const ownStreaming=_isSessionEffectivelyStreaming(s);
|
||
const isStreaming=ownStreaming||!!s._child_session_streaming;
|
||
_rememberRenderedStreamingState(s, ownStreaming);
|
||
_rememberRenderedSessionSnapshot(s);
|
||
const hasUnread=(_hasUnreadForSession(s)||!!s._child_session_has_unread)&&!isActive;
|
||
const attention=_sessionAttentionState(s)||_sessionAttentionState({_child:true,attention:s._child_session_attention});
|
||
const attentionClass=attention?(attention.kind==='approval'?' attention-approval':(attention.kind==='clarify'?' attention-clarify':' attention-attention')):'';
|
||
const readOnly=_isReadOnlySession(s);
|
||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(ownStreaming?' streaming':'')+(hasUnread?' unread':'')+(attention?' needs-attention':'')+attentionClass;
|
||
const swipeReturnOffset=_sessionSwipeReturnOffsets.get(s.session_id);
|
||
if(swipeReturnOffset!==undefined){
|
||
_sessionSwipeReturnOffsets.delete(s.session_id);
|
||
el.style.setProperty('--session-swipe-return-offset',swipeReturnOffset);
|
||
el.classList.add('session-swipe-returning');
|
||
el.addEventListener('animationend',()=>{
|
||
el.classList.remove('session-swipe-returning');
|
||
el.style.removeProperty('--session-swipe-return-offset');
|
||
},{once:true});
|
||
}
|
||
if(animateRefresh&&(enterAllAnimatedRows||!(flipBefore&&flipBefore.has(s.session_id)))){
|
||
el.classList.add('session-list-flip-enter');
|
||
}
|
||
if(s.is_cli_session||_isMessagingSession(s)){
|
||
el.classList.add('cli-session');
|
||
el.dataset.source=_getChannelLabel(s)||'CLI';
|
||
el.dataset.sourceKey=_sourceKeyForSession(s)||'cli';
|
||
}
|
||
if(readOnly) el.classList.add('read-only-session');
|
||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||
const rawTitle=_sessionDisplayTitle(s);
|
||
const tags=_sessionTitleTags(rawTitle);
|
||
let cleanTitle=tags.length?rawTitle.replace(/#(?!\d+\b)[\w-]+/g,'').trim():rawTitle;
|
||
// Guard: system prompt content must never surface as a visible session title
|
||
if(cleanTitle.startsWith('[SYSTEM:')){
|
||
cleanTitle='Session';
|
||
}
|
||
// Checkbox for batch select mode
|
||
if(_sessionSelectMode&&!readOnly){
|
||
const cbWrapper=document.createElement('label');cbWrapper.className='session-select-cb-wrapper';
|
||
const cb=document.createElement('input');cb.type='checkbox';cb.className='session-select-cb';
|
||
cb.dataset.sid=s.session_id;cb.checked=_selectedSessions.has(s.session_id);
|
||
cb.onchange=(e)=>{e.stopPropagation();setSessionSelected(s.session_id,cb.checked);};
|
||
cb.onclick=(e)=>{e.stopPropagation();};
|
||
cb.onpointerup=(e)=>{e.stopPropagation();};
|
||
cbWrapper.onpointerup=(e)=>{e.stopPropagation();};
|
||
cbWrapper.onclick=(e)=>{e.stopPropagation();};
|
||
cbWrapper.appendChild(cb);
|
||
el.classList.toggle('selected',_selectedSessions.has(s.session_id));
|
||
el.appendChild(cbWrapper);
|
||
}
|
||
const sessionText=document.createElement('div');
|
||
sessionText.className='session-text';
|
||
const titleRow=document.createElement('div');
|
||
titleRow.className='session-title-row';
|
||
if(s.pinned&&!isPinnedGroup){
|
||
const pinInd=document.createElement('span');
|
||
pinInd.className='session-pin-indicator';
|
||
pinInd.innerHTML=ICONS.pin;
|
||
titleRow.appendChild(pinInd);
|
||
}
|
||
if(s.worktree_path){
|
||
const wtInd=document.createElement('span');
|
||
wtInd.className='session-worktree-indicator';
|
||
wtInd.innerHTML=li('git-branch',12);
|
||
const wtLabel=(typeof t==='function'?t('session_worktree_badge'):'Worktree');
|
||
wtInd.title=`${wtLabel}: ${s.worktree_branch||s.worktree_path}`;
|
||
titleRow.appendChild(wtInd);
|
||
}
|
||
// Parent session indicator for forked/branched sessions (#465)
|
||
if(s.parent_session_id){
|
||
const branchInd=document.createElement('span');
|
||
branchInd.className='session-branch-indicator';
|
||
branchInd.innerHTML=li('git-branch',12);
|
||
const parentLabel=_sessionTitleForForkParent(s.parent_session_id)||_truncatedSessionId(s.parent_session_id);
|
||
branchInd.title=_sessionForkTooltip(parentLabel);
|
||
titleRow.appendChild(branchInd);
|
||
}
|
||
const title=document.createElement('span');
|
||
title.className='session-title';
|
||
const displayTitle=cleanTitle||'Untitled';
|
||
const titleMatched=Boolean(searchQueryRaw&&displayTitle.toLowerCase().includes(searchQueryRaw.toLowerCase()));
|
||
if(titleMatched) _appendHighlightedText(title,displayTitle,searchQueryRaw,'session-search-hit');
|
||
else title.textContent=displayTitle;
|
||
title.title=_sessionFullTitleTooltip(rawTitle,cleanTitle,s);
|
||
const tsMs=_sessionTimestampMs(s);
|
||
const ts=document.createElement('span');
|
||
const hasAttentionState=isStreaming||hasUnread||Boolean(attention);
|
||
ts.className='session-time'+(hasAttentionState?' is-hidden':'');
|
||
ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs);
|
||
titleRow.appendChild(title);
|
||
// Project color dot: placed BETWEEN title and timestamp, not inside the
|
||
// title span. Inside the title span it would be clipped by the ellipsis
|
||
// truncation, becoming invisible exactly when the title is long enough
|
||
// to need the project marker. As a flex-flow sibling it stays visible
|
||
// regardless of title length and sits next to the timestamp on the right.
|
||
if(s.project_id){
|
||
const proj=_allProjects.find(p=>p.project_id===s.project_id);
|
||
if(proj){
|
||
const dot=document.createElement('span');
|
||
dot.className='session-project-dot';
|
||
dot.style.background=proj.color||'var(--blue)';
|
||
dot.title=proj.name;
|
||
titleRow.appendChild(dot);
|
||
}
|
||
}
|
||
const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
|
||
const showLineageMetadata=density==='detailed';
|
||
const lineageKey=_sidebarLineageKeyForRow(s);
|
||
const segmentCount=showLineageMetadata?_sessionSegmentCount(s):0;
|
||
const needsLineageReport=showLineageMetadata?_lineageReportNeedsFetch(s,lineageKey,segmentCount):false;
|
||
const lineageSegments=showLineageMetadata?_lineageSegmentsForRender(s,lineageKey,needsLineageReport):[];
|
||
const lineageReportKey=showLineageMetadata?_lineageReportCacheKey(s,lineageKey):null;
|
||
const canExpandLineageSegments=showLineageMetadata&&Boolean(lineageKey&&segmentCount>1&&(lineageSegments.length>0||needsLineageReport||_lineageReportInflight.has(lineageReportKey)));
|
||
const lineageSegmentsExpanded=canExpandLineageSegments&&_expandedLineageKeys.has(lineageKey);
|
||
if(lineageSegmentsExpanded&&needsLineageReport){
|
||
_fetchLineageReportForRow(s,lineageKey).then(()=>renderSessionListFromCache());
|
||
}
|
||
if(segmentCount>0){
|
||
const segmentCountEl=document.createElement('span');
|
||
segmentCountEl.className='session-lineage-count'+(canExpandLineageSegments?' expandable':'');
|
||
const segmentLabel=t('session_meta_segments', segmentCount);
|
||
segmentCountEl.textContent=segmentLabel;
|
||
segmentCountEl.title=_sessionLineageBadgeTooltip(segmentLabel,canExpandLineageSegments);
|
||
if(canExpandLineageSegments){
|
||
segmentCountEl.setAttribute('role','button');
|
||
segmentCountEl.setAttribute('tabindex','0');
|
||
segmentCountEl.setAttribute('aria-expanded',lineageSegmentsExpanded?'true':'false');
|
||
['pointerdown','pointerup','click'].forEach(ev=>segmentCountEl.addEventListener(ev,e=>e.stopPropagation()));
|
||
const toggleLineageSegments=(e)=>{
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if(_expandedLineageKeys.has(lineageKey)) _expandedLineageKeys.delete(lineageKey);
|
||
else {
|
||
_expandedLineageKeys.add(lineageKey);
|
||
if(needsLineageReport) _fetchLineageReportForRow(s,lineageKey).then(()=>renderSessionListFromCache());
|
||
}
|
||
renderSessionListFromCache();
|
||
};
|
||
segmentCountEl.onclick=toggleLineageSegments;
|
||
segmentCountEl.onkeydown=(e)=>{
|
||
if(e.key==='Enter'||e.key===' '){toggleLineageSegments(e);}
|
||
};
|
||
}
|
||
titleRow.appendChild(segmentCountEl);
|
||
}
|
||
const childCount=typeof s._child_session_count==='number'?s._child_session_count:(Array.isArray(s._child_sessions)?s._child_sessions.length:0);
|
||
if(childCount>0){
|
||
const childCountEl=document.createElement('span');
|
||
childCountEl.className='session-child-count';
|
||
const childLabel=t('session_meta_children', childCount);
|
||
childCountEl.textContent=childLabel;
|
||
childCountEl.title=_sessionChildBadgeTooltip(childLabel);
|
||
['pointerdown','pointerup','click'].forEach(ev=>childCountEl.addEventListener(ev,e=>e.stopPropagation()));
|
||
childCountEl.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
const key=_sidebarLineageKeyForRow(s);
|
||
if(_expandedChildSessionKeys.has(key)) _expandedChildSessionKeys.delete(key);
|
||
else _expandedChildSessionKeys.add(key);
|
||
renderSessionListFromCache();
|
||
};
|
||
titleRow.appendChild(childCountEl);
|
||
}
|
||
if(s.is_cli_session||_isMessagingSession(s)){
|
||
const chipLabel=_getChannelLabel(s)||'CLI';
|
||
const chip=document.createElement('span');
|
||
chip.className='session-source-chip';
|
||
chip.textContent=chipLabel;
|
||
chip.dataset.sourceKey=_sourceKeyForSession(s)||'cli';
|
||
titleRow.appendChild(chip);
|
||
}
|
||
titleRow.appendChild(ts);
|
||
sessionText.appendChild(titleRow);
|
||
if(density==='detailed'){
|
||
const metaBits=[];
|
||
const msgCount=typeof s.message_count==='number'?s.message_count:0;
|
||
const msgLabel=(typeof t==='function')
|
||
? t('session_meta_messages', msgCount)
|
||
: `${msgCount} msg${msgCount===1?'':'s'}`;
|
||
metaBits.push(msgLabel);
|
||
if(childCount>0) metaBits.push(t('session_meta_children', childCount));
|
||
const modelMeta=_formatSessionModelWithGateway(s);
|
||
if(modelMeta) metaBits.push(modelMeta);
|
||
const sourceLabel=_getChannelLabel(s);
|
||
if(sourceLabel&&(s.is_cli_session||_isMessagingSession(s))) metaBits.push(sourceLabel);
|
||
if(readOnly) metaBits.push('read-only');
|
||
if(_showAllProfiles&&s.profile) metaBits.push(s.profile);
|
||
const meta=document.createElement('div');
|
||
meta.className='session-meta';
|
||
meta.textContent=metaBits.join(' · ');
|
||
sessionText.appendChild(meta);
|
||
}
|
||
const contentPreview=titleMatched?'':_sessionSearchContentPreview(s,searchQueryRaw);
|
||
if(contentPreview){
|
||
const preview=document.createElement('div');
|
||
preview.className='session-search-preview';
|
||
preview.title=contentPreview;
|
||
_appendHighlightedText(preview,contentPreview,searchQueryRaw,'session-search-hit session-search-hit-preview');
|
||
sessionText.appendChild(preview);
|
||
}
|
||
if(lineageSegmentsExpanded){
|
||
const lineageList=document.createElement('div');
|
||
lineageList.className='session-lineage-segments';
|
||
['pointerdown','pointerup','click'].forEach(ev=>lineageList.addEventListener(ev,e=>e.stopPropagation()));
|
||
const sortedSegments=[...lineageSegments].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||
for(const seg of sortedSegments){
|
||
const row=document.createElement('button');
|
||
row.type='button';
|
||
row.className='session-lineage-segment'+(activeSidForSidebar&&seg.session_id===activeSidForSidebar?' active':'');
|
||
const segTitle=_sessionDisplayTitle(seg)||t('session_lineage_segment_untitled');
|
||
const segTime=_formatRelativeSessionTime(_sessionTimestampMs(seg));
|
||
row.textContent=`-> ${segTitle} - ${segTime}`;
|
||
row.title=t('session_lineage_segment_open');
|
||
row.onclick=async(e)=>{
|
||
e.stopPropagation();
|
||
await _openSidebarSession(seg, {skipLineageResolve:true});
|
||
};
|
||
lineageList.appendChild(row);
|
||
}
|
||
sessionText.appendChild(lineageList);
|
||
}
|
||
if(childCount>0&&Array.isArray(s._child_sessions)&&(_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw)){
|
||
const childList=document.createElement('div');
|
||
childList.className='session-child-sessions';
|
||
['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation()));
|
||
const sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||
const openChildSession=async(childSession)=>{
|
||
await _openSidebarSession(childSession, {skipLineageResolve:true});
|
||
};
|
||
const childLabelFor=(child)=>{
|
||
const childTitle=_sessionDisplayTitle(child)||'Untitled child session';
|
||
const childTime=_formatRelativeSessionTime(_sessionTimestampMs(child));
|
||
const parentNote=child._parent_segment_title?` via ${child._parent_segment_title}`:'';
|
||
return `-> ${childTitle}${parentNote} - ${childTime}`;
|
||
};
|
||
const installForkChildSwipe=(rowEl, childSession, actionsEl)=>{
|
||
let _pointerDownX=0;
|
||
let _pointerDownY=0;
|
||
let _pointerX=0;
|
||
let _pointerY=0;
|
||
let _gestureState='idle';
|
||
let _swipeTracking=false;
|
||
let _gesturePointerType='';
|
||
let _clearDragTimer=null;
|
||
let _longPressTimer=null;
|
||
let _longPressMenuOpened=false;
|
||
const _isForkSwipeTarget=()=>_gesturePointerType!=='mouse'&&!_sessionSelectMode;
|
||
const _isForkActionTarget=(target)=>!!(actionsEl&&target&&actionsEl.contains(target));
|
||
const _clearForkLongPressTimer=()=>{
|
||
if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;}
|
||
if(!_longPressMenuOpened) rowEl.classList.remove('long-pressing');
|
||
};
|
||
const _beginForkGesture=(clientX,clientY,pointerType='')=>{
|
||
_gesturePointerType=pointerType;
|
||
_pointerDownX=clientX;
|
||
_pointerDownY=clientY;
|
||
_pointerX=clientX;
|
||
_pointerY=clientY;
|
||
_gestureState='pressing';
|
||
_swipeTracking=false;
|
||
_longPressMenuOpened=false;
|
||
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
|
||
rowEl.classList.remove('dragging','swipe-committed','swipe-removing');
|
||
rowEl.style.removeProperty('height');
|
||
rowEl.style.removeProperty('min-height');
|
||
};
|
||
const _scheduleForkLongPressMenu=()=>{
|
||
_clearForkLongPressTimer();
|
||
rowEl.classList.add('long-pressing');
|
||
_longPressTimer=setTimeout(()=>{
|
||
if(_gestureState!=='pressing'||_renamingSid||_sessionSelectMode) return;
|
||
_longPressMenuOpened=true;
|
||
rowEl._skipNextChildOpen=true;
|
||
_openSessionActionMenu(childSession, rowEl);
|
||
},SESSION_LONG_PRESS_DELAY_MS);
|
||
};
|
||
const _paintForkSwipe=(signedDx)=>{
|
||
const rawOffset=signedDx*.55;
|
||
const revealedOffset=Math.max(-72,Math.min(72,rawOffset));
|
||
const overshoot=Math.max(0,Math.abs(rawOffset)-72);
|
||
const offset=Math.sign(rawOffset)*(Math.abs(revealedOffset)+Math.sqrt(overshoot)*5);
|
||
const progress=Math.min(1,Math.abs(revealedOffset)/72);
|
||
const reveal=Math.abs(offset);
|
||
const actionRevealScale=1.15;
|
||
const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale));
|
||
const badgeSize=34*iconScale;
|
||
const iconSize=18*iconScale;
|
||
const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale));
|
||
const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale));
|
||
const actionInset=6;
|
||
const tileGap=6;
|
||
const stretchStart=72/actionRevealScale;
|
||
const stretchProgress=Math.max(0,reveal-stretchStart);
|
||
const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));
|
||
rowEl.style.setProperty('--session-swipe-offset',offset+'px');
|
||
rowEl.style.setProperty('--session-swipe-reveal',reveal+'px');
|
||
rowEl.style.setProperty('--session-swipe-badge-size',badgeSize+'px');
|
||
rowEl.style.setProperty('--session-swipe-icon-size',iconSize+'px');
|
||
rowEl.style.setProperty('--session-swipe-label-scale',labelScale);
|
||
rowEl.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px');
|
||
rowEl.style.setProperty('--session-swipe-progress',actionOpacity);
|
||
rowEl.classList.toggle('swiping-right',offset>0);
|
||
rowEl.classList.toggle('swiping-left',offset<0);
|
||
};
|
||
const _clearForkSwipePaint=()=>{
|
||
rowEl.style.removeProperty('--session-swipe-offset');
|
||
rowEl.style.removeProperty('--session-swipe-reveal');
|
||
rowEl.style.removeProperty('--session-swipe-badge-size');
|
||
rowEl.style.removeProperty('--session-swipe-icon-size');
|
||
rowEl.style.removeProperty('--session-swipe-label-scale');
|
||
rowEl.style.removeProperty('--session-swipe-badge-stretch');
|
||
rowEl.style.removeProperty('--session-swipe-progress');
|
||
rowEl.style.removeProperty('height');
|
||
rowEl.style.removeProperty('min-height');
|
||
rowEl.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing');
|
||
};
|
||
const _settleForkSwipePaint=()=>{
|
||
rowEl.classList.remove('dragging');
|
||
requestAnimationFrame(()=>requestAnimationFrame(_clearForkSwipePaint));
|
||
};
|
||
const _completeForkSwipePaint=(signedDx)=>{
|
||
rowEl.classList.remove('dragging');
|
||
rowEl.classList.add('swipe-committed');
|
||
rowEl.style.setProperty('--session-swipe-progress','0');
|
||
rowEl.style.setProperty('--session-swipe-offset',(signedDx>0?1:-1)*window.innerWidth+'px');
|
||
const rect=rowEl.getBoundingClientRect();
|
||
rowEl.style.height=rect.height+'px';
|
||
rowEl.style.minHeight=rect.height+'px';
|
||
requestAnimationFrame(()=>rowEl.classList.add('swipe-removing'));
|
||
};
|
||
const _canSwipeDeleteFork=()=>_isForkSwipeTarget()&&!_isMessagingSession(childSession)&&!_isCliSession(childSession);
|
||
const _handleForkSwipe=(signedDx,signedDy)=>{
|
||
if(_gestureState==='committed'||!_isForkSwipeTarget()) return false;
|
||
const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX;
|
||
if(Math.abs(signedDx)<actionThreshold) return false;
|
||
if(Math.abs(signedDy)>Math.abs(signedDx)*SESSION_SWIPE_CANCEL_RATIO) return false;
|
||
_gestureState='committed';
|
||
closeSessionActionMenu();
|
||
if(signedDx>0){
|
||
if(childSession.archived){
|
||
_settleForkSwipePaint();
|
||
_archiveSession(childSession,false,()=>_waitForSessionMotion(committedSwipeDuration)).then((restored)=>{
|
||
if(!restored) _settleForkSwipePaint();
|
||
});
|
||
}else if(_showArchived){
|
||
_settleForkSwipePaint();
|
||
_archiveSession(childSession,true,()=>_waitForSessionMotion(committedSwipeDuration)).then((archived)=>{
|
||
if(!archived) _settleForkSwipePaint();
|
||
});
|
||
}else{
|
||
_completeForkSwipePaint(signedDx);
|
||
_archiveSession(childSession,true,()=>_waitForSessionMotion(committedSwipeReflowDelay)).then((archived)=>{
|
||
if(!archived) _settleForkSwipePaint();
|
||
});
|
||
}
|
||
}else if(_canSwipeDeleteFork()){
|
||
rowEl.classList.remove('dragging');
|
||
deleteSession(childSession.session_id,async()=>{
|
||
_completeForkSwipePaint(signedDx);
|
||
await _waitForSessionMotion(committedSwipeReflowDelay);
|
||
}).then((deleted)=>{
|
||
if(!deleted) _settleForkSwipePaint();
|
||
});
|
||
}else if(typeof showToast==='function'){
|
||
showToast('Imported sessions cannot be deleted here.',3000);
|
||
_gestureState='dragging';
|
||
_settleForkSwipePaint();
|
||
}
|
||
return true;
|
||
};
|
||
const _clearForkPointerState=()=>{
|
||
_clearForkLongPressTimer();
|
||
const wasDragging=_gestureState==='dragging'||_swipeTracking;
|
||
_gestureState='idle';
|
||
if(wasDragging){
|
||
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
|
||
_clearDragTimer=setTimeout(()=>{_settleForkSwipePaint();_clearDragTimer=null;},50);
|
||
}
|
||
};
|
||
rowEl.onpointerdown=(e)=>{
|
||
if(e.pointerType==='mouse'||e.button!==0||_isForkActionTarget(e.target)) return;
|
||
_beginForkGesture(e.clientX,e.clientY,e.pointerType||'');
|
||
if(e.pointerType==='touch'||e.pointerType==='pen') _scheduleForkLongPressMenu();
|
||
};
|
||
rowEl.onpointermove=(e)=>{
|
||
if(e.pointerType==='mouse'||_gestureState==='idle') return;
|
||
_pointerX=e.clientX;
|
||
_pointerY=e.clientY;
|
||
const signedDx=e.clientX-_pointerDownX;
|
||
const signedDy=e.clientY-_pointerDownY;
|
||
const dx=Math.abs(signedDx);
|
||
const dy=Math.abs(signedDy);
|
||
if(dx>8&&dx>dy*1.1) _swipeTracking=true;
|
||
if(_gestureState==='pressing'&&(dx>5||dy>5)){
|
||
_clearForkLongPressTimer();
|
||
_gestureState='dragging';
|
||
rowEl.classList.add('dragging');
|
||
}
|
||
if(_isForkSwipeTarget()&&(_swipeTracking||dx>dy)) _paintForkSwipe(signedDx);
|
||
};
|
||
rowEl.onpointerup=(e)=>{
|
||
if(e.pointerType==='mouse'||e.button!==0) return;
|
||
if(_gestureState==='idle') return;
|
||
if(_longPressMenuOpened){_gestureState='idle';return;}
|
||
if(_isForkActionTarget(e.target)){_gestureState='idle';return;}
|
||
_pointerX=e.clientX;
|
||
_pointerY=e.clientY;
|
||
if(_handleForkSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY)){
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
_clearForkPointerState();
|
||
};
|
||
rowEl.onpointercancel=()=>_clearForkPointerState();
|
||
rowEl.onpointerleave=()=>{
|
||
if(_gesturePointerType!=='mouse'&&_gestureState!=='idle') _clearForkPointerState();
|
||
};
|
||
};
|
||
for(const child of sortedChildren){
|
||
if(child.session_source==='fork'){
|
||
const childIsActive=!!(activeSidForSidebar&&child.session_id===activeSidForSidebar);
|
||
const childStreaming=_isSessionEffectivelyStreaming(child);
|
||
const childHasUnread=_hasUnreadForSession(child)&&!childIsActive;
|
||
const childAttention=_sessionAttentionState(child);
|
||
const childAttentionClass=childAttention?(childAttention.kind==='approval'?' attention-approval':(childAttention.kind==='clarify'?' attention-clarify':' attention-attention')):'';
|
||
const row=document.createElement('div');
|
||
row.className='session-child-session session-child-session-fork'
|
||
+(childIsActive?' active':'')
|
||
+(childStreaming?' streaming':'')
|
||
+(childHasUnread?' unread':'')
|
||
+(childAttention?' needs-attention':'')
|
||
+childAttentionClass;
|
||
row.dataset.sid=child.session_id;
|
||
if(_sessionSelectMode&&!_isReadOnlySession(child)){
|
||
const cbW=document.createElement('label');cbW.className='session-select-cb-wrapper';
|
||
const cb=document.createElement('input');cb.type='checkbox';cb.className='session-select-cb';
|
||
cb.dataset.sid=child.session_id;cb.checked=_selectedSessions.has(child.session_id);
|
||
cb.onchange=(e)=>{e.stopPropagation();setSessionSelected(child.session_id,cb.checked);};
|
||
cb.onclick=(e)=>{e.stopPropagation();};
|
||
cb.onpointerup=(e)=>{e.stopPropagation();};
|
||
cbW.onpointerup=(e)=>{e.stopPropagation();};
|
||
cbW.onclick=(e)=>{e.stopPropagation();};
|
||
cbW.appendChild(cb);
|
||
row.classList.toggle('selected',_selectedSessions.has(child.session_id));
|
||
row.appendChild(cbW);
|
||
}
|
||
const mainBtn=document.createElement('button');
|
||
mainBtn.type='button';
|
||
mainBtn.className='session-child-session-main'+(childIsActive?' active':'');
|
||
mainBtn.textContent=childLabelFor(child);
|
||
mainBtn.title='Open forked session';
|
||
mainBtn.onclick=async(e)=>{
|
||
if(row._skipNextChildOpen){
|
||
row._skipNextChildOpen=false;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
e.stopPropagation();
|
||
await openChildSession(child);
|
||
};
|
||
row._startRename=_buildSessionRenameStarter(child, mainBtn, ()=>{
|
||
mainBtn.textContent=childLabelFor(child);
|
||
});
|
||
row.appendChild(mainBtn);
|
||
const state=document.createElement('span');
|
||
state.className='session-state-indicator session-child-session-state'
|
||
+(childStreaming?' is-streaming':'')
|
||
+(childHasUnread?' is-unread':'')
|
||
+(childAttention?(childAttention.kind==='approval'?' is-attention-approval':(childAttention.kind==='clarify'?' is-attention-clarify':' is-attention-generic')):'');
|
||
state.setAttribute('aria-hidden','true');
|
||
const childStateTip=_sessionStateTooltip({isStreaming:childStreaming,hasUnread:childHasUnread});
|
||
if(childAttention&&childAttention.title) state.title=childAttention.title;
|
||
else if(childStateTip) state.title=childStateTip;
|
||
row.appendChild(state);
|
||
const readOnlyChild=_isReadOnlySession(child);
|
||
let actions=null;
|
||
if(!readOnlyChild){
|
||
actions=document.createElement('div');
|
||
actions.className='session-actions';
|
||
const menuBtn=document.createElement('button');
|
||
menuBtn.type='button';
|
||
menuBtn.className='session-actions-trigger';
|
||
menuBtn.title='Conversation actions';
|
||
menuBtn.setAttribute('aria-haspopup','menu');
|
||
menuBtn.setAttribute('aria-expanded','false');
|
||
menuBtn.setAttribute('aria-label','Conversation actions');
|
||
menuBtn.innerHTML=ICONS.more;
|
||
const stopMenuPointer=(e)=>e.stopPropagation();
|
||
menuBtn.onpointerdown=stopMenuPointer;
|
||
menuBtn.onpointerup=stopMenuPointer;
|
||
menuBtn.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
_openSessionActionMenu(child, menuBtn);
|
||
};
|
||
actions.appendChild(menuBtn);
|
||
row.appendChild(actions);
|
||
row.append(
|
||
_makeSessionSwipeAffordance('right',child.archived?'undo':'archive',child.archived?'Restore':t('session_batch_archive')),
|
||
_makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')),
|
||
);
|
||
installForkChildSwipe(row, child, actions);
|
||
}
|
||
row.oncontextmenu=(e)=>{
|
||
if(readOnlyChild) return;
|
||
e.preventDefault();
|
||
if(e.pointerType==='touch'||e.pointerType==='pen') return;
|
||
e.stopPropagation();
|
||
_openSessionActionMenu(child, actions||row);
|
||
};
|
||
childList.appendChild(row);
|
||
continue;
|
||
}
|
||
const row=document.createElement('button');
|
||
row.type='button';
|
||
row.className='session-child-session'+(activeSidForSidebar&&child.session_id===activeSidForSidebar?' active':'');
|
||
row.textContent=childLabelFor(child);
|
||
row.title='Open child session';
|
||
row.onclick=async(e)=>{
|
||
e.stopPropagation();
|
||
await openChildSession(child);
|
||
};
|
||
childList.appendChild(row);
|
||
}
|
||
sessionText.appendChild(childList);
|
||
}
|
||
// Append tag chips after the title text
|
||
for(const tag of tags){
|
||
const chip=document.createElement('span');
|
||
chip.className='session-tag';
|
||
chip.textContent=tag;
|
||
chip.title='Click to filter by '+tag;
|
||
chip.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
const searchBox=$('sessionSearch');
|
||
if(searchBox){searchBox.value=tag;filterSessions();}
|
||
};
|
||
title.appendChild(chip);
|
||
}
|
||
|
||
// Rename: called directly when we confirm it's a double-click
|
||
const startRename=_buildSessionRenameStarter(
|
||
s,
|
||
title,
|
||
(nextTitle)=>{
|
||
title.textContent=nextTitle;
|
||
title.title=_sessionFullTitleTooltip(nextTitle,nextTitle,s);
|
||
}
|
||
);
|
||
// Expose the rename closure on the row so the three-dot action menu
|
||
// (`_openSessionActionMenu`, defined elsewhere) can trigger it without
|
||
// needing a separate DOM hunt or a duplicate copy of all this state
|
||
// (oldTitle / applyTitle / finish / _renamingSid bookkeeping). The
|
||
// double-click path on this element still calls startRename() directly.
|
||
el._startRename = startRename;
|
||
el.dataset.sid = s.session_id;
|
||
|
||
// (Project dot is appended above, between title and timestamp, so it
|
||
// sits outside the truncating title span and stays visible.)
|
||
el.appendChild(sessionText);
|
||
const state=document.createElement('span');
|
||
const attentionDotClass=attention?(attention.kind==='approval'?' is-attention-approval':(attention.kind==='clarify'?' is-attention-clarify':' is-attention-generic')):'';
|
||
state.className='session-attention-indicator session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''))+attentionDotClass;
|
||
state.setAttribute('aria-hidden','true');
|
||
// Tooltip precedence: a localized attention title (pending approval/clarify,
|
||
// from the attention-indicator feature) is more specific and actionable than
|
||
// the generic running/unread state tooltip, so it wins. Fall back to the state
|
||
// tooltip only when there is no attention title AND the state tooltip is
|
||
// non-empty — never blank an otherwise-meaningful tooltip.
|
||
const _stateTip=_sessionStateTooltip({isStreaming,hasUnread});
|
||
if(attention&&attention.title) state.title=attention.title;
|
||
else if(_stateTip) state.title=_stateTip;
|
||
el.appendChild(state);
|
||
// Single trigger button that opens a shared dropdown menu
|
||
let actions=null;
|
||
if(!readOnly){
|
||
actions=document.createElement('div');
|
||
actions.className='session-actions';
|
||
const menuBtn=document.createElement('button');
|
||
menuBtn.type='button';
|
||
menuBtn.className='session-actions-trigger';
|
||
menuBtn.title='Conversation actions';
|
||
menuBtn.setAttribute('aria-haspopup','menu');
|
||
menuBtn.setAttribute('aria-expanded','false');
|
||
menuBtn.setAttribute('aria-label','Conversation actions');
|
||
menuBtn.innerHTML=ICONS.more;
|
||
const stopMenuPointer=(e)=>e.stopPropagation();
|
||
menuBtn.onpointerdown=stopMenuPointer;
|
||
menuBtn.onpointerup=stopMenuPointer;
|
||
menuBtn.onclick=(e)=>{
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
_openSessionActionMenu(s, menuBtn);
|
||
};
|
||
actions.appendChild(menuBtn);
|
||
el.appendChild(actions);
|
||
}
|
||
el.oncontextmenu=(e)=>{
|
||
if(readOnly) return;
|
||
e.preventDefault();
|
||
if(e.pointerType==='touch'||e.pointerType==='pen') return;
|
||
e.stopPropagation();
|
||
clearTimeout(_tapTimer);
|
||
_tapTimer=null;
|
||
_lastTapTime=0;
|
||
_clearPointerDragState();
|
||
_openSessionActionMenu(s, actions||el);
|
||
};
|
||
|
||
if(!readOnly){
|
||
el.append(
|
||
_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive')),
|
||
_makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')),
|
||
);
|
||
}
|
||
|
||
// Use release events + manual double-tap detection instead of onclick/ondblclick.
|
||
// onclick/ondblclick are unreliable on touch devices (iPad Safari especially):
|
||
// hover-triggered layout shifts, ghost clicks, and 300ms delay all break
|
||
// single-tap navigation.
|
||
// Mouse clicks are instant; touch presses need a 300ms delay to distinguish
|
||
// a tap from a scroll-drag gesture on mobile.
|
||
// Movement promotes pressing into dragging; drag release cancels a pending tap.
|
||
let _lastTapTime=0;
|
||
let _tapTimer=null;
|
||
let _pointerDownX=0;
|
||
let _pointerDownY=0;
|
||
let _gestureState='idle'; // idle | pressing | dragging | committed
|
||
let _clearDragTimer=null;
|
||
let _longPressTimer=null;
|
||
let _longPressMenuOpened=false;
|
||
let _swipeTracking=false;
|
||
let _pointerX=0;
|
||
let _pointerY=0;
|
||
let _gesturePointerType='';
|
||
const _clearLongPressTimer=()=>{
|
||
if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;}
|
||
if(!_longPressMenuOpened) el.classList.remove('long-pressing');
|
||
};
|
||
const _beginSessionGesture=(clientX,clientY,pointerType='')=>{
|
||
_gesturePointerType=pointerType;
|
||
_pointerDownX=clientX;
|
||
_pointerDownY=clientY;
|
||
_pointerX=clientX;
|
||
_pointerY=clientY;
|
||
_gestureState='pressing';
|
||
_swipeTracking=false;
|
||
_longPressMenuOpened=false;
|
||
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
|
||
el.classList.remove('dragging','swipe-committed','swipe-removing');
|
||
el.style.removeProperty('height');
|
||
el.style.removeProperty('min-height');
|
||
};
|
||
const _scheduleSessionLongPressMenu=()=>{
|
||
_clearLongPressTimer();
|
||
el.classList.add('long-pressing');
|
||
_longPressTimer=setTimeout(()=>{
|
||
if(_gestureState!=='pressing'||_renamingSid||_sessionSelectMode||readOnly) return;
|
||
_longPressMenuOpened=true;
|
||
clearTimeout(_tapTimer);
|
||
_tapTimer=null;
|
||
_lastTapTime=0;
|
||
_openSessionActionMenu(s, el);
|
||
},SESSION_LONG_PRESS_DELAY_MS);
|
||
};
|
||
const _isSessionSwipeTarget=()=>{
|
||
return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode;
|
||
};
|
||
const _isSessionActionTarget=(target)=>{
|
||
return !!(actions&&target&&actions.contains(target));
|
||
};
|
||
const _trackHorizontalSwipe=(dx,dy)=>{
|
||
if(dx>8&&dx>dy*1.1) _swipeTracking=true;
|
||
};
|
||
const _promoteSessionDrag=(dx,dy)=>{
|
||
if(_gestureState!=='pressing'||(dx<=5&&dy<=5)) return;
|
||
if(dy>8||dx>10) _clearLongPressTimer();
|
||
_gestureState='dragging';
|
||
el.classList.add('dragging');
|
||
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
|
||
};
|
||
const _updateSessionGesture=(clientX,clientY)=>{
|
||
if(_gestureState==='idle') return false;
|
||
_pointerX=clientX;
|
||
_pointerY=clientY;
|
||
const signedDx=clientX-_pointerDownX;
|
||
const signedDy=clientY-_pointerDownY;
|
||
const dx=Math.abs(signedDx);
|
||
const dy=Math.abs(signedDy);
|
||
_promoteSessionDrag(dx,dy);
|
||
_trackHorizontalSwipe(dx,dy);
|
||
if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx);
|
||
return _swipeTracking;
|
||
};
|
||
const _canSwipeDeleteSession=()=>{
|
||
return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s);
|
||
};
|
||
const _paintSessionSwipe=(signedDx)=>{
|
||
const rawOffset=signedDx*.55;
|
||
const revealedOffset=Math.max(-72,Math.min(72,rawOffset));
|
||
const overshoot=Math.max(0,Math.abs(rawOffset)-72);
|
||
const offset=Math.sign(rawOffset)*(Math.abs(revealedOffset)+Math.sqrt(overshoot)*5);
|
||
const progress=Math.min(1,Math.abs(revealedOffset)/72);
|
||
const reveal=Math.abs(offset);
|
||
const actionRevealScale=1.15;
|
||
const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale));
|
||
const badgeSize=34*iconScale;
|
||
const iconSize=18*iconScale;
|
||
const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale));
|
||
const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale));
|
||
const actionInset=6;
|
||
const tileGap=6;
|
||
const stretchStart=72/actionRevealScale;
|
||
const stretchProgress=Math.max(0,reveal-stretchStart);
|
||
const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));
|
||
el.style.setProperty('--session-swipe-offset',offset+'px');
|
||
el.style.setProperty('--session-swipe-reveal',reveal+'px');
|
||
el.style.setProperty('--session-swipe-badge-size',badgeSize+'px');
|
||
el.style.setProperty('--session-swipe-icon-size',iconSize+'px');
|
||
el.style.setProperty('--session-swipe-label-scale',labelScale);
|
||
el.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px');
|
||
el.style.setProperty('--session-swipe-progress',actionOpacity);
|
||
el.classList.toggle('swiping-right',offset>0);
|
||
el.classList.toggle('swiping-left',offset<0);
|
||
};
|
||
const _clearSessionSwipePaint=()=>{
|
||
el.style.removeProperty('--session-swipe-offset');
|
||
el.style.removeProperty('--session-swipe-reveal');
|
||
el.style.removeProperty('--session-swipe-badge-size');
|
||
el.style.removeProperty('--session-swipe-icon-size');
|
||
el.style.removeProperty('--session-swipe-label-scale');
|
||
el.style.removeProperty('--session-swipe-badge-stretch');
|
||
el.style.removeProperty('--session-swipe-progress');
|
||
el.style.removeProperty('height');
|
||
el.style.removeProperty('min-height');
|
||
el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing');
|
||
};
|
||
const _settleSessionSwipePaint=()=>{
|
||
el.classList.remove('dragging');
|
||
requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint));
|
||
};
|
||
const _completeSessionSwipePaint=(signedDx)=>{
|
||
el.classList.remove('dragging');
|
||
el.classList.add('swipe-committed');
|
||
el.style.setProperty('--session-swipe-progress','0');
|
||
el.style.setProperty('--session-swipe-offset',(signedDx>0?1:-1)*window.innerWidth+'px');
|
||
const rect=el.getBoundingClientRect();
|
||
el.style.height=rect.height+'px';
|
||
el.style.minHeight=rect.height+'px';
|
||
requestAnimationFrame(()=>el.classList.add('swipe-removing'));
|
||
};
|
||
const _handleSessionSwipe=(signedDx,signedDy)=>{
|
||
if(_gestureState==='committed'||!_isSessionSwipeTarget()) return false;
|
||
const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX;
|
||
if(Math.abs(signedDx)<actionThreshold) return false;
|
||
if(Math.abs(signedDy)>Math.abs(signedDx)*SESSION_SWIPE_CANCEL_RATIO) return false;
|
||
_gestureState='committed';
|
||
_clearLongPressTimer();
|
||
clearTimeout(_tapTimer);
|
||
_tapTimer=null;
|
||
_lastTapTime=0;
|
||
if(signedDx>0){
|
||
if(s.archived){
|
||
_settleSessionSwipePaint();
|
||
_archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration)).then((restored)=>{
|
||
if(!restored) _settleSessionSwipePaint();
|
||
});
|
||
}else if(_showArchived){
|
||
_settleSessionSwipePaint();
|
||
_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeDuration)).then((archived)=>{
|
||
if(!archived) _settleSessionSwipePaint();
|
||
});
|
||
}else{
|
||
_completeSessionSwipePaint(signedDx);
|
||
_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay)).then((archived)=>{
|
||
if(!archived) _settleSessionSwipePaint();
|
||
});
|
||
}
|
||
}else if(_canSwipeDeleteSession()){
|
||
el.classList.remove('dragging');
|
||
deleteSession(s.session_id,async()=>{
|
||
_completeSessionSwipePaint(signedDx);
|
||
await _waitForSessionMotion(committedSwipeReflowDelay);
|
||
}).then((deleted)=>{
|
||
if(!deleted) _settleSessionSwipePaint();
|
||
});
|
||
}else if(typeof showToast==='function'){
|
||
showToast('Imported sessions cannot be deleted here.',3000);
|
||
_gestureState='dragging';
|
||
_settleSessionSwipePaint();
|
||
}
|
||
return true;
|
||
};
|
||
const _commitSessionSwipe=()=>{
|
||
return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY);
|
||
};
|
||
const _clearPointerDragState=()=>{
|
||
if(_gestureState==='committed'){
|
||
_clearLongPressTimer();
|
||
return;
|
||
}
|
||
const wasDragging=_gestureState==='dragging'||_swipeTracking;
|
||
_gestureState='idle';
|
||
_clearLongPressTimer();
|
||
if(wasDragging){
|
||
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
|
||
_clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50);
|
||
}
|
||
};
|
||
const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{
|
||
if(_gestureState==='idle') return false; // press never began on this row
|
||
const wasDragging=_gestureState==='dragging'||_swipeTracking;
|
||
_clearLongPressTimer();
|
||
if(_renamingSid){_gestureState='idle';return false;}
|
||
if(_isSessionActionTarget(target)){_gestureState='idle';return false;}
|
||
_pointerX=clientX;
|
||
_pointerY=clientY;
|
||
_commitSessionSwipe();
|
||
if(_longPressMenuOpened){_gestureState='idle';return true;}
|
||
if(_gestureState==='committed') return true;
|
||
if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){
|
||
closeSessionActionMenu();
|
||
return true;
|
||
}
|
||
if(target&&target.closest&&target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return false;
|
||
if(_sessionSelectMode){if(!readOnly)toggleSessionSelect(s.session_id);return true;}
|
||
if(wasDragging){
|
||
clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;
|
||
_gestureState='idle';
|
||
_clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50);
|
||
return false;
|
||
}
|
||
_gestureState='idle';
|
||
const now=Date.now();
|
||
if(now-_lastTapTime<350){
|
||
clearTimeout(_tapTimer);
|
||
_tapTimer=null;
|
||
_lastTapTime=0;
|
||
el.classList.remove('loading');
|
||
startRename();
|
||
return false;
|
||
}
|
||
_lastTapTime=now;
|
||
clearTimeout(_tapTimer);
|
||
const delay=pointerType==='mouse'?0:300;
|
||
if(pointerType!=='mouse') el.classList.add('loading');
|
||
_tapTimer=setTimeout(async()=>{
|
||
_tapTimer=null;
|
||
_lastTapTime=0;
|
||
if(_renamingSid) return;
|
||
try{
|
||
if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true;
|
||
await _openSidebarSession(s);
|
||
}finally{
|
||
el.classList.remove('loading');
|
||
}
|
||
}, delay);
|
||
return false;
|
||
};
|
||
el.onpointerdown=(e)=>{
|
||
if(e.pointerType==='touch') return;
|
||
if(e.pointerType==='mouse' && e.button!==0) return;
|
||
if(_isSessionActionTarget(e.target)) return;
|
||
_beginSessionGesture(e.clientX,e.clientY,e.pointerType||'');
|
||
if(e.pointerType==='pen'){
|
||
_scheduleSessionLongPressMenu();
|
||
}
|
||
};
|
||
el.onpointermove=(e)=>{
|
||
if(e.pointerType==='touch') return;
|
||
// Plain hover also dispatches pointermove. Only mark a row as dragging
|
||
// after an actual press starts on this row; otherwise hovered rows stay
|
||
// faded until the next sidebar rerender clears their DOM nodes.
|
||
_updateSessionGesture(e.clientX,e.clientY);
|
||
};
|
||
el.onpointercancel=(e)=>{
|
||
if(e.pointerType==='touch') return;
|
||
_clearPointerDragState();
|
||
};
|
||
el.onpointerleave=()=>{
|
||
if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();
|
||
};
|
||
el.onpointerup=(e)=>{
|
||
if(e.pointerType==='touch') return;
|
||
if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click
|
||
if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();
|
||
};
|
||
// Add ondblclick for more reliable double-click detection
|
||
el.ondblclick=(e)=>{
|
||
if(e.pointerType==='mouse' && e.button!==0) return;
|
||
if(_renamingSid) return;
|
||
if(actions&&actions.contains(e.target)) return;
|
||
if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;}
|
||
// Guard: prevent renaming if session is currently being loaded
|
||
if (_loadingSessionId && _loadingSessionId !== s.session_id) return;
|
||
startRename();
|
||
};
|
||
el.addEventListener('touchstart',(e)=>{
|
||
if(_isSessionActionTarget(e.target)) return;
|
||
const touch=e.changedTouches&&e.changedTouches[0];
|
||
if(!touch) return;
|
||
_beginSessionGesture(touch.clientX,touch.clientY,'touch');
|
||
_scheduleSessionLongPressMenu();
|
||
},{passive:true});
|
||
el.addEventListener('touchmove',(e)=>{
|
||
const touch=e.changedTouches&&e.changedTouches[0];
|
||
if(!touch) return;
|
||
if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();
|
||
},{passive:false});
|
||
el.addEventListener('touchcancel',_clearPointerDragState,{passive:true});
|
||
el.addEventListener('touchend',(e)=>{
|
||
const touch=e.changedTouches&&e.changedTouches[0];
|
||
if(!touch) return;
|
||
if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation();
|
||
},{passive:true});
|
||
return el;
|
||
}
|
||
}
|
||
|
||
async function _handleActiveSessionStorageEvent(e){
|
||
if(!e || e.key !== 'hermes-webui-session') return;
|
||
// Do not treat localStorage as a global active-session bus. Each tab owns its
|
||
// active conversation via its URL (/session/<id>), so another tab switching
|
||
// sessions must not force this tab to navigate away from an in-flight turn.
|
||
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
|
||
}
|
||
|
||
async function _handleShowAllProfilesStorageEvent(e){
|
||
if(!e || e.key !== SHOW_ALL_PROFILES_STORAGE_KEY) return;
|
||
const next=e.newValue==='1'||e.newValue==='true';
|
||
if(_showAllProfiles===next) return;
|
||
_showAllProfiles=next;
|
||
if(typeof renderSessionList==='function') await renderSessionList({deferWhileInteracting:false});
|
||
}
|
||
|
||
if(typeof window!=='undefined'){
|
||
window.addEventListener('storage', (e) => {
|
||
void _handleActiveSessionStorageEvent(e);
|
||
void _handleShowAllProfilesStorageEvent(e);
|
||
});
|
||
window.addEventListener('popstate', () => {
|
||
const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
||
if(!sid || (S.session && S.session.session_id===sid)) return;
|
||
// Refuse to switch sessions mid-stream — same UX guard the storage-event
|
||
// handler had. A user mid-turn who hits browser Back should NOT lose the
|
||
// active stream. They can hit Back again once the turn ends.
|
||
if(S.busy){
|
||
if(typeof showToast==='function') showToast('Finish the current turn before switching sessions.',3000);
|
||
return;
|
||
}
|
||
void loadSession(sid);
|
||
});
|
||
}
|
||
|
||
async function removeWorktree(session){
|
||
// Fetch status first
|
||
let status=null;
|
||
try{
|
||
const statusResp=await api('/api/session/worktree/status?session_id='+encodeURIComponent(session.session_id));
|
||
status=statusResp.status;
|
||
}catch(e){
|
||
showToast(t('session_worktree_remove_status_failed')+e.message,0,'error');
|
||
return;
|
||
}
|
||
if(!status){
|
||
showToast(t('session_worktree_remove_status_failed'),0,'error');
|
||
return;
|
||
}
|
||
// Build confirm message
|
||
let details='';
|
||
if(!status.exists){
|
||
details=t('session_worktree_remove_not_exists',status.path);
|
||
}else{
|
||
details=t('session_worktree_remove_confirm',status.path);
|
||
if(status.locked_by_stream){
|
||
showToast(t('session_worktree_remove_locked_by_stream'),0,'error');
|
||
return;
|
||
}
|
||
if(status.locked_by_terminal){
|
||
showToast(t('session_worktree_remove_locked_by_terminal'),0,'error');
|
||
return;
|
||
}
|
||
if(status.dirty){
|
||
details+='\n\n'+t('session_worktree_remove_dirty_warning');
|
||
}
|
||
if(status.untracked_count>0){
|
||
details+='\n'+t('session_worktree_remove_untracked_warning',status.untracked_count);
|
||
}
|
||
if(status.ahead_behind&&status.ahead_behind.ahead>0){
|
||
details+='\n'+t('session_worktree_remove_ahead_warning',status.ahead_behind.ahead);
|
||
}
|
||
if(status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)){
|
||
showToast(t('session_worktree_remove_failed')+t('session_worktree_remove_unsafe_blocked'),0,'error');
|
||
await showConfirmDialog({
|
||
message:details,
|
||
confirmLabel:t('dialog_confirm_btn'),
|
||
danger:true,
|
||
focusCancel:true
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
const ok=await showConfirmDialog({
|
||
message:details,
|
||
confirmLabel:t('session_worktree_remove_confirm_label'),
|
||
danger:true
|
||
});
|
||
if(!ok)return;
|
||
try{
|
||
const result=await api('/api/session/worktree/remove',{
|
||
method:'POST',
|
||
body:JSON.stringify({session_id:session.session_id, force:false})
|
||
});
|
||
const warn=result.warnings&&result.warnings.length?(' '+result.warnings.join(' ')):'';
|
||
showToast(t('session_worktree_removed')+warn);
|
||
// Clear the worktree_path from cached session so menu doesn't show stale remove action
|
||
if(session.worktree_path){
|
||
session.worktree_path=null;
|
||
}
|
||
// Re-render the list if this is the active session
|
||
if(S.session&&S.session.session_id===session.session_id&&S.session.worktree_path){
|
||
S.session.worktree_path=null;
|
||
}
|
||
await renderSessionList();
|
||
}catch(e){
|
||
showToast(t('session_worktree_remove_failed')+e.message,0,'error');
|
||
}
|
||
}
|
||
|
||
async function deleteSession(sid, beforeDelete=null){
|
||
const session=_sessionSnapshotById(sid);
|
||
const ok=await showConfirmDialog({
|
||
message:session&&session.worktree_path?t('session_delete_worktree_confirm',session.worktree_path):t('session_delete_confirm'),
|
||
confirmLabel:t('delete_title'),
|
||
danger:true
|
||
});
|
||
if(!ok)return false;
|
||
const reflowPositions=_captureSessionReflowPositions();
|
||
const beforeDeleteHold=beforeDelete?Promise.resolve().then(beforeDelete):null;
|
||
const previousSessions=_allSessions;
|
||
let optimisticRendered=false;
|
||
const deleteRequest=api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}).then(response=>{
|
||
_clearHandoffStorageForSession(sid);
|
||
return {response};
|
||
}, error=>({error}));
|
||
if(beforeDeleteHold){
|
||
await beforeDeleteHold;
|
||
_optimisticallyRemovedSessionIds.add(sid);
|
||
_pendingSessionReflowPositions=reflowPositions;
|
||
_optimisticallyRemoveSessionFromList(sid);
|
||
optimisticRendered=true;
|
||
}
|
||
const deleteResult=await deleteRequest;
|
||
if(deleteResult&&deleteResult.error){
|
||
_pendingSessionReflowPositions=null;
|
||
if(optimisticRendered){
|
||
_optimisticallyRemovedSessionIds.delete(sid);
|
||
_allSessions=previousSessions;
|
||
renderSessionListFromCache();
|
||
}
|
||
const err=deleteResult.error;
|
||
setStatus(`Delete failed: ${err&&err.message?err.message:String(err)}`);
|
||
return false;
|
||
}
|
||
const response=deleteResult&&deleteResult.response;
|
||
const cleanupFailed=!!(response&&response.state_db_cleanup_failed);
|
||
if(typeof _clearPersistedSessionQueue==='function') _clearPersistedSessionQueue(sid);
|
||
if(!optimisticRendered){
|
||
_pendingSessionReflowPositions=reflowPositions;
|
||
_optimisticallyRemoveSessionFromList(sid);
|
||
}
|
||
if(S.session&&S.session.session_id===sid){
|
||
S.session=null;S.messages=[];S.entries=[];
|
||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null);
|
||
localStorage.removeItem('hermes-webui-session');
|
||
// load the most recent remaining session, or show blank if none left
|
||
const remaining=await api('/api/sessions'+_sessionListQueryString());
|
||
if(remaining.sessions&&remaining.sessions.length){
|
||
await loadSession(remaining.sessions[0].session_id);
|
||
}else{
|
||
const _tt=$('topbarTitle');if(_tt)_tt.textContent=assistantDisplayName();
|
||
const _tm=$('topbarMeta');if(_tm)_tm.textContent='Start a new conversation';
|
||
$('msgInner').innerHTML='';
|
||
$('emptyState').style.display='';
|
||
$('fileTree').innerHTML='';
|
||
if(typeof S!=='undefined') S.session=null;
|
||
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
|
||
}
|
||
}
|
||
if(cleanupFailed) showToast(t('delete_failed'),0,'error');
|
||
else showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
|
||
if(optimisticRendered) void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid));
|
||
else await renderSessionList();
|
||
return !cleanupFailed;
|
||
}
|
||
|
||
// ── Project helpers ─────────────────────────────────────────────────────
|
||
|
||
const PROJECT_COLORS=['#7cb9ff','#f5c542','#e94560','#50c878','#c084fc','#fb923c','#67e8f9','#f472b6'];
|
||
|
||
function _showProjectPicker(session, anchorEl){
|
||
// Close any existing picker
|
||
document.querySelectorAll('.project-picker').forEach(p=>p.remove());
|
||
const picker=document.createElement('div');
|
||
picker.className='project-picker';
|
||
// "No project" option
|
||
const none=document.createElement('div');
|
||
none.className='project-picker-item'+(!session.project_id?' active':'');
|
||
none.textContent='No project';
|
||
none.onclick=async()=>{
|
||
picker.remove();
|
||
document.removeEventListener('click',close);
|
||
try {
|
||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
|
||
// Sidebar rows are shallow copies of _allSessions entries (see
|
||
// _attachChildSessionsToSidebarRows), so mutating `session` only updates
|
||
// the discarded copy. Write into the authoritative cache so the next
|
||
// renderSessionListFromCache() reflects the move. (#2551)
|
||
const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
|
||
if(idx>=0) _allSessions[idx].project_id=null;
|
||
renderSessionListFromCache();
|
||
showToast('Removed from project');
|
||
} catch(e) {
|
||
showToast('Unassign failed: '+(e.message||e));
|
||
}
|
||
};
|
||
picker.appendChild(none);
|
||
// Project options — only show projects matching the session's profile.
|
||
// #3331 follow-up (Codex gate): mirror the server's root-alias tolerance —
|
||
// `_profiles_match` treats the literal 'default' and a renamed-root display
|
||
// name as equivalent, so a server-approved `profile:'default'` project must
|
||
// not be hidden for a session stamped with the renamed-root profile (and
|
||
// vice versa). Only hide when BOTH sides are explicit, distinct, AND neither
|
||
// is the 'default' alias; let the server's allowlist be authoritative for the
|
||
// default/renamed-root case.
|
||
const sessionProfile = session ? (session.profile || undefined) : undefined;
|
||
const _profileHidesProject = (projProfile) => {
|
||
if(!sessionProfile || !projProfile) return false;
|
||
if(projProfile === sessionProfile) return false;
|
||
if(projProfile === 'default' || sessionProfile === 'default') return false;
|
||
return true;
|
||
};
|
||
for(const p of _allProjects){
|
||
if (_profileHidesProject(p.profile)) continue;
|
||
const item=document.createElement('div');
|
||
item.className='project-picker-item'+(session.project_id===p.project_id?' active':'');
|
||
if(p.color){
|
||
const dot=document.createElement('span');
|
||
dot.className='color-dot';
|
||
dot.style.cssText='width:6px;height:6px;border-radius:50%;background:'+p.color+';flex-shrink:0;';
|
||
item.appendChild(dot);
|
||
}
|
||
const name=document.createElement('span');
|
||
name.textContent=p.name;
|
||
item.appendChild(name);
|
||
item.onclick=async()=>{
|
||
picker.remove();
|
||
document.removeEventListener('click',close);
|
||
try{
|
||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
|
||
// See #2551 — write to _allSessions, not the shallow sidebar copy.
|
||
const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
|
||
if(idx>=0) _allSessions[idx].project_id=p.project_id;
|
||
renderSessionListFromCache();
|
||
showToast('Moved to '+p.name);
|
||
}catch(e){showToast('Move failed: '+(e.message||e));}
|
||
};
|
||
picker.appendChild(item);
|
||
}
|
||
// "+ New project" shortcut at the bottom
|
||
const createItem=document.createElement('div');
|
||
createItem.className='project-picker-item project-picker-create';
|
||
createItem.textContent='+ New project';
|
||
createItem.onclick=async()=>{
|
||
picker.remove();
|
||
document.removeEventListener('click',close);
|
||
const name=await showPromptDialog({
|
||
message:t('project_name_prompt'),
|
||
confirmLabel:t('create'),
|
||
placeholder:'Project name'
|
||
});
|
||
if(!name||!name.trim()) return;
|
||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||
const profile = session.profile || undefined;
|
||
const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color,profile})});
|
||
if(res.project){
|
||
_allProjects.push(res.project);
|
||
// Guard the move so a 503 (session busy/streaming, #3746) shows a toast
|
||
// instead of an unhandled rejection. Keep the authoritative refetch (#2551).
|
||
try{
|
||
await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:res.project.project_id})});
|
||
session.project_id=res.project.project_id;
|
||
await renderSessionList();
|
||
showToast('Created "'+res.project.name+'" and moved session');
|
||
}catch(e){
|
||
await renderSessionList();
|
||
showToast('Created "'+res.project.name+'" but move failed: '+(e&&e.message||'try again'));
|
||
}
|
||
}
|
||
};
|
||
picker.appendChild(createItem);
|
||
// Append to body and position using getBoundingClientRect so it isn't clipped
|
||
// by overflow:hidden on .session-item ancestors
|
||
document.body.appendChild(picker);
|
||
const rect=anchorEl.getBoundingClientRect();
|
||
picker.style.position='fixed';
|
||
picker.style.zIndex='999';
|
||
// Prefer opening below; flip above if too close to bottom of viewport
|
||
const spaceBelow=window.innerHeight-rect.bottom;
|
||
if(spaceBelow<160&&rect.top>160){
|
||
picker.style.bottom=(window.innerHeight-rect.top+4)+'px';
|
||
picker.style.top='auto';
|
||
}else{
|
||
picker.style.top=(rect.bottom+4)+'px';
|
||
picker.style.bottom='auto';
|
||
}
|
||
// Align right edge of picker with right edge of button; keep within viewport
|
||
const pickerW=Math.min(220,Math.max(160,picker.scrollWidth||160));
|
||
let left=rect.right-pickerW;
|
||
if(left<8) left=8;
|
||
picker.style.left=left+'px';
|
||
// Close on outside click
|
||
const close=(e)=>{if(!picker.contains(e.target)&&e.target!==anchorEl){picker.remove();document.removeEventListener('click',close);}};
|
||
setTimeout(()=>document.addEventListener('click',close),0);
|
||
}
|
||
|
||
// Resize a .project-create-input to fit its current value (or placeholder).
|
||
// Bounded by the CSS min-width:40px / max-width:180px on the same class so
|
||
// the input is never comically tiny nor wider than the project bar.
|
||
// Uses a hidden span sized with the same font/padding to measure text width.
|
||
function _resizeProjectInput(inp){
|
||
const sizer=document.createElement('span');
|
||
const cs=getComputedStyle(inp);
|
||
// Read font from the live element so the sizer stays calibrated if CSS changes.
|
||
// Horizontal padding only (0 vertical) — we're measuring width, not height.
|
||
sizer.style.cssText='position:absolute;visibility:hidden;white-space:pre;';
|
||
sizer.style.fontSize=cs.fontSize;
|
||
sizer.style.fontFamily=cs.fontFamily;
|
||
sizer.style.padding='0 '+cs.paddingRight;
|
||
sizer.textContent=inp.value||inp.placeholder||' ';
|
||
document.body.appendChild(sizer);
|
||
const w=Math.min(180,Math.max(40,sizer.offsetWidth+2));
|
||
document.body.removeChild(sizer);
|
||
inp.style.width=w+'px';
|
||
}
|
||
|
||
function _startProjectCreate(bar, addBtn){
|
||
const inp=document.createElement('input');
|
||
inp.className='project-create-input';
|
||
inp.placeholder='Project name';
|
||
let _finishDone=false;
|
||
const finish=async(save)=>{
|
||
if(_finishDone) return;
|
||
_finishDone=true;
|
||
if(save&&inp.value.trim()){
|
||
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
|
||
try{
|
||
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
|
||
}catch(e){
|
||
_finishDone=false;
|
||
showToast('Project create failed: '+(e.message||e));
|
||
return;
|
||
}
|
||
await renderSessionList();
|
||
showToast('Project created');
|
||
}else{
|
||
inp.replaceWith(addBtn);
|
||
}
|
||
};
|
||
inp.onkeydown=(e)=>{
|
||
if(e.key==='Enter'){
|
||
if(window._isImeEnter&&window._isImeEnter(e)){return;}
|
||
e.preventDefault();
|
||
finish(true);
|
||
}
|
||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||
};
|
||
inp.onblur=()=>finish(true);
|
||
inp.addEventListener('input',()=>_resizeProjectInput(inp));
|
||
addBtn.replaceWith(inp);
|
||
_resizeProjectInput(inp);
|
||
setTimeout(()=>inp.focus(),10);
|
||
}
|
||
|
||
function _startProjectRename(proj, chip){
|
||
const inp=document.createElement('input');
|
||
inp.className='project-create-input';
|
||
inp.value=proj.name;
|
||
let _finishDone=false;
|
||
const finish=async(save)=>{
|
||
if(_finishDone) return;
|
||
_finishDone=true;
|
||
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
|
||
try {
|
||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
|
||
await renderSessionList();
|
||
showToast('Project renamed');
|
||
} catch(e) {
|
||
_finishDone=false;
|
||
showToast('Rename failed: '+(e.message||e));
|
||
}
|
||
}else{
|
||
renderSessionListFromCache();
|
||
}
|
||
};
|
||
inp.onkeydown=(e)=>{
|
||
if(e.key==='Enter'){
|
||
if(window._isImeEnter&&window._isImeEnter(e)){return;}
|
||
e.preventDefault();
|
||
finish(true);
|
||
}
|
||
if(e.key==='Escape'){e.preventDefault();finish(false);}
|
||
};
|
||
inp.onblur=()=>finish(true);
|
||
inp.onclick=(e)=>e.stopPropagation();
|
||
inp.addEventListener('input',()=>_resizeProjectInput(inp));
|
||
chip.replaceWith(inp);
|
||
_resizeProjectInput(inp);
|
||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||
}
|
||
|
||
function _showProjectContextMenu(e, proj, chip){
|
||
document.querySelectorAll('.project-ctx-menu').forEach(el=>el.remove());
|
||
const menu=document.createElement('div');
|
||
menu.className='project-ctx-menu';
|
||
// background: var(--surface) — fully-opaque theme variable (not var(--panel),
|
||
// which is undefined in this codebase and falls back to transparent, letting
|
||
// the session list show through the menu). Same variable used by
|
||
// .session-action-menu and other floating popovers.
|
||
menu.style.cssText='position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.35);';
|
||
menu.style.left=e.clientX+'px';
|
||
menu.style.top=e.clientY+'px';
|
||
|
||
// Rename option
|
||
const renameItem=document.createElement('div');
|
||
renameItem.textContent='Rename';
|
||
renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||
renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)';
|
||
renameItem.onmouseleave=()=>renameItem.style.background='';
|
||
renameItem.onclick=()=>{menu.remove();_startProjectRename(proj,chip);};
|
||
menu.appendChild(renameItem);
|
||
|
||
// Color picker row
|
||
const colorRow=document.createElement('div');
|
||
colorRow.style.cssText='display:flex;gap:5px;padding:7px 14px;align-items:center;';
|
||
PROJECT_COLORS.forEach(hex=>{
|
||
const dot=document.createElement('span');
|
||
dot.style.cssText=`width:16px;height:16px;border-radius:50%;background:${hex};cursor:pointer;display:inline-block;flex-shrink:0;`;
|
||
if(hex===(proj.color||'')) dot.style.outline='2px solid var(--text)';
|
||
dot.onclick=async()=>{
|
||
menu.remove();
|
||
try {
|
||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})});
|
||
await renderSessionList();
|
||
showToast('Color updated');
|
||
} catch(e) {
|
||
showToast('Color update failed: '+(e.message||e));
|
||
}
|
||
};
|
||
colorRow.appendChild(dot);
|
||
});
|
||
menu.appendChild(colorRow);
|
||
|
||
// Divider + Delete
|
||
const sep=document.createElement('hr');
|
||
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
|
||
menu.appendChild(sep);
|
||
const delItem=document.createElement('div');
|
||
delItem.textContent='Delete';
|
||
delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);';
|
||
delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)';
|
||
delItem.onmouseleave=()=>delItem.style.background='';
|
||
delItem.onclick=()=>{menu.remove();_confirmDeleteProject(proj);};
|
||
menu.appendChild(delItem);
|
||
|
||
document.body.appendChild(menu);
|
||
const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);};
|
||
setTimeout(()=>document.addEventListener('click',dismiss),0);
|
||
}
|
||
|
||
async function _confirmDeleteProject(proj){
|
||
const ok=await showConfirmDialog({
|
||
message:'Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.',
|
||
confirmLabel:t('delete_title'),
|
||
danger:true
|
||
});
|
||
if(!ok){return;}
|
||
try {
|
||
await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
|
||
if(_activeProject===proj.project_id) _activeProject=null;
|
||
await renderSessionList();
|
||
showToast('Project deleted');
|
||
} catch(e) {
|
||
showToast('Delete failed: '+(e.message||e));
|
||
}
|
||
}
|
||
|
||
// Global Escape handler for batch select mode
|
||
document.addEventListener('keydown',(e)=>{
|
||
if(e.key==='Escape'&&_sessionSelectMode) exitSessionSelectMode();
|
||
});
|
||
|
||
// Keyboard session navigation — J/K bindings
|
||
function navigateSession(dir){
|
||
const rows=[...document.querySelectorAll('.session-item[data-sid]')];
|
||
const sids=rows.map(r=>r.dataset.sid);
|
||
const cur=S.session&&S.session.session_id;
|
||
const i=sids.indexOf(cur);
|
||
if(i<0||!sids.length)return;
|
||
const next=sids[Math.min(Math.max(i+dir,0),sids.length-1)];
|
||
if(next&&next!==cur) loadSession(next);
|
||
}
|
||
|
||
document.addEventListener('keydown',(e)=>{
|
||
if(e.key!=='j'&&e.key!=='k') return;
|
||
if(e.ctrlKey||e.metaKey||e.altKey) return;
|
||
if(typeof _isInteractiveSwipeTarget==='function'&&_isInteractiveSwipeTarget(e.target)) return;
|
||
e.preventDefault();
|
||
navigateSession(e.key==='j'?1:-1);
|
||
});
|