detached d338fc4

perf(sessions): stream session tree loading

commit
d338fc4506436ef44d7855961b487700d6217598
author
brice · · 2026-08-11 02:33 UTC
committer
brice · · 2026-08-11 02:52 UTC
parent
94b5351
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +499 -77
M README.md +1 -1
@@ -340,7 +340,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
340340 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zutsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
341341 - **`/session import <path>`**. Copies a `.zutsession` file into `$ZUT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zut strips the surrounding quotes automatically).
342342 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zut copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
343-- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
343+- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. The tree opens immediately while its history loads in the background; rows appear from recent to older content, and `esc` cancels the load or closes the tree. Use `up`/`down` and `enter` to choose an available row. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
344344
345345 Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant-message row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.
346346
@@ -340,7 +340,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
340 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zutsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.340 **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zutsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
341 - **`/session import <path>`**. Copies a `.zutsession` file into `$ZUT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zut strips the surrounding quotes automatically).341 - **`/session import <path>`**. Copies a `.zutsession` file into `$ZUT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zut strips the surrounding quotes automatically).
342 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zut copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.342 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zut copies the transcript through that turn into a new branch and switches to it without starting a provider request. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
343-- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.343+- **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. The tree opens immediately while its history loads in the background; rows appear from recent to older content, and `esc` cancels the load or closes the tree. Use `up`/`down` and `enter` to choose an available row. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
344 344
345 Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant-message row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.345 Selecting a user-message row creates a navigation branch before that message and restores the complete editable draft, including text blocks and image attachments; no provider request is made until you submit it. Selecting an assistant-message row checks out a safe message boundary, and selecting an empty or detached boundary checks out that branch point with an empty editor. Navigation branches are hidden from `/sessions` but remain in the tree.
346 346
M packages/agent/modes/double_escape.go +16 -17
@@ -1,11 +1,11 @@
11 package modes
22
33 import (
4+ "context"
45 "fmt"
56 "strings"
67 "time"
78
8- "github.com/bnema/zut/packages/core"
99 "github.com/bnema/zut/packages/provider"
1010 "github.com/bnema/zut/packages/tui"
1111 )
@@ -129,9 +129,9 @@ func (i *Interactive) canArmSessionTreeEscape() bool {
129129 }
130130
131131 // canOpenSessionTree is the shared, fail-closed precondition for both the
132-// /session tree command and the bare-Escape gesture. It does not flush or
133-// mutate session state. In particular, callers must not flush until this
134-// check has returned true.
132+// /session tree command and the bare-Escape gesture. It intentionally avoids
133+// filesystem work so a slow or large session family cannot stall input; the
134+// asynchronous dialog loader validates the family after the overlay opens.
135135 func (i *Interactive) canOpenSessionTree() bool {
136136 if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
137137 i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
@@ -155,12 +155,7 @@ func (i *Interactive) canOpenSessionTree() bool {
155155 return false
156156 }
157157
158- current := i.currentSessionPath()
159- if current == "" {
160- return false
161- }
162- roots, err := core.BuildSessionTreeFamilyStrict(i.sessionsRoot(), i.cfg.CWD, current)
163- return err == nil && len(roots) != 0
158+ return i.currentSessionPath() != ""
164159 }
165160
166161 // canCommitSessionTreeSelection is the open-gate subset used after the tree
@@ -195,8 +190,8 @@ func (i *Interactive) setSessionTreeError(message string) {
195190 }
196191
197192 // openSessionTree is the only path that activates the session-tree overlay.
198-// The gate runs before FlushSession, and a failed family read leaves the
199-// dialog closed instead of falling back to a stale in-memory transcript.
193+// It queues the family read rather than performing it on the input goroutine;
194+// a failed asynchronous read closes the overlay and reports the usual error.
200195 func (i *Interactive) openSessionTree() bool {
201196 if !i.canOpenSessionTree() {
202197 i.setSessionTreeError("tree: session tree is unavailable")
@@ -207,14 +202,18 @@ func (i *Interactive) openSessionTree() bool {
207202 i.setSessionTreeError("tree: no session is active")
208203 return false
209204 }
210- if i.cfg.FlushSession != nil {
211- i.cfg.FlushSession()
212- }
213- if i.sessionTreeDialog == nil || !i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
214- i.setSessionTreeError("tree: no readable session family")
205+ if i.sessionTreeDialog == nil {
206+ i.setSessionTreeError("tree: session tree is unavailable")
215207 return false
216208 }
217209 i.mu.Lock()
210+ loadCtx := i.runCtx
211+ i.mu.Unlock()
212+ if loadCtx == nil {
213+ loadCtx = context.Background()
214+ }
215+ i.sessionTreeLoads = i.sessionTreeDialog.OpenSessionFamilyAsync(loadCtx, i.sessionsRoot(), i.cfg.CWD, current, i.cfg.FlushSession)
216+ i.mu.Lock()
218217 i.statusErr = ""
219218 i.statusOK = ""
220219 i.mu.Unlock()
@@ -1,11 +1,11 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "strings"6 "strings"
6 "time"7 "time"
7 8
8- "github.com/bnema/zut/packages/core"
9 "github.com/bnema/zut/packages/provider"9 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
11 )11 )
@@ -129,9 +129,9 @@ func (i *Interactive) canArmSessionTreeEscape() bool {
129 }129 }
130 130
131 // canOpenSessionTree is the shared, fail-closed precondition for both the131 // canOpenSessionTree is the shared, fail-closed precondition for both the
132-// /session tree command and the bare-Escape gesture. It does not flush or132+// /session tree command and the bare-Escape gesture. It intentionally avoids
133-// mutate session state. In particular, callers must not flush until this133+// filesystem work so a slow or large session family cannot stall input; the
134-// check has returned true.134+// asynchronous dialog loader validates the family after the overlay opens.
135 func (i *Interactive) canOpenSessionTree() bool {135 func (i *Interactive) canOpenSessionTree() bool {
136 if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||136 if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" ||
137 i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {137 i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
@@ -155,12 +155,7 @@ func (i *Interactive) canOpenSessionTree() bool {
155 return false155 return false
156 }156 }
157 157
158- current := i.currentSessionPath()158+ return i.currentSessionPath() != ""
159- if current == "" {
160- return false
161- }
162- roots, err := core.BuildSessionTreeFamilyStrict(i.sessionsRoot(), i.cfg.CWD, current)
163- return err == nil && len(roots) != 0
164 }159 }
165 160
166 // canCommitSessionTreeSelection is the open-gate subset used after the tree161 // canCommitSessionTreeSelection is the open-gate subset used after the tree
@@ -195,8 +190,8 @@ func (i *Interactive) setSessionTreeError(message string) {
195 }190 }
196 191
197 // openSessionTree is the only path that activates the session-tree overlay.192 // openSessionTree is the only path that activates the session-tree overlay.
198-// The gate runs before FlushSession, and a failed family read leaves the193+// It queues the family read rather than performing it on the input goroutine;
199-// dialog closed instead of falling back to a stale in-memory transcript.194+// a failed asynchronous read closes the overlay and reports the usual error.
200 func (i *Interactive) openSessionTree() bool {195 func (i *Interactive) openSessionTree() bool {
201 if !i.canOpenSessionTree() {196 if !i.canOpenSessionTree() {
202 i.setSessionTreeError("tree: session tree is unavailable")197 i.setSessionTreeError("tree: session tree is unavailable")
@@ -207,14 +202,18 @@ func (i *Interactive) openSessionTree() bool {
207 i.setSessionTreeError("tree: no session is active")202 i.setSessionTreeError("tree: no session is active")
208 return false203 return false
209 }204 }
210- if i.cfg.FlushSession != nil {205+ if i.sessionTreeDialog == nil {
211- i.cfg.FlushSession()206+ i.setSessionTreeError("tree: session tree is unavailable")
212- }
213- if i.sessionTreeDialog == nil || !i.sessionTreeDialog.OpenSessionFamily(i.sessionsRoot(), i.cfg.CWD, current) {
214- i.setSessionTreeError("tree: no readable session family")
215 return false207 return false
216 }208 }
217 i.mu.Lock()209 i.mu.Lock()
210+ loadCtx := i.runCtx
211+ i.mu.Unlock()
212+ if loadCtx == nil {
213+ loadCtx = context.Background()
214+ }
215+ i.sessionTreeLoads = i.sessionTreeDialog.OpenSessionFamilyAsync(loadCtx, i.sessionsRoot(), i.cfg.CWD, current, i.cfg.FlushSession)
216+ i.mu.Lock()
218 i.statusErr = ""217 i.statusErr = ""
219 i.statusOK = ""218 i.statusOK = ""
220 i.mu.Unlock()219 i.mu.Unlock()
M packages/agent/modes/double_escape_test.go +27 -10
@@ -10,6 +10,17 @@ import (
1010 "github.com/bnema/zut/packages/tui"
1111 )
1212
13+func applySessionTreeLoads(d *sessionTreeDialog, events <-chan sessionTreeLoadEvent) error {
14+ var loadErr error
15+ for event := range events {
16+ if err := d.ApplyLoad(event); err != nil {
17+ loadErr = err
18+ }
19+ }
20+ d.ApplyLoadClosed()
21+ return loadErr
22+}
23+
1324 func TestInteractiveCtrlCExitMarker(t *testing.T) {
1425 i := NewInteractive(InteractiveConfig{})
1526 key := tui.Key{Kind: tui.KeyCtrlC}
@@ -290,10 +301,13 @@ func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
290301 t.Fatal("second Escape exited")
291302 }
292303 if !i.sessionTreeDialog.Active() {
293- t.Fatal("readable session tree did not open")
304+ t.Fatal("session tree did not open before its background load")
305+ }
306+ if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err != nil {
307+ t.Fatalf("background session tree load: %v", err)
294308 }
295309 if flushes != 1 {
296- t.Fatalf("FlushSession called %d times, want once after the gate", flushes)
310+ t.Fatalf("FlushSession called %d times, want once in the background", flushes)
297311 }
298312 if got := len(ag.Messages()); got != 1 {
299313 t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
@@ -381,8 +395,8 @@ func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
381395 if pathCalls == 1 {
382396 return path
383397 }
384- // Keep the cheap gesture eligibility true while making the full
385- // tree-family gate reject the second tap.
398+ // Keep the cheap gesture eligibility true while making the
399+ // asynchronous family read fail after the overlay opens.
386400 return path + ".missing"
387401 },
388402 LoadSession: func(string) error { return nil },
@@ -399,14 +413,17 @@ func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
399413 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
400414 t.Fatal("second Escape exited")
401415 }
402- if i.sessionTreeDialog.Active() {
403- t.Fatal("fail-closed tree gate opened an overlay")
416+ if !i.sessionTreeDialog.Active() {
417+ t.Fatal("tree did not open before the asynchronous family validation")
404418 }
405- if i.statusErr != "tree: session tree is unavailable" {
406- t.Fatalf("gate status = %q, want the fail-closed tree error", i.statusErr)
419+ if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err == nil {
420+ t.Fatal("unreadable family did not fail in the background")
407421 }
408- if flushes != 0 {
409- t.Fatalf("FlushSession called %d times after the rejected gate", flushes)
422+ if i.sessionTreeDialog.Active() {
423+ t.Fatal("unreadable family left the tree overlay active")
424+ }
425+ if flushes != 1 {
426+ t.Fatalf("FlushSession called %d times, want one asynchronous flush", flushes)
410427 }
411428 }
412429
@@ -10,6 +10,17 @@ import (
10 "github.com/bnema/zut/packages/tui"10 "github.com/bnema/zut/packages/tui"
11 )11 )
12 12
13+func applySessionTreeLoads(d *sessionTreeDialog, events <-chan sessionTreeLoadEvent) error {
14+ var loadErr error
15+ for event := range events {
16+ if err := d.ApplyLoad(event); err != nil {
17+ loadErr = err
18+ }
19+ }
20+ d.ApplyLoadClosed()
21+ return loadErr
22+}
23+
13 func TestInteractiveCtrlCExitMarker(t *testing.T) {24 func TestInteractiveCtrlCExitMarker(t *testing.T) {
14 i := NewInteractive(InteractiveConfig{})25 i := NewInteractive(InteractiveConfig{})
15 key := tui.Key{Kind: tui.KeyCtrlC}26 key := tui.Key{Kind: tui.KeyCtrlC}
@@ -290,10 +301,13 @@ func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
290 t.Fatal("second Escape exited")301 t.Fatal("second Escape exited")
291 }302 }
292 if !i.sessionTreeDialog.Active() {303 if !i.sessionTreeDialog.Active() {
293- t.Fatal("readable session tree did not open")304+ t.Fatal("session tree did not open before its background load")
305+ }
306+ if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err != nil {
307+ t.Fatalf("background session tree load: %v", err)
294 }308 }
295 if flushes != 1 {309 if flushes != 1 {
296- t.Fatalf("FlushSession called %d times, want once after the gate", flushes)310+ t.Fatalf("FlushSession called %d times, want once in the background", flushes)
297 }311 }
298 if got := len(ag.Messages()); got != 1 {312 if got := len(ag.Messages()); got != 1 {
299 t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)313 t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
@@ -381,8 +395,8 @@ func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
381 if pathCalls == 1 {395 if pathCalls == 1 {
382 return path396 return path
383 }397 }
384- // Keep the cheap gesture eligibility true while making the full398+ // Keep the cheap gesture eligibility true while making the
385- // tree-family gate reject the second tap.399+ // asynchronous family read fail after the overlay opens.
386 return path + ".missing"400 return path + ".missing"
387 },401 },
388 LoadSession: func(string) error { return nil },402 LoadSession: func(string) error { return nil },
@@ -399,14 +413,17 @@ func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
399 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {413 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
400 t.Fatal("second Escape exited")414 t.Fatal("second Escape exited")
401 }415 }
402- if i.sessionTreeDialog.Active() {416+ if !i.sessionTreeDialog.Active() {
403- t.Fatal("fail-closed tree gate opened an overlay")417+ t.Fatal("tree did not open before the asynchronous family validation")
404 }418 }
405- if i.statusErr != "tree: session tree is unavailable" {419+ if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err == nil {
406- t.Fatalf("gate status = %q, want the fail-closed tree error", i.statusErr)420+ t.Fatal("unreadable family did not fail in the background")
407 }421 }
408- if flushes != 0 {422+ if i.sessionTreeDialog.Active() {
409- t.Fatalf("FlushSession called %d times after the rejected gate", flushes)423+ t.Fatal("unreadable family left the tree overlay active")
424+ }
425+ if flushes != 1 {
426+ t.Fatalf("FlushSession called %d times, want one asynchronous flush", flushes)
410 }427 }
411 }428 }
412 429
M packages/agent/modes/interactive.go +20 -0
@@ -882,6 +882,10 @@ type Interactive struct {
882882 // rendering never races with background file reads.
883883 sessionLoads <-chan sessionLoadEvent
884884
885+ // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
886+ // picker, the main loop is the sole owner of dialog mutation and rendering.
887+ sessionTreeLoads <-chan sessionTreeLoadEvent
888+
885889 // pendingRescuePrompt / pendingRescueImages stash the prompt and
886890 // images that should be re-run after the user picks a model in
887891 // the rescue dialog. Cleared once applyRescueSelection consumes
@@ -1309,6 +1313,22 @@ func (i *Interactive) Run(ctx context.Context) error {
13091313 i.sessionDialog.ApplyLoad(event)
13101314 i.mu.Unlock()
13111315 i.invalidate()
1316+ case event, ok := <-i.sessionTreeLoads:
1317+ i.mu.Lock()
1318+ if !ok {
1319+ i.sessionTreeLoads = nil
1320+ i.sessionTreeDialog.ApplyLoadClosed()
1321+ i.mu.Unlock()
1322+ i.invalidate()
1323+ break
1324+ }
1325+ if err := i.sessionTreeDialog.ApplyLoad(event); err != nil {
1326+ i.sessionTreeLoads = nil
1327+ i.statusErr = "tree: no readable session family"
1328+ i.statusOK = ""
1329+ }
1330+ i.mu.Unlock()
1331+ i.invalidate()
13121332 case <-i.dirty:
13131333 requestRedraw()
13141334 case <-tick.C:
@@ -882,6 +882,10 @@ type Interactive struct {
882 // rendering never races with background file reads.882 // rendering never races with background file reads.
883 sessionLoads <-chan sessionLoadEvent883 sessionLoads <-chan sessionLoadEvent
884 884
885+ // sessionTreeLoads delivers asynchronously-read tree rows. Like the flat
886+ // picker, the main loop is the sole owner of dialog mutation and rendering.
887+ sessionTreeLoads <-chan sessionTreeLoadEvent
888+
885 // pendingRescuePrompt / pendingRescueImages stash the prompt and889 // pendingRescuePrompt / pendingRescueImages stash the prompt and
886 // images that should be re-run after the user picks a model in890 // images that should be re-run after the user picks a model in
887 // the rescue dialog. Cleared once applyRescueSelection consumes891 // the rescue dialog. Cleared once applyRescueSelection consumes
@@ -1309,6 +1313,22 @@ func (i *Interactive) Run(ctx context.Context) error {
1309 i.sessionDialog.ApplyLoad(event)1313 i.sessionDialog.ApplyLoad(event)
1310 i.mu.Unlock()1314 i.mu.Unlock()
1311 i.invalidate()1315 i.invalidate()
1316+ case event, ok := <-i.sessionTreeLoads:
1317+ i.mu.Lock()
1318+ if !ok {
1319+ i.sessionTreeLoads = nil
1320+ i.sessionTreeDialog.ApplyLoadClosed()
1321+ i.mu.Unlock()
1322+ i.invalidate()
1323+ break
1324+ }
1325+ if err := i.sessionTreeDialog.ApplyLoad(event); err != nil {
1326+ i.sessionTreeLoads = nil
1327+ i.statusErr = "tree: no readable session family"
1328+ i.statusOK = ""
1329+ }
1330+ i.mu.Unlock()
1331+ i.invalidate()
1312 case <-i.dirty:1332 case <-i.dirty:
1313 requestRedraw()1333 requestRedraw()
1314 case <-tick.C:1334 case <-tick.C:
M packages/agent/modes/session_tree_dialog.go +280 -16
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "context"
45 "fmt"
56 "reflect"
67 "strings"
@@ -73,6 +74,13 @@ type sessionTreeDialog struct {
7374 items []sessionTreeItem
7475 cursor int
7576
77+ loading bool
78+ loadingDone int
79+ loadGeneration uint64
80+ loadCancel context.CancelFunc
81+ loadDone chan struct{}
82+ loadFamily func(context.Context, string, string, string) ([]sessionTreeItem, error)
83+
7684 // MaxRows is the maximum number of transcript/boundary rows rendered in
7785 // one viewport. The interactive host may set it from terminal height. A
7886 // bounded default is deliberately used when it is left at zero so a large
@@ -117,6 +125,22 @@ type sessionTreeSnapshot struct {
117125 history []core.SessionHistorySegment
118126 }
119127
128+type sessionTreeLoadEventKind uint8
129+
130+const (
131+ sessionTreeLoadStarted sessionTreeLoadEventKind = iota
132+ sessionTreeLoadEntry
133+ sessionTreeLoadFinished
134+ sessionTreeLoadFailed
135+)
136+
137+type sessionTreeLoadEvent struct {
138+ kind sessionTreeLoadEventKind
139+ generation uint64
140+ item sessionTreeItem
141+ err error
142+}
143+
120144 const defaultSessionTreeRows = 12
121145
122146 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
@@ -137,22 +161,226 @@ func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
137161 // recoverable by choosing an arbitrary forest root. Failed reads leave the
138162 // prior dialog state untouched.
139163 func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
140- roots, err := core.BuildSessionTreeFamilyStrict(root, cwd, currentPath)
141- if err != nil || len(roots) == 0 {
164+ items, err := loadSessionTreeFamily(context.Background(), root, cwd, currentPath)
165+ if err != nil || len(items) == 0 {
142166 return false
143167 }
168+ d.activate(items, indexCurrentTreeItem(items))
169+ return true
170+}
171+
172+// OpenSessionFamilyAsync shows the tree immediately and streams rows from the
173+// newest visible content toward the oldest. The caller applies returned events
174+// on its UI goroutine, so disk reads never contend with input or rendering.
175+func (d *sessionTreeDialog) OpenSessionFamilyAsync(parent context.Context, root, cwd, currentPath string, beforeLoad func()) <-chan sessionTreeLoadEvent {
176+ if d.loadCancel != nil {
177+ d.loadCancel()
178+ }
179+ previousDone := d.loadDone
180+ if parent == nil {
181+ parent = context.Background()
182+ }
183+ ctx, cancel := context.WithCancel(parent)
184+ d.loadCancel = cancel
185+ d.loadGeneration++
186+ generation := d.loadGeneration
187+ d.loadDone = make(chan struct{})
188+ d.items = nil
189+ d.cursor = 0
190+ d.viewTop = 0
191+ d.loading = true
192+ d.loadingDone = 0
193+ d.active = true
194+
195+ // Keep the producer ahead of a redraw so a large family does not require
196+ // one main-loop iteration for every emitted row.
197+ events := make(chan sessionTreeLoadEvent, 64)
198+ send := func(event sessionTreeLoadEvent) bool {
199+ select {
200+ case events <- event:
201+ return true
202+ case <-ctx.Done():
203+ return false
204+ }
205+ }
206+ loadFamily := d.loadFamily
207+ done := d.loadDone
208+ go func() {
209+ defer close(done)
210+ defer close(events)
211+ if previousDone != nil {
212+ select {
213+ case <-previousDone:
214+ case <-ctx.Done():
215+ return
216+ }
217+ }
218+ if beforeLoad != nil {
219+ beforeLoad()
220+ }
221+ if ctx.Err() != nil {
222+ return
223+ }
224+ if !send(sessionTreeLoadEvent{kind: sessionTreeLoadStarted, generation: generation}) {
225+ return
226+ }
227+ emit := func(item sessionTreeItem) bool {
228+ return send(sessionTreeLoadEvent{kind: sessionTreeLoadEntry, generation: generation, item: item})
229+ }
230+ var err error
231+ if loadFamily != nil {
232+ items, loadErr := loadFamily(ctx, root, cwd, currentPath)
233+ err = loadErr
234+ for index := len(items) - 1; err == nil && index >= 0; index-- {
235+ if !emit(items[index]) {
236+ return
237+ }
238+ }
239+ } else {
240+ err = streamSessionTreeFamily(ctx, root, cwd, currentPath, emit)
241+ }
242+ if err != nil {
243+ if ctx.Err() == nil {
244+ send(sessionTreeLoadEvent{kind: sessionTreeLoadFailed, generation: generation, err: err})
245+ }
246+ return
247+ }
248+ send(sessionTreeLoadEvent{kind: sessionTreeLoadFinished, generation: generation})
249+ }()
250+ return events
251+}
252+
253+// ApplyLoad incorporates a background event on the UI goroutine. A non-nil
254+// result means the complete family could not be read and should be reported by
255+// the host after it closes the overlay.
256+func (d *sessionTreeDialog) ApplyLoad(event sessionTreeLoadEvent) error {
257+ if !d.active || event.generation != d.loadGeneration {
258+ return nil
259+ }
260+ switch event.kind {
261+ case sessionTreeLoadStarted:
262+ case sessionTreeLoadEntry:
263+ d.items = append(d.items, event.item)
264+ d.loadingDone++
265+ case sessionTreeLoadFinished:
266+ if cursor := indexCurrentTreeItem(d.items); cursor >= 0 {
267+ d.cursor = cursor
268+ }
269+ d.finishLoad()
270+ case sessionTreeLoadFailed:
271+ d.finishLoad()
272+ d.active = false
273+ return event.err
274+ }
275+ return nil
276+}
277+
278+func (d *sessionTreeDialog) ApplyLoadClosed() {
279+ if d.active && d.loading {
280+ d.finishLoad()
281+ d.active = false
282+ }
283+}
284+
285+func (d *sessionTreeDialog) finishLoad() {
286+ d.loading = false
287+ if d.loadCancel != nil {
288+ d.loadCancel()
289+ d.loadCancel = nil
290+ }
291+}
292+
293+func loadSessionTreeFamily(ctx context.Context, root, cwd, currentPath string) ([]sessionTreeItem, error) {
294+ roots, err := core.BuildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
295+ if err != nil {
296+ return nil, err
297+ }
298+ if len(roots) == 0 {
299+ return nil, fmt.Errorf("session tree: no readable session family")
300+ }
301+ familyRoot := roots[0]
302+ snapshots, err := preflightSessionTreeFamilyContext(ctx, familyRoot)
303+ if err != nil {
304+ return nil, err
305+ }
306+ items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
307+ if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
308+ return nil, fmt.Errorf("session tree: no messages in this family")
309+ }
310+ return items, nil
311+}
312+
313+func streamSessionTreeFamily(ctx context.Context, root, cwd, currentPath string, emit func(sessionTreeItem) bool) error {
314+ roots, err := core.BuildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
315+ if err != nil {
316+ return err
317+ }
318+ if len(roots) == 0 {
319+ return fmt.Errorf("session tree: no readable session family")
320+ }
144321 familyRoot := roots[0]
322+ current := findSessionTreeNode(familyRoot, currentPath)
323+ if current == nil {
324+ return fmt.Errorf("session tree: current session is missing from its family")
325+ }
326+ currentHistory, err := core.ReadSessionHistoryContext(ctx, current.Summary.Path)
327+ if err != nil {
328+ return fmt.Errorf("session tree: read current %q: %w", current.Summary.Path, err)
329+ }
330+ if currentHistory.Meta.ID != current.Meta.ID {
331+ return fmt.Errorf("session tree: current session id changed in %q", current.Summary.Path)
332+ }
333+ currentSnapshots := map[string]sessionTreeSnapshot{
334+ current.Summary.Path: {path: current.Summary.Path, messages: sessionHistoryMessages(currentHistory), history: currentHistory.Segments},
335+ }
336+ preview := flattenSessionNode(current, currentPath, currentSnapshots, 0, current.Meta.ForkPoint, false, false)
337+ seen := make(map[sessionTreeTarget]struct{}, len(preview))
338+ for index := len(preview) - 1; index >= 0; index-- {
339+ seen[preview[index].target] = struct{}{}
340+ if !emit(preview[index]) {
341+ return ctx.Err()
342+ }
343+ }
145344
146- snapshots, err := preflightSessionTreeFamily(familyRoot)
345+ snapshots, err := preflightSessionTreeFamilyContext(ctx, familyRoot)
147346 if err != nil {
148- return false
347+ return err
149348 }
150349 items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
151350 if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
152- return false
351+ return fmt.Errorf("session tree: no messages in this family")
153352 }
154- d.activate(items, indexCurrentTreeItem(items))
155- return true
353+ for index := len(items) - 1; index >= 0; index-- {
354+ if _, alreadyEmitted := seen[items[index].target]; alreadyEmitted {
355+ continue
356+ }
357+ if !emit(items[index]) {
358+ return ctx.Err()
359+ }
360+ }
361+ return nil
362+}
363+
364+func findSessionTreeNode(node *core.TreeNode, path string) *core.TreeNode {
365+ if node == nil {
366+ return nil
367+ }
368+ if node.Summary.Path == path {
369+ return node
370+ }
371+ for _, child := range node.Children {
372+ if found := findSessionTreeNode(child, path); found != nil {
373+ return found
374+ }
375+ }
376+ return nil
377+}
378+
379+func sessionHistoryMessages(history core.SessionHistory) []provider.Message {
380+ if len(history.Segments) == 0 {
381+ return nil
382+ }
383+ return history.Segments[len(history.Segments)-1].Messages
156384 }
157385
158386 func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {
@@ -165,6 +393,11 @@ func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {
165393 }
166394
167395 func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {
396+ if d.loadCancel != nil {
397+ d.loadCancel()
398+ d.loadCancel = nil
399+ }
400+ d.loading = false
168401 if cursor < 0 {
169402 cursor = 0
170403 }
@@ -177,8 +410,15 @@ func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {
177410 d.active = true
178411 }
179412
180-// Close hides the dialog.
181-func (d *sessionTreeDialog) Close() { d.active = false }
413+// Close hides the dialog and cancels any in-flight family read.
414+func (d *sessionTreeDialog) Close() {
415+ if d.loadCancel != nil {
416+ d.loadCancel()
417+ d.loadCancel = nil
418+ }
419+ d.loading = false
420+ d.active = false
421+}
182422
183423 // CursorPos returns -1 because this dialog has no inline editor.
184424 func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
@@ -290,6 +530,17 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
290530 line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) }
291531
292532 lines := []string{line(frameHeader(th, "session tree", renderWidth))}
533+ if d.loading {
534+ progress := fmt.Sprintf("loading session history... %d loaded", d.loadingDone)
535+ lines = append(lines, line(th.FGColor(th.Muted, progress)))
536+ if len(d.items) == 0 {
537+ lines = append(lines,
538+ line(th.FGColor(th.Muted, "recent messages appear first; esc cancels")),
539+ line(frameRule(th, renderWidth)),
540+ )
541+ return lines
542+ }
543+ }
293544 if len(d.items) == 0 {
294545 lines = append(lines,
295546 line(th.FGColor(th.Muted, "no messages in this session yet")),
@@ -298,8 +549,11 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
298549 )
299550 return lines
300551 }
301- lines = append(lines, line(th.FGColor(th.Muted,
302- "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))
552+ hint := "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):"
553+ if d.loading {
554+ hint = "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel; more loading):"
555+ }
556+ lines = append(lines, line(th.FGColor(th.Muted, hint)))
303557
304558 d.followCursor()
305559 start := d.viewTop
@@ -368,10 +622,13 @@ func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string {
368622 return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix
369623 }
370624
371-// preflightSessionTreeFamily reads every node before any dialog state is
372-// changed. The returned messages are the shared effective snapshots used by
373-// all flattening and target construction for this open operation.
374-func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnapshot, error) {
625+// preflightSessionTreeFamilyContext reads every node and returns the shared
626+// effective snapshots used by all flattening and target construction for one
627+// open operation.
628+func preflightSessionTreeFamilyContext(ctx context.Context, root *core.TreeNode) (map[string]sessionTreeSnapshot, error) {
629+ if err := ctx.Err(); err != nil {
630+ return nil, err
631+ }
375632 if root == nil {
376633 return nil, fmt.Errorf("session tree: nil family root")
377634 }
@@ -379,6 +636,9 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap
379636 visiting := make(map[string]bool)
380637 var walk func(*core.TreeNode) error
381638 walk = func(node *core.TreeNode) error {
639+ if err := ctx.Err(); err != nil {
640+ return err
641+ }
382642 if node == nil {
383643 return nil
384644 }
@@ -390,11 +650,15 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap
390650 return nil
391651 }
392652 visiting[path] = true
393- history, err := core.ReadSessionHistory(path)
653+ history, err := core.ReadSessionHistoryContext(ctx, path)
394654 if err != nil {
395655 delete(visiting, path)
396656 return fmt.Errorf("session tree: read %q: %w", path, err)
397657 }
658+ if history.Meta.ID != node.Meta.ID {
659+ delete(visiting, path)
660+ return fmt.Errorf("session tree: session id changed in %q", path)
661+ }
398662 var messages []provider.Message
399663 if len(history.Segments) > 0 {
400664 messages = history.Segments[len(history.Segments)-1].Messages
@@ -1,6 +1,7 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "reflect"6 "reflect"
6 "strings"7 "strings"
@@ -73,6 +74,13 @@ type sessionTreeDialog struct {
73 items []sessionTreeItem74 items []sessionTreeItem
74 cursor int75 cursor int
75 76
77+ loading bool
78+ loadingDone int
79+ loadGeneration uint64
80+ loadCancel context.CancelFunc
81+ loadDone chan struct{}
82+ loadFamily func(context.Context, string, string, string) ([]sessionTreeItem, error)
83+
76 // MaxRows is the maximum number of transcript/boundary rows rendered in84 // MaxRows is the maximum number of transcript/boundary rows rendered in
77 // one viewport. The interactive host may set it from terminal height. A85 // one viewport. The interactive host may set it from terminal height. A
78 // bounded default is deliberately used when it is left at zero so a large86 // bounded default is deliberately used when it is left at zero so a large
@@ -117,6 +125,22 @@ type sessionTreeSnapshot struct {
117 history []core.SessionHistorySegment125 history []core.SessionHistorySegment
118 }126 }
119 127
128+type sessionTreeLoadEventKind uint8
129+
130+const (
131+ sessionTreeLoadStarted sessionTreeLoadEventKind = iota
132+ sessionTreeLoadEntry
133+ sessionTreeLoadFinished
134+ sessionTreeLoadFailed
135+)
136+
137+type sessionTreeLoadEvent struct {
138+ kind sessionTreeLoadEventKind
139+ generation uint64
140+ item sessionTreeItem
141+ err error
142+}
143+
120 const defaultSessionTreeRows = 12144 const defaultSessionTreeRows = 12
121 145
122 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }146 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
@@ -137,22 +161,226 @@ func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
137 // recoverable by choosing an arbitrary forest root. Failed reads leave the161 // recoverable by choosing an arbitrary forest root. Failed reads leave the
138 // prior dialog state untouched.162 // prior dialog state untouched.
139 func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {163 func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
140- roots, err := core.BuildSessionTreeFamilyStrict(root, cwd, currentPath)164+ items, err := loadSessionTreeFamily(context.Background(), root, cwd, currentPath)
141- if err != nil || len(roots) == 0 {165+ if err != nil || len(items) == 0 {
142 return false166 return false
143 }167 }
168+ d.activate(items, indexCurrentTreeItem(items))
169+ return true
170+}
171+
172+// OpenSessionFamilyAsync shows the tree immediately and streams rows from the
173+// newest visible content toward the oldest. The caller applies returned events
174+// on its UI goroutine, so disk reads never contend with input or rendering.
175+func (d *sessionTreeDialog) OpenSessionFamilyAsync(parent context.Context, root, cwd, currentPath string, beforeLoad func()) <-chan sessionTreeLoadEvent {
176+ if d.loadCancel != nil {
177+ d.loadCancel()
178+ }
179+ previousDone := d.loadDone
180+ if parent == nil {
181+ parent = context.Background()
182+ }
183+ ctx, cancel := context.WithCancel(parent)
184+ d.loadCancel = cancel
185+ d.loadGeneration++
186+ generation := d.loadGeneration
187+ d.loadDone = make(chan struct{})
188+ d.items = nil
189+ d.cursor = 0
190+ d.viewTop = 0
191+ d.loading = true
192+ d.loadingDone = 0
193+ d.active = true
194+
195+ // Keep the producer ahead of a redraw so a large family does not require
196+ // one main-loop iteration for every emitted row.
197+ events := make(chan sessionTreeLoadEvent, 64)
198+ send := func(event sessionTreeLoadEvent) bool {
199+ select {
200+ case events <- event:
201+ return true
202+ case <-ctx.Done():
203+ return false
204+ }
205+ }
206+ loadFamily := d.loadFamily
207+ done := d.loadDone
208+ go func() {
209+ defer close(done)
210+ defer close(events)
211+ if previousDone != nil {
212+ select {
213+ case <-previousDone:
214+ case <-ctx.Done():
215+ return
216+ }
217+ }
218+ if beforeLoad != nil {
219+ beforeLoad()
220+ }
221+ if ctx.Err() != nil {
222+ return
223+ }
224+ if !send(sessionTreeLoadEvent{kind: sessionTreeLoadStarted, generation: generation}) {
225+ return
226+ }
227+ emit := func(item sessionTreeItem) bool {
228+ return send(sessionTreeLoadEvent{kind: sessionTreeLoadEntry, generation: generation, item: item})
229+ }
230+ var err error
231+ if loadFamily != nil {
232+ items, loadErr := loadFamily(ctx, root, cwd, currentPath)
233+ err = loadErr
234+ for index := len(items) - 1; err == nil && index >= 0; index-- {
235+ if !emit(items[index]) {
236+ return
237+ }
238+ }
239+ } else {
240+ err = streamSessionTreeFamily(ctx, root, cwd, currentPath, emit)
241+ }
242+ if err != nil {
243+ if ctx.Err() == nil {
244+ send(sessionTreeLoadEvent{kind: sessionTreeLoadFailed, generation: generation, err: err})
245+ }
246+ return
247+ }
248+ send(sessionTreeLoadEvent{kind: sessionTreeLoadFinished, generation: generation})
249+ }()
250+ return events
251+}
252+
253+// ApplyLoad incorporates a background event on the UI goroutine. A non-nil
254+// result means the complete family could not be read and should be reported by
255+// the host after it closes the overlay.
256+func (d *sessionTreeDialog) ApplyLoad(event sessionTreeLoadEvent) error {
257+ if !d.active || event.generation != d.loadGeneration {
258+ return nil
259+ }
260+ switch event.kind {
261+ case sessionTreeLoadStarted:
262+ case sessionTreeLoadEntry:
263+ d.items = append(d.items, event.item)
264+ d.loadingDone++
265+ case sessionTreeLoadFinished:
266+ if cursor := indexCurrentTreeItem(d.items); cursor >= 0 {
267+ d.cursor = cursor
268+ }
269+ d.finishLoad()
270+ case sessionTreeLoadFailed:
271+ d.finishLoad()
272+ d.active = false
273+ return event.err
274+ }
275+ return nil
276+}
277+
278+func (d *sessionTreeDialog) ApplyLoadClosed() {
279+ if d.active && d.loading {
280+ d.finishLoad()
281+ d.active = false
282+ }
283+}
284+
285+func (d *sessionTreeDialog) finishLoad() {
286+ d.loading = false
287+ if d.loadCancel != nil {
288+ d.loadCancel()
289+ d.loadCancel = nil
290+ }
291+}
292+
293+func loadSessionTreeFamily(ctx context.Context, root, cwd, currentPath string) ([]sessionTreeItem, error) {
294+ roots, err := core.BuildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
295+ if err != nil {
296+ return nil, err
297+ }
298+ if len(roots) == 0 {
299+ return nil, fmt.Errorf("session tree: no readable session family")
300+ }
301+ familyRoot := roots[0]
302+ snapshots, err := preflightSessionTreeFamilyContext(ctx, familyRoot)
303+ if err != nil {
304+ return nil, err
305+ }
306+ items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
307+ if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
308+ return nil, fmt.Errorf("session tree: no messages in this family")
309+ }
310+ return items, nil
311+}
312+
313+func streamSessionTreeFamily(ctx context.Context, root, cwd, currentPath string, emit func(sessionTreeItem) bool) error {
314+ roots, err := core.BuildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
315+ if err != nil {
316+ return err
317+ }
318+ if len(roots) == 0 {
319+ return fmt.Errorf("session tree: no readable session family")
320+ }
144 familyRoot := roots[0]321 familyRoot := roots[0]
322+ current := findSessionTreeNode(familyRoot, currentPath)
323+ if current == nil {
324+ return fmt.Errorf("session tree: current session is missing from its family")
325+ }
326+ currentHistory, err := core.ReadSessionHistoryContext(ctx, current.Summary.Path)
327+ if err != nil {
328+ return fmt.Errorf("session tree: read current %q: %w", current.Summary.Path, err)
329+ }
330+ if currentHistory.Meta.ID != current.Meta.ID {
331+ return fmt.Errorf("session tree: current session id changed in %q", current.Summary.Path)
332+ }
333+ currentSnapshots := map[string]sessionTreeSnapshot{
334+ current.Summary.Path: {path: current.Summary.Path, messages: sessionHistoryMessages(currentHistory), history: currentHistory.Segments},
335+ }
336+ preview := flattenSessionNode(current, currentPath, currentSnapshots, 0, current.Meta.ForkPoint, false, false)
337+ seen := make(map[sessionTreeTarget]struct{}, len(preview))
338+ for index := len(preview) - 1; index >= 0; index-- {
339+ seen[preview[index].target] = struct{}{}
340+ if !emit(preview[index]) {
341+ return ctx.Err()
342+ }
343+ }
145 344
146- snapshots, err := preflightSessionTreeFamily(familyRoot)345+ snapshots, err := preflightSessionTreeFamilyContext(ctx, familyRoot)
147 if err != nil {346 if err != nil {
148- return false347+ return err
149 }348 }
150 items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)349 items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots)
151 if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {350 if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) {
152- return false351+ return fmt.Errorf("session tree: no messages in this family")
153 }352 }
154- d.activate(items, indexCurrentTreeItem(items))353+ for index := len(items) - 1; index >= 0; index-- {
155- return true354+ if _, alreadyEmitted := seen[items[index].target]; alreadyEmitted {
355+ continue
356+ }
357+ if !emit(items[index]) {
358+ return ctx.Err()
359+ }
360+ }
361+ return nil
362+}
363+
364+func findSessionTreeNode(node *core.TreeNode, path string) *core.TreeNode {
365+ if node == nil {
366+ return nil
367+ }
368+ if node.Summary.Path == path {
369+ return node
370+ }
371+ for _, child := range node.Children {
372+ if found := findSessionTreeNode(child, path); found != nil {
373+ return found
374+ }
375+ }
376+ return nil
377+}
378+
379+func sessionHistoryMessages(history core.SessionHistory) []provider.Message {
380+ if len(history.Segments) == 0 {
381+ return nil
382+ }
383+ return history.Segments[len(history.Segments)-1].Messages
156 }384 }
157 385
158 func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {386 func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {
@@ -165,6 +393,11 @@ func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool {
165 }393 }
166 394
167 func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {395 func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {
396+ if d.loadCancel != nil {
397+ d.loadCancel()
398+ d.loadCancel = nil
399+ }
400+ d.loading = false
168 if cursor < 0 {401 if cursor < 0 {
169 cursor = 0402 cursor = 0
170 }403 }
@@ -177,8 +410,15 @@ func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) {
177 d.active = true410 d.active = true
178 }411 }
179 412
180-// Close hides the dialog.413+// Close hides the dialog and cancels any in-flight family read.
181-func (d *sessionTreeDialog) Close() { d.active = false }414+func (d *sessionTreeDialog) Close() {
415+ if d.loadCancel != nil {
416+ d.loadCancel()
417+ d.loadCancel = nil
418+ }
419+ d.loading = false
420+ d.active = false
421+}
182 422
183 // CursorPos returns -1 because this dialog has no inline editor.423 // CursorPos returns -1 because this dialog has no inline editor.
184 func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }424 func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
@@ -290,6 +530,17 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
290 line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) }530 line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) }
291 531
292 lines := []string{line(frameHeader(th, "session tree", renderWidth))}532 lines := []string{line(frameHeader(th, "session tree", renderWidth))}
533+ if d.loading {
534+ progress := fmt.Sprintf("loading session history... %d loaded", d.loadingDone)
535+ lines = append(lines, line(th.FGColor(th.Muted, progress)))
536+ if len(d.items) == 0 {
537+ lines = append(lines,
538+ line(th.FGColor(th.Muted, "recent messages appear first; esc cancels")),
539+ line(frameRule(th, renderWidth)),
540+ )
541+ return lines
542+ }
543+ }
293 if len(d.items) == 0 {544 if len(d.items) == 0 {
294 lines = append(lines,545 lines = append(lines,
295 line(th.FGColor(th.Muted, "no messages in this session yet")),546 line(th.FGColor(th.Muted, "no messages in this session yet")),
@@ -298,8 +549,11 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
298 )549 )
299 return lines550 return lines
300 }551 }
301- lines = append(lines, line(th.FGColor(th.Muted,552+ hint := "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):"
302- "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel):")))553+ if d.loading {
554+ hint = "session history and branches (↑/↓, pgup/pgdn, home/end, enter checkout, esc cancel; more loading):"
555+ }
556+ lines = append(lines, line(th.FGColor(th.Muted, hint)))
303 557
304 d.followCursor()558 d.followCursor()
305 start := d.viewTop559 start := d.viewTop
@@ -368,10 +622,13 @@ func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string {
368 return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix622 return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix
369 }623 }
370 624
371-// preflightSessionTreeFamily reads every node before any dialog state is625+// preflightSessionTreeFamilyContext reads every node and returns the shared
372-// changed. The returned messages are the shared effective snapshots used by626+// effective snapshots used by all flattening and target construction for one
373-// all flattening and target construction for this open operation.627+// open operation.
374-func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnapshot, error) {628+func preflightSessionTreeFamilyContext(ctx context.Context, root *core.TreeNode) (map[string]sessionTreeSnapshot, error) {
629+ if err := ctx.Err(); err != nil {
630+ return nil, err
631+ }
375 if root == nil {632 if root == nil {
376 return nil, fmt.Errorf("session tree: nil family root")633 return nil, fmt.Errorf("session tree: nil family root")
377 }634 }
@@ -379,6 +636,9 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap
379 visiting := make(map[string]bool)636 visiting := make(map[string]bool)
380 var walk func(*core.TreeNode) error637 var walk func(*core.TreeNode) error
381 walk = func(node *core.TreeNode) error {638 walk = func(node *core.TreeNode) error {
639+ if err := ctx.Err(); err != nil {
640+ return err
641+ }
382 if node == nil {642 if node == nil {
383 return nil643 return nil
384 }644 }
@@ -390,11 +650,15 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap
390 return nil650 return nil
391 }651 }
392 visiting[path] = true652 visiting[path] = true
393- history, err := core.ReadSessionHistory(path)653+ history, err := core.ReadSessionHistoryContext(ctx, path)
394 if err != nil {654 if err != nil {
395 delete(visiting, path)655 delete(visiting, path)
396 return fmt.Errorf("session tree: read %q: %w", path, err)656 return fmt.Errorf("session tree: read %q: %w", path, err)
397 }657 }
658+ if history.Meta.ID != node.Meta.ID {
659+ delete(visiting, path)
660+ return fmt.Errorf("session tree: session id changed in %q", path)
661+ }
398 var messages []provider.Message662 var messages []provider.Message
399 if len(history.Segments) > 0 {663 if len(history.Segments) > 0 {
400 messages = history.Segments[len(history.Segments)-1].Messages664 messages = history.Segments[len(history.Segments)-1].Messages
M packages/agent/modes/session_tree_dialog_test.go +77 -0
@@ -1,8 +1,10 @@
11 package modes
22
33 import (
4+ "context"
45 "encoding/json"
56 "os"
7+ "reflect"
68 "strings"
79 "testing"
810 "time"
@@ -12,6 +14,81 @@ import (
1214 "github.com/bnema/zut/packages/tui"
1315 )
1416
17+func TestSessionTreeAsyncLoadOpensImmediatelyAndStreamsNewestFirst(t *testing.T) {
18+ d := newSessionTreeDialog()
19+ started := make(chan struct{})
20+ release := make(chan struct{})
21+ d.loadFamily = func(context.Context, string, string, string) ([]sessionTreeItem, error) {
22+ close(started)
23+ <-release
24+ return []sessionTreeItem{{label: "oldest"}, {label: "newest"}}, nil
25+ }
26+
27+ events := d.OpenSessionFamilyAsync(context.Background(), "root", "cwd", "current", nil)
28+ if !d.Active() || !d.loading {
29+ t.Fatal("tree was not visible and loading immediately")
30+ }
31+ select {
32+ case <-started:
33+ case <-time.After(time.Second):
34+ t.Fatal("background tree loader did not start")
35+ }
36+ if len(d.items) != 0 {
37+ t.Fatalf("tree showed items before the loader released them: %#v", d.items)
38+ }
39+
40+ close(release)
41+ if err := applySessionTreeLoads(d, events); err != nil {
42+ t.Fatalf("apply background tree load: %v", err)
43+ }
44+ if d.loading {
45+ t.Fatal("tree remained loading after its terminal event")
46+ }
47+ if got := []string{d.items[0].label, d.items[1].label}; !reflect.DeepEqual(got, []string{"newest", "oldest"}) {
48+ t.Fatalf("stream order = %v, want newest to oldest", got)
49+ }
50+}
51+
52+func TestSessionTreeAsyncLoadCancelsWhenClosed(t *testing.T) {
53+ d := newSessionTreeDialog()
54+ started := make(chan struct{})
55+ d.loadFamily = func(ctx context.Context, _, _, _ string) ([]sessionTreeItem, error) {
56+ close(started)
57+ <-ctx.Done()
58+ return nil, ctx.Err()
59+ }
60+
61+ events := d.OpenSessionFamilyAsync(context.Background(), "root", "cwd", "current", nil)
62+ select {
63+ case <-started:
64+ case <-time.After(time.Second):
65+ t.Fatal("background tree loader did not start")
66+ }
67+ select {
68+ case event := <-events:
69+ if event.kind != sessionTreeLoadStarted {
70+ t.Fatalf("first load event = %v, want started", event.kind)
71+ }
72+ if err := d.ApplyLoad(event); err != nil {
73+ t.Fatalf("apply startup event: %v", err)
74+ }
75+ case <-time.After(time.Second):
76+ t.Fatal("tree loader did not announce startup")
77+ }
78+ d.Close()
79+ if d.Active() {
80+ t.Fatal("Close left the tree active")
81+ }
82+ select {
83+ case _, ok := <-events:
84+ if ok {
85+ t.Fatal("canceled tree loader emitted an event")
86+ }
87+ case <-time.After(time.Second):
88+ t.Fatal("canceled tree loader did not stop")
89+ }
90+}
91+
1592 func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {
1693 msgs := []provider.Message{
1794 treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}),
@@ -1,8 +1,10 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "os"6 "os"
7+ "reflect"
6 "strings"8 "strings"
7 "testing"9 "testing"
8 "time"10 "time"
@@ -12,6 +14,81 @@ import (
12 "github.com/bnema/zut/packages/tui"14 "github.com/bnema/zut/packages/tui"
13 )15 )
14 16
17+func TestSessionTreeAsyncLoadOpensImmediatelyAndStreamsNewestFirst(t *testing.T) {
18+ d := newSessionTreeDialog()
19+ started := make(chan struct{})
20+ release := make(chan struct{})
21+ d.loadFamily = func(context.Context, string, string, string) ([]sessionTreeItem, error) {
22+ close(started)
23+ <-release
24+ return []sessionTreeItem{{label: "oldest"}, {label: "newest"}}, nil
25+ }
26+
27+ events := d.OpenSessionFamilyAsync(context.Background(), "root", "cwd", "current", nil)
28+ if !d.Active() || !d.loading {
29+ t.Fatal("tree was not visible and loading immediately")
30+ }
31+ select {
32+ case <-started:
33+ case <-time.After(time.Second):
34+ t.Fatal("background tree loader did not start")
35+ }
36+ if len(d.items) != 0 {
37+ t.Fatalf("tree showed items before the loader released them: %#v", d.items)
38+ }
39+
40+ close(release)
41+ if err := applySessionTreeLoads(d, events); err != nil {
42+ t.Fatalf("apply background tree load: %v", err)
43+ }
44+ if d.loading {
45+ t.Fatal("tree remained loading after its terminal event")
46+ }
47+ if got := []string{d.items[0].label, d.items[1].label}; !reflect.DeepEqual(got, []string{"newest", "oldest"}) {
48+ t.Fatalf("stream order = %v, want newest to oldest", got)
49+ }
50+}
51+
52+func TestSessionTreeAsyncLoadCancelsWhenClosed(t *testing.T) {
53+ d := newSessionTreeDialog()
54+ started := make(chan struct{})
55+ d.loadFamily = func(ctx context.Context, _, _, _ string) ([]sessionTreeItem, error) {
56+ close(started)
57+ <-ctx.Done()
58+ return nil, ctx.Err()
59+ }
60+
61+ events := d.OpenSessionFamilyAsync(context.Background(), "root", "cwd", "current", nil)
62+ select {
63+ case <-started:
64+ case <-time.After(time.Second):
65+ t.Fatal("background tree loader did not start")
66+ }
67+ select {
68+ case event := <-events:
69+ if event.kind != sessionTreeLoadStarted {
70+ t.Fatalf("first load event = %v, want started", event.kind)
71+ }
72+ if err := d.ApplyLoad(event); err != nil {
73+ t.Fatalf("apply startup event: %v", err)
74+ }
75+ case <-time.After(time.Second):
76+ t.Fatal("tree loader did not announce startup")
77+ }
78+ d.Close()
79+ if d.Active() {
80+ t.Fatal("Close left the tree active")
81+ }
82+ select {
83+ case _, ok := <-events:
84+ if ok {
85+ t.Fatal("canceled tree loader emitted an event")
86+ }
87+ case <-time.After(time.Second):
88+ t.Fatal("canceled tree loader did not stop")
89+ }
90+}
91+
15 func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {92 func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) {
16 msgs := []provider.Message{93 msgs := []provider.Message{
17 treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}),94 treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}),
M packages/core/session.go +15 -1
@@ -500,6 +500,20 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
500500 // branching; this audit projection exists for session-tree navigation so a
501501 // compaction cannot make earlier fork points disappear.
502502 func ReadSessionHistory(path string) (SessionHistory, error) {
503+ return readSessionHistory(context.Background(), path)
504+}
505+
506+// ReadSessionHistoryContext is the cancellation-aware counterpart to
507+// ReadSessionHistory. It is intended for background consumers such as the
508+// interactive session-tree loader.
509+func ReadSessionHistoryContext(ctx context.Context, path string) (SessionHistory, error) {
510+ return readSessionHistory(ctx, path)
511+}
512+
513+func readSessionHistory(ctx context.Context, path string) (SessionHistory, error) {
514+ if err := contextErr(ctx); err != nil {
515+ return SessionHistory{}, err
516+ }
503517 f, err := os.Open(path)
504518 if err != nil {
505519 return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err)
@@ -537,7 +551,7 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
537551 current = rawSegment{}
538552 }
539553
540- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
554+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
541555 var head sessionLineHead
542556 if err := json.Unmarshal(line, &head); err != nil {
543557 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -500,6 +500,20 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
500 // branching; this audit projection exists for session-tree navigation so a500 // branching; this audit projection exists for session-tree navigation so a
501 // compaction cannot make earlier fork points disappear.501 // compaction cannot make earlier fork points disappear.
502 func ReadSessionHistory(path string) (SessionHistory, error) {502 func ReadSessionHistory(path string) (SessionHistory, error) {
503+ return readSessionHistory(context.Background(), path)
504+}
505+
506+// ReadSessionHistoryContext is the cancellation-aware counterpart to
507+// ReadSessionHistory. It is intended for background consumers such as the
508+// interactive session-tree loader.
509+func ReadSessionHistoryContext(ctx context.Context, path string) (SessionHistory, error) {
510+ return readSessionHistory(ctx, path)
511+}
512+
513+func readSessionHistory(ctx context.Context, path string) (SessionHistory, error) {
514+ if err := contextErr(ctx); err != nil {
515+ return SessionHistory{}, err
516+ }
503 f, err := os.Open(path)517 f, err := os.Open(path)
504 if err != nil {518 if err != nil {
505 return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err)519 return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err)
@@ -537,7 +551,7 @@ func ReadSessionHistory(path string) (SessionHistory, error) {
537 current = rawSegment{}551 current = rawSegment{}
538 }552 }
539 553
540- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {554+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
541 var head sessionLineHead555 var head sessionLineHead
542 if err := json.Unmarshal(line, &head); err != nil {556 if err := json.Unmarshal(line, &head); err != nil {
543 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)557 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
M packages/core/session_portable.go +63 -32
@@ -682,6 +682,66 @@ func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
682682 // readable current family, while a corrupt member of the selected family still
683683 // fails the whole preflight.
684684 func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
685+ return BuildSessionTreeFamilyStrictContext(context.Background(), root, cwd, currentPath)
686+}
687+
688+// BuildSessionTreeFamilyStrictContext is the cancellation-aware counterpart
689+// to BuildSessionTreeFamilyStrict. It performs all filesystem reads on behalf
690+// of callers that must remain responsive while a family is being discovered.
691+func BuildSessionTreeFamilyStrictContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
692+ roots, err := buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
693+ if err != nil || len(roots) == 0 {
694+ return roots, err
695+ }
696+ var validate func(*TreeNode) error
697+ validate = func(node *TreeNode) error {
698+ if err := contextErr(ctx); err != nil {
699+ return err
700+ }
701+ snapshot, err := readSessionSnapshot(ctx, node.Summary.Path)
702+ if err != nil {
703+ return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
704+ }
705+ if snapshot.Meta.ID != node.Meta.ID {
706+ return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
707+ }
708+ node.Meta = snapshot.Meta
709+ node.Summary = SessionSummary{
710+ Path: node.Summary.Path,
711+ Started: snapshot.Meta.Started,
712+ Model: snapshot.Meta.Model,
713+ Provider: snapshot.Meta.Provider,
714+ MessageCount: len(snapshot.Messages),
715+ FirstUserText: firstUserTextFromMessages(snapshot.Messages),
716+ Title: snapshot.Meta.Title,
717+ }
718+ if len(snapshot.UsageCheckpoints) > 0 {
719+ node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
720+ }
721+ for _, child := range node.Children {
722+ if err := validate(child); err != nil {
723+ return err
724+ }
725+ }
726+ return nil
727+ }
728+ if err := validate(roots[0]); err != nil {
729+ return nil, err
730+ }
731+ return roots, nil
732+}
733+
734+// BuildSessionTreeFamilyContext discovers the current family from metadata
735+// headers. Consumers that read every member themselves can start that work in
736+// their preferred order without a redundant whole-family snapshot pass.
737+func BuildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
738+ return buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
739+}
740+
741+func buildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
742+ if err := contextErr(ctx); err != nil {
743+ return nil, err
744+ }
685745 if currentPath == "" {
686746 return nil, errors.New("build session family: current path is empty")
687747 }
@@ -697,6 +757,9 @@ func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, e
697757 nodes := make(map[string]*TreeNode)
698758 order := make([]string, 0, len(entries))
699759 for _, entry := range entries {
760+ if err := contextErr(ctx); err != nil {
761+ return nil, err
762+ }
700763 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
701764 continue
702765 }
@@ -731,38 +794,6 @@ func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, e
731794 if familyRoot == nil {
732795 return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
733796 }
734- var validate func(*TreeNode) error
735- validate = func(node *TreeNode) error {
736- snapshot, err := ReadSessionSnapshot(node.Summary.Path)
737- if err != nil {
738- return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
739- }
740- if snapshot.Meta.ID != node.Meta.ID {
741- return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
742- }
743- node.Meta = snapshot.Meta
744- node.Summary = SessionSummary{
745- Path: node.Summary.Path,
746- Started: snapshot.Meta.Started,
747- Model: snapshot.Meta.Model,
748- Provider: snapshot.Meta.Provider,
749- MessageCount: len(snapshot.Messages),
750- FirstUserText: firstUserTextFromMessages(snapshot.Messages),
751- Title: snapshot.Meta.Title,
752- }
753- if len(snapshot.UsageCheckpoints) > 0 {
754- node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
755- }
756- for _, child := range node.Children {
757- if err := validate(child); err != nil {
758- return err
759- }
760- }
761- return nil
762- }
763- if err := validate(familyRoot); err != nil {
764- return nil, err
765- }
766797 return []*TreeNode{familyRoot}, nil
767798 }
768799
@@ -682,6 +682,66 @@ func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
682 // readable current family, while a corrupt member of the selected family still682 // readable current family, while a corrupt member of the selected family still
683 // fails the whole preflight.683 // fails the whole preflight.
684 func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {684 func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
685+ return BuildSessionTreeFamilyStrictContext(context.Background(), root, cwd, currentPath)
686+}
687+
688+// BuildSessionTreeFamilyStrictContext is the cancellation-aware counterpart
689+// to BuildSessionTreeFamilyStrict. It performs all filesystem reads on behalf
690+// of callers that must remain responsive while a family is being discovered.
691+func BuildSessionTreeFamilyStrictContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
692+ roots, err := buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
693+ if err != nil || len(roots) == 0 {
694+ return roots, err
695+ }
696+ var validate func(*TreeNode) error
697+ validate = func(node *TreeNode) error {
698+ if err := contextErr(ctx); err != nil {
699+ return err
700+ }
701+ snapshot, err := readSessionSnapshot(ctx, node.Summary.Path)
702+ if err != nil {
703+ return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
704+ }
705+ if snapshot.Meta.ID != node.Meta.ID {
706+ return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
707+ }
708+ node.Meta = snapshot.Meta
709+ node.Summary = SessionSummary{
710+ Path: node.Summary.Path,
711+ Started: snapshot.Meta.Started,
712+ Model: snapshot.Meta.Model,
713+ Provider: snapshot.Meta.Provider,
714+ MessageCount: len(snapshot.Messages),
715+ FirstUserText: firstUserTextFromMessages(snapshot.Messages),
716+ Title: snapshot.Meta.Title,
717+ }
718+ if len(snapshot.UsageCheckpoints) > 0 {
719+ node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
720+ }
721+ for _, child := range node.Children {
722+ if err := validate(child); err != nil {
723+ return err
724+ }
725+ }
726+ return nil
727+ }
728+ if err := validate(roots[0]); err != nil {
729+ return nil, err
730+ }
731+ return roots, nil
732+}
733+
734+// BuildSessionTreeFamilyContext discovers the current family from metadata
735+// headers. Consumers that read every member themselves can start that work in
736+// their preferred order without a redundant whole-family snapshot pass.
737+func BuildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
738+ return buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
739+}
740+
741+func buildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
742+ if err := contextErr(ctx); err != nil {
743+ return nil, err
744+ }
685 if currentPath == "" {745 if currentPath == "" {
686 return nil, errors.New("build session family: current path is empty")746 return nil, errors.New("build session family: current path is empty")
687 }747 }
@@ -697,6 +757,9 @@ func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, e
697 nodes := make(map[string]*TreeNode)757 nodes := make(map[string]*TreeNode)
698 order := make([]string, 0, len(entries))758 order := make([]string, 0, len(entries))
699 for _, entry := range entries {759 for _, entry := range entries {
760+ if err := contextErr(ctx); err != nil {
761+ return nil, err
762+ }
700 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {763 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
701 continue764 continue
702 }765 }
@@ -731,38 +794,6 @@ func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, e
731 if familyRoot == nil {794 if familyRoot == nil {
732 return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)795 return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
733 }796 }
734- var validate func(*TreeNode) error
735- validate = func(node *TreeNode) error {
736- snapshot, err := ReadSessionSnapshot(node.Summary.Path)
737- if err != nil {
738- return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
739- }
740- if snapshot.Meta.ID != node.Meta.ID {
741- return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
742- }
743- node.Meta = snapshot.Meta
744- node.Summary = SessionSummary{
745- Path: node.Summary.Path,
746- Started: snapshot.Meta.Started,
747- Model: snapshot.Meta.Model,
748- Provider: snapshot.Meta.Provider,
749- MessageCount: len(snapshot.Messages),
750- FirstUserText: firstUserTextFromMessages(snapshot.Messages),
751- Title: snapshot.Meta.Title,
752- }
753- if len(snapshot.UsageCheckpoints) > 0 {
754- node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
755- }
756- for _, child := range node.Children {
757- if err := validate(child); err != nil {
758- return err
759- }
760- }
761- return nil
762- }
763- if err := validate(familyRoot); err != nil {
764- return nil, err
765- }
766 return []*TreeNode{familyRoot}, nil797 return []*TreeNode{familyRoot}, nil
767 }798 }
768 799

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