diff --git a/internal/tui/plan.go b/internal/tui/plan.go index fc0416d..458965d 100644 --- a/internal/tui/plan.go +++ b/internal/tui/plan.go @@ -206,6 +206,11 @@ func (m *Model) planStripLabel() string { // syncPlanPanelMsg refreshes the tab's empty/unavailable copy from state. func (m *Model) syncPlanPanelMsg() { switch { + case m.cl == nil || m.sessionID == "": + // No live session: fetchPlan is a silent no-op, so "loading plan…" + // would never resolve. Say why instead (attach completes → reset + + // refetch at the tail resolves this to a real snapshot). + m.panelMsg = "no active session yet — the plan loads when a run starts" case m.planAvail == planUnavailable: m.panelMsg = "plan unavailable on this engine · r retries" case m.planAvail == planUnknown && !m.planInit: diff --git a/internal/tui/plan_tab_test.go b/internal/tui/plan_tab_test.go index ea96127..df49da1 100644 --- a/internal/tui/plan_tab_test.go +++ b/internal/tui/plan_tab_test.go @@ -74,6 +74,25 @@ func TestPlanTab_OpenerAndRenderedRows(t *testing.T) { } } +func TestPlanTab_UnattachedOpenerNeverShowsEternalLoading(t *testing.T) { + // Regression: opening the tab with no live session (no client / no + // session id) used to render "loading plan…" forever — fetchPlan was + // a silent no-op, so no reply ever resolved the placeholder. + m := newTestModel() + if c := m.openPlan(); c != nil { + t.Fatal("unattached opener must not issue a fetch") + } + if m.panel != panelPlan { + t.Fatalf("opener panel = %d", m.panel) + } + if strings.Contains(m.panelMsg, "loading") { + t.Fatalf("unattached tab shows eternal loading copy %q", m.panelMsg) + } + if got := plain(m.View()); !strings.Contains(got, "no active session") { + t.Errorf("unattached copy missing from view:\n%s", got) + } +} + func TestPlanTab_DetailFoldHouseGrammar(t *testing.T) { m := newTestModel() m.cl = &client.Client{}