From 4cbc944c0bcb938300c964437d745d15a3ea608f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 21 Sep 2026 17:04:56 +0200 Subject: [PATCH 1/3] perf: keep the sessions page fast with many ai sessions (#11224) * perf: skip whole-document menu lookup on pointerdown when closed Co-Authored-By: Claude Opus 5 (1M context) * perf: load only a session's own chats in its chat history Co-Authored-By: Claude Opus 5 (1M context) * perf: stop mounting a chat per listed session on the sessions page Co-Authored-By: Claude Opus 5 (1M context) * fix: keep unsent input, init and chat rotation safe from runtime eviction Co-Authored-By: Claude Opus 5 (1M context) * refactor: cap mounted chat panes instead of evicting session runtimes Co-Authored-By: Claude Opus 5 (1M context) * refactor: read only message count and error for sessions without a runtime Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a chat pane mounted while its composer reads attachments Co-Authored-By: Claude Opus 5 (1M context) * fix: hold a session's first send until its chat has been restored Co-Authored-By: Claude Opus 5 (1M context) * fix: queue a second send behind one waiting on the chat restore Co-Authored-By: Claude Opus 5 (1M context) * fix: queue raw-app inline prompts behind a send waiting on the restore Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a chat pane mounted while its send waits on the restore Co-Authored-By: Claude Opus 5 (1M context) * fix: preserve session chat drafts across async updates --------- Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/lib/components/DropdownV2.svelte | 3 + .../copilot/chat/AIChatInput.svelte | 12 +- .../copilot/chat/AIChatManager.svelte.ts | 51 +++++++- .../copilot/chat/AIChatManager.test.ts | 19 +++ .../copilot/chat/HistoryManager.svelte.ts | 16 ++- .../copilot/chat/HistoryManager.test.ts | 33 +++++ .../components/copilot/chat/chatViewHost.ts | 4 + .../conversations/flowChatViewHost.svelte.ts | 3 + .../lib/components/meltComponents/Menu.svelte | 3 + .../components/meltComponents/Popover.svelte | 3 + .../sessions/RawAppEditorView.svelte | 9 +- .../components/sessions/SessionPicker.svelte | 23 ++-- .../sessions/sessionRuntime.svelte.ts | 115 +++++++++++++++++- .../sessions/sessionUnread.svelte.ts | 17 ++- .../(root)/(logged)/sessions/+page.svelte | 34 ++++-- 15 files changed, 309 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index 6738e7ace8..5061636835 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -145,6 +145,9 @@ } } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted dropdown. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || !open) return [] return Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[] } diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index d3cc2c8bd9..6f15937cf2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -378,6 +378,16 @@ ) $effect(() => { chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) + chatHost.setComposerHasDraft( + composerKey, + !draft.isEmpty || + (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0) || + pendingFiles > 0 || + pendingFileBytes > 0 || + pendingImages > 0 || + pendingBlobs > 0 || + ingestionHolds > 0 + ) }) $effect(() => () => chatHost.clearComposerStaged(composerKey)) @@ -889,7 +899,7 @@ contextTextareaComponent?.clearForSend() return } - if (chatHost.loading) { + if (chatHost.loading || chatHost.sendInFlight || chatHost.sendPending) { // Queue the message instead of silently discarding it — it is // auto-sent when the streaming turn completes successfully. // Editing-while-loading keeps the old discard behavior. Paste diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 97854a166e..922f64ad93 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2224,7 +2224,7 @@ export class AIChatManager implements ChatViewHost { * Gated on `sendInFlight` as well as `loading`: `loading` only rises after a * send's attachment upkeep, so between the two a click would slip past. */ sendOrQueue(text: string) { - if (this.loading || this.sendInFlight) { + if (this.loading || this.sendInFlight || this.sendPending) { this.queueMessage(text) return } @@ -2819,6 +2819,23 @@ export class AIChatManager implements ChatViewHost { clearComposerStaged(key: string) { this.#composerStaged.delete(key) + this.#composersWithDraft.delete(key) + } + + // Composers whose draft is non-empty. The draft itself is component-local, so + // this is the only way to know an unmount would lose unsent input. + #composersWithDraft = new SvelteSet() + + setComposerHasDraft(key: string, hasDraft: boolean) { + if (hasDraft) this.#composersWithDraft.add(key) + else this.#composersWithDraft.delete(key) + } + + /** Unsent input an unmount of this chat would lose: a composer draft, or a + * queued message (whose text may be empty when it carries only attachments + * or context). */ + get hasUnsentInput(): boolean { + return this.#composersWithDraft.size > 0 || this.#hasQueuedMessage() } /** Release the outgoing-files reservation identified by `key` (a per-send token). @@ -3174,7 +3191,39 @@ export class AIChatManager implements ChatViewHost { return this.#sendsInFlight > 0 } + // Resolves once this chat's stored transcript has been restored. A session + // runtime hands its manager out before that finishes, and `loadPastChat` + // refuses to swap a transcript under a send, so a turn started first lands in + // a chat of its own and is lost when the session's real one loads. + // + // Cleared once awaited, and only awaited when set: every later send — and + // every send of the chats that have no gate — reaches the `sendInFlight` + // counter below synchronously, which is what makes a second Enter queue + // behind the first instead of starting a turn of its own. + #ready: Promise | undefined + setReadyGate(ready: Promise | undefined) { + this.#ready = ready + } + + #sendsAwaitingReady = $state(0) + /** A send parked on the gate above. It has not reached `sendInFlight` yet — + * and must not, since `loadPastChat` reads that flag and would then skip the + * restore this send is waiting for — so every guard that decides between + * sending and queueing tests this as well. */ + get sendPending(): boolean { + return this.#sendsAwaitingReady > 0 + } + sendRequest = async (options: Parameters[0] = {}) => { + if (this.#ready) { + this.#sendsAwaitingReady++ + try { + await this.#ready + } finally { + this.#sendsAwaitingReady-- + this.#ready = undefined + } + } // A turn with nowhere to render still streams, spends tokens and applies // tool calls — entirely off-screen. Refuse instead. `sendInlineRequest` is // exempt: the ⌘K widget renders its own composer inside Monaco. diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 35ac65253c..ef15df4e8d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -514,6 +514,25 @@ describe('AIChatManager.sendOrQueue', () => { releaseUpkeep?.() await sending }) + + // A session's chat is restored after its manager is handed out, so the first + // send waits on that restore — before `sendInFlight`, which the restore reads + // to decide whether to bail. + it('queues while a send waits on the chat restore gate', async () => { + const manager = new AIChatManager() + let openGate: (() => void) | undefined + manager.setReadyGate(new Promise((resolve) => (openGate = resolve))) + manager.instructions = 'first turn' + const sending = manager.sendRequest() + await vi.waitFor(() => expect(manager.sendPending).toBe(true)) + expect(manager.sendInFlight).toBe(false) + + manager.sendOrQueue('fix the failing run') + expect(manager.queuedMessage).toBe('fix the failing run') + + openGate?.() + await sending + }) }) describe('AIChatManager request errors', () => { diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d956b4c0c0..7843169dd9 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -394,7 +394,10 @@ export default class HistoryManager { .sort((a, b) => b.lastModified - a.lastModified) ) - async init() { + /** With `session`, loads only that session's chats plus `session.chatId`, which + * may predate session tagging. Loading every chat instead costs one full copy + * of the user's history per session runtime. */ + async init(session?: { id: string; chatId?: string }) { // (Re)initializing adopts a new identity's history: drop the previous // identity's cached blob ids with it. this.imageIdByUrl.clear() @@ -404,7 +407,16 @@ export default class HistoryManager { const db = await this.dbh.whenReady() if (!db) return try { - const chats = await db.getAll('chats') + let chats: StoredChat[] + if (session) { + chats = await db.getAllFromIndex('chats', 'by-session', session.id) + if (session.chatId && !chats.some((c) => c.id === session.chatId)) { + const current = await db.get('chats', session.chatId) + if (current) chats.push(current) + } + } else { + chats = await db.getAll('chats') + } this.savedChats = chats.reduce( (acc, chat) => { acc[chat.id] = chat diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts index cdbf6f2020..3ee9edad39 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -816,3 +816,36 @@ describe('HistoryManager.reloadChat', () => { expect(await hm.reloadChat(hm.getCurrentChatId())).toBe('unavailable') }) }) + +describe('HistoryManager.init for a session', () => { + it("loads the session's chats and its current chat, not other sessions'", async () => { + const setup = new HistoryManager() + await setup.init() + setup.close() + const chat = (id: string, sessionId?: string) => ({ + id, + actualMessages: [], + displayMessages: [], + title: id, + lastModified: 1, + ...(sessionId ? { sessionId } : {}) + }) + const db = await openDB('copilot-chat-history::admin@test') + await db.put('chats' as never, chat('mine', 's1') as never) + await db.put('chats' as never, chat('other-session', 's2') as never) + await db.put('chats' as never, chat('untagged-current') as never) + await db.put('chats' as never, chat('untagged-other') as never) + db.close() + + const hm = new HistoryManager() + await hm.init({ id: 's1', chatId: 'untagged-current' }) + + // The current chat can predate session tagging, so it is read by key. + expect( + hm + .getAllSavedChats() + .map((c) => c.id) + .sort() + ).toEqual(['mine', 'untagged-current']) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index 4af23337e2..5c4dceb7f1 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -52,6 +52,8 @@ export interface ChatViewHost { * pins away from the navigated one. */ readonly operatingWorkspace: string | undefined loading: boolean + /** A send waiting for the chat's transcript to be restored; queue behind it. */ + sendPending: boolean /** A turn this tab can neither follow nor stop, held by another tab on the same chat. */ readonly runHeldElsewhere: boolean loadingLabel: string | undefined @@ -85,6 +87,8 @@ export interface ChatViewHost { ) => void dequeueMessage: () => void setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void + /** Whether this composer holds anything unsent (text, pastes, attachments, reads in flight). */ + setComposerHasDraft: (key: string, hasDraft: boolean) => void clearComposerStaged: (key: string) => void attachmentBytesExcluding: (selfKey: string) => number diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 37bb868982..6ef18b7212 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -398,6 +398,8 @@ export class FlowChatViewHost implements ChatViewHost { get loading(): boolean { return isBusy(this.#state.status) } + // No transcript restore to wait for: a flow chat renders from its rows. + sendPending = false runHeldElsewhere = false loadingLabel = undefined compacting = false @@ -580,6 +582,7 @@ export class FlowChatViewHost implements ChatViewHost { return taken } setComposerStaged = () => {} + setComposerHasDraft = () => {} clearComposerStaged = () => {} attachmentBytesExcluding = () => 0 diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index 4a821ceacc..ab6de64123 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -176,6 +176,9 @@ } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted menu. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || !open) return [] // Tooltip content counts as menu territory for the same reason as the // onOutsideClick veto above. return Array.from( diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index 6a6b7d681b..dd69d16917 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -194,6 +194,9 @@ } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted popover. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || (!isOpen && !fullScreen)) return [] const selector = excludeSelectors ? `[data-popover], ${excludeSelectors}` : '[data-popover]' return Array.from(document.querySelectorAll(selector)) as HTMLElement[] } diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 4e2fb2bbd4..e95bc142e5 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -172,9 +172,10 @@ // The inline mini-composer over a selected element sends a chat turn; the // element is already an app_dom_selector chip, so it rides along as context. - // Mirror the composer: while a turn is streaming, queue it (a second concurrent - // sendRequest would race the shared abortController / streaming buffers) — it - // auto-sends when the current turn completes. + // Mirror the composer: while another send holds the chat — streaming, in + // preflight, or waiting for the transcript to be restored — queue it (a second + // concurrent sendRequest would race the shared abortController / streaming + // buffers) — it auto-sends when the current turn completes. function onInlinePrompt(selector: string, prompt: string) { // Snapshot the selection synchronously at submit time so a re-selection // during the async send preflight (immediate path) or before the queue @@ -190,7 +191,7 @@ .filter( (c) => c.type !== 'app_dom_selector' || (c.selector === selector && c.appPath === path) ) - if (runtime.manager.loading) { + if (runtime.manager.loading || runtime.manager.sendInFlight || runtime.manager.sendPending) { runtime.manager.queueMessage(prompt, [], snapshot) } else { void runtime.manager.sendRequest({ instructions: prompt, contextOverride: snapshot }) diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index e5b62da0eb..81ce3ff9af 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -44,8 +44,9 @@ import { unreadCountFor } from './sessionUnread.svelte' import Toggle from '$lib/components/Toggle.svelte' import { - getOrCreateRuntime, + ensureSessionChatPeek, getRuntime, + getSessionChatPeek, getSessionChatStatus, removeSession, resetSessionPreviewTabs @@ -87,7 +88,7 @@ // displayMessages array vs. the localStorage-backed lastSeen map; // both are reactive so the badge updates without polling. function unreadFor(session: Session): number { - return unreadCountFor(session.id, getRuntime(session.id)) + return unreadCountFor(session.id, getRuntime(session.id), getSessionChatPeek(session.id)) } // Whether the composer for a session holds non-whitespace text. We @@ -378,14 +379,12 @@ } }) - // Eagerly create a runtime per VISIBLE session so the status dot reflects - // the persisted chat (last message, pending confirmation, etc.) without - // requiring the user to open the session first. Sessions outside the - // current workspace scope are left cold to avoid opening IDB connections - // for unrelated work. + // Read each VISIBLE session's stored chat so the status dot and unread count + // reflect it without the user opening the session. A runtime per listed + // session would hold every listed chat in memory for as long as the page lives. $effect(() => { for (const session of visibleSessions) { - getOrCreateRuntime(session) + if (!getRuntime(session.id)) void ensureSessionChatPeek(session) } }) @@ -768,7 +767,9 @@ {/if} {#each group.sessions as session (session.id)} {@const runtime = getRuntime(session.id)} - {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const status = runtime + ? getSessionChatStatus(runtime) + : (getSessionChatPeek(session.id)?.status ?? 'idle')} {@const isSelected = sessionActive && session.id === sessionState.currentSessionId} {@const unread = unreadFor(session)} @@ -986,7 +987,9 @@ {/snippet} {#snippet sessionRow(session, indented, treeDepth)} {@const runtime = getRuntime(session.id)} - {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const status = runtime + ? getSessionChatStatus(runtime) + : (getSessionChatPeek(session.id)?.status ?? 'idle')} {@const isSelected = sessionActive && session.id === sessionState.currentSessionId} {@const isEditing = editingId === session.id} {@const unread = unreadFor(session)} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 640b484478..fbb84e1431 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -99,7 +99,13 @@ import type { RawAppDomResult } from '$lib/components/raw_apps/rawAppDom' import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib' -import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared' +import { + pendingUserAction, + type ChatJob, + type DisplayMessage +} from '$lib/components/copilot/chat/shared' +import { readStoredChat } from '$lib/components/copilot/chat/HistoryManager.svelte' +import { getCurrentUserEmail } from '$lib/userScopedStorage' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' // Per-kind load state for a session's editor target. Pure state container the @@ -985,7 +991,7 @@ function createRuntime(session: Session): SessionRuntime { async function initRuntime(runtime: SessionRuntime, session: Session) { const { manager } = runtime - await manager.historyManager.init() + await manager.historyManager.init(session) manager.historyManager.setSessionId(session.id) // Restore linked files persisted for this session (live handles re-grant on send; // snapshots restore directly). Non-transient sessions persist immediately. @@ -1000,7 +1006,9 @@ async function initRuntime(runtime: SessionRuntime, session: Session) { if (session.chatId) { manager.historyManager.setCurrentChatId(session.chatId) await manager.historyManager.tagChatWithSession(session.chatId, session.id) - await manager.loadPastChat(session.chatId) + // preserveQueue: restoring a session's own chat is not a conversation switch. + // A message queued while the restore ran is unsent user input waiting for it. + await manager.loadPastChat(session.chatId, { preserveQueue: true }) // loadPastChat only seeds the mask when the chat exists in history; a chatId // pointing at a chat not yet persisted (no turn saved) would leave it // undefined, and the Edits surface would then show every workspace draft. @@ -1020,7 +1028,11 @@ export function getOrCreateRuntime(session: Session): SessionRuntime { if (!runtime) { runtime = createRuntime(session) runtimes.set(session.id, runtime) - initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + // The gate opens even when the restore fails: a session that cannot read its + // history is still usable, and a send that waits forever is not. + runtime.manager.setReadyGate( + initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + ) } return runtime } @@ -1031,6 +1043,9 @@ export function disposeRuntime(sessionId: string) { runtime.manager.cancel('runtime disposed') runtime.manager.historyManager.close() runtimes.delete(sessionId) + // The sidebar reads the stored chat again rather than trust a read that + // predates everything this runtime saw. + peeks.delete(sessionId) } export function listRuntimes(): SessionRuntime[] { @@ -1064,7 +1079,13 @@ onRemoteTurnEnd((sessionId, chatId) => { async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise { const runtime = runtimes.get(sessionId) - if (!runtime) return + if (!runtime) { + // The driving tab may have rotated to a new chat; follow it, or the next + // read and a later runtime would open the old one. + setSessionChatId(sessionId, chatId) + refreshSessionChatPeek(sessionId) + return + } const m = runtime.manager // Two transient states get a short retry rather than a skip, because the // composer unlocks when this promise settles and a skip would unlock it on @@ -1405,3 +1426,87 @@ export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user' return 'idle' } + +// --------------------------------------------------------------------------- +// Sessions without a runtime +// --------------------------------------------------------------------------- + +// A runtime costs a chat manager and its loaded transcript, and lives until its +// session is deleted, so the sidebar does not create one per listed session. It +// reads the session's one stored chat instead, once. Without a runtime nothing +// runs in this tab, so only two facts of that chat can matter: its length (for +// the unread count) and whether its last message failed. Every other status is +// a live one. +export interface SessionChatPeek { + status: 'error' | 'idle' + messageCount: number +} + +const peeks = new SvelteMap() +const peeksInFlight = new Set() +const peeksPendingRefresh = new Set() + +// Not gated on `detached`: a chat saved mid-wait stores its job undetached, and +// loadPastChat resumes polling every unfinished job either way. +function isLiveJob(j: ChatJob): boolean { + return ( + j.status === 'queued' || + j.status === 'running' || + j.status === 'suspended' || + j.status === 'scheduled' + ) +} + +function sessionById(sessionId: string): Session | undefined { + return sessionState.sessions.find((s) => s.id === sessionId) +} + +function refreshSessionChatPeek(sessionId: string): void { + peeks.delete(sessionId) + if (peeksInFlight.has(sessionId)) { + peeksPendingRefresh.add(sessionId) + return + } + const session = sessionById(sessionId) + if (session) void ensureSessionChatPeek(session) +} + +export function getSessionChatPeek(sessionId: string): SessionChatPeek | undefined { + return peeks.get(sessionId) +} + +/** Read the stored chat of a session that has no runtime. A chat with a detached + * job still running gets its runtime instead: only a runtime polls the job and + * resumes the conversation when it finishes. */ +export async function ensureSessionChatPeek(session: Session): Promise { + const id = session.id + const chatId = session.chatId + if (runtimes.has(id) || peeks.has(id) || peeksInFlight.has(id) || !chatId) return + const email = getCurrentUserEmail() + if (!email) return + peeksInFlight.add(id) + try { + const chat = await readStoredChat(chatId, email) + if (sessionById(id)?.chatId !== chatId) { + refreshSessionChatPeek(id) + return + } + if (!chat || runtimes.has(id)) return + if (chat.backgroundJobs?.some(isLiveJob)) { + getOrCreateRuntime(session) + return + } + const last = chat.displayMessages[chat.displayMessages.length - 1] + peeks.set(id, { + status: last?.role === 'user' && last.error ? 'error' : 'idle', + messageCount: chat.displayMessages.length + }) + } catch (e) { + console.error('Failed to read session chat', e) + } finally { + peeksInFlight.delete(id) + if (peeksPendingRefresh.delete(id)) { + refreshSessionChatPeek(id) + } + } +} diff --git a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts index 281c6571bc..26ce3f205b 100644 --- a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts @@ -1,4 +1,4 @@ -import type { SessionRuntime } from './sessionRuntime.svelte' +import type { SessionChatPeek, SessionRuntime } from './sessionRuntime.svelte' import { sessionState, putSession } from './sessionState.svelte' // Per-session "last seen" watermark — the displayMessages count the last time @@ -20,11 +20,16 @@ export function markSessionSeen(sessionId: string, count: number) { void putSession(s) } -// Number of unread messages for a session. Undefined / unloaded runtime -// returns 0 — until messages are hydrated we don't know what's new. -export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number { - if (!runtime) return 0 +// Number of unread messages for a session, from its runtime when it has one, else +// from the sidebar's read of its stored chat. Neither loaded returns 0 — until +// messages are read we don't know what's new. +export function unreadCountFor( + sessionId: string, + runtime: SessionRuntime | undefined, + peek: SessionChatPeek | undefined +): number { + const total = runtime ? runtime.manager.displayMessages.length : peek?.messageCount + if (total === undefined) return 0 const seen = sessionState.sessions.find((x) => x.id === sessionId)?.lastSeenCount ?? 0 - const total = runtime.manager.displayMessages.length return Math.max(0, total - seen) } diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a3712f3f24..e773e7603f 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -220,19 +220,39 @@ // deep links the same way they react to picker clicks. selectSession(session.id) getOrCreateRuntime(session) + mountChat(session.id) }) }) - // Warm = sessions with a live runtime. The picker eagerly creates runtimes - // for its visible sessions, so this tracks whatever it shows. Keeping warm - // chats mounted (stacked, visibility-toggled) preserves their scroll/draft - // state across switches. + // Warm = sessions with a live runtime: the ones visited this page load plus + // any a chat tool or a running job started. const warmSessions = $derived( listRuntimes() .map((r) => sessionState.sessions.find((s) => s.id === r.sessionId)) .filter((s): s is NonNullable => s != null) ) + // Chat columns stay mounted (stacked, visibility-toggled) so switching back + // keeps scroll position, MRU-capped like preview tabs: every mounted column + // is a full transcript in the DOM. Unmounting drops only the column — the + // runtime keeps the chat — so a column is kept while its composer holds + // unsent input (component-local) or its turn is running. + const MAX_MOUNTED_CHATS = 5 + const mountedChatIds = new SvelteSet() + function keepsChatMounted(id: string): boolean { + const m = getRuntime(id)?.manager + return !!m && (m.loading || m.sendInFlight || m.sendPending || m.hasUnsentInput) + } + function mountChat(id: string) { + mountedChatIds.delete(id) + mountedChatIds.add(id) + for (const oldest of [...mountedChatIds]) { + if (mountedChatIds.size <= MAX_MOUNTED_CHATS) break + if (oldest !== id && !keepsChatMounted(oldest)) mountedChatIds.delete(oldest) + } + } + const mountedChatSessions = $derived(warmSessions.filter((s) => mountedChatIds.has(s.id))) + // Mark the active session "seen" up to its current message count: arrive → // clear unread; AI streams a new message while we're here → clear again. The // effect depends only on the length, not the array contents, so token-by-token @@ -915,11 +935,11 @@ class="flex-1 min-h-0 session-splitter {previewCollapsed ? 'splitter-off' : ''}" > {#if !fullscreen} - +
- {#each warmSessions as s (s.id)} + {#each mountedChatSessions as s (s.id)}