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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 340 | 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 | 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 | 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 | 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 | ||
| @@ -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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | - | "github.com/bnema/zut/packages/core" | |
| 9 | 9 | "github.com/bnema/zut/packages/provider" | |
| 10 | 10 | "github.com/bnema/zut/packages/tui" | |
| 11 | 11 | ) | |
| @@ -129,9 +129,9 @@ func (i *Interactive) canArmSessionTreeEscape() bool { | |||
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | 131 | // 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. | |
| 135 | 135 | func (i *Interactive) canOpenSessionTree() bool { | |
| 136 | 136 | if i == nil || i.ed == nil || i.cfg.CWD == "" || i.sessionsRoot() == "" || | |
| 137 | 137 | i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil { | |
| @@ -155,12 +155,7 @@ func (i *Interactive) canOpenSessionTree() bool { | |||
| 155 | 155 | return false | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 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() != "" | |
| 164 | 159 | } | |
| 165 | 160 | ||
| 166 | 161 | // 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 | 192 | // 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. | |
| 200 | 195 | func (i *Interactive) openSessionTree() bool { | |
| 201 | 196 | if !i.canOpenSessionTree() { | |
| 202 | 197 | i.setSessionTreeError("tree: session tree is unavailable") | |
| @@ -207,14 +202,18 @@ func (i *Interactive) openSessionTree() bool { | |||
| 207 | 202 | i.setSessionTreeError("tree: no session is active") | |
| 208 | 203 | return false | |
| 209 | 204 | } | |
| 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") | |
| 215 | 207 | return false | |
| 216 | 208 | } | |
| 217 | 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 | 217 | i.statusErr = "" | |
| 219 | 218 | i.statusOK = "" | |
| 220 | 219 | i.mu.Unlock() | |
| @@ -1,11 +1,11 @@ | |||||
| 1 | package modes | 1 | 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 the | 131 | // canOpenSessionTree is the shared, fail-closed precondition for both the | ||
| 132 | - | // /session tree command and the bare-Escape gesture. It does not flush or | 132 | + | // /session tree command and the bare-Escape gesture. It intentionally avoids |
| 133 | - | // mutate session state. In particular, callers must not flush until this | 133 | + | // 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 false | 155 | 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 tree | 161 | // 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 the | 193 | + | // 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 false | 203 | 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 false | 207 | 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 ( | |||
| 10 | 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 | 24 | func TestInteractiveCtrlCExitMarker(t *testing.T) { | |
| 14 | 25 | i := NewInteractive(InteractiveConfig{}) | |
| 15 | 26 | key := tui.Key{Kind: tui.KeyCtrlC} | |
| @@ -290,10 +301,13 @@ func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) { | |||
| 290 | 301 | t.Fatal("second Escape exited") | |
| 291 | 302 | } | |
| 292 | 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 | 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 | 312 | if got := len(ag.Messages()); got != 1 { | |
| 299 | 313 | t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got) | |
| @@ -381,8 +395,8 @@ func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) { | |||
| 381 | 395 | if pathCalls == 1 { | |
| 382 | 396 | return path | |
| 383 | 397 | } | |
| 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. | |
| 386 | 400 | return path + ".missing" | |
| 387 | 401 | }, | |
| 388 | 402 | LoadSession: func(string) error { return nil }, | |
| @@ -399,14 +413,17 @@ func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) { | |||
| 399 | 413 | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done { | |
| 400 | 414 | t.Fatal("second Escape exited") | |
| 401 | 415 | } | |
| 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") | |
| 404 | 418 | } | |
| 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") | |
| 407 | 421 | } | |
| 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) | |
| 410 | 427 | } | |
| 411 | 428 | } | |
| 412 | 429 | ||
| @@ -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 path | 396 | return path | ||
| 383 | } | 397 | } | ||
| 384 | - | // Keep the cheap gesture eligibility true while making the full | 398 | + | // 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 { | |||
| 882 | 882 | // rendering never races with background file reads. | |
| 883 | 883 | 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 | 889 | // pendingRescuePrompt / pendingRescueImages stash the prompt and | |
| 886 | 890 | // images that should be re-run after the user picks a model in | |
| 887 | 891 | // the rescue dialog. Cleared once applyRescueSelection consumes | |
| @@ -1309,6 +1313,22 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1309 | 1313 | i.sessionDialog.ApplyLoad(event) | |
| 1310 | 1314 | i.mu.Unlock() | |
| 1311 | 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 | 1332 | case <-i.dirty: | |
| 1313 | 1333 | requestRedraw() | |
| 1314 | 1334 | 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 sessionLoadEvent | 883 | 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 and | 889 | // pendingRescuePrompt / pendingRescueImages stash the prompt and | ||
| 886 | // images that should be re-run after the user picks a model in | 890 | // images that should be re-run after the user picks a model in | ||
| 887 | // the rescue dialog. Cleared once applyRescueSelection consumes | 891 | // 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "reflect" | |
| 6 | 7 | "strings" | |
| @@ -73,6 +74,13 @@ type sessionTreeDialog struct { | |||
| 73 | 74 | items []sessionTreeItem | |
| 74 | 75 | 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 | 84 | // MaxRows is the maximum number of transcript/boundary rows rendered in | |
| 77 | 85 | // one viewport. The interactive host may set it from terminal height. A | |
| 78 | 86 | // bounded default is deliberately used when it is left at zero so a large | |
| @@ -117,6 +125,22 @@ type sessionTreeSnapshot struct { | |||
| 117 | 125 | 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 | 144 | const defaultSessionTreeRows = 12 | |
| 121 | 145 | ||
| 122 | 146 | func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} } | |
| @@ -137,22 +161,226 @@ func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool { | |||
| 137 | 161 | // recoverable by choosing an arbitrary forest root. Failed reads leave the | |
| 138 | 162 | // prior dialog state untouched. | |
| 139 | 163 | 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 { | |
| 142 | 166 | 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 | 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 | 346 | if err != nil { | |
| 148 | - | return false | |
| 347 | + | return err | |
| 149 | 348 | } | |
| 150 | 349 | items := flattenSessionFamilySnapshot(familyRoot, currentPath, snapshots) | |
| 151 | 350 | if len(items) == 0 || !sessionTreeItemsMeaningful(items, familyRoot.Summary.Path) { | |
| 152 | - | return false | |
| 351 | + | return fmt.Errorf("session tree: no messages in this family") | |
| 153 | 352 | } | |
| 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 | |
| 156 | 384 | } | |
| 157 | 385 | ||
| 158 | 386 | func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool { | |
| @@ -165,6 +393,11 @@ func sessionTreeItemsMeaningful(items []sessionTreeItem, rootPath string) bool { | |||
| 165 | 393 | } | |
| 166 | 394 | ||
| 167 | 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 | 401 | if cursor < 0 { | |
| 169 | 402 | cursor = 0 | |
| 170 | 403 | } | |
| @@ -177,8 +410,15 @@ func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) { | |||
| 177 | 410 | d.active = true | |
| 178 | 411 | } | |
| 179 | 412 | ||
| 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 | + | } | |
| 182 | 422 | ||
| 183 | 423 | // CursorPos returns -1 because this dialog has no inline editor. | |
| 184 | 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 | 530 | line := func(s string) string { return truncateSessionTreeANSI(s, renderWidth) } | |
| 291 | 531 | ||
| 292 | 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 | 544 | if len(d.items) == 0 { | |
| 294 | 545 | lines = append(lines, | |
| 295 | 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 | 550 | return lines | |
| 300 | 551 | } | |
| 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))) | |
| 303 | 557 | ||
| 304 | 558 | d.followCursor() | |
| 305 | 559 | start := d.viewTop | |
| @@ -368,10 +622,13 @@ func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string { | |||
| 368 | 622 | return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix | |
| 369 | 623 | } | |
| 370 | 624 | ||
| 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 | + | } | |
| 375 | 632 | if root == nil { | |
| 376 | 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 | 636 | visiting := make(map[string]bool) | |
| 380 | 637 | var walk func(*core.TreeNode) error | |
| 381 | 638 | walk = func(node *core.TreeNode) error { | |
| 639 | + | if err := ctx.Err(); err != nil { | |
| 640 | + | return err | |
| 641 | + | } | |
| 382 | 642 | if node == nil { | |
| 383 | 643 | return nil | |
| 384 | 644 | } | |
| @@ -390,11 +650,15 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap | |||
| 390 | 650 | return nil | |
| 391 | 651 | } | |
| 392 | 652 | visiting[path] = true | |
| 393 | - | history, err := core.ReadSessionHistory(path) | |
| 653 | + | history, err := core.ReadSessionHistoryContext(ctx, path) | |
| 394 | 654 | if err != nil { | |
| 395 | 655 | delete(visiting, path) | |
| 396 | 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 | 662 | var messages []provider.Message | |
| 399 | 663 | if len(history.Segments) > 0 { | |
| 400 | 664 | messages = history.Segments[len(history.Segments)-1].Messages | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package modes | 1 | 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 []sessionTreeItem | 74 | items []sessionTreeItem | ||
| 74 | cursor int | 75 | 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 in | 84 | // MaxRows is the maximum number of transcript/boundary rows rendered in | ||
| 77 | // one viewport. The interactive host may set it from terminal height. A | 85 | // 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 large | 86 | // bounded default is deliberately used when it is left at zero so a large | ||
| @@ -117,6 +125,22 @@ type sessionTreeSnapshot struct { | |||||
| 117 | history []core.SessionHistorySegment | 125 | 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 = 12 | 144 | 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 the | 161 | // 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 false | 166 | 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 false | 347 | + | 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 false | 351 | + | 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 true | 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 | |||
| 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 = 0 | 402 | cursor = 0 | ||
| 170 | } | 403 | } | ||
| @@ -177,8 +410,15 @@ func (d *sessionTreeDialog) activate(items []sessionTreeItem, cursor int) { | |||||
| 177 | d.active = true | 410 | 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 lines | 550 | 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.viewTop | 559 | start := d.viewTop | ||
| @@ -368,10 +622,13 @@ func fitSessionTreeItemPlain(it sessionTreeItem, maxWidth int) string { | |||||
| 368 | return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix | 622 | return prefix + fitSessionTreeLabel(it.label, labelWidth) + suffix | ||
| 369 | } | 623 | } | ||
| 370 | 624 | ||||
| 371 | - | // preflightSessionTreeFamily reads every node before any dialog state is | 625 | + | // preflightSessionTreeFamilyContext reads every node and returns the shared |
| 372 | - | // changed. The returned messages are the shared effective snapshots used by | 626 | + | // 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) error | 637 | 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 nil | 643 | return nil | ||
| 384 | } | 644 | } | ||
| @@ -390,11 +650,15 @@ func preflightSessionTreeFamily(root *core.TreeNode) (map[string]sessionTreeSnap | |||||
| 390 | return nil | 650 | return nil | ||
| 391 | } | 651 | } | ||
| 392 | visiting[path] = true | 652 | 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.Message | 662 | var messages []provider.Message | ||
| 399 | if len(history.Segments) > 0 { | 663 | if len(history.Segments) > 0 { | ||
| 400 | messages = history.Segments[len(history.Segments)-1].Messages | 664 | messages = history.Segments[len(history.Segments)-1].Messages | ||
M packages/agent/modes/session_tree_dialog_test.go +77 -0
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "os" | |
| 7 | + | "reflect" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| @@ -12,6 +14,81 @@ import ( | |||
| 12 | 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 | 92 | func TestSessionTreeTargetCarriesEffectiveBoundaryAndDraft(t *testing.T) { | |
| 16 | 93 | msgs := []provider.Message{ | |
| 17 | 94 | treeTestMessage(provider.RoleUser, provider.TextBlock{Text: "first"}), | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package modes | 1 | 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 | |||
| 500 | 500 | // branching; this audit projection exists for session-tree navigation so a | |
| 501 | 501 | // compaction cannot make earlier fork points disappear. | |
| 502 | 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 | 517 | f, err := os.Open(path) | |
| 504 | 518 | if err != nil { | |
| 505 | 519 | return SessionHistory{}, fmt.Errorf("session history: open %q: %w", path, err) | |
| @@ -537,7 +551,7 @@ func ReadSessionHistory(path string) (SessionHistory, error) { | |||
| 537 | 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 | 555 | var head sessionLineHead | |
| 542 | 556 | if err := json.Unmarshal(line, &head); err != nil { | |
| 543 | 557 | 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 a | 500 | // 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 sessionLineHead | 555 | 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) { | |||
| 682 | 682 | // readable current family, while a corrupt member of the selected family still | |
| 683 | 683 | // fails the whole preflight. | |
| 684 | 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 | 745 | if currentPath == "" { | |
| 686 | 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 | 757 | nodes := make(map[string]*TreeNode) | |
| 698 | 758 | order := make([]string, 0, len(entries)) | |
| 699 | 759 | for _, entry := range entries { | |
| 760 | + | if err := contextErr(ctx); err != nil { | |
| 761 | + | return nil, err | |
| 762 | + | } | |
| 700 | 763 | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { | |
| 701 | 764 | continue | |
| 702 | 765 | } | |
| @@ -731,38 +794,6 @@ func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, e | |||
| 731 | 794 | if familyRoot == nil { | |
| 732 | 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 | 797 | return []*TreeNode{familyRoot}, nil | |
| 767 | 798 | } | |
| 768 | 799 | ||
| @@ -682,6 +682,66 @@ func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) { | |||||
| 682 | // readable current family, while a corrupt member of the selected family still | 682 | // 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 | continue | 764 | 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}, nil | 797 | return []*TreeNode{familyRoot}, nil | ||
| 767 | } | 798 | } | ||
| 768 | 799 | ||||