feat(tui): open the /resume picker immediately with a loading state

The selector overlay opens as soon as the command dispatches: the
picker renders a loading placeholder over an undefined candidate set,
owns terminal input from its first frame, answers Enter with a
still-loading error, and cancels on Escape exactly like the loaded
list. The finished scan swaps rows in through setCandidates without
replacing the overlay; a scan failure closes it and keeps the existing
notice.
This commit is contained in:
Turtle
2026-07-31 16:39:33 +08:00
parent 3c08ca3606
commit b7ee38fef0
11 changed files with 181 additions and 44 deletions

View File

@@ -195,6 +195,38 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
}
const scan = ++resumeScan
void resumeOverlay?.close()
// The picker opens before the scan settles so the terminal stops feeding
// the editor immediately; a queued activation (the closing predecessor
// still holds the slot) receives an already-scanned set through
// `scanned` instead of a loading placeholder.
let picker: ResumePicker | undefined
let scanned: ResumeCandidate[] | undefined
const session = overlayManager.open({
create: (host) => {
picker = new ResumePicker(
scanned,
resolved.maxResumeOptions,
workspaceLabel(agent.session.header.cwd),
() => host.viewport.rows,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
)
return picker
},
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* 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
// Every workspace in the store is summarized; the picker owns the
@@ -222,31 +254,13 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (deps.isDisposed() || scan !== resumeScan) return
const session = overlayManager.open({
create: host => new ResumePicker(
candidates,
resolved.maxResumeOptions,
workspaceLabel(agent.session.header.cwd),
() => host.viewport.rows,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
scanned = candidates
picker?.setCandidates(candidates)
deps.requestRender()
}, (error: unknown) => {
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
if (deps.isDisposed() || scan !== resumeScan) return
void session.close()
deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})
},
}

View File

@@ -537,6 +537,10 @@ export type ResumeScope = 'workspace' | 'all'
* current session's workspace, `all` lists every workspace and labels each row
* with its own. Tab toggles between them; the search query and selection reset
* on a scope change so the highlighted row always belongs to the visible list.
*
* The picker opens before the session scan settles: an `undefined` candidate
* set renders a loading placeholder that keeps input away from the editor,
* and `setCandidates` swaps the scanned rows in without replacing the overlay.
*/
export class ResumePicker implements Component, Focusable {
private readonly search = new Input()
@@ -544,27 +548,41 @@ export class ResumePicker implements Component, Focusable {
private selectedIndex = 0
private error = ''
private scope: ResumeScope = 'workspace'
private candidates: readonly ResumeCandidate[] | undefined
focused = false
constructor(
private readonly candidates: readonly ResumeCandidate[],
candidates: readonly ResumeCandidate[] | undefined,
private readonly maxVisible: number,
private readonly workspaceLabel: string,
private readonly viewportRows: () => number,
private readonly palette: Palette,
private readonly done: (candidate: ResumeCandidate) => void,
private readonly cancel: () => void,
) {}
) {
this.candidates = candidates
}
invalidate(): void {
this.search.invalidate()
}
/**
* Replace the loading placeholder with the scanned candidate set.
* @param candidates - the summarized rows the finished scan produced.
*/
setCandidates(candidates: readonly ResumeCandidate[]): void {
this.candidates = candidates
this.selectedIndex = 0
this.invalidate()
}
/** Candidates in the active scope, before the search query narrows them. */
private scoped(): ResumeCandidate[] {
const candidates = this.candidates ?? []
return this.scope === 'all'
? [...this.candidates]
: this.candidates.filter(candidate => candidate.currentWorkspace)
? [...candidates]
: candidates.filter(candidate => candidate.currentWorkspace)
}
private filtered(): ResumeCandidate[] {
@@ -646,7 +664,8 @@ export class ResumePicker implements Component, Focusable {
this.error = ''
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
if (this.candidates === undefined) this.error = 'Sessions are still loading.'
else if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else {
@@ -666,12 +685,13 @@ export class ResumePicker implements Component, Focusable {
* workspace it means, and the inactive scope with the count Tab would reveal.
*/
private renderScopeLine(): string {
const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length
const candidates = this.candidates ?? []
const inWorkspace = candidates.filter(candidate => candidate.currentWorkspace).length
const active = this.scope === 'workspace'
? `this workspace ${displayText(this.workspaceLabel)}`
: `all workspaces (${this.candidates.length})`
: `all workspaces (${candidates.length})`
const other = this.scope === 'workspace'
? `all workspaces (${this.candidates.length})`
? `all workspaces (${candidates.length})`
: `this workspace (${inWorkspace})`
return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}`
}
@@ -686,9 +706,12 @@ export class ResumePicker implements Component, Focusable {
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const selected = filtered[this.selectedIndex]
const position = selected === undefined ? 0 : this.selectedIndex + 1
const title = this.candidates === undefined
? 'Resume session'
: `Resume session (${position} of ${filtered.length})`
const lines: string[] = [
'',
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
`${indent}${this.palette.bold(this.palette.accent(title))}`,
'',
]
@@ -737,7 +760,8 @@ export class ResumePicker implements Component, Focusable {
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.candidates === undefined) push(this.palette.dim('Loading sessions…'))
else if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.error !== '') {
lines.push('')
push(this.palette.error(displayText(this.error)))