From 75fcecee40fb79dcd320c54a7e0a05526651676c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 16 Jun 2026 20:14:40 +0200 Subject: [PATCH] fix(coding-agent): hydrate resume search text lazily --- .../coding-agent/src/cli/session-picker.ts | 4 +- .../coding-agent/src/core/session-manager.ts | 7 ++ packages/coding-agent/src/main.ts | 5 +- .../components/session-selector-search.ts | 2 +- .../components/session-selector.ts | 68 ++++++++++++++++--- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 42dcb808d..df9dd1dea 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -8,11 +8,13 @@ import type { SessionInfo, SessionListProgress } from "../core/session-manager.t import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts"; type SessionsLoader = (onProgress?: SessionListProgress) => Promise; +type SessionHydrator = (path: string) => Promise; /** Show TUI session selector and return selected session path or null if cancelled */ export async function selectSession( currentSessionsLoader: SessionsLoader, allSessionsLoader: SessionsLoader, + hydrateSession?: SessionHydrator, ): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); @@ -42,7 +44,7 @@ export async function selectSession( process.exit(0); }, () => ui.requestRender(), - { showRenameHint: false, keybindings }, + { showRenameHint: false, keybindings, hydrateSession }, ); ui.addChild(selector); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index b2a6d49fd..4c0350c1f 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1609,6 +1609,13 @@ export class SessionManager { return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true); } + /** + * Hydrate a thin session listing with full searchable text. + */ + static async hydrateSessionForSearch(path: string): Promise { + return buildSessionInfo(path); + } + /** * List sessions using stat mtime and only enough file content for initial display. * Full searchable text is intentionally omitted. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 098894f7f..bb5f85129 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -311,8 +311,9 @@ async function createSessionManager( initTheme(settingsManager.getTheme(), true); try { const selectedPath = await selectSession( - (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), - (onProgress) => SessionManager.listAll(sessionDir, onProgress), + (onProgress) => SessionManager.listThin(cwd, sessionDir, onProgress), + (onProgress) => SessionManager.listAllThin(sessionDir, onProgress), + (path) => SessionManager.hydrateSessionForSearch(path), ); if (!selectedPath) { console.log(chalk.dim("No session selected")); diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts index 9b5bf2327..a52fa7cfa 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -24,7 +24,7 @@ function normalizeWhitespaceLower(text: string): string { } function getSessionSearchText(session: SessionInfo): string { - return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; + return `${session.id} ${session.name ?? ""} ${session.firstMessage} ${session.allMessagesText} ${session.cwd} ${session.path}`; } export function hasSessionName(session: SessionInfo): boolean { diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index a92f0762a..4187d022b 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -624,6 +624,7 @@ class SessionList implements Component, Focusable { } type SessionsLoader = (onProgress?: SessionListProgress) => Promise; +type SessionHydrator = (path: string) => Promise; /** * Delete a session file, trying the `trash` CLI first, then falling back to unlink @@ -694,10 +695,12 @@ export class SessionSelectorComponent extends Container implements Focusable { private allSessions: SessionInfo[] | null = null; private currentSessionsLoader: SessionsLoader; private allSessionsLoader: SessionsLoader; + private hydrateSession?: SessionHydrator; private requestRender: () => void; private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; private currentLoading = false; private allLoading = false; + private currentLoadSeq = 0; private allLoadSeq = 0; private mode: "list" | "rename" = "list"; @@ -743,6 +746,7 @@ export class SessionSelectorComponent extends Container implements Focusable { renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; showRenameHint?: boolean; keybindings?: KeybindingsManager; + hydrateSession?: SessionHydrator; }, currentSessionFilePath?: string, ) { @@ -750,6 +754,7 @@ export class SessionSelectorComponent extends Container implements Focusable { this.keybindings = options?.keybindings ?? KeybindingsManager.create(); this.currentSessionsLoader = currentSessionsLoader; this.allSessionsLoader = allSessionsLoader; + this.hydrateSession = options?.hydrateSession; this.requestRender = requestRender; this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender); const renameSession = options?.renameSession; @@ -907,6 +912,7 @@ export class SessionSelectorComponent extends Container implements Focusable { private async loadScope(scope: SessionScope, reason: "initial" | "refresh" | "toggle"): Promise { const showCwd = scope === "all"; + const seq = scope === "current" ? ++this.currentLoadSeq : ++this.allLoadSeq; // Mark loading if (scope === "current") { @@ -915,14 +921,15 @@ export class SessionSelectorComponent extends Container implements Focusable { this.allLoading = true; } - const seq = scope === "all" ? ++this.allLoadSeq : undefined; this.header.setScope(scope); this.header.setLoading(true); this.requestRender(); + const isCurrentLoad = (): boolean => + scope === "current" ? seq === this.currentLoadSeq : seq === this.allLoadSeq; + const onProgress = (loaded: number, total: number) => { - if (scope !== this.scope) return; - if (seq !== undefined && seq !== this.allLoadSeq) return; + if (scope !== this.scope || !isCurrentLoad()) return; this.header.setProgress(loaded, total); this.requestRender(); }; @@ -932,6 +939,8 @@ export class SessionSelectorComponent extends Container implements Focusable { ? this.currentSessionsLoader(onProgress) : this.allSessionsLoader(onProgress)); + if (!isCurrentLoad()) return; + if (scope === "current") { this.currentSessions = sessions; this.currentLoading = false; @@ -940,13 +949,16 @@ export class SessionSelectorComponent extends Container implements Focusable { this.allLoading = false; } - if (scope !== this.scope) return; - if (seq !== undefined && seq !== this.allLoadSeq) return; + if (scope === this.scope) { + this.header.setLoading(false); + this.sessionList.setSessions(sessions, showCwd); + this.requestRender(); + } - this.header.setLoading(false); - this.sessionList.setSessions(sessions, showCwd); - this.requestRender(); + void this.hydrateScopeForSearch(scope, seq, sessions); } catch (err) { + if (!isCurrentLoad()) return; + if (scope === "current") { this.currentLoading = false; } else { @@ -954,7 +966,6 @@ export class SessionSelectorComponent extends Container implements Focusable { } if (scope !== this.scope) return; - if (seq !== undefined && seq !== this.allLoadSeq) return; const message = err instanceof Error ? err.message : String(err); this.header.setLoading(false); @@ -967,6 +978,45 @@ export class SessionSelectorComponent extends Container implements Focusable { } } + private async hydrateScopeForSearch(scope: SessionScope, seq: number, sessions: SessionInfo[]): Promise { + const hydrateSession = this.hydrateSession; + if (!hydrateSession) return; + + const isCurrentLoad = (): boolean => + scope === "current" ? seq === this.currentLoadSeq : seq === this.allLoadSeq; + let lastRender = 0; + + for (const session of sessions) { + if (!isCurrentLoad()) return; + const hydrated = await hydrateSession(session.path); + if (!hydrated || !isCurrentLoad()) continue; + + const targetSessions = scope === "current" ? this.currentSessions : this.allSessions; + if (!targetSessions) continue; + + const index = targetSessions.findIndex((candidate) => candidate.path === hydrated.path); + if (index < 0) continue; + + const existing = targetSessions[index]!; + targetSessions[index] = { ...hydrated, modified: existing.modified }; + + if (scope === this.scope) { + const now = Date.now(); + if (now - lastRender > 100) { + this.sessionList.setSessions(targetSessions, scope === "all"); + this.requestRender(); + lastRender = now; + } + } + } + + if (scope === this.scope && isCurrentLoad()) { + const targetSessions = scope === "current" ? this.currentSessions : this.allSessions; + this.sessionList.setSessions(targetSessions ?? [], scope === "all"); + this.requestRender(); + } + } + private toggleSortMode(): void { // Cycle: threaded -> recent -> relevance -> threaded this.sortMode = this.sortMode === "threaded" ? "recent" : this.sortMode === "recent" ? "relevance" : "threaded";