From dfbf793b785120c10a25804492ff26713e459bb6 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 20 Sep 2026 00:48:09 +0200 Subject: [PATCH] feat(coding-agent): load session picker progressively --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/cli/session-picker.ts | 2 +- .../coding-agent/src/core/session-manager.ts | 268 +++++++++++------- packages/coding-agent/src/main.ts | 15 +- .../components/session-selector.ts | 120 ++++---- .../src/modes/interactive/interactive-mode.ts | 15 +- .../session-manager/file-operations.test.ts | 16 ++ .../test/session-selector-path-delete.test.ts | 8 +- 8 files changed, 274 insertions(+), 171 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3bb3d5cef..1e584db02 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -30,6 +30,7 @@ ### Changed +- Made `--resume` session results appear progressively, using file modification times to prioritize all-folder loading and cancelling outstanding transcript reads after selection. - Replaced the external native clipboard dependency with bundled asynchronous macOS, Windows, and X11 helpers while preserving platform command and OSC 52 fallbacks ([#9163](https://github.com/earendil-works/pi/pull/9163)). - Reduced inherited fuzzy search latency for long texts by using native substring search instead of scanning each character in JavaScript ([#9267](https://github.com/earendil-works/pi/issues/9267)). - Moved compaction, branch summarization, and retry spinners into the editor border alongside the working indicator. Custom editors use the same embedding opt-in for all status spinners. diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 793f1d0a3..268a28c41 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -9,7 +9,7 @@ import type { SettingsManager } from "../core/settings-manager.ts"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts"; import { createStartupTui, startStartupTui } from "./startup-ui.ts"; -type SessionsLoader = (onProgress?: SessionListProgress) => Promise; +type SessionsLoader = (onProgress?: SessionListProgress, signal?: AbortSignal) => Promise; /** Show TUI session selector and return selected session path or null if cancelled */ export async function selectSession( diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index d228b0f31..fda3d8f44 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -18,11 +18,12 @@ import { openSync, readdirSync, readSync, + type Stats, statSync, writeFileSync, } from "fs"; import { readdir, stat } from "fs/promises"; -import { join, resolve } from "path"; +import { basename, join, resolve } from "path"; import { createInterface } from "readline"; import { StringDecoder } from "string_decoder"; import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; @@ -708,9 +709,13 @@ function getMessageActivityTime(entry: SessionMessageEntry): number | undefined return Number.isNaN(t) ? undefined : t; } -async function buildSessionInfo(filePath: string): Promise { +async function buildSessionInfo( + filePath: string, + signal?: AbortSignal, + fileStats?: Stats, +): Promise { try { - const stats = await stat(filePath); + const stats = fileStats ?? (await stat(filePath)); let header: SessionHeader | null = null; let messageCount = 0; let firstMessage = ""; @@ -719,7 +724,7 @@ async function buildSessionInfo(filePath: string): Promise { let lastActivityTime: number | undefined; const rl = createInterface({ - input: createReadStream(filePath, { encoding: "utf8" }), + input: createReadStream(filePath, { encoding: "utf8", signal }), crlfDelay: Infinity, }); @@ -784,85 +789,101 @@ async function buildSessionInfo(filePath: string): Promise { allMessagesText: allMessages.join(" "), }; } catch { + signal?.throwIfAborted(); return null; } } -export type SessionListProgress = (loaded: number, total: number) => void; +export type SessionListProgress = ( + loaded: number, + total: number, + /** Sessions loaded so far, sorted by activity. Present on periodic updates. */ + partialSessions?: readonly SessionInfo[], +) => void; const MAX_CONCURRENT_SESSION_INFO_LOADS = 10; +const MAX_CONCURRENT_SESSION_DISCOVERY_LOADS = 64; +const CURRENT_SESSION_LIST_PUBLISH_INTERVAL = 10; +const ALL_SESSION_LIST_PUBLISH_INTERVAL = 100; -async function buildSessionInfosWithConcurrency( - files: string[], - onLoaded: () => void, -): Promise<(SessionInfo | null)[]> { - const results: (SessionInfo | null)[] = new Array(files.length).fill(null); - const inFlight = new Set>(); +interface SessionFileCandidate { + path: string; + stats?: Stats; +} + +async function mapWithConcurrency( + items: T[], + limit: number, + map: (item: T, index: number) => Promise, + signal?: AbortSignal, +): Promise { + const results = new Array(items.length); let nextIndex = 0; - - const startNext = (): void => { - const index = nextIndex++; - const file = files[index]; - if (!file) return; - - let task: Promise; - task = buildSessionInfo(file) - .then((info) => { - results[index] = info; - }) - .catch(() => { - results[index] = null; - }) - .finally(() => { - inFlight.delete(task); - onLoaded(); - }); - inFlight.add(task); + const worker = async (): Promise => { + while (nextIndex < items.length) { + signal?.throwIfAborted(); + const index = nextIndex++; + results[index] = await map(items[index]!, index); + } }; - - while (nextIndex < files.length || inFlight.size > 0) { - while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) { - startNext(); - } - if (inFlight.size > 0) { - await Promise.race(inFlight); - } - } - + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); return results; } +function sortSessionInfos(sessions: SessionInfo[]): SessionInfo[] { + return sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); +} + +function buildSessionInfosWithConcurrency( + files: SessionFileCandidate[], + onLoaded: (info: SessionInfo | null, index: number) => void, + signal?: AbortSignal, +): Promise<(SessionInfo | null)[]> { + return mapWithConcurrency( + files, + MAX_CONCURRENT_SESSION_INFO_LOADS, + async (file, index) => { + const info = await buildSessionInfo(file.path, signal, file.stats); + onLoaded(info, index); + return info; + }, + signal, + ); +} + async function listSessionsFromDir( dir: string, onProgress?: SessionListProgress, - progressOffset = 0, - progressTotal?: number, + signal?: AbortSignal, ): Promise { - const sessions: SessionInfo[] = []; - if (!existsSync(dir)) { - return sessions; - } + signal?.throwIfAborted(); + if (!existsSync(dir)) return []; try { const dirEntries = await readdir(dir); - const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f)); - const total = progressTotal ?? files.length; - + const files = dirEntries + .filter((file) => file.endsWith(".jsonl")) + .sort((a, b) => b.localeCompare(a)) + .map((file) => ({ path: join(dir, file) })); + const total = files.length; + const partialSessions: SessionInfo[] = []; let loaded = 0; - const results = await buildSessionInfosWithConcurrency(files, () => { - loaded++; - onProgress?.(progressOffset + loaded, total); - }); - for (const info of results) { - if (info) { - sessions.push(info); - } - } + const results = await buildSessionInfosWithConcurrency( + files, + (info) => { + loaded++; + if (info) partialSessions.push(info); + const publishPartial = + loaded === 1 || loaded % CURRENT_SESSION_LIST_PUBLISH_INTERVAL === 0 || loaded === files.length; + onProgress?.(loaded, total, publishPartial ? sortSessionInfos([...partialSessions]) : undefined); + }, + signal, + ); + return results.filter((info): info is SessionInfo => info !== null); } catch { - // Return empty list on error + signal?.throwIfAborted(); + return []; } - - return sessions; } /** @@ -1736,79 +1757,116 @@ export class SessionManager { * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). * @param onProgress Optional callback for progress updates (loaded, total) */ - static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { + static async list( + cwd: string, + sessionDir?: string, + onProgress?: SessionListProgress, + signal?: AbortSignal, + ): Promise { const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); const resolvedCwd = resolvePath(cwd); - const sessions = (await listSessionsFromDir(dir, onProgress)).filter( - (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd), - ); - sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - return sessions; + const includeSession = (session: SessionInfo) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd); + const progress: SessionListProgress | undefined = onProgress + ? (loaded, total, partialSessions) => onProgress(loaded, total, partialSessions?.filter(includeSession)) + : undefined; + const sessions = (await listSessionsFromDir(dir, progress, signal)).filter(includeSession); + return sortSessionInfos(sessions); } /** * List all sessions across all project directories. * @param onProgress Optional callback for progress updates (loaded, total) */ - static async listAll(onProgress?: SessionListProgress): Promise; - static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise; + static async listAll(onProgress?: SessionListProgress, signal?: AbortSignal): Promise; + static async listAll( + sessionDir?: string, + onProgress?: SessionListProgress, + signal?: AbortSignal, + ): Promise; static async listAll( sessionDirOrOnProgress?: string | SessionListProgress, - onProgress?: SessionListProgress, + onProgressOrSignal?: SessionListProgress | AbortSignal, + signal?: AbortSignal, ): Promise { const customSessionDir = typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined; - const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress; + const progress = + typeof sessionDirOrOnProgress === "function" + ? sessionDirOrOnProgress + : typeof onProgressOrSignal === "function" + ? onProgressOrSignal + : undefined; + const abortSignal = + typeof sessionDirOrOnProgress === "string" || typeof onProgressOrSignal === "function" + ? signal + : (onProgressOrSignal ?? signal); + abortSignal?.throwIfAborted(); if (customSessionDir) { - const sessions = await listSessionsFromDir(customSessionDir, progress); - sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - return sessions; + return sortSessionInfos(await listSessionsFromDir(customSessionDir, progress, abortSignal)); } const sessionsDir = getSessionsDir(); try { - if (!existsSync(sessionsDir)) { - return []; - } + if (!existsSync(sessionsDir)) return []; const entries = await readdir(sessionsDir, { withFileTypes: true }); const dirs = entries .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) .map((entry) => join(sessionsDir, entry.name)); - // Count total files first for accurate progress - let totalFiles = 0; - const dirFiles: string[][] = []; - for (const dir of dirs) { - try { - const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")); - dirFiles.push(files.map((f) => join(dir, f))); - totalFiles += files.length; - } catch { - dirFiles.push([]); - } - } - - // Process all files with progress tracking - let loaded = 0; - const sessions: SessionInfo[] = []; + const dirFiles = await mapWithConcurrency( + dirs, + MAX_CONCURRENT_SESSION_DISCOVERY_LOADS, + async (dir) => { + try { + return (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file)); + } catch { + return []; + } + }, + abortSignal, + ); const allFiles = dirFiles.flat(); + const candidates = await mapWithConcurrency( + allFiles, + MAX_CONCURRENT_SESSION_DISCOVERY_LOADS, + async (path): Promise => { + try { + return { path, stats: await stat(path) }; + } catch { + return { path }; + } + }, + abortSignal, + ); + candidates.sort( + (a, b) => + (b.stats?.mtimeMs ?? Number.NEGATIVE_INFINITY) - (a.stats?.mtimeMs ?? Number.NEGATIVE_INFINITY) || + basename(b.path).localeCompare(basename(a.path)), + ); - const results = await buildSessionInfosWithConcurrency(allFiles, () => { - loaded++; - progress?.(loaded, totalFiles); - }); + const totalFiles = candidates.length; + let loaded = 0; + let firstCandidateLoaded = false; + const partialSessions: SessionInfo[] = []; + const results = await buildSessionInfosWithConcurrency( + candidates, + (info, index) => { + loaded++; + if (index === 0) firstCandidateLoaded = true; + if (info) partialSessions.push(info); + const publishPartial = + firstCandidateLoaded && + (index === 0 || loaded % ALL_SESSION_LIST_PUBLISH_INTERVAL === 0 || loaded === totalFiles); + progress?.(loaded, totalFiles, publishPartial ? sortSessionInfos([...partialSessions]) : undefined); + }, + abortSignal, + ); - for (const info of results) { - if (info) { - sessions.push(info); - } - } - - sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - return sessions; + return sortSessionInfos(results.filter((info): info is SessionInfo => info !== null)); } catch { + abortSignal?.throwIfAborted(); return []; } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 53a9a5ec3..5272cba6d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -254,10 +254,15 @@ async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: return { type: "path", path: resolvePath(sessionArg, cwd) }; } - // Try to match as session ID in current project first + // Exact IDs only require reading session headers. Fall back to the full + // metadata listing for prefix matches. + const exactLocalMatch = findLocalSessionByExactId(sessionArg, cwd, sessionDir); + if (exactLocalMatch) { + return exactLocalMatch; + } + const localSessions = await SessionManager.list(cwd, sessionDir); - const localMatch = - localSessions.find((s) => s.id === sessionArg) ?? localSessions.find((s) => s.id.startsWith(sessionArg)); + const localMatch = localSessions.find((s) => s.id.startsWith(sessionArg)); if (localMatch) { return { type: "local", path: localMatch.path }; @@ -409,8 +414,8 @@ export async function createSessionManager( if (parsed.resume) { try { const selectedPath = await selectSession( - (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), - (onProgress) => SessionManager.listAll(sessionDir, onProgress), + (onProgress, signal) => SessionManager.list(cwd, sessionDir, onProgress, signal), + (onProgress, signal) => SessionManager.listAll(sessionDir, onProgress, signal), settingsManager, ); if (!selectedPath) { 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 4949eee51..d60eaffc8 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -288,6 +288,7 @@ class SessionList implements Component, Focusable { private allSessions: SessionInfo[] = []; private filteredSessions: FlatSessionNode[] = []; private selectedIndex: number = 0; + private selectionTouched = false; private searchInput: Input; private showCwd = false; private sortMode: SortMode = "threaded"; @@ -359,9 +360,16 @@ class SessionList implements Component, Focusable { } setSessions(sessions: SessionInfo[], showCwd: boolean): void { + const selectedPath = this.selectionTouched ? this.getSelectedSessionPath() : undefined; this.allSessions = sessions; this.showCwd = showCwd; this.filterSessions(this.searchInput.getValue()); + if (!this.selectionTouched) { + this.selectedIndex = 0; + } else if (selectedPath) { + const selectedIndex = this.filteredSessions.findIndex((node) => node.session.path === selectedPath); + if (selectedIndex >= 0) this.selectedIndex = selectedIndex; + } } private filterSessions(query: string): void { @@ -600,6 +608,7 @@ class SessionList implements Component, Focusable { return; } + this.selectionTouched = true; // Up arrow if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); @@ -637,7 +646,7 @@ class SessionList implements Component, Focusable { } } -type SessionsLoader = (onProgress?: SessionListProgress) => Promise; +type SessionsLoader = (onProgress?: SessionListProgress, signal?: AbortSignal) => Promise; /** * Delete a session file, trying the `trash` CLI first, then falling back to unlink @@ -710,9 +719,8 @@ export class SessionSelectorComponent extends Container implements Focusable { private allSessionsLoader: SessionsLoader; private requestRender: () => void; private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; - private currentLoading = false; - private allLoading = false; - private allLoadSeq = 0; + private currentLoad: AbortController | null = null; + private allLoad: AbortController | null = null; private mode: "list" | "rename" = "list"; private renameInput = new Input(); @@ -791,14 +799,17 @@ export class SessionSelectorComponent extends Container implements Focusable { const clearStatusMessage = () => this.header.setStatusMessage(null); this.sessionList.onSelect = (sessionPath) => { clearStatusMessage(); + this.cancelLoads(); onSelect(sessionPath); }; this.sessionList.onCancel = () => { clearStatusMessage(); + this.cancelLoads(); onCancel(); }; this.sessionList.onExit = () => { clearStatusMessage(); + this.cancelLoads(); onExit(); }; this.sessionList.onToggleScope = () => this.toggleScope(); @@ -806,8 +817,7 @@ export class SessionSelectorComponent extends Container implements Focusable { this.sessionList.onToggleNameFilter = () => this.toggleNameFilter(); this.sessionList.onRenameSession = (sessionPath) => { if (!renameSession) return; - if (this.scope === "current" && this.currentLoading) return; - if (this.scope === "all" && this.allLoading) return; + if (this.scope === "current" ? this.currentLoad : this.allLoad) return; const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []); const session = sessions.find((s) => s.path === sessionPath); @@ -856,11 +866,20 @@ export class SessionSelectorComponent extends Container implements Focusable { }; // Start loading current sessions immediately - this.loadCurrentSessions(); + void this.loadScope("current"); } - private loadCurrentSessions(): void { - void this.loadScope("current", "initial"); + private cancelLoads(): void { + if (this.currentLoad) { + this.currentLoad.abort(); + this.currentLoad = null; + this.currentSessions = null; + } + if (this.allLoad) { + this.allLoad.abort(); + this.allLoad = null; + this.allSessions = null; + } } private enterRenameMode(sessionPath: string, currentName: string | undefined): void { @@ -919,64 +938,70 @@ export class SessionSelectorComponent extends Container implements Focusable { } } - private async loadScope(scope: SessionScope, reason: "initial" | "refresh" | "toggle"): Promise { + private async loadScope(scope: SessionScope): Promise { + if (scope === "current" ? this.currentLoad : this.allLoad) return; + const showCwd = scope === "all"; - - // Mark loading + const controller = new AbortController(); if (scope === "current") { - this.currentLoading = true; + this.currentLoad = controller; } else { - this.allLoading = true; + this.allLoad = controller; } - - const seq = scope === "all" ? ++this.allLoadSeq : undefined; this.header.setScope(scope); this.header.setLoading(true); this.requestRender(); - const onProgress = (loaded: number, total: number) => { + const isActive = () => (scope === "current" ? this.currentLoad : this.allLoad) === controller; + const onProgress: SessionListProgress = (loaded, total, partialSessions) => { + if (!isActive()) return; + if (partialSessions) { + const sessions = [...partialSessions]; + if (scope === "current") { + this.currentSessions = sessions; + } else { + this.allSessions = sessions; + } + if (scope === this.scope) this.sessionList.setSessions(sessions, showCwd); + } if (scope !== this.scope) return; - if (seq !== undefined && seq !== this.allLoadSeq) return; this.header.setProgress(loaded, total); this.requestRender(); }; try { const sessions = await (scope === "current" - ? this.currentSessionsLoader(onProgress) - : this.allSessionsLoader(onProgress)); + ? this.currentSessionsLoader(onProgress, controller.signal) + : this.allSessionsLoader(onProgress, controller.signal)); + if (!isActive()) return; if (scope === "current") { this.currentSessions = sessions; - this.currentLoading = false; + this.currentLoad = null; } else { this.allSessions = sessions; - this.allLoading = false; + this.allLoad = null; } if (scope !== this.scope) return; - if (seq !== undefined && seq !== this.allLoadSeq) return; - this.header.setLoading(false); this.sessionList.setSessions(sessions, showCwd); this.requestRender(); } catch (err) { + if (!isActive()) return; if (scope === "current") { - this.currentLoading = false; + this.currentLoad = null; + this.currentSessions = null; } else { - this.allLoading = false; + this.allLoad = null; + this.allSessions = null; } - 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); this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); - - if (reason === "initial") { - this.sessionList.setSessions([], showCwd); - } + this.sessionList.setSessions([], showCwd); this.requestRender(); } } @@ -997,32 +1022,21 @@ export class SessionSelectorComponent extends Container implements Focusable { } private async refreshSessionsAfterMutation(): Promise { - await this.loadScope(this.scope, "refresh"); + this.cancelLoads(); + this.currentSessions = null; + this.allSessions = null; + await this.loadScope(this.scope); } private toggleScope(): void { - if (this.scope === "current") { - this.scope = "all"; - this.header.setScope(this.scope); - - if (this.allSessions !== null) { - this.header.setLoading(false); - this.sessionList.setSessions(this.allSessions, true); - this.requestRender(); - return; - } - - if (!this.allLoading) { - void this.loadScope("all", "toggle"); - } - return; - } - - this.scope = "current"; + this.scope = this.scope === "current" ? "all" : "current"; + const sessions = this.scope === "current" ? this.currentSessions : this.allSessions; + const loading = (this.scope === "current" ? this.currentLoad : this.allLoad) !== null; this.header.setScope(this.scope); - this.header.setLoading(this.currentLoading); - this.sessionList.setSessions(this.currentSessions ?? [], false); + this.header.setLoading(loading); + this.sessionList.setSessions(sessions ?? [], this.scope === "all"); this.requestRender(); + if (sessions === null && !loading) void this.loadScope(this.scope); } getSessionList(): SessionList { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ac16fa9a2..3e32dbf0a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5465,12 +5465,17 @@ export class InteractiveMode { private showSessionSelector(): void { this.showSelector((done) => { const selector = new SessionSelectorComponent( - (onProgress) => - SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), - (onProgress) => + (onProgress, signal) => + SessionManager.list( + this.sessionManager.getCwd(), + this.sessionManager.getSessionDir(), + onProgress, + signal, + ), + (onProgress, signal) => this.sessionManager.usesDefaultSessionDir() - ? SessionManager.listAll(onProgress) - : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress), + ? SessionManager.listAll(onProgress, signal) + : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress, signal), async (sessionPath) => { done(); await this.handleResumeSession(sessionPath); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 40f88131b..5b201488b 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -325,6 +325,22 @@ describe("SessionManager custom flat session directory", () => { const continuedA = SessionManager.continueRecent(projectA, tempDir); expect(continuedA.getSessionFile()).toBe(sessionA); }); + + it("rejects a cancelled session listing", async () => { + createPersistedSession(projectA, "from A"); + createPersistedSession(projectB, "from B"); + const controller = new AbortController(); + const listing = SessionManager.listAll( + tempDir, + (_loaded, _total, partialSessions) => { + if (partialSessions) controller.abort(); + }, + controller.signal, + ); + + await expect(listing).rejects.toMatchObject({ name: "AbortError" }); + await expect(SessionManager.listAll(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + }); }); describe("SessionManager.setSessionFile with corrupted files", () => { diff --git a/packages/coding-agent/test/session-selector-path-delete.test.ts b/packages/coding-agent/test/session-selector-path-delete.test.ts index cb702d5e2..4a0ed3642 100644 --- a/packages/coding-agent/test/session-selector-path-delete.test.ts +++ b/packages/coding-agent/test/session-selector-path-delete.test.ts @@ -218,13 +218,15 @@ describe("session selector path/delete interactions", () => { it("does not start redundant All loads when toggling scopes while All is already loading", async () => { const currentSessions = [makeSession({ id: "current" })]; + const allSessions = [makeSession({ id: "all" })]; const allDeferred = createDeferred(); let allLoadCalls = 0; const selector = new SessionSelectorComponent( async () => currentSessions, - async () => { + async (onProgress) => { allLoadCalls++; + onProgress?.(1, 2, allSessions); return allDeferred.promise; }, () => {}, @@ -241,8 +243,10 @@ describe("session selector path/delete interactions", () => { list.handleInput("\t"); // current -> all again while load pending expect(allLoadCalls).toBe(1); + expect(selector.getSessionList().getSelectedSessionPath()).toBe(allSessions[0]!.path); + expect(selector.render(120).join("\n")).toContain("Loading"); - allDeferred.resolve([makeSession({ id: "all" })]); + allDeferred.resolve(allSessions); await flushPromises(); });