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

@@ -0,0 +1,47 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=6 viewportRow=4 bufferRow=4
buffer
0| " "
1| " Resume session "
style 2-15 fg=bright-magenta bold
2| " "
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
style 2-89 dim
4| " │ ⌕ │ "
style 2-2 dim
style 6-6 inverse
style 89-89 dim
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
style 2-89 dim
6| " "
7| " this workspace /workspace/project ⇥ all workspaces (0) "
style 2-34 fg=bright-magenta
style 35-56 dim
8| " "
9| " Loading sessions… "
style 2-18 dim
10| " "
11| " "
12| " "
13| " "
14| " "
15| " "
16| " "
17| " "
18| " "
19| " "
20| " "
21| " "
22| " "
23| " "
24| " "
25| " "
26| " "
27| " "
28| " "
29| " "
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
style 2-84 dim
31| " "

View File

@@ -57,6 +57,7 @@ const CHECKPOINTS = [
'model-switching',
'errors-and-help',
'disposed-terminal',
'resume-sessions-loading',
'resume-sessions',
'resume-sessions-all-workspaces',
'status-diagnostics',
@@ -883,9 +884,13 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
],
})
const listGate = Promise.withResolvers<undefined>()
const harness = await setupSnapshot({
sessionPersistence: {
list: async () => [earlier, elsewhere],
list: async () => {
await listGate.promise
return [earlier, elsewhere]
},
load: async id => id === elsewhere.id
? log(elsewhere, 'Other workspace work', '2024-02-02')
: log(earlier, 'Resume selector design', '2024-01-01'),
@@ -893,8 +898,15 @@ describe('TUI terminal-state snapshots', () => {
}, { columns: 92, rows: 32 })
harness.terminal.send('/resume')
harness.terminal.send('\r')
// `/resume` scans persistence asynchronously, so the listing renders a tick
// after submit (the unit suite waits the same way); settle, then flush.
// The picker opens as soon as the command dispatches and owns input while
// the persistence scan is still pending, rendering a loading placeholder
// in place of rows; only the scan is gated, so this settle never lists.
await new Promise(resolve => setTimeout(resolve, 60))
await harness.terminal.flush()
await checkpoint('resume-sessions-loading', harness.terminal, { includeScrollback: true })
listGate.resolve(undefined)
// With the scan released, the listing renders a tick later (the unit suite
// waits the same way); settle, then flush.
await new Promise(resolve => setTimeout(resolve, 60))
await harness.terminal.flush()
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })

View File

@@ -616,6 +616,11 @@ describe('goodbye message and /resume', () => {
})
result.terminal.send('/resume')
result.terminal.send('\r')
// The loading picker owns input as soon as /resume runs, so the second
// scan starts after dismissing the first overlay, not by typing a second
// slash command over it.
result.terminal.send('\u001B')
await tick()
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
@@ -646,6 +651,37 @@ describe('goodbye message and /resume', () => {
expect(result.terminal.stopped).toBeGreaterThan(0)
})
it('opens a loading picker immediately and swaps in the scanned rows', async () => {
const target = header('late-listing', 10, '/workspace')
const listing = Promise.withResolvers<SessionRecord[]>()
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Late listing'),
})
ctx.provide('sessionQuery', {
listSessions: () => listing.promise,
readSession,
projectSessions: projectViaReadSession(readSession),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Loading sessions…')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Sessions are still loading.')
listing.resolve([{ header: target, live: false, persisted: true }])
await tick(); await tick()
expect(result.terminal.output).toContain('Late listing')
await dispose(result)
})
it('drops loaded selector summaries when the TUI disposed during log reads', async () => {
const target = header('dispose-during-load', 10, '/workspace')
const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()