fix(tui): abort the resume scan with its overlay

Review findings from ds-review-bot: closing the loading picker now
aborts the scan through the AbortSignal both query methods accept, a
signal-ignoring backend's late settlement is dropped by a staleness
check, one catch spans listing and projection so a projection failure
closes the overlay instead of stranding the loading placeholder,
setCandidates clears a stale still-loading error, and the batch
comment no longer overstates the win as scaling with session count.
This commit is contained in:
Turtle
2026-07-31 18:57:24 +08:00
parent 6472533845
commit a942c726f5
6 changed files with 118 additions and 13 deletions

View File

@@ -222,21 +222,28 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
},
})
resumeOverlay = session
// Closing the picker — Escape, supersession, disposal — aborts the scan:
// the borrowed-log pass over a large store must not outlive its overlay.
const scanAbort = new AbortController()
void session.closed.then(() => {
scanAbort.abort()
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
deps.requestRender()
void listQuery.listSessions().then(async (records) => {
if (deps.isDisposed() || scan !== resumeScan) return
/** Whether this scan's overlay, session generation, or TUI is gone. */
const scanStale = (): boolean =>
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
const scanCandidates = async (): Promise<void> => {
const records = await listQuery.listSessions(scanAbort.signal)
if (scanStale()) return
// Every workspace in the store is summarized; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
// One bounded batch projection over borrowed logs: unlike a
// per-candidate readSession, it lists persistence once and skips
// replay validation and log cloning, so opening the selector scales
// with session count instead of total log size. A corrupt neighbor
// degrades to one disabled row.
// replay validation and log cloning, bounding memory by what each
// summary retains. A corrupt neighbor degrades to one disabled row.
const recordById = new Map(records.map(record => [record.header.id, record]))
const listedRecord = (id: SessionId): SessionRecord => {
const record = recordById.get(id)
@@ -247,18 +254,23 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
const results = await listQuery.projectSessions(
records.map(record => record.header.id),
source => summarize(listedRecord(source.header.id), source, providers),
scanAbort.signal,
)
const candidates = results.map(result => result.status === 'fulfilled'
? result.value
: unreadableCandidate(listedRecord(result.sessionId), result.reason))
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (deps.isDisposed() || scan !== resumeScan) return
if (scanStale()) return
scanned = candidates
picker?.setCandidates(candidates)
deps.requestRender()
}, (error: unknown) => {
if (deps.isDisposed() || scan !== resumeScan) return
}
// One catch covers both stages, so a projection failure cannot strand
// the overlay on its loading placeholder; an aborted scan's rejection
// stays silent because the user already dismissed the picker.
void scanCandidates().catch((error: unknown) => {
if (scanStale()) return
void session.close()
deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})

View File

@@ -574,6 +574,8 @@ export class ResumePicker implements Component, Focusable {
setCandidates(candidates: readonly ResumeCandidate[]): void {
this.candidates = candidates
this.selectedIndex = 0
// A still-loading error is false the moment rows exist.
this.error = ''
this.invalidate()
}