detached e121559

fix(agent): finish canceled session loads

commit
e121559e9458b5b14646f5feb2ba5f38f42a0445
author
brice · · 2026-08-05 09:01 UTC
parent
e1d752e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
314314
315315 ### `/sessions`
316316
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.
318318
319319 ### `/session`
320320
@@ -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 {
10781078 }
10791079 changelog = nil // single-shot
10801080 case event, ok := <-i.sessionLoads:
1081+ i.mu.Lock()
10811082 if !ok {
10821083 i.sessionLoads = nil
1084+ i.sessionDialog.ApplyLoadClosed()
1085+ i.mu.Unlock()
1086+ i.invalidate()
10831087 break
10841088 }
1085- i.mu.Lock()
10861089 i.sessionDialog.ApplyLoad(event)
10871090 i.mu.Unlock()
10881091 i.invalidate()
@@ -2522,27 +2525,36 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
25222525 i.invalidate()
25232526 return false
25242527 }
2525- if i.sessionDialog.Active() {
2528+ i.mu.Lock()
2529+ sessionDialogActive := i.sessionDialog.Active()
2530+ i.mu.Unlock()
2531+ if sessionDialogActive {
25262532 if k.Kind == tui.KeyCtrlC {
2533+ i.mu.Lock()
25272534 i.sessionDialog.Close()
25282535 i.sessionLoads = nil
2536+ i.mu.Unlock()
25292537 i.invalidate()
25302538 return false
25312539 }
2532- manualRenameCurrent := false
2540+ manualRenamePath := ""
2541+ i.mu.Lock()
25332542 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
25392544 }
2545+ i.mu.Unlock()
2546+ manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
2547+ if manualRenameCurrent {
2548+ i.markSessionTitleSwitching()
2549+ }
2550+ i.mu.Lock()
25402551 act := i.sessionDialog.HandleKey(k)
2541- if act.Select {
2552+ if act.Select || act.Close {
25422553 i.sessionLoads = nil
2554+ }
2555+ i.mu.Unlock()
2556+ if act.Select {
25432557 i.applySessionSelection(act.Path)
2544- } else if act.Close {
2545- i.sessionLoads = nil
25462558 }
25472559 if act.Err != nil {
25482560 if manualRenameCurrent {
@@ -1078,11 +1078,14 @@ func (i *Interactive) Run(ctx context.Context) error {
1078 }1078 }
1079 changelog = nil // single-shot1079 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 = nil1083 i.sessionLoads = nil
1084+ i.sessionDialog.ApplyLoadClosed()
1085+ i.mu.Unlock()
1086+ i.invalidate()
1083 break1087 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 false2526 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 = nil2535 i.sessionLoads = nil
2536+ i.mu.Unlock()
2529 i.invalidate()2537 i.invalidate()
2530 return false2538 return false
2531 }2539 }
2532- manualRenameCurrent := false2540+ 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].Path2543+ 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 = nil2553 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) {
212212 d.loadSlots[event.index] = sessionLoadSlot{loaded: true, summary: event.summary}
213213 d.loadingDone++
214214 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 {
215230 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
222239 }
223240 }
224241
@@ -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 = false231+ } else {
217- d.loadSlots = nil232+ d.sessions = nil
218- if d.loadCancel != nil {233+ }
219- d.loadCancel()234+ d.loading = false
220- d.loadCancel = nil235+ 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) {
2929
3030 d := newSessionDialog()
3131 events := d.Open(context.Background(), root, cwd)
32+ t.Cleanup(d.Close)
3233 if !d.Active() || !d.Loading() {
3334 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
3435 }
@@ -44,7 +45,6 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
4445 for event := range events {
4546 d.ApplyLoad(event)
4647 }
47- defer d.Close()
4848
4949 if d.Loading() {
5050 t.Fatal("dialog still loading after final result")
@@ -75,6 +75,30 @@ func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
7575 }
7676 }
7777
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+
78102 func TestSessionDialogAppliesLoadedEntriesInPathOrder(t *testing.T) {
79103 d := newSessionDialog()
80104 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 = true104 d.active = true
M packages/core/session.go +25 -9
@@ -292,6 +292,9 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) {
292292 }
293293
294294 func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, error) {
295+ if err := contextErr(ctx); err != nil {
296+ return SessionSnapshot{}, err
297+ }
295298 f, err := os.Open(path)
296299 if err != nil {
297300 return SessionSnapshot{}, fmt.Errorf("session snapshot: open %q: %w", path, err)
@@ -1086,28 +1089,41 @@ func ListSessionsContext(ctx context.Context, root, cwd string) []string {
10861089 return nil
10871090 }
10881091 dir := SessionsDir(root, cwd)
1089- entries, err := os.ReadDir(dir)
1092+ dirFile, err := os.Open(dir)
10901093 if err != nil {
10911094 return nil
10921095 }
1096+ defer dirFile.Close()
10931097 type rec struct {
10941098 path string
10951099 mod time.Time
10961100 }
10971101 var files []rec
1098- for _, e := range entries {
1102+ for {
10991103 if err := contextErr(ctx); err != nil {
11001104 return nil
11011105 }
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
11041109 }
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
11091126 }
1110- files = append(files, rec{path: p, mod: info.ModTime()})
11111127 }
11121128 sort.Slice(files, func(i, j int) bool {
11131129 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 nil1089 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 nil1094 return nil
1092 }1095 }
1096+ defer dirFile.Close()
1093 type rec struct {1097 type rec struct {
1094 path string1098 path string
1095 mod time.Time1099 mod time.Time
1096 }1100 }
1097 var files []rec1101 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 nil1104 return nil
1101 }1105 }
1102- if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {1106+ entries, readErr := dirFile.ReadDir(128)
1103- continue1107+ 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- continue1113+ }
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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close