Skip to content

Commit e19f53b

Browse files
committed
feat(tui): show the active delegation chain in the sidebar
Track an agentChain alongside the sidebar's sessionStack — pushed on StreamStarted, popped on StreamStopped, and reset on cancel and in ResetStreamTracking — maintaining the invariant len(agentChain) == len(sessionStack). When delegation depth exceeds 1, render a compact breadcrumb (e.g. "root ⏵ librarian") under the Agents title, each name in its accent color, eliding the middle to fit the width. buildAgentClickZones skips the single-line breadcrumb block so per-agent click rows stay aligned. The "+N background agents" count depends on the Phase 3 snapshot and is left as a TODO (Appendix C.2). Refs #3102
1 parent 8b85b46 commit e19f53b

2 files changed

Lines changed: 226 additions & 1 deletion

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package sidebar
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/charmbracelet/x/ansi"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/docker/docker-agent/pkg/runtime"
12+
"github.com/docker/docker-agent/pkg/session"
13+
"github.com/docker/docker-agent/pkg/tui/messages"
14+
"github.com/docker/docker-agent/pkg/tui/service"
15+
)
16+
17+
func newBreadcrumbSidebar(t *testing.T) *model {
18+
t.Helper()
19+
sess := session.New()
20+
sessionState := service.NewSessionState(sess)
21+
sessionState.SetCurrentAgentName("root")
22+
23+
m := New(sessionState).(*model)
24+
// Driving the real Update handlers starts the model's spinner, which registers
25+
// with the process-global animation coordinator. Release it on cleanup so a
26+
// leaked registration can't make HasActive() true for other sidebar tests
27+
// (e.g. TestSidebar_TitleRegenerating asserts the first animation is active).
28+
t.Cleanup(func() { m.spinner.Stop() })
29+
m.sessionHasContent = true
30+
m.titleGenerated = true
31+
m.sessionTitle = "Test"
32+
m.currentAgent = "root"
33+
m.availableAgents = []runtime.AgentDetails{
34+
{Name: "root", Provider: "openai", Model: "gpt-4o", Description: "Orchestrator"},
35+
{Name: "librarian", Provider: "openai", Model: "gpt-4o", Description: "Finds documents"},
36+
}
37+
m.width = 60
38+
m.height = 60
39+
return m
40+
}
41+
42+
func streamStarted(agent, sessionID string) *runtime.StreamStartedEvent {
43+
return &runtime.StreamStartedEvent{
44+
AgentContext: runtime.AgentContext{AgentName: agent},
45+
SessionID: sessionID,
46+
}
47+
}
48+
49+
// TestAgentChainTracksSessionStack enforces the invariant
50+
// len(agentChain) == len(sessionStack): the chain is pushed on StreamStarted and
51+
// popped on StreamStopped, in lockstep with the session stack.
52+
//
53+
// Not parallel: it drives the real Update handlers, which start the spinner and
54+
// touch the process-global animation coordinator shared across tests.
55+
func TestAgentChainTracksSessionStack(t *testing.T) {
56+
m := newBreadcrumbSidebar(t)
57+
require.Len(t, m.agentChain, len(m.sessionStack))
58+
59+
m.Update(streamStarted("root", "s-root"))
60+
assert.Equal(t, []string{"root"}, m.agentChain)
61+
assert.Len(t, m.agentChain, len(m.sessionStack))
62+
63+
m.Update(streamStarted("librarian", "s-lib"))
64+
assert.Equal(t, []string{"root", "librarian"}, m.agentChain)
65+
assert.Len(t, m.agentChain, len(m.sessionStack))
66+
67+
m.Update(&runtime.StreamStoppedEvent{SessionID: "s-lib"})
68+
assert.Equal(t, []string{"root"}, m.agentChain)
69+
assert.Len(t, m.agentChain, len(m.sessionStack))
70+
71+
m.Update(&runtime.StreamStoppedEvent{SessionID: "s-root"})
72+
assert.Empty(t, m.agentChain)
73+
assert.Len(t, m.agentChain, len(m.sessionStack))
74+
}
75+
76+
// Not parallel: drives the real Update handlers, which touch the process-global
77+
// animation coordinator shared across tests.
78+
func TestAgentChainResets(t *testing.T) {
79+
t.Run("StreamCancelledMsg clears the chain", func(t *testing.T) {
80+
m := newBreadcrumbSidebar(t)
81+
m.Update(streamStarted("root", "s-root"))
82+
m.Update(streamStarted("librarian", "s-lib"))
83+
84+
m.Update(messages.StreamCancelledMsg{})
85+
86+
assert.Empty(t, m.agentChain)
87+
assert.Empty(t, m.sessionStack)
88+
})
89+
90+
t.Run("ResetStreamTracking clears the chain", func(t *testing.T) {
91+
m := newBreadcrumbSidebar(t)
92+
m.Update(streamStarted("root", "s-root"))
93+
m.Update(streamStarted("librarian", "s-lib"))
94+
95+
m.ResetStreamTracking()
96+
97+
assert.Empty(t, m.agentChain)
98+
assert.Empty(t, m.sessionStack)
99+
})
100+
}
101+
102+
// TestDelegationBreadcrumbRendersOnlyWhenNested verifies the breadcrumb shows the
103+
// active chain (e.g. "root ⏵ librarian") only once delegation depth exceeds 1.
104+
func TestDelegationBreadcrumbRendersOnlyWhenNested(t *testing.T) {
105+
t.Parallel()
106+
107+
t.Run("hidden at depth <= 1", func(t *testing.T) {
108+
t.Parallel()
109+
m := newBreadcrumbSidebar(t)
110+
m.agentChain = []string{"root"}
111+
assert.NotContains(t, ansi.Strip(m.View()), "⏵")
112+
})
113+
114+
t.Run("shown at depth > 1", func(t *testing.T) {
115+
t.Parallel()
116+
m := newBreadcrumbSidebar(t)
117+
m.agentChain = []string{"root", "librarian"}
118+
assert.Contains(t, ansi.Strip(m.View()), "root ⏵ librarian")
119+
})
120+
}
121+
122+
// TestDelegationBreadcrumbPreservesAgentClickZones guards the buildAgentClickZones
123+
// skip logic: the breadcrumb block agentInfo prepends must not become a click
124+
// zone or shift the per-agent rows. Without the skip, the breadcrumb would claim
125+
// the first agent slot and the current-agent row would mis-map.
126+
func TestDelegationBreadcrumbPreservesAgentClickZones(t *testing.T) {
127+
t.Parallel()
128+
129+
m := newBreadcrumbSidebar(t)
130+
m.agentChain = []string{"root", "librarian"}
131+
132+
_ = m.View() // populate agentClickZones + cachedLines
133+
134+
for i, line := range m.cachedLines {
135+
stripped := ansi.Strip(line)
136+
// The breadcrumb (joined by ⏵) must never be an agent click target.
137+
if strings.Contains(stripped, "⏵") {
138+
_, isZone := m.agentClickZones[i]
139+
assert.False(t, isZone, "breadcrumb line %d must not be a click zone", i)
140+
}
141+
// The current-agent roster row (prefixed with ▶) must map to root.
142+
if strings.Contains(stripped, "▶") {
143+
assert.Equal(t, "root", m.agentClickZones[i], "current-agent row %d should map to root", i)
144+
}
145+
}
146+
147+
var clickable []string
148+
for _, name := range m.agentClickZones {
149+
clickable = append(clickable, name)
150+
}
151+
assert.Contains(t, clickable, "root")
152+
assert.Contains(t, clickable, "librarian")
153+
}

pkg/tui/components/sidebar/sidebar.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ type model struct {
137137
sessionState *service.SessionState
138138
workingAgent string // Name of the agent currently working (empty if none)
139139
sessionStack []string // Active stream session IDs; the top is the active (deepest) session
140+
agentChain []string // Agent name per active stream level; invariant: len == len(sessionStack)
140141
rootSessionID string // Main (top-level) session, shown when no stream is active
141142
scrollview *scrollview.Model
142143
workingDirectory string
@@ -477,6 +478,7 @@ func (m *model) LoadFromSession(sess *session.Session) {
477478
// loaded session has no in-flight streams, so clear any stale stack entries.
478479
m.rootSessionID = sess.ID
479480
m.sessionStack = nil
481+
m.agentChain = nil
480482

481483
// Load session title
482484
if sess.Title != "" {
@@ -509,6 +511,7 @@ func (m *model) ResetStreamTracking() {
509511
return
510512
}
511513
m.sessionStack = nil
514+
m.agentChain = nil
512515
m.invalidateCache()
513516
}
514517

@@ -736,6 +739,7 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
736739
m.rootSessionID = msg.SessionID
737740
}
738741
m.sessionStack = append(m.sessionStack, msg.SessionID)
742+
m.agentChain = append(m.agentChain, msg.AgentName)
739743
// If title hasn't been generated yet, show the title generation spinner
740744
if !m.titleGenerated {
741745
m.titleRegenerating = true
@@ -748,6 +752,9 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
748752
if n := len(m.sessionStack); n > 0 {
749753
m.sessionStack = m.sessionStack[:n-1]
750754
}
755+
if n := len(m.agentChain); n > 0 {
756+
m.agentChain = m.agentChain[:n-1]
757+
}
751758
m.invalidateCache()
752759
m.stopSpinner() // Will only stop if no other state needs it
753760
return m, nil
@@ -777,6 +784,7 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
777784
m.streamCancelled = true
778785
m.workingAgent = ""
779786
m.sessionStack = nil
787+
m.agentChain = nil
780788
m.toolsLoading = false
781789
m.mcpInit = false
782790
m.titleRegenerating = false
@@ -1250,6 +1258,9 @@ func (m *model) agentInfo(contentWidth int) string {
12501258
}
12511259

12521260
var content strings.Builder
1261+
if breadcrumb := m.delegationBreadcrumb(contentWidth); breadcrumb != "" {
1262+
content.WriteString(breadcrumb)
1263+
}
12531264
for i, agent := range m.availableAgents {
12541265
if content.Len() > 0 {
12551266
content.WriteString("\n\n")
@@ -1261,6 +1272,47 @@ func (m *model) agentInfo(contentWidth int) string {
12611272
return m.renderTab(agentTitle, content.String(), contentWidth)
12621273
}
12631274

1275+
// delegationBreadcrumb renders the active delegation chain (e.g.
1276+
// "root ⏵ librarian") shown under the Agents title while a sub-agent runs. It
1277+
// returns "" unless the chain is deeper than the root (len > 1). When the full
1278+
// chain would exceed contentWidth the middle is elided as "root ⏵ … ⏵ leaf".
1279+
//
1280+
// TODO(#3103, Appendix C.2): append a muted "+N background" count here once a
1281+
// background-task snapshot is available.
1282+
func (m *model) delegationBreadcrumb(contentWidth int) string {
1283+
chain := m.agentChain
1284+
if len(chain) <= 1 {
1285+
return ""
1286+
}
1287+
1288+
sep := styles.MutedStyle.Render(" ⏵ ")
1289+
colored := func(name string) string { return styles.AgentAccentStyleFor(name).Render(name) }
1290+
1291+
var b strings.Builder
1292+
b.WriteString(colored(chain[0]))
1293+
for _, name := range chain[1:] {
1294+
b.WriteString(sep)
1295+
b.WriteString(colored(name))
1296+
}
1297+
full := b.String()
1298+
if contentWidth <= 0 || ansi.StringWidth(full) <= contentWidth {
1299+
return full
1300+
}
1301+
1302+
// Too wide: keep the root and the deepest agent, elide the middle.
1303+
elided := colored(chain[0]) + sep + styles.MutedStyle.Render("…") + sep + colored(chain[len(chain)-1])
1304+
if ansi.StringWidth(elided) <= contentWidth {
1305+
return elided
1306+
}
1307+
return ansi.Truncate(full, contentWidth, "…")
1308+
}
1309+
1310+
// hasDelegationBreadcrumb reports whether agentInfo prepends a delegation
1311+
// breadcrumb block; buildAgentClickZones uses it to keep click rows aligned.
1312+
func (m *model) hasDelegationBreadcrumb() bool {
1313+
return len(m.agentChain) > 1
1314+
}
1315+
12641316
func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDetails, isCurrent bool, index, contentWidth int) {
12651317
agentStyle := styles.AgentAccentStyleFor(agent.Name)
12661318
var prefix string
@@ -1317,6 +1369,20 @@ func isVisuallyBlank(line string) bool {
13171369
return strings.TrimSpace(ansi.Strip(line)) == ""
13181370
}
13191371

1372+
// skipLeadingBlock returns the index just past the first run of non-blank lines
1373+
// and the blank separator that follows it. agentInfo emits the delegation
1374+
// breadcrumb as one such block above the roster; buildAgentClickZones skips it.
1375+
func skipLeadingBlock(lines []string, start int) int {
1376+
i := start
1377+
for i < len(lines) && !isVisuallyBlank(lines[i]) {
1378+
i++
1379+
}
1380+
for i < len(lines) && isVisuallyBlank(lines[i]) {
1381+
i++
1382+
}
1383+
return i
1384+
}
1385+
13201386
// buildAgentClickZones populates agentClickZones by scanning the rendered lines
13211387
// to find which lines belong to which agent. It relies on the structure produced
13221388
// by renderTab + agentInfo: a 2-line tab header, then agent blocks separated by
@@ -1329,10 +1395,16 @@ func (m *model) buildAgentClickZones(agentSectionStart int, lines []string) {
13291395
}
13301396

13311397
const tabHeaderLines = 2 // tab title + TabStyle top padding
1398+
start := agentSectionStart + tabHeaderLines
1399+
// agentInfo may prepend a single-line delegation breadcrumb block above the
1400+
// roster; skip it (and its trailing blank) so click rows map to agents.
1401+
if m.hasDelegationBreadcrumb() {
1402+
start = skipLeadingBlock(lines, start)
1403+
}
13321404
agentIdx := 0
13331405
inBlock := false
13341406

1335-
for i := agentSectionStart + tabHeaderLines; i < len(lines) && agentIdx < len(m.availableAgents); i++ {
1407+
for i := start; i < len(lines) && agentIdx < len(m.availableAgents); i++ {
13361408
if isVisuallyBlank(lines[i]) {
13371409
// Blank line: if we were inside a block, advance to the next agent
13381410
if inBlock {

0 commit comments

Comments
 (0)