detached caa47a7

fix(sessions): harden picker search lifecycle

commit
caa47a71af00d7287ae98c62eb54658b5ca6030a
author
brice · · 2026-08-19 05:45 UTC
parent
0c5f7c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +70 -10
M packages/agent/cli.go +1 -2
@@ -2045,14 +2045,13 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
20452045 }
20462046 return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD)
20472047 }
2048- workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2049-
20502048 // Hold the transition lock from the pre-flush through the commit.
20512049 // Persistence callbacks take the read side, so an active session
20522050 // cannot be snapshotted before its lazy writes land or overwritten by
20532051 // a callback while the candidate is being installed.
20542052 sessionTransitionMu.Lock()
20552053 defer sessionTransitionMu.Unlock()
2054+ workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
20562055
20572056 currentAg := liveInteractiveAgent(iv, ag)
20582057 if currentAg == nil {
@@ -2045,14 +2045,13 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2045 }2045 }
2046 return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD)2046 return fmt.Errorf("selected session cwd is not a directory: %s", selectedMeta.CWD)
2047 }2047 }
2048- workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2049-
2050 // Hold the transition lock from the pre-flush through the commit.2048 // Hold the transition lock from the pre-flush through the commit.
2051 // Persistence callbacks take the read side, so an active session2049 // Persistence callbacks take the read side, so an active session
2052 // cannot be snapshotted before its lazy writes land or overwritten by2050 // cannot be snapshotted before its lazy writes land or overwritten by
2053 // a callback while the candidate is being installed.2051 // a callback while the candidate is being installed.
2054 sessionTransitionMu.Lock()2052 sessionTransitionMu.Lock()
2055 defer sessionTransitionMu.Unlock()2053 defer sessionTransitionMu.Unlock()
2054+ workspaceChanged := filepath.Clean(selectedMeta.CWD) != filepath.Clean(args.CWD)
2056 2055
2057 currentAg := liveInteractiveAgent(iv, ag)2056 currentAg := liveInteractiveAgent(iv, ag)
2058 if currentAg == nil {2057 if currentAg == nil {
M packages/agent/modes/session_dialog.go +59 -7
@@ -80,9 +80,12 @@ type sessionDialog struct {
8080 paths []string
8181 query string
8282 searching bool
83+ searchPending bool
8384 searchReady bool
8485 searchGeneration uint64
86+ searchContext context.Context
8587 searchCancel context.CancelFunc
88+ matchCancel context.CancelFunc
8689 searchEvents chan sessionSearchEvent
8790 searchSegments []core.SessionSearchSegment
8891 searchMatches map[string]sessionSearchMatch
@@ -145,6 +148,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
145148 d.searchCancel()
146149 d.searchCancel = nil
147150 }
151+ if d.matchCancel != nil {
152+ d.matchCancel()
153+ d.matchCancel = nil
154+ }
148155 previousDone := d.loadDone
149156 if parent == nil {
150157 parent = context.Background()
@@ -167,6 +174,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
167174 d.paths = nil
168175 d.query = ""
169176 d.searching = false
177+ d.searchPending = false
170178 d.searchReady = false
171179 d.searchSegments = nil
172180 d.searchMatches = nil
@@ -291,6 +299,10 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
291299 d.loadingTotal = event.total
292300 d.loadingDone = 0
293301 d.paths = append([]string(nil), event.paths...)
302+ if d.searchPending {
303+ d.searchPending = false
304+ d.startSearchReader()
305+ }
294306 d.loadSlots = make([]sessionLoadSlot, event.total)
295307 d.loadReadyThrough = 0
296308 d.sessions = nil
@@ -411,6 +423,10 @@ func (d *sessionDialog) Close() {
411423 d.searchCancel()
412424 d.searchCancel = nil
413425 }
426+ if d.matchCancel != nil {
427+ d.matchCancel()
428+ d.matchCancel = nil
429+ }
414430 d.loading = false
415431 d.searching = false
416432 d.loadSlots = nil
@@ -429,13 +445,28 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch
429445 parent = context.Background()
430446 }
431447 ctx, cancel := context.WithCancel(parent)
448+ d.searchContext = ctx
432449 d.searchCancel = cancel
433450 d.searching = true
434451 d.searchGeneration++
435- generation := d.searchGeneration
436452 events := make(chan sessionSearchEvent, 1)
437453 d.searchEvents = events
454+ if len(d.paths) == 0 && d.loading {
455+ d.searchPending = true
456+ return events
457+ }
458+ d.startSearchReader()
459+ return events
460+}
461+
462+func (d *sessionDialog) startSearchReader() {
463+ if d.searchEvents == nil || d.searchCancel == nil {
464+ return
465+ }
466+ ctx := d.searchContext
438467 paths := append([]string(nil), d.paths...)
468+ generation := d.searchGeneration
469+ events := d.searchEvents
439470 go func() {
440471 segments := make([]core.SessionSearchSegment, 0)
441472 skipped := 0
@@ -458,7 +489,6 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch
458489 case <-ctx.Done():
459490 }
460491 }()
461- return events
462492 }
463493
464494 // ApplySearch incorporates corpus and match results on the UI goroutine.
@@ -488,15 +518,23 @@ func (d *sessionDialog) scheduleSearchMatch() {
488518 if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {
489519 return
490520 }
521+ if d.matchCancel != nil {
522+ d.matchCancel()
523+ }
491524 query := d.query
492525 segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)
493526 generation := d.searchGeneration
494- ctx := context.Background()
527+ events := d.searchEvents
528+ ctx, cancel := context.WithCancel(d.searchContext)
529+ d.matchCancel = cancel
495530 go func() {
496531 matches := matchSessionSearchSegments(ctx, query, segments)
532+ if ctx.Err() != nil {
533+ return
534+ }
497535 select {
498- case d.searchEvents <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:
499- default:
536+ case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:
537+ case <-ctx.Done():
500538 }
501539 }()
502540 }
@@ -647,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
647685 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
648686 }
649687 if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" {
650- lines = append(lines, th.FGColor(th.Muted, " cwd: "+friendlyPath(d.sessions[d.cursor].CWD)))
688+ cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
689+ lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))
651690 }
652691 if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
653692 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
@@ -837,6 +876,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
837876 renameErr = err
838877 } else {
839878 d.sessions[d.cursor].Title = title
879+ for index := range d.baseSessions {
880+ if d.baseSessions[index].Path == path {
881+ d.baseSessions[index].Title = title
882+ break
883+ }
884+ }
840885 renamed = true
841886 }
842887 }
@@ -874,6 +919,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
874919 if d.query != "" || d.searchEvents != nil {
875920 switch k.Kind {
876921 case tui.KeyEsc:
922+ if d.query == "" {
923+ d.Close()
924+ return sessionDialogAction{Close: true}
925+ }
877926 d.query = ""
878927 d.searchMatches = nil
879928 d.applySearchFilter()
@@ -894,8 +943,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
894943 d.query += string(k.Rune)
895944 }
896945 default:
897- return sessionDialogAction{}
946+ // Navigation and selection operate on the current filtered rows.
947+ goto navigate
898948 }
949+ d.query = sanitizeSessionTreeText(d.query)
899950 if len([]rune(d.query)) > 256 {
900951 d.query = string([]rune(d.query)[:256])
901952 }
@@ -913,6 +964,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
913964 return sessionDialogAction{}
914965 }
915966
967+navigate:
916968 page := d.MaxRows
917969 if page <= 0 {
918970 page = 10
@@ -80,9 +80,12 @@ type sessionDialog struct {
80 paths []string80 paths []string
81 query string81 query string
82 searching bool82 searching bool
83+ searchPending bool
83 searchReady bool84 searchReady bool
84 searchGeneration uint6485 searchGeneration uint64
86+ searchContext context.Context
85 searchCancel context.CancelFunc87 searchCancel context.CancelFunc
88+ matchCancel context.CancelFunc
86 searchEvents chan sessionSearchEvent89 searchEvents chan sessionSearchEvent
87 searchSegments []core.SessionSearchSegment90 searchSegments []core.SessionSearchSegment
88 searchMatches map[string]sessionSearchMatch91 searchMatches map[string]sessionSearchMatch
@@ -145,6 +148,10 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
145 d.searchCancel()148 d.searchCancel()
146 d.searchCancel = nil149 d.searchCancel = nil
147 }150 }
151+ if d.matchCancel != nil {
152+ d.matchCancel()
153+ d.matchCancel = nil
154+ }
148 previousDone := d.loadDone155 previousDone := d.loadDone
149 if parent == nil {156 if parent == nil {
150 parent = context.Background()157 parent = context.Background()
@@ -167,6 +174,7 @@ func (d *sessionDialog) Open(parent context.Context, root, cwd string, allScope
167 d.paths = nil174 d.paths = nil
168 d.query = ""175 d.query = ""
169 d.searching = false176 d.searching = false
177+ d.searchPending = false
170 d.searchReady = false178 d.searchReady = false
171 d.searchSegments = nil179 d.searchSegments = nil
172 d.searchMatches = nil180 d.searchMatches = nil
@@ -291,6 +299,10 @@ func (d *sessionDialog) ApplyLoad(event sessionLoadEvent) {
291 d.loadingTotal = event.total299 d.loadingTotal = event.total
292 d.loadingDone = 0300 d.loadingDone = 0
293 d.paths = append([]string(nil), event.paths...)301 d.paths = append([]string(nil), event.paths...)
302+ if d.searchPending {
303+ d.searchPending = false
304+ d.startSearchReader()
305+ }
294 d.loadSlots = make([]sessionLoadSlot, event.total)306 d.loadSlots = make([]sessionLoadSlot, event.total)
295 d.loadReadyThrough = 0307 d.loadReadyThrough = 0
296 d.sessions = nil308 d.sessions = nil
@@ -411,6 +423,10 @@ func (d *sessionDialog) Close() {
411 d.searchCancel()423 d.searchCancel()
412 d.searchCancel = nil424 d.searchCancel = nil
413 }425 }
426+ if d.matchCancel != nil {
427+ d.matchCancel()
428+ d.matchCancel = nil
429+ }
414 d.loading = false430 d.loading = false
415 d.searching = false431 d.searching = false
416 d.loadSlots = nil432 d.loadSlots = nil
@@ -429,13 +445,28 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch
429 parent = context.Background()445 parent = context.Background()
430 }446 }
431 ctx, cancel := context.WithCancel(parent)447 ctx, cancel := context.WithCancel(parent)
448+ d.searchContext = ctx
432 d.searchCancel = cancel449 d.searchCancel = cancel
433 d.searching = true450 d.searching = true
434 d.searchGeneration++451 d.searchGeneration++
435- generation := d.searchGeneration
436 events := make(chan sessionSearchEvent, 1)452 events := make(chan sessionSearchEvent, 1)
437 d.searchEvents = events453 d.searchEvents = events
454+ if len(d.paths) == 0 && d.loading {
455+ d.searchPending = true
456+ return events
457+ }
458+ d.startSearchReader()
459+ return events
460+}
461+
462+func (d *sessionDialog) startSearchReader() {
463+ if d.searchEvents == nil || d.searchCancel == nil {
464+ return
465+ }
466+ ctx := d.searchContext
438 paths := append([]string(nil), d.paths...)467 paths := append([]string(nil), d.paths...)
468+ generation := d.searchGeneration
469+ events := d.searchEvents
439 go func() {470 go func() {
440 segments := make([]core.SessionSearchSegment, 0)471 segments := make([]core.SessionSearchSegment, 0)
441 skipped := 0472 skipped := 0
@@ -458,7 +489,6 @@ func (d *sessionDialog) StartSearch(parent context.Context) <-chan sessionSearch
458 case <-ctx.Done():489 case <-ctx.Done():
459 }490 }
460 }()491 }()
461- return events
462 }492 }
463 493
464 // ApplySearch incorporates corpus and match results on the UI goroutine.494 // ApplySearch incorporates corpus and match results on the UI goroutine.
@@ -488,15 +518,23 @@ func (d *sessionDialog) scheduleSearchMatch() {
488 if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {518 if !d.searchReady || d.query == "" || d.searchEvents == nil || d.searchCancel == nil {
489 return519 return
490 }520 }
521+ if d.matchCancel != nil {
522+ d.matchCancel()
523+ }
491 query := d.query524 query := d.query
492 segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)525 segments := append([]core.SessionSearchSegment(nil), d.searchSegments...)
493 generation := d.searchGeneration526 generation := d.searchGeneration
494- ctx := context.Background()527+ events := d.searchEvents
528+ ctx, cancel := context.WithCancel(d.searchContext)
529+ d.matchCancel = cancel
495 go func() {530 go func() {
496 matches := matchSessionSearchSegments(ctx, query, segments)531 matches := matchSessionSearchSegments(ctx, query, segments)
532+ if ctx.Err() != nil {
533+ return
534+ }
497 select {535 select {
498- case d.searchEvents <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:536+ case events <- sessionSearchEvent{kind: sessionSearchMatchesReady, generation: generation, query: query, matches: matches}:
499- default:537+ case <-ctx.Done():
500 }538 }
501 }()539 }()
502 }540 }
@@ -647,7 +685,8 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
647 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))685 lines = append(lines, th.FGColor(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
648 }686 }
649 if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" {687 if d.allScope && d.cursor >= 0 && d.cursor < len(d.sessions) && d.sessions[d.cursor].CWD != "" {
650- lines = append(lines, th.FGColor(th.Muted, " cwd: "+friendlyPath(d.sessions[d.cursor].CWD)))688+ cwd := sanitizeSessionTreeText(friendlyPath(d.sessions[d.cursor].CWD))
689+ lines = append(lines, th.FGColor(th.Muted, " cwd: "+cwd))
651 }690 }
652 if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {691 if d.query != "" && d.cursor >= 0 && d.cursor < len(d.sessions) {
653 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {692 if match := d.searchMatches[d.sessions[d.cursor].Path]; match.excerpt != "" {
@@ -837,6 +876,12 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
837 renameErr = err876 renameErr = err
838 } else {877 } else {
839 d.sessions[d.cursor].Title = title878 d.sessions[d.cursor].Title = title
879+ for index := range d.baseSessions {
880+ if d.baseSessions[index].Path == path {
881+ d.baseSessions[index].Title = title
882+ break
883+ }
884+ }
840 renamed = true885 renamed = true
841 }886 }
842 }887 }
@@ -874,6 +919,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
874 if d.query != "" || d.searchEvents != nil {919 if d.query != "" || d.searchEvents != nil {
875 switch k.Kind {920 switch k.Kind {
876 case tui.KeyEsc:921 case tui.KeyEsc:
922+ if d.query == "" {
923+ d.Close()
924+ return sessionDialogAction{Close: true}
925+ }
877 d.query = ""926 d.query = ""
878 d.searchMatches = nil927 d.searchMatches = nil
879 d.applySearchFilter()928 d.applySearchFilter()
@@ -894,8 +943,10 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
894 d.query += string(k.Rune)943 d.query += string(k.Rune)
895 }944 }
896 default:945 default:
897- return sessionDialogAction{}946+ // Navigation and selection operate on the current filtered rows.
947+ goto navigate
898 }948 }
949+ d.query = sanitizeSessionTreeText(d.query)
899 if len([]rune(d.query)) > 256 {950 if len([]rune(d.query)) > 256 {
900 d.query = string([]rune(d.query)[:256])951 d.query = string([]rune(d.query)[:256])
901 }952 }
@@ -913,6 +964,7 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
913 return sessionDialogAction{}964 return sessionDialogAction{}
914 }965 }
915 966
967+navigate:
916 page := d.MaxRows968 page := d.MaxRows
917 if page <= 0 {969 if page <= 0 {
918 page = 10970 page = 10
M packages/core/session_picker.go +10 -1
@@ -60,7 +60,12 @@ func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []
6060 dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))
6161 }
6262 } else {
63- dirs = []string{SessionsDir(root, cwd)}
63+ dir := SessionsDir(root, cwd)
64+ info, err := os.Lstat(dir)
65+ if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
66+ return nil
67+ }
68+ dirs = []string{dir}
6469 }
6570
6671 type candidate struct {
@@ -139,6 +144,10 @@ func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, er
139144 return SessionMeta{}, errors.New("managed session: invalid session metadata")
140145 }
141146 wantDir := filepath.Clean(SessionsDir(root, meta.CWD))
147+ dirInfo, err := os.Lstat(wantDir)
148+ if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() {
149+ return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory")
150+ }
142151 if filepath.Clean(filepath.Dir(path)) != wantDir {
143152 return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")
144153 }
@@ -60,7 +60,12 @@ func ListSessionPathsContext(ctx context.Context, root, cwd string, all bool) []
60 dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))60 dirs = append(dirs, filepath.Join(sessionsDir, entry.Name()))
61 }61 }
62 } else {62 } else {
63- dirs = []string{SessionsDir(root, cwd)}63+ dir := SessionsDir(root, cwd)
64+ info, err := os.Lstat(dir)
65+ if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
66+ return nil
67+ }
68+ dirs = []string{dir}
64 }69 }
65 70
66 type candidate struct {71 type candidate struct {
@@ -139,6 +144,10 @@ func ManagedSessionMeta(ctx context.Context, root, path string) (SessionMeta, er
139 return SessionMeta{}, errors.New("managed session: invalid session metadata")144 return SessionMeta{}, errors.New("managed session: invalid session metadata")
140 }145 }
141 wantDir := filepath.Clean(SessionsDir(root, meta.CWD))146 wantDir := filepath.Clean(SessionsDir(root, meta.CWD))
147+ dirInfo, err := os.Lstat(wantDir)
148+ if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() {
149+ return SessionMeta{}, errors.New("managed session: cwd bucket is not a directory")
150+ }
142 if filepath.Clean(filepath.Dir(path)) != wantDir {151 if filepath.Clean(filepath.Dir(path)) != wantDir {
143 return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")152 return SessionMeta{}, errors.New("managed session: cwd bucket does not match metadata")
144 }153 }

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