Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 68 additions & 7 deletions internal/tui/btw.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
parent.flushQueue = nil

side := parent
// The side surface must own its file-view lifecycle. Because `side` is a
// value copy of `parent`, its liveSeq pointer would otherwise alias the
// hidden parent's: closing or switching the view in BTW would revoke the
// parent's in-flight load. Detach it and give the side a fresh lifetime so
// inherited markers cannot masquerade as the parent's snapshot.
side.fileView.liveSeq = nil
if side.fileView.active {
side.fileView.lifetimeToken = nextFileViewLifetimeToken()
side.fileView.loadedToken = [16]byte{}
side.fileView.loadedSeq = 0
side.fileView.loadedRev = 0
side.fileView.snapshotReady = false
side.fileView.renderedContent = ""
}
side.activeSession = fork
side.sessionEvents = events
side.transcript = initialTranscript()
Expand Down Expand Up @@ -156,10 +170,16 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
sideRunIDBase: side.runID,
}

var btwFileViewCmd tea.Cmd
if side.fileView.active && side.fileView.mode == fileViewFull {
side, btwFileViewCmd = side.startFileViewLoadCmd(side.chatColumnWidth())
}

if question == "" {
return side, nil
return side, btwFileViewCmd
}
return side.launchPrompt(question)
next, launchCmd := side.launchPrompt(question)
return next, batchCommands(btwFileViewCmd, launchCmd)
}

func (m model) leaveBTW() (model, tea.Cmd) {
Expand All @@ -175,6 +195,10 @@ func (m model) leaveBTW() (model, tea.Cmd) {
if m.compactInFlight {
return m.appendSystemNotice("BTW compaction is still running. Wait for it to finish before returning."), nil
}
// The side surface is being discarded. Revoke its file-view lifetime token so
// a load still queued behind BTW cannot run or land after the view closes.
// The restored parent keeps its own request and snapshot untouched.
m.revokeFileViewRequest()
m, _ = m.clearLoopsForSessionSwitch()
parent := *m.btw.parent
parent.goalContinuationsSuspended = false
Expand All @@ -201,7 +225,9 @@ func (m model) leaveBTW() (model, tea.Cmd) {
parent.resetFlushFrontier("· returned from btw ·")
var goalCmd tea.Cmd
parent, goalCmd = parent.launchGoalContinuationIfReady()
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd)
var fileCmd tea.Cmd
parent, fileCmd = parent.recoverInvalidatedFileView()
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd, fileCmd)
}

func btwCommandUnavailable(command parsedCommand) bool {
Expand Down Expand Up @@ -236,9 +262,9 @@ func btwCommandUnavailable(command parsedCommand) bool {
// messages have no run ID, so normal BTW routing intentionally leaves them on
// the visible side surface; copying the layout fields prevents stale wrapping
// after the parent is restored.
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) (model, tea.Cmd) {
if !m.btw.active || m.btw.parent == nil {
return m
return m, nil
}
parent := *m.btw.parent
parent.width = msg.Width
Expand All @@ -247,8 +273,15 @@ func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
parent.lineAges = nil
parent.lastStreamActivity = parent.now()
parent.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
// The parent's full-file view wrapped at the old width; reload it at the new
// one. Its completion has no run ID, so routeBTWParentMessage matches it by
// lifetime token and delivers it to the hidden parent.
var cmd tea.Cmd
if parent.fileView.active && parent.fileView.mode == fileViewFull {
parent, cmd = parent.startFileViewLoadCmd(parent.chatColumnWidth())
}
m.btw.parent = &parent
return m
return m, cmd
}

func btwTitle(parent string) string {
Expand All @@ -272,13 +305,37 @@ func (m model) routeBTWParentMessage(msg tea.Msg) (model, tea.Cmd, bool) {
}
return m.routeBTWMessageToParent(msg)
}
if loaded, ok := msg.(fileViewLoadedMsg); ok {
// File-view completions carry no run ID. Deliver the hidden parent's
// load (matched by lifetime token) so it does not stay stuck on
// "Loading…" while BTW is active; side-owned loads fall through.
if m.btw.parent.fileView.active && m.btw.parent.fileView.lifetimeToken == loaded.lifetimeToken {
return m.routeBTWMessageToParent(msg)
}
return m, nil, false
}
runID, ok := btwMessageRunID(msg)
if !ok || runID <= 0 || runID >= m.btw.sideRunIDBase {
return m, nil, false
}
return m.routeBTWMessageToParent(msg)
}

// refreshHiddenParentFileView reloads the hidden parent's full-file view when a
// side-surface mutation advanced the shared path revision. The load completion
// carries the parent's lifetime token, so routeBTWParentMessage delivers it
// back to the hidden model without waiting for a git sweep or for /btw return.
func (m model) refreshHiddenParentFileView() (model, tea.Cmd) {
if !m.btw.active || m.btw.parent == nil {
return m, nil
}
parent := *m.btw.parent
var cmd tea.Cmd
parent, cmd = parent.recoverInvalidatedFileView()
m.btw.parent = &parent
return m, cmd
}

func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
parentNext, cmd := m.btw.parent.updateModel(msg)
parent, ok := parentNext.(model)
Expand All @@ -296,7 +353,9 @@ func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
case agentResponseMsg:
m.btw.parentNeedsInput = parent.pendingPermission != nil || parent.pendingAskUser != nil
}
return m, cmd, true
var fileCmd tea.Cmd
m, fileCmd = m.recoverInvalidatedFileView()
return m, batchCommands(cmd, fileCmd), true
}

func btwMessageRunID(msg tea.Msg) (int, bool) {
Expand Down Expand Up @@ -327,6 +386,8 @@ func btwMessageRunID(msg tea.Msg) (int, bool) {
return typed.runID, true
case specialistProgressMsg:
return typed.runID, true
case unknownScopeMutationMsg:
return typed.runID, true
case swarmSessionsMsg:
return typed.runID, true
case permissionRequestMsg:
Expand Down
67 changes: 67 additions & 0 deletions internal/tui/btw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -535,3 +536,69 @@ func TestBTWCtrlCDuringRunDoesNotClearDraft(t *testing.T) {
t.Fatalf("missing in-flight return guidance: %#v", got.transcript)
}
}

// TestBTWLeaveRevokesSideFileLoad verifies that leaving BTW revokes the side
// surface's file-view lifetime so a still-queued side load cannot run or land
// after the view closes, without cancelling the restored parent's own load.
func TestBTWLeaveRevokesSideFileLoad(t *testing.T) {
resetFileViewCacheForTest()
m := newBTWTestModel(t)
dir := t.TempDir()
m.cwd = dir
name := "side_view.go"
if err := os.WriteFile(filepath.Join(dir, name), []byte("package side\n"), 0o644); err != nil {
t.Fatal(err)
}
// Open a full view so the side surface inherits an active file view.
m, parentLoadCmd := m.openFileView(name)
if parentLoadCmd == nil {
t.Fatal("expected a pending parent load command")
}
parentLiveSeq := m.fileView.liveSeq
if parentLiveSeq == nil {
t.Fatal("parent load token missing")
}
parentSeq := parentLiveSeq.Load()

// Enter BTW: the side surface detaches and schedules its own load.
side, sideLoadCmd := m.handleBTWCommand("")
if !side.btw.active || side.btw.parent == nil {
t.Fatal("expected an active BTW conversation")
}
if sideLoadCmd == nil {
t.Fatal("side surface should schedule its own file load")
}
sideLiveSeq := side.fileView.liveSeq
if sideLiveSeq == nil || sideLiveSeq == parentLiveSeq {
t.Fatal("side load must own a detached lifetime token")
}
sideSeq := sideLiveSeq.Load()

// Return while the side load is still queued.
parent, _ := side.leaveBTW()
if sideLiveSeq.Load() == sideSeq {
t.Fatal("leaveBTW must revoke the side file-view request")
}
if parentLiveSeq.Load() != parentSeq || parentLiveSeq == sideLiveSeq {
t.Fatal("leaveBTW must not revoke the restored parent's request")
}

// The held side command must now be a superseded no-op.
held := sideLoadCmd()
loaded, ok := held.(fileViewLoadedMsg)
if !ok {
t.Fatalf("held side command produced %T, want fileViewLoadedMsg", held)
}
if !errors.Is(loaded.err, errFileViewSuperseded) {
t.Fatalf("queued side load must be superseded after leaving BTW, got %v", loaded.err)
}

// The parent's own load still completes and settles its snapshot.
parent = deliverCommandMessages(t, parent, parentLoadCmd)
if parent.fileView.loading {
t.Fatal("parent load must remain valid after the side revocation")
}
if !strings.Contains(plainRender(t, parent.renderFileViewFull(80)), "package side") {
t.Fatal("restored parent must keep its snapshot")
}
}
9 changes: 9 additions & 0 deletions internal/tui/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ func (c *staticRenderCache) stats() renderCacheStats {
return c.statsData
}

func fileViewCacheStatsForTest() fileViewCacheStats {
return defaultFileViewCache.stats()
}

func resetFileViewCacheForTest() {
defaultFileViewCache.clear()
defaultFileViewCache.resetStats()
}

func renderSelectableList(options selectableListOptions) string {
if len(options.Items) == 0 {
return ""
Expand Down
Loading
Loading