Merge pull request #31 from bnema/fix/session-picker-streaming
fix(agent): stream session picker entries
3 files · +277 -13
M README.md +1 -1
| @@ -314,7 +314,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||
| 314 | 314 | ||
| 315 | 315 | ### `/sessions` | |
| 316 | 316 | ||
| 317 | - | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background while the picker stays responsive and shows a spinner until loading completes. While loading, `esc` cancels and closes the picker; other keys, including `up`, `down`, and `enter`, are ignored until loading finishes. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 317 | + | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background and are revealed incrementally in newest-first order; an older result may wait briefly for entries ahead of it so the list never reorders, while the picker keeps showing progress for the remaining entries. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` cancels and closes the picker. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | |
| 318 | 318 | ||
| 319 | 319 | ### `/session` | |
| 320 | 320 | ||
| @@ -314,7 +314,7 @@ Type `!` followed by a command to run it directly without going through the mode | |||||
| 314 | 314 | ||||
| 315 | ### `/sessions` | 315 | ### `/sessions` | ||
| 316 | 316 | ||||
| 317 | - | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background while the picker stays responsive and shows a spinner until loading completes. While loading, `esc` cancels and closes the picker; other keys, including `up`, `down`, and `enter`, are ignored until loading finishes. Pick one with `up`/`down`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. | 317 | + | Shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the session title. Session entries load in the background and are revealed incrementally in newest-first order; an older result may wait briefly for entries ahead of it so the list never reorders, while the picker keeps showing progress for the remaining entries. Once an entry appears, `up`/`down` and `enter` can select it immediately; `esc` cancels and closes the picker. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. Sessions remember their stored provider and model: resuming rebuilds the live agent when necessary instead of silently using a different model; if that rebuild fails, the current session is left unchanged. Branches created for tree navigation remain available to `/session tree` but are hidden from this flat picker. A fresh interactive session asks the active model for a concise title after the first real prompt and restores that title when resumed. |
| 318 | 318 | ||||
| 319 | ### `/session` | 319 | ### `/session` | ||
| 320 | 320 | ||||
M packages/agent/modes/session_dialog.go +68 -10
| @@ -54,7 +54,15 @@ type sessionDialog struct { | |||
| 54 | 54 | loadGeneration uint64 | |
| 55 | 55 | loadCancel context.CancelFunc | |
| 56 | 56 | loadDone chan struct{} | |
| 57 | + | // loadReadyThrough is the contiguous path prefix whose summaries have | |
| 58 | + | // arrived. It prevents an older worker result from appearing before a | |
| 59 | + | // newer session that is still being read. | |
| 57 | 60 | loadSlots []sessionLoadSlot | |
| 61 | + | loadReadyThrough int | |
| 62 | + | ||
| 63 | + | // describeSession is replaceable in tests so cancellation can be | |
| 64 | + | // exercised without relying on file-size-dependent timing. | |
| 65 | + | describeSession func(context.Context, string) core.SessionSummary | |
| 58 | 66 | ||
| 59 | 67 | // MaxRows is the maximum number of session rows the dialog | |
| 60 | 68 | // will render in a single frame. Set by the host right before | |
| @@ -110,12 +118,15 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||
| 110 | 118 | d.loadingTotal = 0 | |
| 111 | 119 | d.loadingStartedAt = time.Now() | |
| 112 | 120 | d.loadSlots = nil | |
| 121 | + | d.loadReadyThrough = 0 | |
| 113 | 122 | d.active = true | |
| 114 | 123 | ||
| 115 | 124 | if ctxErr := ctx.Err(); ctxErr != nil { | |
| 116 | 125 | cancel() | |
| 117 | 126 | d.loadCancel = nil | |
| 118 | 127 | d.loading = false | |
| 128 | + | d.active = false | |
| 129 | + | d.loadDone = previousDone | |
| 119 | 130 | events := make(chan sessionLoadEvent) | |
| 120 | 131 | close(events) | |
| 121 | 132 | close(done) | |
| @@ -131,6 +142,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||
| 131 | 142 | return false | |
| 132 | 143 | } | |
| 133 | 144 | } | |
| 145 | + | describeSession := d.describeSession | |
| 146 | + | if describeSession == nil { | |
| 147 | + | describeSession = core.DescribeSessionContext | |
| 148 | + | } | |
| 134 | 149 | go func() { | |
| 135 | 150 | defer close(done) | |
| 136 | 151 | defer close(events) | |
| @@ -169,7 +184,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||
| 169 | 184 | if !ok { | |
| 170 | 185 | return | |
| 171 | 186 | } | |
| 172 | - | summary := core.DescribeSessionContext(ctx, job.path) | |
| 187 | + | summary := describeSession(ctx, job.path) | |
| 173 | 188 | select { | |
| 174 | 189 | case <-ctx.Done(): | |
| 175 | 190 | return | |
| @@ -197,6 +212,9 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||
| 197 | 212 | } | |
| 198 | 213 | close(jobs) | |
| 199 | 214 | wg.Wait() | |
| 215 | + | if ctx.Err() != nil { | |
| 216 | + | return | |
| 217 | + | } | |
| 200 | 218 | send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation}) | |
| 201 | 219 | }() | |
| 202 | 220 | return events | |
| @@ -213,6 +231,7 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||
| 213 | 231 | d.loadingTotal = event.total | |
| 214 | 232 | d.loadingDone = 0 | |
| 215 | 233 | d.loadSlots = make([]sessionLoadSlot, event.total) | |
| 234 | + | d.loadReadyThrough = 0 | |
| 216 | 235 | d.sessions = nil | |
| 217 | 236 | case sessionLoadEntry: | |
| 218 | 237 | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | |
| @@ -220,6 +239,11 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||
| 220 | 239 | } | |
| 221 | 240 | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | |
| 222 | 241 | d.loadingDone++ | |
| 242 | + | previousReady := d.loadReadyThrough | |
| 243 | + | for d.loadReadyThrough < len(d.loadSlots) && d.loadSlots[d.loadReadyThrough].loaded { | |
| 244 | + | d.loadReadyThrough++ | |
| 245 | + | } | |
| 246 | + | d.appendLoadedSessions(previousReady, d.loadReadyThrough) | |
| 223 | 247 | case sessionLoadFinished: | |
| 224 | 248 | d.finishLoad(true) | |
| 225 | 249 | } | |
| @@ -232,25 +256,56 @@ func (d *sessionDialog) ApplyLoadClosed() { | |||
| 232 | 256 | return | |
| 233 | 257 | } | |
| 234 | 258 | d.finishLoad(false) | |
| 259 | + | d.active = false | |
| 235 | 260 | } | |
| 236 | 261 | ||
| 237 | 262 | func (d *sessionDialog) finishLoad(complete bool) { | |
| 238 | 263 | if complete { | |
| 239 | - | d.rebuildLoadedSessions() | |
| 264 | + | d.rebuildLoadedSessions(len(d.loadSlots)) | |
| 240 | 265 | } else { | |
| 241 | 266 | d.sessions = nil | |
| 242 | 267 | } | |
| 243 | 268 | d.loading = false | |
| 244 | 269 | d.loadSlots = nil | |
| 270 | + | d.loadReadyThrough = 0 | |
| 245 | 271 | if d.loadCancel != nil { | |
| 246 | 272 | d.loadCancel() | |
| 247 | 273 | d.loadCancel = nil | |
| 248 | 274 | } | |
| 249 | 275 | } | |
| 250 | 276 | ||
| 251 | - | func (d *sessionDialog) rebuildLoadedSessions() { | |
| 252 | - | filtered := make([]core.SessionSummary, 0, len(d.loadSlots)) | |
| 253 | - | for _, slot := range d.loadSlots { | |
| 277 | + | // appendLoadedSessions exposes the newly contiguous portion of the path | |
| 278 | + | // list. Paths are already newest-first, so this keeps the first visible entry | |
| 279 | + | // stable while workers finish out of order without rescanning old slots. | |
| 280 | + | func (d *sessionDialog) appendLoadedSessions(start, end int) { | |
| 281 | + | limit := len(d.loadSlots) | |
| 282 | + | if start < 0 { | |
| 283 | + | start = 0 | |
| 284 | + | } else if start > limit { | |
| 285 | + | start = limit | |
| 286 | + | } | |
| 287 | + | if end < 0 { | |
| 288 | + | end = 0 | |
| 289 | + | } else if end > limit { | |
| 290 | + | end = limit | |
| 291 | + | } | |
| 292 | + | if start >= end { | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | for _, slot := range d.loadSlots[start:end] { | |
| 296 | + | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |
| 297 | + | continue | |
| 298 | + | } | |
| 299 | + | d.sessions = append(d.sessions, slot.summary) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func (d *sessionDialog) rebuildLoadedSessions(limit int) { | |
| 304 | + | if limit > len(d.loadSlots) { | |
| 305 | + | limit = len(d.loadSlots) | |
| 306 | + | } | |
| 307 | + | filtered := make([]core.SessionSummary, 0, limit) | |
| 308 | + | for _, slot := range d.loadSlots[:limit] { | |
| 254 | 309 | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |
| 255 | 310 | continue | |
| 256 | 311 | } | |
| @@ -284,6 +339,7 @@ func (d *sessionDialog) Close() { | |||
| 284 | 339 | } | |
| 285 | 340 | d.loading = false | |
| 286 | 341 | d.loadSlots = nil | |
| 342 | + | d.loadReadyThrough = 0 | |
| 287 | 343 | d.active = false | |
| 288 | 344 | } | |
| 289 | 345 | ||
| @@ -303,7 +359,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 303 | 359 | if d.loading { | |
| 304 | 360 | lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th))) | |
| 305 | 361 | if len(d.sessions) == 0 { | |
| 306 | - | lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes")) | |
| 362 | + | lines = append(lines, th.FG256(th.Muted, "session entries appear as they load")) | |
| 307 | 363 | lines = append(lines, frameRule(th, width)) | |
| 308 | 364 | return lines | |
| 309 | 365 | } | |
| @@ -325,9 +381,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 325 | 381 | lines = append(lines, frameRule(th, width)) | |
| 326 | 382 | return lines | |
| 327 | 383 | } | |
| 328 | - | if !d.loading { | |
| 329 | - | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | |
| 384 | + | hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)" | |
| 385 | + | if d.loading { | |
| 386 | + | hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)" | |
| 330 | 387 | } | |
| 388 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |
| 331 | 389 | ||
| 332 | 390 | // Viewport: windowed slice of d.sessions around d.cursor so a | |
| 333 | 391 | // list taller than the terminal still scrolls. Caller sets | |
| @@ -526,7 +584,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 526 | 584 | return sessionDialogAction{} | |
| 527 | 585 | } | |
| 528 | 586 | ||
| 529 | - | if d.loading { | |
| 587 | + | if d.loading && len(d.sessions) == 0 { | |
| 530 | 588 | if k.Kind == tui.KeyEsc { | |
| 531 | 589 | d.Close() | |
| 532 | 590 | return sessionDialogAction{Close: true} | |
| @@ -581,7 +639,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 581 | 639 | d.Close() | |
| 582 | 640 | return sessionDialogAction{Select: true, Path: s.Path} | |
| 583 | 641 | case tui.KeyRune: | |
| 584 | - | if k.Rune == 'r' && len(d.sessions) > 0 { | |
| 642 | + | if k.Rune == 'r' && !d.loading && len(d.sessions) > 0 { | |
| 585 | 643 | s := d.sessions[d.cursor] | |
| 586 | 644 | d.renaming = true | |
| 587 | 645 | if s.Title != "" { | |
| @@ -54,7 +54,15 @@ type sessionDialog struct { | |||||
| 54 | loadGeneration uint64 | 54 | loadGeneration uint64 | ||
| 55 | loadCancel context.CancelFunc | 55 | loadCancel context.CancelFunc | ||
| 56 | loadDone chan struct{} | 56 | loadDone chan struct{} | ||
| 57 | + | // loadReadyThrough is the contiguous path prefix whose summaries have | |||
| 58 | + | // arrived. It prevents an older worker result from appearing before a | |||
| 59 | + | // newer session that is still being read. | |||
| 57 | loadSlots []sessionLoadSlot | 60 | loadSlots []sessionLoadSlot | ||
| 61 | + | loadReadyThrough int | |||
| 62 | + | ||||
| 63 | + | // describeSession is replaceable in tests so cancellation can be | |||
| 64 | + | // exercised without relying on file-size-dependent timing. | |||
| 65 | + | describeSession func(context.Context, string) core.SessionSummary | |||
| 58 | 66 | ||||
| 59 | // MaxRows is the maximum number of session rows the dialog | 67 | // MaxRows is the maximum number of session rows the dialog | ||
| 60 | // will render in a single frame. Set by the host right before | 68 | // will render in a single frame. Set by the host right before | ||
| @@ -110,12 +118,15 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||||
| 110 | d.loadingTotal = 0 | 118 | d.loadingTotal = 0 | ||
| 111 | d.loadingStartedAt = time.Now() | 119 | d.loadingStartedAt = time.Now() | ||
| 112 | d.loadSlots = nil | 120 | d.loadSlots = nil | ||
| 121 | + | d.loadReadyThrough = 0 | |||
| 113 | d.active = true | 122 | d.active = true | ||
| 114 | 123 | ||||
| 115 | if ctxErr := ctx.Err(); ctxErr != nil { | 124 | if ctxErr := ctx.Err(); ctxErr != nil { | ||
| 116 | cancel() | 125 | cancel() | ||
| 117 | d.loadCancel = nil | 126 | d.loadCancel = nil | ||
| 118 | d.loading = false | 127 | d.loading = false | ||
| 128 | + | d.active = false | |||
| 129 | + | d.loadDone = previousDone | |||
| 119 | events := make(chan sessionLoadEvent) | 130 | events := make(chan sessionLoadEvent) | ||
| 120 | close(events) | 131 | close(events) | ||
| 121 | close(done) | 132 | close(done) | ||
| @@ -131,6 +142,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||||
| 131 | return false | 142 | return false | ||
| 132 | } | 143 | } | ||
| 133 | } | 144 | } | ||
| 145 | + | describeSession := d.describeSession | |||
| 146 | + | if describeSession == nil { | |||
| 147 | + | describeSession = core.DescribeSessionContext | |||
| 148 | + | } | |||
| 134 | go func() { | 149 | go func() { | ||
| 135 | defer close(done) | 150 | defer close(done) | ||
| 136 | defer close(events) | 151 | defer close(events) | ||
| @@ -169,7 +184,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||||
| 169 | if !ok { | 184 | if !ok { | ||
| 170 | return | 185 | return | ||
| 171 | } | 186 | } | ||
| 172 | - | summary := core.DescribeSessionContext(ctx, job.path) | 187 | + | summary := describeSession(ctx, job.path) |
| 173 | select { | 188 | select { | ||
| 174 | case <-ctx.Done(): | 189 | case <-ctx.Done(): | ||
| 175 | return | 190 | return | ||
| @@ -197,6 +212,9 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string) <-chan se | |||||
| 197 | } | 212 | } | ||
| 198 | close(jobs) | 213 | close(jobs) | ||
| 199 | wg.Wait() | 214 | wg.Wait() | ||
| 215 | + | if ctx.Err() != nil { | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 200 | send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation}) | 218 | send(sessionLoadEvent{kind: sessionLoadFinished, generation: generation}) | ||
| 201 | }() | 219 | }() | ||
| 202 | return events | 220 | return events | ||
| @@ -213,6 +231,7 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||||
| 213 | d.loadingTotal = event.total | 231 | d.loadingTotal = event.total | ||
| 214 | d.loadingDone = 0 | 232 | d.loadingDone = 0 | ||
| 215 | d.loadSlots = make([]sessionLoadSlot, event.total) | 233 | d.loadSlots = make([]sessionLoadSlot, event.total) | ||
| 234 | + | d.loadReadyThrough = 0 | |||
| 216 | d.sessions = nil | 235 | d.sessions = nil | ||
| 217 | case sessionLoadEntry: | 236 | case sessionLoadEntry: | ||
| 218 | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | 237 | if event.index < 0 || event.index >= len(d.loadSlots) || d.loadSlots[event.index].loaded { | ||
| @@ -220,6 +239,11 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||||
| 220 | } | 239 | } | ||
| 221 | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | 240 | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | ||
| 222 | d.loadingDone++ | 241 | d.loadingDone++ | ||
| 242 | + | previousReady := d.loadReadyThrough | |||
| 243 | + | for d.loadReadyThrough < len(d.loadSlots) && d.loadSlots[d.loadReadyThrough].loaded { | |||
| 244 | + | d.loadReadyThrough++ | |||
| 245 | + | } | |||
| 246 | + | d.appendLoadedSessions(previousReady, d.loadReadyThrough) | |||
| 223 | case sessionLoadFinished: | 247 | case sessionLoadFinished: | ||
| 224 | d.finishLoad(true) | 248 | d.finishLoad(true) | ||
| 225 | } | 249 | } | ||
| @@ -232,25 +256,56 @@ func (d *sessionDialog) ApplyLoadClosed() { | |||||
| 232 | return | 256 | return | ||
| 233 | } | 257 | } | ||
| 234 | d.finishLoad(false) | 258 | d.finishLoad(false) | ||
| 259 | + | d.active = false | |||
| 235 | } | 260 | } | ||
| 236 | 261 | ||||
| 237 | func (d *sessionDialog) finishLoad(complete bool) { | 262 | func (d *sessionDialog) finishLoad(complete bool) { | ||
| 238 | if complete { | 263 | if complete { | ||
| 239 | - | d.rebuildLoadedSessions() | 264 | + | d.rebuildLoadedSessions(len(d.loadSlots)) |
| 240 | } else { | 265 | } else { | ||
| 241 | d.sessions = nil | 266 | d.sessions = nil | ||
| 242 | } | 267 | } | ||
| 243 | d.loading = false | 268 | d.loading = false | ||
| 244 | d.loadSlots = nil | 269 | d.loadSlots = nil | ||
| 270 | + | d.loadReadyThrough = 0 | |||
| 245 | if d.loadCancel != nil { | 271 | if d.loadCancel != nil { | ||
| 246 | d.loadCancel() | 272 | d.loadCancel() | ||
| 247 | d.loadCancel = nil | 273 | d.loadCancel = nil | ||
| 248 | } | 274 | } | ||
| 249 | } | 275 | } | ||
| 250 | 276 | ||||
| 251 | - | func (d *sessionDialog) rebuildLoadedSessions() { | 277 | + | // appendLoadedSessions exposes the newly contiguous portion of the path |
| 252 | - | filtered := make([]core.SessionSummary, 0, len(d.loadSlots)) | 278 | + | // list. Paths are already newest-first, so this keeps the first visible entry |
| 253 | - | for _, slot := range d.loadSlots { | 279 | + | // stable while workers finish out of order without rescanning old slots. |
| 280 | + | func (d *sessionDialog) appendLoadedSessions(start, end int) { | |||
| 281 | + | limit := len(d.loadSlots) | |||
| 282 | + | if start < 0 { | |||
| 283 | + | start = 0 | |||
| 284 | + | } else if start > limit { | |||
| 285 | + | start = limit | |||
| 286 | + | } | |||
| 287 | + | if end < 0 { | |||
| 288 | + | end = 0 | |||
| 289 | + | } else if end > limit { | |||
| 290 | + | end = limit | |||
| 291 | + | } | |||
| 292 | + | if start >= end { | |||
| 293 | + | return | |||
| 294 | + | } | |||
| 295 | + | for _, slot := range d.loadSlots[start:end] { | |||
| 296 | + | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | |||
| 297 | + | continue | |||
| 298 | + | } | |||
| 299 | + | d.sessions = append(d.sessions, slot.summary) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func (d *sessionDialog) rebuildLoadedSessions(limit int) { | |||
| 304 | + | if limit > len(d.loadSlots) { | |||
| 305 | + | limit = len(d.loadSlots) | |||
| 306 | + | } | |||
| 307 | + | filtered := make([]core.SessionSummary, 0, limit) | |||
| 308 | + | for _, slot := range d.loadSlots[:limit] { | |||
| 254 | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | 309 | if !slot.loaded || slot.summary.HideFromSessions || slot.summary.MessageCount == 0 { | ||
| 255 | continue | 310 | continue | ||
| 256 | } | 311 | } | ||
| @@ -284,6 +339,7 @@ func (d *sessionDialog) Close() { | |||||
| 284 | } | 339 | } | ||
| 285 | d.loading = false | 340 | d.loading = false | ||
| 286 | d.loadSlots = nil | 341 | d.loadSlots = nil | ||
| 342 | + | d.loadReadyThrough = 0 | |||
| 287 | d.active = false | 343 | d.active = false | ||
| 288 | } | 344 | } | ||
| 289 | 345 | ||||
| @@ -303,7 +359,7 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 303 | if d.loading { | 359 | if d.loading { | ||
| 304 | lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th))) | 360 | lines = append(lines, th.FG256(th.Muted, d.loadingMessage(th))) | ||
| 305 | if len(d.sessions) == 0 { | 361 | if len(d.sessions) == 0 { | ||
| 306 | - | lines = append(lines, th.FG256(th.Muted, "session entries will appear when loading completes")) | 362 | + | lines = append(lines, th.FG256(th.Muted, "session entries appear as they load")) |
| 307 | lines = append(lines, frameRule(th, width)) | 363 | lines = append(lines, frameRule(th, width)) | ||
| 308 | return lines | 364 | return lines | ||
| 309 | } | 365 | } | ||
| @@ -325,9 +381,11 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 325 | lines = append(lines, frameRule(th, width)) | 381 | lines = append(lines, frameRule(th, width)) | ||
| 326 | return lines | 382 | return lines | ||
| 327 | } | 383 | } | ||
| 328 | - | if !d.loading { | 384 | + | hint := "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)" |
| 329 | - | lines = append(lines, th.FG256(th.Muted, "pick a session (↑/↓, pgup/pgdn, enter resume, r rename, esc cancel)")) | 385 | + | if d.loading { |
| 386 | + | hint = "pick a session (↑/↓, pgup/pgdn, enter resume, esc cancel; more loading)" | |||
| 330 | } | 387 | } | ||
| 388 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |||
| 331 | 389 | ||||
| 332 | // Viewport: windowed slice of d.sessions around d.cursor so a | 390 | // Viewport: windowed slice of d.sessions around d.cursor so a | ||
| 333 | // list taller than the terminal still scrolls. Caller sets | 391 | // list taller than the terminal still scrolls. Caller sets | ||
| @@ -526,7 +584,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 526 | return sessionDialogAction{} | 584 | return sessionDialogAction{} | ||
| 527 | } | 585 | } | ||
| 528 | 586 | ||||
| 529 | - | if d.loading { | 587 | + | if d.loading && len(d.sessions) == 0 { |
| 530 | if k.Kind == tui.KeyEsc { | 588 | if k.Kind == tui.KeyEsc { | ||
| 531 | d.Close() | 589 | d.Close() | ||
| 532 | return sessionDialogAction{Close: true} | 590 | return sessionDialogAction{Close: true} | ||
| @@ -581,7 +639,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||||
| 581 | d.Close() | 639 | d.Close() | ||
| 582 | return sessionDialogAction{Select: true, Path: s.Path} | 640 | return sessionDialogAction{Select: true, Path: s.Path} | ||
| 583 | case tui.KeyRune: | 641 | case tui.KeyRune: | ||
| 584 | - | if k.Rune == 'r' && len(d.sessions) > 0 { | 642 | + | if k.Rune == 'r' && !d.loading && len(d.sessions) > 0 { |
| 585 | s := d.sessions[d.cursor] | 643 | s := d.sessions[d.cursor] | ||
| 586 | d.renaming = true | 644 | d.renaming = true | ||
| 587 | if s.Title != "" { | 645 | if s.Title != "" { | ||
M packages/agent/modes/session_dialog_test.go +208 -2
| @@ -3,7 +3,9 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "strings" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | "unicode" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/patriceckhart/zot/packages/core" | |
| @@ -86,6 +88,9 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { | |||
| 86 | 88 | if d.Loading() { | |
| 87 | 89 | t.Fatal("dialog remained loading with an already-canceled parent") | |
| 88 | 90 | } | |
| 91 | + | if d.Active() { | |
| 92 | + | t.Fatal("dialog remained active with an already-canceled parent") | |
| 93 | + | } | |
| 89 | 94 | if _, ok := <-events; ok { | |
| 90 | 95 | t.Fatal("canceled dialog emitted a load event") | |
| 91 | 96 | } | |
| @@ -102,6 +107,159 @@ func TestSessionDialogCancellationFinishesLoading(t *testing.T) { | |||
| 102 | 107 | if d.Loading() { | |
| 103 | 108 | t.Fatal("dialog remained loading after parent cancellation") | |
| 104 | 109 | } | |
| 110 | + | if d.Active() { | |
| 111 | + | t.Fatal("dialog remained active after parent cancellation") | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) { | |
| 116 | + | root := t.TempDir() | |
| 117 | + | cwd := t.TempDir() | |
| 118 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |
| 119 | + | if err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | if err := session.AppendMessage(provider.Message{ | |
| 123 | + | Role: provider.RoleUser, | |
| 124 | + | Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}}, | |
| 125 | + | }); err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | if err := session.Close(); err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | entered := make(chan struct{}) | |
| 133 | + | d := newSessionDialog() | |
| 134 | + | d.describeSession = func(ctx context.Context, path string) core.SessionSummary { | |
| 135 | + | close(entered) | |
| 136 | + | <-ctx.Done() | |
| 137 | + | return core.SessionSummary{Path: path} | |
| 138 | + | } | |
| 139 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 140 | + | events := d.Open(ctx, root, cwd) | |
| 141 | + | t.Cleanup(func() { | |
| 142 | + | cancel() | |
| 143 | + | d.Close() | |
| 144 | + | }) | |
| 145 | + | ||
| 146 | + | select { | |
| 147 | + | case event, ok := <-events: | |
| 148 | + | if !ok || event.kind != sessionLoadStarted { | |
| 149 | + | t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok) | |
| 150 | + | } | |
| 151 | + | d.ApplyLoad(event) | |
| 152 | + | case <-time.After(time.Second): | |
| 153 | + | t.Fatal("timed out waiting for sessionLoadStarted") | |
| 154 | + | } | |
| 155 | + | select { | |
| 156 | + | case <-entered: | |
| 157 | + | case <-time.After(time.Second): | |
| 158 | + | t.Fatal("timed out waiting for the session worker") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | cancel() | |
| 162 | + | for { | |
| 163 | + | select { | |
| 164 | + | case event, ok := <-events: | |
| 165 | + | if !ok { | |
| 166 | + | d.ApplyLoadClosed() | |
| 167 | + | if d.Loading() || d.Active() { | |
| 168 | + | t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active()) | |
| 169 | + | } | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | if event.kind == sessionLoadFinished { | |
| 173 | + | t.Fatal("canceled load emitted sessionLoadFinished") | |
| 174 | + | } | |
| 175 | + | d.ApplyLoad(event) | |
| 176 | + | case <-time.After(time.Second): | |
| 177 | + | t.Fatal("timed out waiting for canceled session load to close") | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) { | |
| 183 | + | root := t.TempDir() | |
| 184 | + | cwd := t.TempDir() | |
| 185 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |
| 186 | + | if err != nil { | |
| 187 | + | t.Fatal(err) | |
| 188 | + | } | |
| 189 | + | if err := session.AppendMessage(provider.Message{ | |
| 190 | + | Role: provider.RoleUser, | |
| 191 | + | Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}}, | |
| 192 | + | }); err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | if err := session.Close(); err != nil { | |
| 196 | + | t.Fatal(err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | entered := make(chan struct{}) | |
| 200 | + | release := make(chan struct{}) | |
| 201 | + | var enteredOnce sync.Once | |
| 202 | + | var releaseOnce sync.Once | |
| 203 | + | releaseLoad := func() { releaseOnce.Do(func() { close(release) }) } | |
| 204 | + | d := newSessionDialog() | |
| 205 | + | d.describeSession = func(ctx context.Context, path string) core.SessionSummary { | |
| 206 | + | enteredOnce.Do(func() { close(entered) }) | |
| 207 | + | <-release | |
| 208 | + | return core.SessionSummary{Path: path, MessageCount: 1} | |
| 209 | + | } | |
| 210 | + | firstCtx, firstCancel := context.WithCancel(context.Background()) | |
| 211 | + | firstEvents := d.Open(firstCtx, root, cwd) | |
| 212 | + | t.Cleanup(func() { | |
| 213 | + | firstCancel() | |
| 214 | + | releaseLoad() | |
| 215 | + | d.Close() | |
| 216 | + | }) | |
| 217 | + | ||
| 218 | + | select { | |
| 219 | + | case event, ok := <-firstEvents: | |
| 220 | + | if !ok || event.kind != sessionLoadStarted { | |
| 221 | + | t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok) | |
| 222 | + | } | |
| 223 | + | d.ApplyLoad(event) | |
| 224 | + | case <-time.After(time.Second): | |
| 225 | + | t.Fatal("timed out waiting for the first load to start") | |
| 226 | + | } | |
| 227 | + | select { | |
| 228 | + | case <-entered: | |
| 229 | + | case <-time.After(time.Second): | |
| 230 | + | t.Fatal("timed out waiting for the first worker") | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | preCanceled, cancelPreCanceled := context.WithCancel(context.Background()) | |
| 234 | + | cancelPreCanceled() | |
| 235 | + | preCanceledEvents := d.Open(preCanceled, root, cwd) | |
| 236 | + | if _, ok := <-preCanceledEvents; ok { | |
| 237 | + | t.Fatal("pre-canceled open emitted a load event") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | thirdCtx, cancelThird := context.WithCancel(context.Background()) | |
| 241 | + | thirdEvents := d.Open(thirdCtx, root, cwd) | |
| 242 | + | select { | |
| 243 | + | case event, ok := <-thirdEvents: | |
| 244 | + | t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok) | |
| 245 | + | case <-time.After(100 * time.Millisecond): | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | releaseLoad() | |
| 249 | + | firstCancel() | |
| 250 | + | select { | |
| 251 | + | case event, ok := <-thirdEvents: | |
| 252 | + | if !ok || event.kind != sessionLoadStarted { | |
| 253 | + | t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok) | |
| 254 | + | } | |
| 255 | + | d.ApplyLoad(event) | |
| 256 | + | case <-time.After(time.Second): | |
| 257 | + | t.Fatal("timed out waiting for third load after releasing the first") | |
| 258 | + | } | |
| 259 | + | cancelThird() | |
| 260 | + | for event := range thirdEvents { | |
| 261 | + | d.ApplyLoad(event) | |
| 262 | + | } | |
| 105 | 263 | } | |
| 106 | 264 | ||
| 107 | 265 | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |
| @@ -115,7 +273,7 @@ func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |||
| 115 | 273 | } | |
| 116 | 274 | } | |
| 117 | 275 | ||
| 118 | - | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |
| 276 | + | func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) { | |
| 119 | 277 | d := newSessionDialog() | |
| 120 | 278 | d.active = true | |
| 121 | 279 | d.loading = true | |
| @@ -132,6 +290,9 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||
| 132 | 290 | index: 2, | |
| 133 | 291 | summary: core.SessionSummary{Path: "third", MessageCount: 1}, | |
| 134 | 292 | }) | |
| 293 | + | if len(d.sessions) != 0 { | |
| 294 | + | t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions) | |
| 295 | + | } | |
| 135 | 296 | d.ApplyLoad(sessionLoadEvent{ | |
| 136 | 297 | kind: sessionLoadEntry, | |
| 137 | 298 | generation: 7, | |
| @@ -150,6 +311,13 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||
| 150 | 311 | index: 0, | |
| 151 | 312 | summary: core.SessionSummary{Path: "first", MessageCount: 1}, | |
| 152 | 313 | }) | |
| 314 | + | if !d.Loading() { | |
| 315 | + | t.Fatal("dialog stopped loading before the terminal event") | |
| 316 | + | } | |
| 317 | + | if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" { | |
| 318 | + | t.Fatalf("incremental sessions = %+v, want first then third", d.sessions) | |
| 319 | + | } | |
| 320 | + | ||
| 153 | 321 | d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7}) | |
| 154 | 322 | ||
| 155 | 323 | if d.Loading() { | |
| @@ -160,15 +328,53 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||
| 160 | 328 | } | |
| 161 | 329 | } | |
| 162 | 330 | ||
| 331 | + | func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) { | |
| 332 | + | d := newSessionDialog() | |
| 333 | + | d.active = true | |
| 334 | + | d.loading = true | |
| 335 | + | d.loadGeneration = 9 | |
| 336 | + | ||
| 337 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 338 | + | kind: sessionLoadStarted, | |
| 339 | + | generation: 9, | |
| 340 | + | total: 2, | |
| 341 | + | }) | |
| 342 | + | d.ApplyLoad(sessionLoadEvent{ | |
| 343 | + | kind: sessionLoadEntry, | |
| 344 | + | generation: 9, | |
| 345 | + | index: 0, | |
| 346 | + | summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"}, | |
| 347 | + | }) | |
| 348 | + | ||
| 349 | + | if !d.Loading() { | |
| 350 | + | t.Fatal("dialog stopped loading after first entry") | |
| 351 | + | } | |
| 352 | + | rendered := strings.Join(d.Render(tui.Dark, 100), "\n") | |
| 353 | + | if !strings.Contains(rendered, "recent work") { | |
| 354 | + | t.Fatalf("incremental render missing newest entry: %q", rendered) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 358 | + | if !act.Select || act.Path != "newest" || d.Active() { | |
| 359 | + | t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active()) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 163 | 363 | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | |
| 164 | 364 | first := newSessionDialog() | |
| 165 | 365 | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| 166 | 366 | firstGeneration := first.loadGeneration | |
| 167 | 367 | ||
| 168 | 368 | secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| 169 | - | if first.loadGeneration == firstGeneration { | |
| 369 | + | secondGeneration := first.loadGeneration | |
| 370 | + | if secondGeneration == firstGeneration { | |
| 170 | 371 | t.Fatal("reopening picker did not advance load generation") | |
| 171 | 372 | } | |
| 373 | + | first.ApplyLoad(sessionLoadEvent{ | |
| 374 | + | kind: sessionLoadStarted, | |
| 375 | + | generation: secondGeneration, | |
| 376 | + | total: 1, | |
| 377 | + | }) | |
| 172 | 378 | ||
| 173 | 379 | first.ApplyLoad(sessionLoadEvent{ | |
| 174 | 380 | kind: sessionLoadEntry, | |
| @@ -3,7 +3,9 @@ package modes | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | + | "sync" | |||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 7 | "unicode" | 9 | "unicode" | ||
| 8 | 10 | ||||
| 9 | "github.com/patriceckhart/zot/packages/core" | 11 | "github.com/patriceckhart/zot/packages/core" | ||
| @@ -86,6 +88,9 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { | |||||
| 86 | if d.Loading() { | 88 | if d.Loading() { | ||
| 87 | t.Fatal("dialog remained loading with an already-canceled parent") | 89 | t.Fatal("dialog remained loading with an already-canceled parent") | ||
| 88 | } | 90 | } | ||
| 91 | + | if d.Active() { | |||
| 92 | + | t.Fatal("dialog remained active with an already-canceled parent") | |||
| 93 | + | } | |||
| 89 | if _, ok := <-events; ok { | 94 | if _, ok := <-events; ok { | ||
| 90 | t.Fatal("canceled dialog emitted a load event") | 95 | t.Fatal("canceled dialog emitted a load event") | ||
| 91 | } | 96 | } | ||
| @@ -102,6 +107,159 @@ func TestSessionDialogCancellationFinishesLoading(t *testing.T) { | |||||
| 102 | if d.Loading() { | 107 | if d.Loading() { | ||
| 103 | t.Fatal("dialog remained loading after parent cancellation") | 108 | t.Fatal("dialog remained loading after parent cancellation") | ||
| 104 | } | 109 | } | ||
| 110 | + | if d.Active() { | |||
| 111 | + | t.Fatal("dialog remained active after parent cancellation") | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) { | |||
| 116 | + | root := t.TempDir() | |||
| 117 | + | cwd := t.TempDir() | |||
| 118 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |||
| 119 | + | if err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | if err := session.AppendMessage(provider.Message{ | |||
| 123 | + | Role: provider.RoleUser, | |||
| 124 | + | Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}}, | |||
| 125 | + | }); err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | if err := session.Close(); err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | entered := make(chan struct{}) | |||
| 133 | + | d := newSessionDialog() | |||
| 134 | + | d.describeSession = func(ctx context.Context, path string) core.SessionSummary { | |||
| 135 | + | close(entered) | |||
| 136 | + | <-ctx.Done() | |||
| 137 | + | return core.SessionSummary{Path: path} | |||
| 138 | + | } | |||
| 139 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 140 | + | events := d.Open(ctx, root, cwd) | |||
| 141 | + | t.Cleanup(func() { | |||
| 142 | + | cancel() | |||
| 143 | + | d.Close() | |||
| 144 | + | }) | |||
| 145 | + | ||||
| 146 | + | select { | |||
| 147 | + | case event, ok := <-events: | |||
| 148 | + | if !ok || event.kind != sessionLoadStarted { | |||
| 149 | + | t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok) | |||
| 150 | + | } | |||
| 151 | + | d.ApplyLoad(event) | |||
| 152 | + | case <-time.After(time.Second): | |||
| 153 | + | t.Fatal("timed out waiting for sessionLoadStarted") | |||
| 154 | + | } | |||
| 155 | + | select { | |||
| 156 | + | case <-entered: | |||
| 157 | + | case <-time.After(time.Second): | |||
| 158 | + | t.Fatal("timed out waiting for the session worker") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | cancel() | |||
| 162 | + | for { | |||
| 163 | + | select { | |||
| 164 | + | case event, ok := <-events: | |||
| 165 | + | if !ok { | |||
| 166 | + | d.ApplyLoadClosed() | |||
| 167 | + | if d.Loading() || d.Active() { | |||
| 168 | + | t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active()) | |||
| 169 | + | } | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | if event.kind == sessionLoadFinished { | |||
| 173 | + | t.Fatal("canceled load emitted sessionLoadFinished") | |||
| 174 | + | } | |||
| 175 | + | d.ApplyLoad(event) | |||
| 176 | + | case <-time.After(time.Second): | |||
| 177 | + | t.Fatal("timed out waiting for canceled session load to close") | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) { | |||
| 183 | + | root := t.TempDir() | |||
| 184 | + | cwd := t.TempDir() | |||
| 185 | + | session, err := core.NewSession(root, cwd, "test", "test-model", "test-version") | |||
| 186 | + | if err != nil { | |||
| 187 | + | t.Fatal(err) | |||
| 188 | + | } | |||
| 189 | + | if err := session.AppendMessage(provider.Message{ | |||
| 190 | + | Role: provider.RoleUser, | |||
| 191 | + | Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}}, | |||
| 192 | + | }); err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | if err := session.Close(); err != nil { | |||
| 196 | + | t.Fatal(err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | entered := make(chan struct{}) | |||
| 200 | + | release := make(chan struct{}) | |||
| 201 | + | var enteredOnce sync.Once | |||
| 202 | + | var releaseOnce sync.Once | |||
| 203 | + | releaseLoad := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 204 | + | d := newSessionDialog() | |||
| 205 | + | d.describeSession = func(ctx context.Context, path string) core.SessionSummary { | |||
| 206 | + | enteredOnce.Do(func() { close(entered) }) | |||
| 207 | + | <-release | |||
| 208 | + | return core.SessionSummary{Path: path, MessageCount: 1} | |||
| 209 | + | } | |||
| 210 | + | firstCtx, firstCancel := context.WithCancel(context.Background()) | |||
| 211 | + | firstEvents := d.Open(firstCtx, root, cwd) | |||
| 212 | + | t.Cleanup(func() { | |||
| 213 | + | firstCancel() | |||
| 214 | + | releaseLoad() | |||
| 215 | + | d.Close() | |||
| 216 | + | }) | |||
| 217 | + | ||||
| 218 | + | select { | |||
| 219 | + | case event, ok := <-firstEvents: | |||
| 220 | + | if !ok || event.kind != sessionLoadStarted { | |||
| 221 | + | t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok) | |||
| 222 | + | } | |||
| 223 | + | d.ApplyLoad(event) | |||
| 224 | + | case <-time.After(time.Second): | |||
| 225 | + | t.Fatal("timed out waiting for the first load to start") | |||
| 226 | + | } | |||
| 227 | + | select { | |||
| 228 | + | case <-entered: | |||
| 229 | + | case <-time.After(time.Second): | |||
| 230 | + | t.Fatal("timed out waiting for the first worker") | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | preCanceled, cancelPreCanceled := context.WithCancel(context.Background()) | |||
| 234 | + | cancelPreCanceled() | |||
| 235 | + | preCanceledEvents := d.Open(preCanceled, root, cwd) | |||
| 236 | + | if _, ok := <-preCanceledEvents; ok { | |||
| 237 | + | t.Fatal("pre-canceled open emitted a load event") | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | thirdCtx, cancelThird := context.WithCancel(context.Background()) | |||
| 241 | + | thirdEvents := d.Open(thirdCtx, root, cwd) | |||
| 242 | + | select { | |||
| 243 | + | case event, ok := <-thirdEvents: | |||
| 244 | + | t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok) | |||
| 245 | + | case <-time.After(100 * time.Millisecond): | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | releaseLoad() | |||
| 249 | + | firstCancel() | |||
| 250 | + | select { | |||
| 251 | + | case event, ok := <-thirdEvents: | |||
| 252 | + | if !ok || event.kind != sessionLoadStarted { | |||
| 253 | + | t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok) | |||
| 254 | + | } | |||
| 255 | + | d.ApplyLoad(event) | |||
| 256 | + | case <-time.After(time.Second): | |||
| 257 | + | t.Fatal("timed out waiting for third load after releasing the first") | |||
| 258 | + | } | |||
| 259 | + | cancelThird() | |||
| 260 | + | for event := range thirdEvents { | |||
| 261 | + | d.ApplyLoad(event) | |||
| 262 | + | } | |||
| 105 | } | 263 | } | ||
| 106 | 264 | ||||
| 107 | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | 265 | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | ||
| @@ -115,7 +273,7 @@ func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |||||
| 115 | } | 273 | } | ||
| 116 | } | 274 | } | ||
| 117 | 275 | ||||
| 118 | - | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | 276 | + | func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) { |
| 119 | d := newSessionDialog() | 277 | d := newSessionDialog() | ||
| 120 | d.active = true | 278 | d.active = true | ||
| 121 | d.loading = true | 279 | d.loading = true | ||
| @@ -132,6 +290,9 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||||
| 132 | index: 2, | 290 | index: 2, | ||
| 133 | summary: core.SessionSummary{Path: "third", MessageCount: 1}, | 291 | summary: core.SessionSummary{Path: "third", MessageCount: 1}, | ||
| 134 | }) | 292 | }) | ||
| 293 | + | if len(d.sessions) != 0 { | |||
| 294 | + | t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions) | |||
| 295 | + | } | |||
| 135 | d.ApplyLoad(sessionLoadEvent{ | 296 | d.ApplyLoad(sessionLoadEvent{ | ||
| 136 | kind: sessionLoadEntry, | 297 | kind: sessionLoadEntry, | ||
| 137 | generation: 7, | 298 | generation: 7, | ||
| @@ -150,6 +311,13 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||||
| 150 | index: 0, | 311 | index: 0, | ||
| 151 | summary: core.SessionSummary{Path: "first", MessageCount: 1}, | 312 | summary: core.SessionSummary{Path: "first", MessageCount: 1}, | ||
| 152 | }) | 313 | }) | ||
| 314 | + | if !d.Loading() { | |||
| 315 | + | t.Fatal("dialog stopped loading before the terminal event") | |||
| 316 | + | } | |||
| 317 | + | if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" { | |||
| 318 | + | t.Fatalf("incremental sessions = %+v, want first then third", d.sessions) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 153 | d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7}) | 321 | d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7}) | ||
| 154 | 322 | ||||
| 155 | if d.Loading() { | 323 | if d.Loading() { | ||
| @@ -160,15 +328,53 @@ func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |||||
| 160 | } | 328 | } | ||
| 161 | } | 329 | } | ||
| 162 | 330 | ||||
| 331 | + | func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) { | |||
| 332 | + | d := newSessionDialog() | |||
| 333 | + | d.active = true | |||
| 334 | + | d.loading = true | |||
| 335 | + | d.loadGeneration = 9 | |||
| 336 | + | ||||
| 337 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 338 | + | kind: sessionLoadStarted, | |||
| 339 | + | generation: 9, | |||
| 340 | + | total: 2, | |||
| 341 | + | }) | |||
| 342 | + | d.ApplyLoad(sessionLoadEvent{ | |||
| 343 | + | kind: sessionLoadEntry, | |||
| 344 | + | generation: 9, | |||
| 345 | + | index: 0, | |||
| 346 | + | summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"}, | |||
| 347 | + | }) | |||
| 348 | + | ||||
| 349 | + | if !d.Loading() { | |||
| 350 | + | t.Fatal("dialog stopped loading after first entry") | |||
| 351 | + | } | |||
| 352 | + | rendered := strings.Join(d.Render(tui.Dark, 100), "\n") | |||
| 353 | + | if !strings.Contains(rendered, "recent work") { | |||
| 354 | + | t.Fatalf("incremental render missing newest entry: %q", rendered) | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 358 | + | if !act.Select || act.Path != "newest" || d.Active() { | |||
| 359 | + | t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active()) | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | ||||
| 163 | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | 363 | func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) { | ||
| 164 | first := newSessionDialog() | 364 | first := newSessionDialog() | ||
| 165 | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | 365 | firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | ||
| 166 | firstGeneration := first.loadGeneration | 366 | firstGeneration := first.loadGeneration | ||
| 167 | 367 | ||||
| 168 | secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | 368 | secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir()) | ||
| 169 | - | if first.loadGeneration == firstGeneration { | 369 | + | secondGeneration := first.loadGeneration |
| 370 | + | if secondGeneration == firstGeneration { | |||
| 170 | t.Fatal("reopening picker did not advance load generation") | 371 | t.Fatal("reopening picker did not advance load generation") | ||
| 171 | } | 372 | } | ||
| 373 | + | first.ApplyLoad(sessionLoadEvent{ | |||
| 374 | + | kind: sessionLoadStarted, | |||
| 375 | + | generation: secondGeneration, | |||
| 376 | + | total: 1, | |||
| 377 | + | }) | |||
| 172 | 378 | ||||
| 173 | first.ApplyLoad(sessionLoadEvent{ | 379 | first.ApplyLoad(sessionLoadEvent{ | ||
| 174 | kind: sessionLoadEntry, | 380 | kind: sessionLoadEntry, | ||