feat(dashboard): auto-focus next agent on stop, or close pane
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m5s

After `x` stops a dashboard-owned bottle, slide focus to the
next agent in the agents pane (the one filling the stopped
row, or the new last row if the stopped was last) and respawn
the right pane with that agent's claude session via `--continue`.
If no agents remain, close the right pane via `tmux kill-pane`.

Two new helpers:

  - `_tmux_close_right_pane(tmux_state)` — kills the tracked
    pane (if it exists) and clears pane_id / slug.
  - `_pick_next_after_stop(agents_before, selected_index,
    stopped_slug)` — pure chooser returning (new_index, agent)
    or None. Tested directly.

Outside tmux, only the selected_agent index slides; no
auto-attach (foreground handoff would take over the terminal,
disruptive). 485 unit tests pass (6 new for the pick helper).
This commit is contained in:
2026-05-26 15:21:20 -04:00
parent 9622bdc619
commit 8d6e382af5
2 changed files with 107 additions and 0 deletions
+58
View File
@@ -890,6 +890,42 @@ def _route_op_to_right_pane(
yield True
def _tmux_close_right_pane(tmux_state: dict) -> None:
"""Close the tracked right pane via `tmux kill-pane`. Clears
both pane_id and slug in `tmux_state`. Used after the last
dashboard-owned agent is stopped — no claude session left
to host, so the pane shouldn't linger."""
pane_id = tmux_state.get("pane_id")
if pane_id and _tmux_pane_exists(pane_id):
try:
subprocess.run(
["tmux", "kill-pane", "-t", pane_id],
capture_output=True, text=True, check=False,
)
except FileNotFoundError:
pass
tmux_state["pane_id"] = None
tmux_state["slug"] = None
def _pick_next_after_stop(
agents_before: list[ActiveAgent],
selected_index: int,
stopped_slug: str,
) -> tuple[int, ActiveAgent] | None:
"""After stopping `stopped_slug` from the agents list, choose
the agent that should take focus next. The agent below the
stopped row (which slides up to fill its index) is the
natural pick; if the stopped agent was last, the row above
instead. Returns (new_index, agent) or None if no agents
remain. Pure — easy to unit-test."""
new_agents = [a for a in agents_before if a.slug != stopped_slug]
if not new_agents:
return None
new_index = min(max(selected_index, 0), len(new_agents) - 1)
return new_index, new_agents[new_index]
def _ensure_right_pane(tmux_state: dict, argv: list[str]) -> str | None:
"""Run `argv` in the dashboard's right pane — respawn an
existing tracked pane if one is alive, split-window to
@@ -1347,6 +1383,28 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
stdscr, bottles, target.slug,
tmux_state=tmux_state,
)
# PRD 0021 follow-up: after stop, slide focus
# to the next agent in the list (the one that
# filled the stopped row) and respawn the
# right pane with its claude session. If
# nothing's left, close the right pane.
pick = _pick_next_after_stop(
agents, selected_agent, target.slug,
)
if pick is None:
_tmux_close_right_pane(tmux_state)
else:
new_index, next_agent = pick
selected_agent = new_index
if _in_tmux():
manifest = manifest_cache[0]
bottle, _hint = _bottle_for_slug(
next_agent.slug, bottles, manifest,
)
_attach_in_tmux(
stdscr, bottle, next_agent.slug,
resume=True, tmux_state=tmux_state,
)
continue
if not pending:
@@ -379,6 +379,55 @@ class TestBottleForSlug(unittest.TestCase):
self.assertEqual("", hint)
class TestPickNextAfterStop(unittest.TestCase):
"""After `x` stops a bottle, the dashboard slides focus to
the next agent the one filling the stopped row, or the
new last row if the stopped was last. Pure helper, easy
to unit-test."""
def _agent(self, slug: str) -> dashboard.ActiveAgent:
return dashboard.ActiveAgent(
slug=slug, agent_name=slug, started_at="", services=(),
)
def test_empty_list_returns_none(self):
self.assertIsNone(
dashboard._pick_next_after_stop([], 0, "anything"),
)
def test_only_agent_being_stopped_returns_none(self):
# Stopping the last agent → nothing to focus.
agents = [self._agent("only")]
self.assertIsNone(
dashboard._pick_next_after_stop(agents, 0, "only"),
)
def test_middle_row_slides_up_to_same_index(self):
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
# Cursor was on "b" at index 1; stopping "b" → "c" now sits
# at index 1 and takes focus.
out = dashboard._pick_next_after_stop(agents, 1, "b")
self.assertEqual((1, self._agent("c")), out)
def test_last_row_wraps_to_new_last(self):
agents = [self._agent("a"), self._agent("b"), self._agent("c")]
# Cursor on "c" at index 2; stopping "c" leaves a 2-agent
# list — index 2 is out of bounds, fall back to new last (1).
out = dashboard._pick_next_after_stop(agents, 2, "c")
self.assertEqual((1, self._agent("b")), out)
def test_first_row(self):
agents = [self._agent("a"), self._agent("b")]
out = dashboard._pick_next_after_stop(agents, 0, "a")
self.assertEqual((0, self._agent("b")), out)
def test_clamps_negative_selection(self):
# Defensive: a stale negative index doesn't crash.
agents = [self._agent("a"), self._agent("b")]
out = dashboard._pick_next_after_stop(agents, -1, "a")
self.assertEqual((0, self._agent("b")), out)
class TestTmuxPaneArgvBuilders(unittest.TestCase):
"""Pure argv builders for the tmux split-pane integration
(PRD 0021 chunk 2). The subprocess invocation itself is