Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,12 @@ The Projects tab (`2`) has three regions: a left **rail**, a middle **coordinato
| `Cmd+↑` / `Cmd+↓` | Move the rail cursor up / down without changing the focused pane (the mod-7 escape sequence is consumed — the pane's PTY never sees it) |
| `ctrl+q` | Return focus to the rail |

When a **worker** is selected the details region shows its live agent terminal. When a **coordinator** is selected it stacks a read-only roster of that orchestrator's roles — a compact table (status, name, diligence archetype, resolved model; ready-to-close/PR fold into the status cell), scrollable via ``/`↓`/`j`/`k` when it has more agents than the panel can show — over the embedded **plan DAG** — the planned + live worker roles laid out by their `hera_blocks` dependency order (the plan the coordinator authored over the `hera_plan*` MCP tools), with same-stage siblings auto-collapsed into parallel groups and a master-detail header above the diagram. Both render at once, no toggle; the plan graph is the interactive surface (a coordinator with no authored plan shows its live roles flat with a "no plan" hint):
When a **worker** is selected the details region shows its live agent terminal. When a **coordinator** is selected it stacks a read-only roster of that orchestrator's roles — a compact table (status, name, diligence archetype, resolved model; ready-to-close/PR fold into the status cell), scrollable via `PgUp`/`PgDn` when it has more agents than the panel can show — over the embedded **plan DAG** — the planned + live worker roles laid out by their `hera_blocks` dependency order (the plan the coordinator authored over the `hera_plan*` MCP tools), with same-stage siblings auto-collapsed into parallel groups and a master-detail header above the diagram. Both render at once, no toggle; the plan graph is the interactive surface (a coordinator with no authored plan shows its live roles flat with a "no plan" hint):

| Key (plan DAG) | Action |
| -------------------- | --------------------------------------------------------------------------------- |
| `↑` / `↓` / `j` / `k` | Scroll the Agents roster first when it has more agents than fit, then move between plan stages once the roster can't scroll further in that direction (collapses any fanned-out group on the way) |
| `↑` / `↓` / `j` / `k` | Move between plan stages (collapses any fanned-out group on the way) — always the plan graph's, never the Agents roster above it |
| `PgUp` / `PgDn` | Scroll the Agents roster when it has more agents than fit — its own dedicated keys, so they never compete with the plan graph's stage nav |
| `←` / `→` / `h` / `l` | Move between slots; inside a fanned-out group, walk its members |
| `Space` | Fan out / collapse a parallel group — a pure toggle that never opens a node (on a lone leaf it is a no-op; opening is `Enter`'s job) |
| `Enter` | Fan out a collapsed group; on a fanned-out group **member**, a sub-coordinator node, or a plain leaf, open that node: drill into a sub-coordinator's child orchestrator's plan, else jump to that node's role within the Projects view (selects it in the rail + focuses its agent pane — no tab switch), reviving a dead/suspended session just like the rail's `Enter`. On a member it does **not** collapse the group — that's `Space` / `Esc` |
Expand Down
1 change: 1 addition & 0 deletions context/knowledge/gotchas/hera-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ M6a scaffolds the native Hera view: a `HeraPage` (rail | coordinator pane | agen
- **planview↔hera import direction is one-way: `hera` imports `planview`, NEVER the reverse.** The projection (`heraPlanNodes*`) and the drill-in resolution (`HeraPage.drillIntoChild` → `bridgeIndex()` → child `OrchView`) live in `hera` because they need the rail Model; `planview` is a generic widget that knows nothing about hera. The seam is the `OnDrillIn(id)` callback: planview fires it with the node id, the page resolves the child + reprojects + `PushOrch`. This is why the App wires `OnEnter` (jump-to-agent-view, App's concern) but the PAGE wires `OnDrillIn` in `NewHeraPage` (it needs hera internals planview can't reach).
- **The widget is exposed via `HeraPage.Plan()`; the App wires only `OnEnter`** (jump to a leaf node's agent view) + `OnBranchChange`→`forceRedraw` (log-only, never Sync). Drill-in (`OnDrillIn`) is page-owned (above). Retitled `" Plan "` via `SetTitle`, full-rect coverage via the widget's own `DrawBorderedPanel`/header strip. `TestDetailsPlan_NoSyncOnDraw` pins no-Sync; `TestDetailsPlan_DrawStacksBothPanels` pins both `" Details "` and `" Plan "` render at once (and asserts no stray `" DAG "`/`"Orchestration Tree"`). Node colour comes from `RoleView.TaskStatus`/`TaskResult` (a `{"failed":true}` result → red `✕`, winning over the workflow status).
- **A status step (`s`/`S`) / `ready_to_close` clear re-projects the plan in lock-step with the rail: `doRefresh`→`applySelection`→`rebuildPlan` runs the SAME refresh, but `planview.UpdateData` short-circuits on an unchanged `projectionSig` — so the sig MUST fold the node's resolved status ICON (glyph + Animated), not just `State.Glyph()` (BUG-012).** The plan node's `State` is task-derived (`TaskStatus`/`TaskResult`), but its ICON also carries the rail-parity glyph (`ready_to_close` ✓, hera role-status mark). A role-status step / `ready_to_close` clear changes the ICON while the task-derived State is unchanged (e.g. still `working`), so without the icon in the sig `UpdateData` no-ops and the DAG node renders a STALE ✓ while the rail already moved. The projected `Icon.Glyph` is a stable frame-0 placeholder (spinner frames re-resolve at Draw), so folding it never spams a reproject. Pinned by `planview.TestUpdateData_IconChangeReinstalls` + page-level `TestRefresh_StatusStepReprojectsPlanNode`.
- **The Agents roster's scroll keys were split OFF the plan widget's j/k/Up/Down/h/l nav onto PgUp/PgDn (`dag-arrow-key-focus-fix`).** The roster used to share the identical physical keys the plan widget binds for stage nav — "layered, not duplicated" — via `HeraPage.handleDetailsKey`/`rosterScrollDelta` claiming j/k/Up/Down FIRST whenever the roster had unseen rows, falling through to the plan widget only once the roster hit its scroll bound. That meant an operator navigating the embedded DAG with arrow keys instead silently scrolled the Agents roster above it — reported live as "arrow keys used to navigate the plan graph instead scroll the agent list" — for as long as the roster had any room left to scroll in that direction. Fix: `rosterScrollDelta` now maps ONLY `PgDn`/`PgUp`; every j/k/Up/Down/h/l keystroke reaches `planview.Widget.InputHandler` unconditionally, and the roster is scrollable only via its own dedicated keys, so the two surfaces can never contend for the same keypress. See `TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster` + `TestHandleDetailsKey_PgDnPgUpScrollRoster` + `TestFocusRouting_ArrowKeysStayWithinFocusedRegion`.

## Rail parity — nesting, coordinator fold, spinner, PR cell, cascade delete

Expand Down
136 changes: 124 additions & 12 deletions internal/tui/hera/dag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,19 +265,81 @@ func TestDetailsPlan_KeyForwardsToWidget(t *testing.T) {
testutil.Equal(t, p.Plan().CursorPos().Stage, 0)
}

// TestHandleDetailsKey_ScrollsRosterBeforePlan: a coordinator with far more
// agents than a short pane can show must scroll the roster FIRST on
// j/k/Up/Down — the plan widget's own stage cursor must NOT move while the
// roster still has room to scroll. Only once the roster is fully scrolled
// does the SAME keystroke reach the plan widget again (ScrollRoster's false
// return falls through), so the two never fight over a keypress.
func TestHandleDetailsKey_ScrollsRosterBeforePlan(t *testing.T) {
// TestFocusRouting_ArrowKeysStayWithinFocusedRegion is the end-to-end
// regression guard for the arrow-key focus-routing fix, driven through the
// real SimulationScreen + full page InputHandler dispatch (planPage/
// toAgentFocus): while the RAIL is focused, arrow-driven cursor nav and other
// rail keys (s/S status-step, etc.) behave exactly as before and never touch
// the plan widget; once focus moves to the Details/plan region (a coordinator
// selection), arrow keys drive the plan widget's stage cursor exclusively and
// never move the rail's cursor or the roster's scroll offset — closing the
// reported "arrow keys meant for the DAG instead move the rail/agent list" gap
// in both directions.
func TestFocusRouting_ArrowKeysStayWithinFocusedRegion(t *testing.T) {
p := planPage(t)
testutil.Equal(t, selectOrchByName(p, "orch"), true)
h := p.InputHandler()

// -- Rail focus: existing nav + mutation keys are unaffected by the fix. --
testutil.Equal(t, p.Machine().State(), FocusRail)
railCursorBefore := p.Rail().CursorIndex()
advanced := false
p.OnStatusAdvance = func(Selection) { advanced = true }
h(tcell.NewEventKey(tcell.KeyRune, 's', tcell.ModNone), noFocus)
testutil.Equal(t, advanced, true) // rail mutation key still fires
testutil.Equal(t, p.Plan().CursorPos().Stage, 0) // plan untouched by a rail-focused key
// Down still drives ordinary rail cursor nav (moves onto the orchestrator's
// planned-role rows) — unaffected by the fix, which only touches the
// Details/plan region's own key routing.
h(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), noFocus)
testutil.Equal(t, p.Rail().CursorIndex() != railCursorBefore, true)
testutil.Equal(t, p.Plan().CursorPos().Stage, 0) // still untouched
// Return the cursor to the coordinator header so the Details/plan region
// (below) is exercised against the coordinator selection.
h(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), noFocus)
testutil.Equal(t, p.Rail().CursorIndex(), railCursorBefore)
testutil.Equal(t, p.detailsMode, true)

// -- Move focus onto the Details/plan region (Tab, then Ctrl+Alt+Right). --
toAgentFocus(p)
testutil.Equal(t, p.Machine().State(), FocusAgent)
testutil.Equal(t, p.detailsMode, true)

// Arrow keys move the plan's stage cursor and leave the rail's cursor
// (still parked on the coordinator header) and the roster's scroll offset
// untouched.
rosterBefore := p.details.rosterScroll
h(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), noFocus)
testutil.Equal(t, p.Plan().CursorPos().Stage, 1)
testutil.Equal(t, p.Rail().CursorIndex(), railCursorBefore)
testutil.Equal(t, p.details.rosterScroll, rosterBefore)

h(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), noFocus)
testutil.Equal(t, p.Plan().CursorPos().Stage, 0)
testutil.Equal(t, p.Rail().CursorIndex(), railCursorBefore)
testutil.Equal(t, p.details.rosterScroll, rosterBefore)

// Focus itself never moved off the Details/plan region during any of this.
testutil.Equal(t, p.Machine().State(), FocusAgent)
}

// TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster: a coordinator with far
// more agents than a short pane can show must NOT have its roster steal
// j/k/Up/Down from the embedded plan widget (BUG: reported "arrow keys used to
// navigate the DAG instead scroll the agent roster"). Those keys are the plan
// widget's own stage-nav keys, so they must reach it unconditionally and the
// roster's scroll offset must never move in response to them, regardless of
// how many agents overflow the roster panel.
func TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster(t *testing.T) {
d := memDB(t)
orch := seedOrch(t, d, "big-orch")
seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord")
for i := 1; i <= 20; i++ {
seedBoundRole(t, d, orch, fmt.Sprintf("agent-%02d", i), db.HeraKindWorker, fmt.Sprintf("t-w%02d", i))
}
a := seedPlannedRole(t, d, orch, "1a-research")
b := seedPlannedRole(t, d, orch, "2a-write")
testutil.NoError(t, d.AddHeraBlock(b.ID, a.ID)) // 2a←1a, so the plan has 2 stages to move between
p := NewHeraPage(d)
p.SetSessionResolver(resolverFor(map[string]*fakeSession{"t-coord": {id: "t-coord", alive: true}}))
p.Refresh()
Expand All @@ -297,24 +359,74 @@ func TestHandleDetailsKey_ScrollsRosterBeforePlan(t *testing.T) {
toAgentFocus(p)
p.Draw(sim) // populate DetailsView.rosterVisibleRows for this selection/size

h := p.InputHandler()
planStage := func() int { return p.Plan().CursorPos().Stage }
startStage := planStage()
testutil.Equal(t, startStage, 0)
for _, key := range []tcell.Key{tcell.KeyDown, tcell.KeyDown, tcell.KeyUp} {
before := p.details.rosterScroll
h(tcell.NewEventKey(key, 0, tcell.ModNone), noFocus)
testutil.Equal(t, p.details.rosterScroll, before) // roster never moves on an arrow key
p.Draw(sim)
}
// j moved the plan cursor down a stage, k moved it back — the roster's
// scroll offset stayed put throughout.
rosterBefore := p.details.rosterScroll
h(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), noFocus)
testutil.Equal(t, planStage(), 1)
testutil.Equal(t, p.details.rosterScroll, rosterBefore)
h(tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone), noFocus)
testutil.Equal(t, planStage(), startStage)
testutil.Equal(t, p.details.rosterScroll, rosterBefore)
}

// TestHandleDetailsKey_PgDnPgUpScrollRoster: PgDn/PgUp are the roster's
// dedicated scroll keys (moved off j/k/Up/Down, which now belong exclusively
// to the plan widget — see TestHandleDetailsKey_ArrowsAlwaysReachPlanNeverRoster).
// The plan widget's stage cursor must not move in response to them.
func TestHandleDetailsKey_PgDnPgUpScrollRoster(t *testing.T) {
d := memDB(t)
orch := seedOrch(t, d, "big-orch")
seedBoundRole(t, d, orch, "coord", db.HeraKindCoordinator, "t-coord")
for i := 1; i <= 20; i++ {
seedBoundRole(t, d, orch, fmt.Sprintf("agent-%02d", i), db.HeraKindWorker, fmt.Sprintf("t-w%02d", i))
}
p := NewHeraPage(d)
p.SetSessionResolver(resolverFor(map[string]*fakeSession{"t-coord": {id: "t-coord", alive: true}}))
p.Refresh()

sim := tcell.NewSimulationScreen("UTF-8")
testutil.NoError(t, sim.Init())
t.Cleanup(sim.Fini)
sim.SetSize(80, 50)
p.SetRect(0, 0, 80, 50)
p.Draw(sim)

testutil.Equal(t, selectOrchByName(p, "big-orch"), true)
toAgentFocus(p)
p.Draw(sim)

h := p.InputHandler()
planStage := func() int { return p.Plan().CursorPos().Stage }
startStage := planStage()
scrolledRoster := false
for i := 0; i < 30; i++ { // far more than needed; ScrollRoster clamps at the bound
before := p.details.rosterScroll
h(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone), noFocus)
h(tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone), noFocus)
if p.details.rosterScroll != before {
scrolledRoster = true
}
p.Draw(sim) // recompute the budget for the next keypress, as a live app would
}
testutil.Equal(t, scrolledRoster, true)
testutil.Equal(t, p.details.rosterScroll, p.details.rosterMaxScroll(len(p.details.workers())))
// This fixture has no authored plan (no planned nodes, no edges) — the
// plan widget's degenerate empty-plan stage never moves regardless, so
// the assertion that matters is that scrolling the roster didn't need the
// plan widget's cursor to change AT ALL.
testutil.Equal(t, planStage(), startStage) // the plan cursor never moved

for i := 0; i < 30; i++ {
h(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone), noFocus)
p.Draw(sim)
}
testutil.Equal(t, p.details.rosterScroll, 0)
testutil.Equal(t, planStage(), startStage)
}

Expand Down
16 changes: 8 additions & 8 deletions internal/tui/hera/details.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ func (d *DetailsView) Draw(screen tcell.Screen, x, y, w, h int, focused bool) {
// rendered as an aligned, SCROLLABLE table: status (icon + label), name,
// diligence archetype, resolved model. When there are more agents than
// the remaining row budget affords, only a window starting at
// d.rosterScroll renders — j/k/Up/Down (routed by HeraPage.handleDetailsKey
// before the embedded plan widget's own nav) move the window so every
// agent stays reachable instead of the tail being silently cut off.
// d.rosterScroll renders — PgUp/PgDn (routed by HeraPage.handleDetailsKey,
// its OWN dedicated keys — see that function's doc) move the window so
// every agent stays reachable instead of the tail being silently cut off.
workers := d.workers()
draw(inner.X, fmt.Sprintf("Agents (%d):", len(workers)), theme.StyleDimmed)
if len(workers) == 0 {
Expand Down Expand Up @@ -361,11 +361,11 @@ func (d *DetailsView) clampRosterScroll(total int) {

// ScrollRoster moves the roster's scroll offset by delta rows (+1 down, -1
// up), clamped to [0, maxScroll]. Returns false — a no-op — when the roster
// is already at the requested bound, or hasn't been drawn yet (rosterVisibleRows
// still zero): the caller (HeraPage.handleDetailsKey) falls through to the
// embedded plan widget's own navigation in that case, so j/k/Up/Down scroll
// the roster first and only reach the plan once the roster can't move
// further in that direction — the two never fight over the same keystroke.
// is already at the requested bound, or hasn't been drawn yet
// (rosterVisibleRows still zero). Driven exclusively by PgUp/PgDn
// (HeraPage.rosterScrollDelta) — disjoint from the plan widget's j/k/Up/Down/
// h/l stage-and-slot nav keys, so the roster and the plan graph never contend
// for the same keystroke (see HeraPage.handleDetailsKey).
func (d *DetailsView) ScrollRoster(delta int) bool {
if d.orch == nil || d.rosterVisibleRows <= 0 {
return false
Expand Down
25 changes: 14 additions & 11 deletions internal/tui/hera/details_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -820,25 +820,28 @@ func TestDetails_ClampRosterScrollAboveMax(t *testing.T) {
testutil.Equal(t, d.rosterScroll < 100, true)
}

// TestRosterScrollDelta pins the roster's OWN scroll keyset — PgDn/PgUp —
// deliberately disjoint from the plan widget's j/k/Up/Down/h/l stage-and-slot
// nav keys, so the two surfaces never contend for the same keystroke (a
// reported bug: arrow keys aimed at the DAG graph were being consumed by the
// roster instead). See HeraPage.handleDetailsKey.
func TestRosterScrollDelta(t *testing.T) {
down, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone))
down, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone))
testutil.Equal(t, ok, true)
testutil.Equal(t, down, 1)

up, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone))
up, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone))
testutil.Equal(t, ok, true)
testutil.Equal(t, up, -1)

jDown, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone))
testutil.Equal(t, ok, true)
testutil.Equal(t, jDown, 1)

kUp, ok := rosterScrollDelta(tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone))
testutil.Equal(t, ok, true)
testutil.Equal(t, kUp, -1)

// Any other key (h/l, Enter, Esc, an unrelated rune) is not a scroll key.
// Every plan-widget nav key (arrows, j/k/h/l, Enter, Esc) and any other
// rune is NOT a roster scroll key — they must reach the plan widget
// unconditionally, never the roster.
for _, ev := range []*tcell.EventKey{
tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone),
tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, 'j', tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, 'k', tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, 'h', tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, 'l', tcell.ModNone),
tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone),
Expand Down
Loading
Loading