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:
@@ -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| " "
|
||||
@@ -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 })
|
||||
|
||||
@@ -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[] }>()
|
||||
|
||||
Reference in New Issue
Block a user