Skip to content

Commit 5c2c69a

Browse files
jsell-rhclaudemergify[bot]
authored
fix(cli): respect project scope on TUI session refresh (#1587)
## Summary - Fix session list reverting to "all sessions" on every poll tick when a project is selected via number keys ## Problem When a user presses a number key (1-9) to switch projects in the TUI, `currentAgentID` is cleared (correct — you're not drilling into a specific agent). The initial fetch correctly calls `FetchSessions(projectName)`. But on the next 5-second poll tick, `fetchActiveView()` checked `currentAgentID != "" && currentProject != ""` — since `currentAgentID` is empty, it fell through to `FetchAllSessions()`, replacing the project-filtered view with all sessions across all projects. The same issue existed in the `:sessions` command handler (`CmdSessions`). ## Fix - `fetchActiveView()`: check `currentProject` alone — if set, fetch project-scoped sessions regardless of agent context - `CmdSessions`: add a `currentProject`-only branch between the agent-scoped and global paths The TUI spec (`docs/internal/design/tui.spec.md`) correctly describes this behavior: "`:sessions` is also accessible globally (all sessions across all projects) or scoped when drilled in." The spec was right, the implementation didn't match. ## Test plan - [x] `go build ./...` passes - [x] `go vet ./...` passes - [x] Manual: press number key to switch project → sessions stay filtered after multiple poll cycles 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Sessions view now prioritizes project-scoped session fetching when viewing sessions within a project context * Improved navigation and polling behavior for sessions management aligned with project context <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent bcb3623 commit 5c2c69a

2 files changed

Lines changed: 209 additions & 12 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
tea "github.com/charmbracelet/bubbletea"
7+
)
8+
9+
// TestFetchActiveView_SessionScopingByProject verifies that the sessions view
10+
// respects project scope on poll refresh, not just on initial navigation.
11+
// Regression test for: project number-key switch correctly fetched project-scoped
12+
// sessions initially, but fetchActiveView() fell through to FetchAllSessions()
13+
// on every subsequent tick because it required currentAgentID to be set.
14+
func TestFetchActiveView_SessionScopingByProject(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
currentProject string
18+
currentAgentID string
19+
wantScoped bool
20+
}{
21+
{
22+
name: "project set via number key (no agent)",
23+
currentProject: "hyperloop",
24+
currentAgentID: "",
25+
wantScoped: true,
26+
},
27+
{
28+
name: "project and agent set (drill-down)",
29+
currentProject: "hyperloop",
30+
currentAgentID: "agent-123",
31+
wantScoped: true,
32+
},
33+
{
34+
name: "no project (global view)",
35+
currentProject: "",
36+
currentAgentID: "",
37+
wantScoped: false,
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
fake := &scopeTrackingClient{}
44+
m := &AppModel{
45+
activeView: "sessions",
46+
currentProject: tt.currentProject,
47+
currentAgentID: tt.currentAgentID,
48+
fetcher: fake,
49+
}
50+
51+
cmd := m.fetchActiveView()
52+
if cmd == nil {
53+
t.Fatal("fetchActiveView() returned nil")
54+
}
55+
cmd()
56+
57+
if tt.wantScoped {
58+
if fake.lastFetchAll {
59+
t.Errorf("expected project-scoped fetch for %q, got FetchAllSessions", tt.currentProject)
60+
}
61+
if fake.lastFetchProject != tt.currentProject {
62+
t.Errorf("expected fetch for project %q, got %q", tt.currentProject, fake.lastFetchProject)
63+
}
64+
} else {
65+
if !fake.lastFetchAll {
66+
t.Errorf("expected FetchAllSessions, got project-scoped fetch for %q", fake.lastFetchProject)
67+
}
68+
}
69+
})
70+
}
71+
}
72+
73+
// TestFetchActiveView_ScheduledSessionScopingByProject verifies scheduled
74+
// sessions respect project scope on refresh.
75+
func TestFetchActiveView_ScheduledSessionScopingByProject(t *testing.T) {
76+
t.Run("with project", func(t *testing.T) {
77+
fake := &scopeTrackingClient{}
78+
m := &AppModel{
79+
activeView: "scheduledsessions",
80+
currentProject: "hyperloop",
81+
fetcher: fake,
82+
}
83+
cmd := m.fetchActiveView()
84+
if cmd == nil {
85+
t.Fatal("fetchActiveView() returned nil")
86+
}
87+
cmd()
88+
if fake.lastFetchProject != "hyperloop" {
89+
t.Errorf("expected fetch for project %q, got %q", "hyperloop", fake.lastFetchProject)
90+
}
91+
})
92+
93+
t.Run("no project returns nil", func(t *testing.T) {
94+
fake := &scopeTrackingClient{}
95+
m := &AppModel{
96+
activeView: "scheduledsessions",
97+
currentProject: "",
98+
fetcher: fake,
99+
}
100+
cmd := m.fetchActiveView()
101+
if cmd != nil {
102+
t.Error("expected nil command for scheduledsessions with no project")
103+
}
104+
})
105+
}
106+
107+
// TestFetchActiveView_AgentsScopingByProject verifies agents view respects
108+
// project scope on refresh.
109+
func TestFetchActiveView_AgentsScopingByProject(t *testing.T) {
110+
fake := &scopeTrackingClient{}
111+
m := &AppModel{
112+
activeView: "agents",
113+
currentProject: "hyperloop",
114+
fetcher: fake,
115+
}
116+
cmd := m.fetchActiveView()
117+
if cmd == nil {
118+
t.Fatal("fetchActiveView() returned nil")
119+
}
120+
cmd()
121+
if fake.lastFetchProject != "hyperloop" {
122+
t.Errorf("expected agents fetch for project %q, got %q", "hyperloop", fake.lastFetchProject)
123+
}
124+
}
125+
126+
// scopeTrackingClient records which fetch method was called and with what scope.
127+
type scopeTrackingClient struct {
128+
lastFetchProject string
129+
lastFetchAll bool
130+
}
131+
132+
var _ dataFetcher = (*scopeTrackingClient)(nil)
133+
134+
func (c *scopeTrackingClient) FetchProjects() tea.Cmd {
135+
return func() tea.Msg { return ProjectsMsg{} }
136+
}
137+
138+
func (c *scopeTrackingClient) FetchAgents(projectID string) tea.Cmd {
139+
c.lastFetchProject = projectID
140+
return func() tea.Msg { return AgentsMsg{} }
141+
}
142+
143+
func (c *scopeTrackingClient) FetchSessions(projectID string) tea.Cmd {
144+
c.lastFetchProject = projectID
145+
c.lastFetchAll = false
146+
return func() tea.Msg { return SessionsMsg{} }
147+
}
148+
149+
func (c *scopeTrackingClient) FetchAllSessions() tea.Cmd {
150+
c.lastFetchAll = true
151+
c.lastFetchProject = ""
152+
return func() tea.Msg { return SessionsMsg{} }
153+
}
154+
155+
func (c *scopeTrackingClient) FetchScheduledSessions(projectID string) tea.Cmd {
156+
c.lastFetchProject = projectID
157+
return func() tea.Msg { return ScheduledSessionsMsg{} }
158+
}
159+
160+
func (c *scopeTrackingClient) FetchInbox(projectID, agentID string) tea.Cmd {
161+
c.lastFetchProject = projectID
162+
return func() tea.Msg { return InboxMsg{} }
163+
}

components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ func getEditor() string {
109109
// AppModel — the TUI model with full navigation hierarchy
110110
// ---------------------------------------------------------------------------
111111

112+
// dataFetcher is the subset of TUIClient used by fetchActiveView for polling.
113+
// Extracted as an interface to enable unit testing of view-scoping logic.
114+
type dataFetcher interface {
115+
FetchProjects() tea.Cmd
116+
FetchAgents(projectID string) tea.Cmd
117+
FetchSessions(projectID string) tea.Cmd
118+
FetchAllSessions() tea.Cmd
119+
FetchScheduledSessions(projectID string) tea.Cmd
120+
FetchInbox(projectID, agentID string) tea.Cmd
121+
}
122+
112123
// AppModel is the top-level Bubbletea model for the rewritten TUI.
113124
// It coexists with the legacy Model type in model.go until migration is
114125
// complete.
@@ -188,6 +199,10 @@ type AppModel struct {
188199
// Rate-limit backoff: skip the next poll cycle when a 429 is received.
189200
skipNextPoll bool
190201

202+
// fetcher overrides client for fetchActiveView. Tests set this to a fake;
203+
// production code leaves it nil (fetchActiveView uses m.client).
204+
fetcher dataFetcher
205+
191206
// Project shortcuts for number-key switching (like k9s namespace shortcuts).
192207
// Holds project names in alphabetical order, refreshed on ProjectsMsg.
193208
projectShortcuts []string
@@ -440,36 +455,40 @@ func (m *AppModel) popView() tea.Cmd {
440455
return m.fetchActiveView()
441456
}
442457

458+
func (m *AppModel) dataFetcher() dataFetcher {
459+
if m.fetcher != nil {
460+
return m.fetcher
461+
}
462+
return m.client
463+
}
464+
443465
// fetchActiveView returns a tea.Cmd to fetch data for the currently active view.
444466
func (m *AppModel) fetchActiveView() tea.Cmd {
467+
f := m.dataFetcher()
445468
switch m.activeView {
446469
case "projects":
447-
return m.client.FetchProjects()
470+
return f.FetchProjects()
448471
case "agents":
449472
if m.currentProject != "" {
450-
return m.client.FetchAgents(m.currentProject)
473+
return f.FetchAgents(m.currentProject)
451474
}
452-
// Fall back to config project if no drill-down context.
453475
if ctx := m.config.Current(); ctx != nil && ctx.Project != "" {
454-
return m.client.FetchAgents(ctx.Project)
476+
return f.FetchAgents(ctx.Project)
455477
}
456478
return nil
457479
case "sessions":
458-
if m.currentAgentID != "" && m.currentProject != "" {
459-
// Agent-scoped sessions — fetch project sessions and filter client-side
460-
// in the handler.
461-
return m.client.FetchSessions(m.currentProject)
480+
if m.currentProject != "" {
481+
return f.FetchSessions(m.currentProject)
462482
}
463-
// Global sessions view.
464-
return m.client.FetchAllSessions()
483+
return f.FetchAllSessions()
465484
case "inbox":
466485
if m.currentAgentID != "" && m.currentProject != "" {
467-
return m.client.FetchInbox(m.currentProject, m.currentAgentID)
486+
return f.FetchInbox(m.currentProject, m.currentAgentID)
468487
}
469488
return nil
470489
case "scheduledsessions":
471490
if m.currentProject != "" {
472-
return m.client.FetchScheduledSessions(m.currentProject)
491+
return f.FetchScheduledSessions(m.currentProject)
473492
}
474493
return nil
475494
case "messages":
@@ -2545,6 +2564,21 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) {
25452564
)
25462565
}
25472566

2567+
if m.currentProject != "" {
2568+
// Project-scoped sessions (no specific agent).
2569+
m.sessionTable.SetScope(m.currentProject)
2570+
m.navStack = append(m.navStack[:0],
2571+
NavEntry{Kind: "projects", Scope: "all"},
2572+
NavEntry{Kind: "sessions", Scope: m.currentProject},
2573+
)
2574+
m.activeView = "sessions"
2575+
m.pollInFlight = true
2576+
return m, tea.Batch(
2577+
m.client.FetchSessions(m.currentProject),
2578+
m.setInfo("Viewing sessions in project "+m.currentProject),
2579+
)
2580+
}
2581+
25482582
// Global sessions view.
25492583
m.sessionTable.SetScope("all")
25502584
m.navStack = []NavEntry{

0 commit comments

Comments
 (0)