fix(agent): finish canceled session loads
- commit
- e121559e9458b5b14646f5feb2ba5f38f42a0445
- author
- brice · · 2026-08-05 09:01 UTC
- parent
- e1d752e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +97 -28
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. 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 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. | |
| 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. 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 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. |
| 318 | 318 | ||||
| 319 | ### `/session` | 319 | ### `/session` | ||
| 320 | 320 | ||||
M packages/agent/modes/interactive.go +23 -11
| @@ -1078,11 +1078,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1078 | 1078 | } | |
| 1079 | 1079 | changelog = nil // single-shot | |
| 1080 | 1080 | case event, ok := <-i.sessionLoads: | |
| 1081 | + | i.mu.Lock() | |
| 1081 | 1082 | if !ok { | |
| 1082 | 1083 | i.sessionLoads = nil | |
| 1084 | + | i.sessionDialog.ApplyLoadClosed() | |
| 1085 | + | i.mu.Unlock() | |
| 1086 | + | i.invalidate() | |
| 1083 | 1087 | break | |
| 1084 | 1088 | } | |
| 1085 | - | i.mu.Lock() | |
| 1086 | 1089 | i.sessionDialog.ApplyLoad(event) | |
| 1087 | 1090 | i.mu.Unlock() | |
| 1088 | 1091 | i.invalidate() | |
| @@ -2522,27 +2525,36 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2522 | 2525 | i.invalidate() | |
| 2523 | 2526 | return false | |
| 2524 | 2527 | } | |
| 2525 | - | if i.sessionDialog.Active() { | |
| 2528 | + | i.mu.Lock() | |
| 2529 | + | sessionDialogActive := i.sessionDialog.Active() | |
| 2530 | + | i.mu.Unlock() | |
| 2531 | + | if sessionDialogActive { | |
| 2526 | 2532 | if k.Kind == tui.KeyCtrlC { | |
| 2533 | + | i.mu.Lock() | |
| 2527 | 2534 | i.sessionDialog.Close() | |
| 2528 | 2535 | i.sessionLoads = nil | |
| 2536 | + | i.mu.Unlock() | |
| 2529 | 2537 | i.invalidate() | |
| 2530 | 2538 | return false | |
| 2531 | 2539 | } | |
| 2532 | - | manualRenameCurrent := false | |
| 2540 | + | manualRenamePath := "" | |
| 2541 | + | i.mu.Lock() | |
| 2533 | 2542 | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | |
| 2534 | - | path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 2535 | - | manualRenameCurrent = i.cfg.CurrentSessionPath() == path | |
| 2536 | - | if manualRenameCurrent { | |
| 2537 | - | i.markSessionTitleSwitching() | |
| 2538 | - | } | |
| 2543 | + | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path | |
| 2539 | 2544 | } | |
| 2545 | + | i.mu.Unlock() | |
| 2546 | + | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |
| 2547 | + | if manualRenameCurrent { | |
| 2548 | + | i.markSessionTitleSwitching() | |
| 2549 | + | } | |
| 2550 | + | i.mu.Lock() | |
| 2540 | 2551 | act := i.sessionDialog.HandleKey(k) | |
| 2541 | - | if act.Select { | |
| 2552 | + | if act.Select || act.Close { | |
| 2542 | 2553 | i.sessionLoads = nil | |
| 2554 | + | } | |
| 2555 | + | i.mu.Unlock() | |
| 2556 | + | if act.Select { | |
| 2543 | 2557 | i.applySessionSelection(act.Path) | |
| 2544 | - | } else if act.Close { | |
| 2545 | - | i.sessionLoads = nil | |
| 2546 | 2558 | } | |
| 2547 | 2559 | if act.Err != nil { | |
| 2548 | 2560 | if manualRenameCurrent { | |
| @@ -1078,11 +1078,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1078 | } | 1078 | } | ||
| 1079 | changelog = nil // single-shot | 1079 | changelog = nil // single-shot | ||
| 1080 | case event, ok := <-i.sessionLoads: | 1080 | case event, ok := <-i.sessionLoads: | ||
| 1081 | + | i.mu.Lock() | |||
| 1081 | if !ok { | 1082 | if !ok { | ||
| 1082 | i.sessionLoads = nil | 1083 | i.sessionLoads = nil | ||
| 1084 | + | i.sessionDialog.ApplyLoadClosed() | |||
| 1085 | + | i.mu.Unlock() | |||
| 1086 | + | i.invalidate() | |||
| 1083 | break | 1087 | break | ||
| 1084 | } | 1088 | } | ||
| 1085 | - | i.mu.Lock() | |||
| 1086 | i.sessionDialog.ApplyLoad(event) | 1089 | i.sessionDialog.ApplyLoad(event) | ||
| 1087 | i.mu.Unlock() | 1090 | i.mu.Unlock() | ||
| 1088 | i.invalidate() | 1091 | i.invalidate() | ||
| @@ -2522,27 +2525,36 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2522 | i.invalidate() | 2525 | i.invalidate() | ||
| 2523 | return false | 2526 | return false | ||
| 2524 | } | 2527 | } | ||
| 2525 | - | if i.sessionDialog.Active() { | 2528 | + | i.mu.Lock() |
| 2529 | + | sessionDialogActive := i.sessionDialog.Active() | |||
| 2530 | + | i.mu.Unlock() | |||
| 2531 | + | if sessionDialogActive { | |||
| 2526 | if k.Kind == tui.KeyCtrlC { | 2532 | if k.Kind == tui.KeyCtrlC { | ||
| 2533 | + | i.mu.Lock() | |||
| 2527 | i.sessionDialog.Close() | 2534 | i.sessionDialog.Close() | ||
| 2528 | i.sessionLoads = nil | 2535 | i.sessionLoads = nil | ||
| 2536 | + | i.mu.Unlock() | |||
| 2529 | i.invalidate() | 2537 | i.invalidate() | ||
| 2530 | return false | 2538 | return false | ||
| 2531 | } | 2539 | } | ||
| 2532 | - | manualRenameCurrent := false | 2540 | + | manualRenamePath := "" |
| 2541 | + | i.mu.Lock() | |||
| 2533 | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | 2542 | if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil { | ||
| 2534 | - | path := i.sessionDialog.sessions[i.sessionDialog.cursor].Path | 2543 | + | manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path |
| 2535 | - | manualRenameCurrent = i.cfg.CurrentSessionPath() == path | |||
| 2536 | - | if manualRenameCurrent { | |||
| 2537 | - | i.markSessionTitleSwitching() | |||
| 2538 | - | } | |||
| 2539 | } | 2544 | } | ||
| 2545 | + | i.mu.Unlock() | |||
| 2546 | + | manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath | |||
| 2547 | + | if manualRenameCurrent { | |||
| 2548 | + | i.markSessionTitleSwitching() | |||
| 2549 | + | } | |||
| 2550 | + | i.mu.Lock() | |||
| 2540 | act := i.sessionDialog.HandleKey(k) | 2551 | act := i.sessionDialog.HandleKey(k) | ||
| 2541 | - | if act.Select { | 2552 | + | if act.Select || act.Close { |
| 2542 | i.sessionLoads = nil | 2553 | i.sessionLoads = nil | ||
| 2554 | + | } | |||
| 2555 | + | i.mu.Unlock() | |||
| 2556 | + | if act.Select { | |||
| 2543 | i.applySessionSelection(act.Path) | 2557 | i.applySessionSelection(act.Path) | ||
| 2544 | - | } else if act.Close { | |||
| 2545 | - | i.sessionLoads = nil | |||
| 2546 | } | 2558 | } | ||
| 2547 | if act.Err != nil { | 2559 | if act.Err != nil { | ||
| 2548 | if manualRenameCurrent { | 2560 | if manualRenameCurrent { | ||
M packages/agent/modes/session_dialog.go +23 -6
| @@ -212,13 +212,30 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||
| 212 | 212 | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | |
| 213 | 213 | d.loadingDone++ | |
| 214 | 214 | case sessionLoadFinished: | |
| 215 | + | d.finishLoad(true) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // ApplyLoadClosed finalizes a load whose producer stopped without delivering a | |
| 220 | + | // terminal event, such as when its parent context is canceled. | |
| 221 | + | func (d *sessionDialog) ApplyLoadClosed() { | |
| 222 | + | if !d.active || !d.loading { | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | d.finishLoad(false) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (d *sessionDialog) finishLoad(complete bool) { | |
| 229 | + | if complete { | |
| 215 | 230 | d.rebuildLoadedSessions() | |
| 216 | - | d.loading = false | |
| 217 | - | d.loadSlots = nil | |
| 218 | - | if d.loadCancel != nil { | |
| 219 | - | d.loadCancel() | |
| 220 | - | d.loadCancel = nil | |
| 221 | - | } | |
| 231 | + | } else { | |
| 232 | + | d.sessions = nil | |
| 233 | + | } | |
| 234 | + | d.loading = false | |
| 235 | + | d.loadSlots = nil | |
| 236 | + | if d.loadCancel != nil { | |
| 237 | + | d.loadCancel() | |
| 238 | + | d.loadCancel = nil | |
| 222 | 239 | } | |
| 223 | 240 | } | |
| 224 | 241 | ||
| @@ -212,13 +212,30 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) { | |||||
| 212 | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | 212 | d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary} | ||
| 213 | d.loadingDone++ | 213 | d.loadingDone++ | ||
| 214 | case sessionLoadFinished: | 214 | case sessionLoadFinished: | ||
| 215 | + | d.finishLoad(true) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // ApplyLoadClosed finalizes a load whose producer stopped without delivering a | |||
| 220 | + | // terminal event, such as when its parent context is canceled. | |||
| 221 | + | func (d *sessionDialog) ApplyLoadClosed() { | |||
| 222 | + | if !d.active || !d.loading { | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | d.finishLoad(false) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (d *sessionDialog) finishLoad(complete bool) { | |||
| 229 | + | if complete { | |||
| 215 | d.rebuildLoadedSessions() | 230 | d.rebuildLoadedSessions() | ||
| 216 | - | d.loading = false | 231 | + | } else { |
| 217 | - | d.loadSlots = nil | 232 | + | d.sessions = nil |
| 218 | - | if d.loadCancel != nil { | 233 | + | } |
| 219 | - | d.loadCancel() | 234 | + | d.loading = false |
| 220 | - | d.loadCancel = nil | 235 | + | d.loadSlots = nil |
| 221 | - | } | 236 | + | if d.loadCancel != nil { |
| 237 | + | d.loadCancel() | |||
| 238 | + | d.loadCancel = nil | |||
| 222 | } | 239 | } | ||
| 223 | } | 240 | } | ||
| 224 | 241 | ||||
M packages/agent/modes/session_dialog_test.go +25 -1
| @@ -29,6 +29,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||
| 29 | 29 | ||
| 30 | 30 | d := newSessionDialog() | |
| 31 | 31 | events := d.Open(context.Background(), root, cwd) | |
| 32 | + | t.Cleanup(d.Close) | |
| 32 | 33 | if !d.Active() || !d.Loading() { | |
| 33 | 34 | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | |
| 34 | 35 | } | |
| @@ -44,7 +45,6 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||
| 44 | 45 | for event := range events { | |
| 45 | 46 | d.ApplyLoad(event) | |
| 46 | 47 | } | |
| 47 | - | defer d.Close() | |
| 48 | 48 | ||
| 49 | 49 | if d.Loading() { | |
| 50 | 50 | t.Fatal("dialog still loading after final result") | |
| @@ -75,6 +75,30 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { | |||
| 75 | 75 | } | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | func TestSessionDialogCancellationFinishesLoading(t *testing.T) { | |
| 79 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 80 | + | d := newSessionDialog() | |
| 81 | + | events := d.Open(ctx, t.TempDir(), t.TempDir()) | |
| 82 | + | cancel() | |
| 83 | + | for range events { | |
| 84 | + | } | |
| 85 | + | d.ApplyLoadClosed() | |
| 86 | + | if d.Loading() { | |
| 87 | + | t.Fatal("dialog remained loading after parent cancellation") | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |
| 92 | + | d := newSessionDialog() | |
| 93 | + | events := d.Open(context.Background(), t.TempDir(), t.TempDir()) | |
| 94 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 95 | + | if !act.Close || d.Active() || d.Loading() { | |
| 96 | + | t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading()) | |
| 97 | + | } | |
| 98 | + | for range events { | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 78 | 102 | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | |
| 79 | 103 | d := newSessionDialog() | |
| 80 | 104 | d.active = true | |
| @@ -29,6 +29,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||||
| 29 | 29 | ||||
| 30 | d := newSessionDialog() | 30 | d := newSessionDialog() | ||
| 31 | events := d.Open(context.Background(), root, cwd) | 31 | events := d.Open(context.Background(), root, cwd) | ||
| 32 | + | t.Cleanup(d.Close) | |||
| 32 | if !d.Active() || !d.Loading() { | 33 | if !d.Active() || !d.Loading() { | ||
| 33 | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | 34 | t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading()) | ||
| 34 | } | 35 | } | ||
| @@ -44,7 +45,6 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) { | |||||
| 44 | for event := range events { | 45 | for event := range events { | ||
| 45 | d.ApplyLoad(event) | 46 | d.ApplyLoad(event) | ||
| 46 | } | 47 | } | ||
| 47 | - | defer d.Close() | |||
| 48 | 48 | ||||
| 49 | if d.Loading() { | 49 | if d.Loading() { | ||
| 50 | t.Fatal("dialog still loading after final result") | 50 | t.Fatal("dialog still loading after final result") | ||
| @@ -75,6 +75,30 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) { | |||||
| 75 | } | 75 | } | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | + | func TestSessionDialogCancellationFinishesLoading(t *testing.T) { | |||
| 79 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 80 | + | d := newSessionDialog() | |||
| 81 | + | events := d.Open(ctx, t.TempDir(), t.TempDir()) | |||
| 82 | + | cancel() | |||
| 83 | + | for range events { | |||
| 84 | + | } | |||
| 85 | + | d.ApplyLoadClosed() | |||
| 86 | + | if d.Loading() { | |||
| 87 | + | t.Fatal("dialog remained loading after parent cancellation") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestSessionDialogEscapeCancelsLoading(t *testing.T) { | |||
| 92 | + | d := newSessionDialog() | |||
| 93 | + | events := d.Open(context.Background(), t.TempDir(), t.TempDir()) | |||
| 94 | + | act := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 95 | + | if !act.Close || d.Active() || d.Loading() { | |||
| 96 | + | t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading()) | |||
| 97 | + | } | |||
| 98 | + | for range events { | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 78 | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | 102 | func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) { | ||
| 79 | d := newSessionDialog() | 103 | d := newSessionDialog() | ||
| 80 | d.active = true | 104 | d.active = true | ||
M packages/core/session.go +25 -9
| @@ -292,6 +292,9 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 292 | 292 | } | |
| 293 | 293 | ||
| 294 | 294 | func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) { | |
| 295 | + | if err := contextErr(ctx); err != nil { | |
| 296 | + | return SessionSnapshot{}, err | |
| 297 | + | } | |
| 295 | 298 | f, err := os.Open(path) | |
| 296 | 299 | if err != nil { | |
| 297 | 300 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | |
| @@ -1086,28 +1089,41 @@ func ListSessionsContext(ctx context.Context, root, cwd string) []string { | |||
| 1086 | 1089 | return nil | |
| 1087 | 1090 | } | |
| 1088 | 1091 | dir := SessionsDir(root, cwd) | |
| 1089 | - | entries, err := os.ReadDir(dir) | |
| 1092 | + | dirFile, err := os.Open(dir) | |
| 1090 | 1093 | if err != nil { | |
| 1091 | 1094 | return nil | |
| 1092 | 1095 | } | |
| 1096 | + | defer dirFile.Close() | |
| 1093 | 1097 | type rec struct { | |
| 1094 | 1098 | path string | |
| 1095 | 1099 | mod time.Time | |
| 1096 | 1100 | } | |
| 1097 | 1101 | var files []rec | |
| 1098 | - | for _, e := range entries { | |
| 1102 | + | for { | |
| 1099 | 1103 | if err := contextErr(ctx); err != nil { | |
| 1100 | 1104 | return nil | |
| 1101 | 1105 | } | |
| 1102 | - | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |
| 1103 | - | continue | |
| 1106 | + | entries, readErr := dirFile.ReadDir(128) | |
| 1107 | + | if readErr != nil && readErr != io.EOF { | |
| 1108 | + | return nil | |
| 1104 | 1109 | } | |
| 1105 | - | p := filepath.Join(dir, e.Name()) | |
| 1106 | - | info, err := e.Info() | |
| 1107 | - | if err != nil { | |
| 1108 | - | continue | |
| 1110 | + | for _, e := range entries { | |
| 1111 | + | if err := contextErr(ctx); err != nil { | |
| 1112 | + | return nil | |
| 1113 | + | } | |
| 1114 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |
| 1115 | + | continue | |
| 1116 | + | } | |
| 1117 | + | p := filepath.Join(dir, e.Name()) | |
| 1118 | + | info, err := e.Info() | |
| 1119 | + | if err != nil { | |
| 1120 | + | continue | |
| 1121 | + | } | |
| 1122 | + | files = append(files, rec{path: p, mod: info.ModTime()}) | |
| 1123 | + | } | |
| 1124 | + | if readErr == io.EOF { | |
| 1125 | + | break | |
| 1109 | 1126 | } | |
| 1110 | - | files = append(files, rec{path: p, mod: info.ModTime()}) | |
| 1111 | 1127 | } | |
| 1112 | 1128 | sort.Slice(files, func(i, j int) bool { | |
| 1113 | 1129 | if !files[i].mod.Equal(files[j].mod) { | |
| @@ -292,6 +292,9 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||||
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| 294 | func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) { | 294 | func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) { | ||
| 295 | + | if err := contextErr(ctx); err != nil { | |||
| 296 | + | return SessionSnapshot{}, err | |||
| 297 | + | } | |||
| 295 | f, err := os.Open(path) | 298 | f, err := os.Open(path) | ||
| 296 | if err != nil { | 299 | if err != nil { | ||
| 297 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | 300 | return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err) | ||
| @@ -1086,28 +1089,41 @@ func ListSessionsContext(ctx context.Context, root, cwd string) []string { | |||||
| 1086 | return nil | 1089 | return nil | ||
| 1087 | } | 1090 | } | ||
| 1088 | dir := SessionsDir(root, cwd) | 1091 | dir := SessionsDir(root, cwd) | ||
| 1089 | - | entries, err := os.ReadDir(dir) | 1092 | + | dirFile, err := os.Open(dir) |
| 1090 | if err != nil { | 1093 | if err != nil { | ||
| 1091 | return nil | 1094 | return nil | ||
| 1092 | } | 1095 | } | ||
| 1096 | + | defer dirFile.Close() | |||
| 1093 | type rec struct { | 1097 | type rec struct { | ||
| 1094 | path string | 1098 | path string | ||
| 1095 | mod time.Time | 1099 | mod time.Time | ||
| 1096 | } | 1100 | } | ||
| 1097 | var files []rec | 1101 | var files []rec | ||
| 1098 | - | for _, e := range entries { | 1102 | + | for { |
| 1099 | if err := contextErr(ctx); err != nil { | 1103 | if err := contextErr(ctx); err != nil { | ||
| 1100 | return nil | 1104 | return nil | ||
| 1101 | } | 1105 | } | ||
| 1102 | - | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | 1106 | + | entries, readErr := dirFile.ReadDir(128) |
| 1103 | - | continue | 1107 | + | if readErr != nil && readErr != io.EOF { |
| 1108 | + | return nil | |||
| 1104 | } | 1109 | } | ||
| 1105 | - | p := filepath.Join(dir, e.Name()) | 1110 | + | for _, e := range entries { |
| 1106 | - | info, err := e.Info() | 1111 | + | if err := contextErr(ctx); err != nil { |
| 1107 | - | if err != nil { | 1112 | + | return nil |
| 1108 | - | continue | 1113 | + | } |
| 1114 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |||
| 1115 | + | continue | |||
| 1116 | + | } | |||
| 1117 | + | p := filepath.Join(dir, e.Name()) | |||
| 1118 | + | info, err := e.Info() | |||
| 1119 | + | if err != nil { | |||
| 1120 | + | continue | |||
| 1121 | + | } | |||
| 1122 | + | files = append(files, rec{path: p, mod: info.ModTime()}) | |||
| 1123 | + | } | |||
| 1124 | + | if readErr == io.EOF { | |||
| 1125 | + | break | |||
| 1109 | } | 1126 | } | ||
| 1110 | - | files = append(files, rec{path: p, mod: info.ModTime()}) | |||
| 1111 | } | 1127 | } | ||
| 1112 | sort.Slice(files, func(i, j int) bool { | 1128 | sort.Slice(files, func(i, j int) bool { | ||
| 1113 | if !files[i].mod.Equal(files[j].mod) { | 1129 | if !files[i].mod.Equal(files[j].mod) { | ||