diff --git a/CHANGELOG.md b/CHANGELOG.md index 0168daa..337fd35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). child agent, even though you were still within that thread. ### Changed +- Agent-initiated `spawn_agent` and `spawn_process` MCP calls no + longer steal viewport focus from the currently active tab. The + new child still appears in the sidebar and tab bar; switch to it + explicitly via the palette or `select_process`. Palette-initiated + spawns and persistence restores are unchanged — they still auto- + focus the new pane. - Sidebar rows (Processes, Agent Tree, Scratchpads) now truncate overflowing names with a trailing `…` instead of spilling into the main viewport. The focused row marquees its name when it diff --git a/internal/app/app.go b/internal/app/app.go index df0d622..1dad7e3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -700,8 +700,26 @@ func (st *uiState) scratchpadsChanged() { } } -// OnChildSpawned auto-focuses the new child. +// OnChildSpawned auto-focuses the new child when the spawn came from +// the user (palette, persistence restore, or an external MCP client with +// no resolved identity). When ParentID is set — meaning a patterm-managed +// agent spawned this child via spawn_agent/spawn_process — focus stays +// on whatever the user was watching; the new child is still surfaced in +// the sidebar/tab bar so it's reachable via the palette or select_process. func (st *uiState) OnChildSpawned(c *Child) { + if c.ParentID != "" { + st.mu.Lock() + if st.palette != nil { + st.palette.children = st.sess.Children() + st.palette.focused = st.focusedID + st.palette.rebuild() + st.renderPaletteLocked() + } + st.mu.Unlock() + st.drawTabBar() + st.drawSidebar() + return + } st.marquee.reset() layout := st.layoutSnapshot() onAlt := childIsOnAlt(c) diff --git a/internal/app/spawn_focus_test.go b/internal/app/spawn_focus_test.go new file mode 100644 index 0000000..15cf2e8 --- /dev/null +++ b/internal/app/spawn_focus_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "testing" +) + +// TestOnChildSpawnedAgentChildKeepsFocus verifies that when a child is +// spawned with a ParentID set (i.e. a patterm-managed agent caused the +// spawn over MCP), OnChildSpawned does NOT steal viewport focus from +// the currently focused child. +func TestOnChildSpawnedAgentChildKeepsFocus(t *testing.T) { + sess := NewSession(t.TempDir(), "test") + st := &uiState{sess: sess} + + parent := newChildEntry("p_parent", "parent", KindAgent, nil, nil, "", "", "") + st.focusedID = parent.ID + st.focusedName = parent.Name + + subAgent := newChildEntry("p_sub", "sub", KindAgent, nil, nil, parent.ID, "", "") + + st.OnChildSpawned(subAgent) + + if got := st.focusedID; got != parent.ID { + t.Fatalf("agent-initiated spawn should not change focusedID: want %q, got %q", parent.ID, got) + } + if got := st.focusedName; got != parent.Name { + t.Fatalf("focusedName changed: want %q, got %q", parent.Name, got) + } +} + +// TestOnChildSpawnedPaletteChildTakesFocus verifies the legacy path is +// preserved: spawns with an empty ParentID (palette, restore, external +// MCP caller) still auto-focus the new child. +func TestOnChildSpawnedPaletteChildTakesFocus(t *testing.T) { + sess := NewSession(t.TempDir(), "test") + st := &uiState{sess: sess} + st.lastExit.Store(-1) + + c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "") + + st.OnChildSpawned(c) + + if got := st.focusedID; got != c.ID { + t.Fatalf("palette-initiated spawn should auto-focus: want %q, got %q", c.ID, got) + } +}