From 73cb17f1316c07f21c7aacc47aa2b71dd698e477 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 24 Oct 2026 10:14:12 -0700 Subject: [PATCH] fix(flow): what needs you comes first, and answering it is clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the UI/UX review of the TUI and web dashboard: - Overlap links no longer cover nodes. Sessions in a project share one column, so the curve and its "file" pill landed on a node and past the region's edge. A dashed bracket in the region's left margin now joins the two sessions; each node's meta already names the file. A test keeps the link clear of every node. - Both surfaces open on the oldest session that needs you, and keep following it until you move the selection (a link to one session still wins on the web). - After Allow or Deny the web shows "Allowed · " in a notice outside the refreshed region, and focus moves to the next need, or to the Needs you heading, instead of falling back to the page. - Answering works the same way on both: y/n answer the focused Needs you card on the web, with the same arming delay, and the TUI says "Tab, then y Allow" until the prompt has focus. Help lists y/n. - On a phone, Needs you sits above the map while anything waits, and the map scrolls with the page instead of in a nested pane. Claude-Session: https://claude.ai/code/session_01T2vtcW6ihn1ywy9vxUq9a4 --- internal/tui/app.go | 1 + internal/tui/board.go | 15 ++++++- internal/tui/board_test.go | 11 ++++++ internal/tui/mouse.go | 1 + internal/tui/testdata/board-busy-100.golden | 35 ++++++++-------- internal/tui/testdata/board-busy-140.golden | 44 ++++++++++----------- internal/tui/testdata/board-busy-200.golden | 44 ++++++++++----------- internal/tui/testdata/board-busy-80.golden | 35 ++++++++-------- internal/view/board_map.go | 9 ++++- internal/view/board_test.go | 12 ++++++ internal/web/static/app.css | 5 +++ internal/web/static/flow-board.css | 5 ++- internal/web/static/flow-board.js | 24 +++++++---- internal/web/templates/board.html | 4 +- internal/web/templates/dashboard.html | 4 +- 15 files changed, 153 insertions(+), 96 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 198a1c9b..a33a4e07 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -101,6 +101,7 @@ type app struct { boardOrder []string boardGroups []string boardCursor int + boardTouched bool // the user moved the Flow selection boardStop int64 // session awaiting Enter to confirm a stop boardMachine bool boardFilter int diff --git a/internal/tui/board.go b/internal/tui/board.go index 97ccc528..34f94c90 100644 --- a/internal/tui/board.go +++ b/internal/tui/board.go @@ -78,8 +78,14 @@ func (m *app) syncBoard() { }) m.boardPinned = previous m.board = b + // Until the user moves the selection, it follows the oldest session that + // needs you rather than whichever row sorts first. + want := previous + if !m.boardTouched && len(b.Needs) > 0 { + want = b.Needs[0].Key + } for i, r := range m.boardRows() { - if r.Key == previous { + if r.Key == want { m.boardCursor = i break } @@ -190,6 +196,7 @@ func (m *app) boardKey(k string) (tea.Cmd, bool) { m.flowAskCursor = max(0, min(len(m.board.Needs)-1, m.flowAskCursor+d)) } else { m.boardCursor = max(0, min(len(m.boardRows())-1, m.boardCursor+d)) + m.boardTouched = true } m.armBoardPrompt() return nil, true @@ -311,7 +318,11 @@ func (m *app) boardLayout() (string, []flowHit) { } action := "Enter open · J Terminal" if r.PromptID != "" { - action = "y Allow · n Deny · Enter open full command" + // y/n answer only a focused prompt, so say how to focus it first. + action = "Tab, then y Allow · n Deny · Enter open full command" + if m.flowAttention { + action = "y Allow · n Deny · Enter open full command" + } } add(" " + action) hits = append(hits, flowHit{y: 4, width: w, height: max(1, len(lines)-3), index: -idx - 1}) diff --git a/internal/tui/board_test.go b/internal/tui/board_test.go index 38395d2d..338bf965 100644 --- a/internal/tui/board_test.go +++ b/internal/tui/board_test.go @@ -236,6 +236,7 @@ func TestBoardStopNeedsConfirmation(t *testing.T) { t.Fatalf("%c confirmed a stop", key) } } + id = m.boardSelected().Node.ID m.Update(tea.KeyPressMsg{Code: 'K'}) _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) execute(t, m, cmd) @@ -305,3 +306,13 @@ func TestFlowFullPromptInspector(t *testing.T) { t.Fatal("prompt command truncated") } } + +func TestBoardOpensOnTheOldestNeed(t *testing.T) { + m := boardApp(t, "busy") + if len(m.board.Needs) == 0 { + t.Fatal("fixture has no needs") + } + if got, want := m.boardSelected().Key, m.board.Needs[0].Key; got != want { + t.Fatalf("first selection = %s, want the oldest need %s", got, want) + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index efb8eb4f..3c6f92ea 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -39,6 +39,7 @@ func (m *app) mouseClick(msg tea.MouseClickMsg) tea.Cmd { } else { m.flowAttention = false m.boardCursor = h.index + m.boardTouched = true } return nil } diff --git a/internal/tui/testdata/board-busy-100.golden b/internal/tui/testdata/board-busy-100.golden index d77008c4..6a504ee3 100644 --- a/internal/tui/testdata/board-busy-100.golden +++ b/internal/tui/testdata/board-busy-100.golden @@ -9,22 +9,23 @@ NEEDS YOU · 7 · oldest first ◆ Add board view · from Editor · relay · 14m +4 more · Tab then j/k ──────────────────────────────────────────────────────────────────────────────────────────────────── -atlas -├── › ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m -├── ▶ Fix retry after reconnect codex ░░━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━━ 1h 26m -├── ○ Investigate cache invalidati… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m -├── ✓ Fix retry after reconnect codex ░░░░░░░░░░░░░━━░░░━━━━━━░░░✓━━━━━ ━━━━━ 1h 2m -└── ○ Investigate cache invalidati… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 50m - └── ‖ Investigate cache invalid… codex ░░░░░░░░░░━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━░ 5m -canon -├── ▶ Add board view · from Editor claude ░░░░━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 49m -├── ◆ 流れを整える 🌊 claude ░░░░░░░░░━━━━━━░░░━━━━━━░░░━━━━━◆◆◆◆◆◆◆◆◆◆ 13m -├── ▶ Add board view · from Editor claude ░━━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 25m -├── ▶ 流れを整える 🌊 claude ░░░░░░░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 13m -├── ▶ Add board view · from Editor claude ░░░░░░░░░░░░━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 1m -└── ▶ 流れを整える 🌊 claude ━━ ░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 49m -driga -├── ▶ 流れを整える 🌊 claude ░━━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━┿━ 1h 45m +echo-pro +├── ▶ Investigate cache invalidati… codex ░░━━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━━ 1h 46m +├── ✓ Investigate cache invalidati… codex ░░░░░░░░░░░░░━━░░░━━━━━━░░░━━━✓━━ ━━━━━ 1h 22m +├── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 10m +├── ‖ Investigate cache invalidati… codex ░░░░░░░░░━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━░ 1m +└── ▶ Fix retry after reconnect codex ━━━━ ━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━━ 46m + └── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 34m +inloop +├── ‖ An intentionally long title … gemini ░░░━━━░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━░ 3m +├── ○ An intentionally long title … gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 24m +├── ✓ Polish onboarding copy gemini ░░░░░░░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░✓━━━━ 1h 12m +├── ○ An intentionally long title … gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 0m +└── ‖ Polish onboarding copy gemini ━━━ ░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━░ 3m + └── ▶ Polish onboarding copy gemini ░░░░░░░░░━━━━━━░░░━━━━━━░░░━━━━━━░░░░━━━━━ 1h 36m +monk +├── › ◆ Ship the release pipeline sa… copilot ░░━━━━░░░━━━━━━░░░━━━━━━░◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆ 23m ──────────────────────────────────────────────────────────────────────────────────────────────────── -Fix retry after reconnect · codex · idle +Ship the release pipeline safely · copilot · waiting +cost $0.49 · retained window Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/tui/testdata/board-busy-140.golden b/internal/tui/testdata/board-busy-140.golden index 11e8b2e3..affa6bb3 100644 --- a/internal/tui/testdata/board-busy-140.golden +++ b/internal/tui/testdata/board-busy-140.golden @@ -1,28 +1,14 @@ Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ───────────────────────────╮ -◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 │ Fix retry after reconnect · atlas · codex │ - ██ events/min · updated 0s ago │ · idle │ +◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 │ Ship the release pipeline safely · monk · │ + ██ events/min · updated 0s ago │ copilot · waiting │ NEEDS YOU · 7 · oldest first │ −60m now │ - ◆ Ship the release pipeline safely · monk · 23m │ └── ○ Fix retry after reconnect │ - Permission · Bash · go test ./... │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ │ - Enter open · J Terminal │ J Terminal · Enter Open · c Ask Chat · K │ - ◆ 流れを整える 🌊 · driga · 15m │ Stop │ - ◆ Add board view · from Editor · relay · 14m ╰────────────────────────────────────────────╯ - +4 more · Tab then j/k + ◆ Ship the release pipeline safely · monk · 23m │ └── ◆ Ship the release pipeline safely │ + Permission · Bash · go test ./... │ ░░━━━░░░━━━━━░░░━━━━━░◆◆◆◆◆◆◆◆◆◆◆◆◆◆ │ + Enter open · J Terminal │ cost $0.49 · retained window │ + ◆ 流れを整える 🌊 · driga · 15m │ J Terminal · Enter Open · c Ask Chat · K │ + ◆ Add board view · from Editor · relay · 14m │ Stop │ + +4 more · Tab then j/k ╰────────────────────────────────────────────╯ ─────────────────────────────────────────────────────────────────────────────────────────── -atlas -├── › ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m -├── ▶ Fix retry after reconnect codex ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━━ 1h 26m -├── ○ Investigate cache invalid… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m -├── ✓ Fix retry after reconnect codex ░░░░░░░░░░░━━░░░━━━━━░░✓━━━━━ ━━━━━ 1h 2m -└── ○ Investigate cache invalid… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 50m - └── ‖ Investigate cache inva… codex ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━░ 5m -canon -├── ▶ Add board view · from Edi… claude ░░░━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 49m -├── ◆ 流れを整える 🌊 claude ░░░░░░░░━━━━━░░░━━━━━░░━━━━━◆◆◆◆◆◆◆◆ 13m -├── ▶ Add board view · from Edi… claude ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 25m -├── ▶ 流れを整える 🌊 claude ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 13m -├── ▶ Add board view · from Edi… claude ░░░░░░░░░░━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 1m -└── ▶ 流れを整える 🌊 claude ━━ ░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 49m driga ├── ▶ 流れを整える 🌊 claude ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 45m ├── ▶ 流れを整える 🌊 claude ░░░░░░░░░░━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 21m @@ -32,3 +18,17 @@ driga └── ▶ Add board view · from … claude ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━┿━ 1h 33m echo-pro ├── ▶ Investigate cache invalid… codex ░━━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━━ 1h 46m +├── ✓ Investigate cache invalid… codex ░░░░░░░░░░░━━░░░━━━━━░░━━━✓━━ ━━━━━ 1h 22m +├── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 10m +├── ‖ Investigate cache invalid… codex ░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━░ 1m +└── ▶ Fix retry after reconnect codex ━━━━ ━━━━━░░░━━━━━░░━━━━━━░░━━━━━ 46m + └── ○ Fix retry after reconn… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 34m +inloop +├── ‖ An intentionally long tit… gemini ░░━━━░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━░ 3m +├── ○ An intentionally long tit… gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 24m +├── ✓ Polish onboarding copy gemini ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━✓━━━ 1h 12m +├── ○ An intentionally long tit… gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 0m +└── ‖ Polish onboarding copy gemini ━━━ ░━━━━━░░░━━━━━░░━━━━━━░░━━━━░ 3m + └── ▶ Polish onboarding copy gemini ░░░░░░░░━━━━━░░░━━━━━░░━━━━━━░░━━━━━ 1h 36m +monk +├── › ◆ Ship the release pipeline… copilot ░░━━━░░░━━━━━░░░━━━━━░◆◆◆◆◆◆◆◆◆◆◆◆◆◆ 23m diff --git a/internal/tui/testdata/board-busy-200.golden b/internal/tui/testdata/board-busy-200.golden index 8eb3b9b7..8afb5ea7 100644 --- a/internal/tui/testdata/board-busy-200.golden +++ b/internal/tui/testdata/board-busy-200.golden @@ -1,28 +1,14 @@ Flow [m Map] [t Timeline] [s Sessions] · Map · last 60m │ ╭ Selected family ─────────────────────────────────╮ -◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 │ Fix retry after reconnect · atlas · codex · idle │ - ██ events/min · updated 0s ago │ −60m now │ -NEEDS YOU · 7 · oldest first │ └── ○ Fix retry after reconnect │ - ◆ Ship the release pipeline safely · monk · 23m │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ │ - Permission · Bash · go test ./... │ J Terminal · Enter Open · c Ask Chat · K Stop │ - Enter open · J Terminal ╰──────────────────────────────────────────────────╯ - ◆ 流れを整える 🌊 · driga · 15m - ◆ Add board view · from Editor · relay · 14m +◆ 7 needs you ▶ 35 ‖ 7 ○ 14 ✓ 7 │ Ship the release pipeline safely · monk · │ + ██ events/min · updated 0s ago │ copilot · waiting │ +NEEDS YOU · 7 · oldest first │ −60m now │ + ◆ Ship the release pipeline safely · monk · 23m │ └── ◆ Ship the release pipeline safely │ + Permission · Bash · go test ./... │ ░░━━━━░░░━━━━━━░░░━━━━━━░◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆ │ + Enter open · J Terminal │ cost $0.49 · retained window │ + ◆ 流れを整える 🌊 · driga · 15m │ J Terminal · Enter Open · c Ask Chat · K Stop │ + ◆ Add board view · from Editor · relay · 14m ╰──────────────────────────────────────────────────╯ +4 more · Tab then j/k ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -atlas -├── › ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m -├── ▶ Fix retry after reconnect codex ░░░━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━ 1h 26m -├── ○ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m -├── ✓ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░━━━━░░░░░░░━━━━━━━━━━━░░░░░✓✓━━━━━━━━━━━ ━━━━━━━━━━ 1h 2m -└── ○ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 50m - └── ‖ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━░ 5m -canon -├── ▶ Add board view · from Editor claude ░░░░░░░━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 49m -├── ◆ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆ 13m -├── ▶ Add board view · from Editor claude ░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 25m -├── ▶ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 13m -├── ▶ Add board view · from Editor claude ░░░░░░░░░░░░░░░░░░░░░░░░━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 1m -└── ▶ 流れを整える 🌊 claude ━━━━ ░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 49m driga ├── ▶ 流れを整える 🌊 claude ░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 45m ├── ▶ 流れを整える 🌊 claude ░░░░░░░░░░░░░░░░░░░░░░░░━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 21m @@ -32,3 +18,17 @@ driga └── ▶ Add board view · from Editor claude ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━┿┿━━ 1h 33m echo-pro ├── ▶ Investigate cache invalidation codex ░░░━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━ 1h 46m +├── ✓ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░░░░░░░░━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━✓✓━━━━ ━━━━━━━━━━ 1h 22m +├── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 10m +├── ‖ Investigate cache invalidation codex ░░░░░░░░░░░░░░░░░░━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━░ 1m +└── ▶ Fix retry after reconnect codex ━━━━━━━━ ━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━ 46m + └── ○ Fix retry after reconnect codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 34m +inloop +├── ‖ An intentionally long title with … gemini ░░░░░░━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━░ 3m +├── ○ An intentionally long title with … gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 24m +├── ✓ Polish onboarding copy gemini ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━✓━━━━━━━━ 1h 12m +├── ○ An intentionally long title with … gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 0m +└── ‖ Polish onboarding copy gemini ━━━━━ ░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━░ 3m + └── ▶ Polish onboarding copy gemini ░░░░░░░░░░░░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━ 1h 36m +monk +├── › ◆ Ship the release pipeline safely copilot ░░░░░━━━━━━░░░░░░░━━━━━━━━━━━░░░░░░░━━━━━━━━━━━░░░◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆◆ 23m diff --git a/internal/tui/testdata/board-busy-80.golden b/internal/tui/testdata/board-busy-80.golden index 7d23fcfe..c76a729e 100644 --- a/internal/tui/testdata/board-busy-80.golden +++ b/internal/tui/testdata/board-busy-80.golden @@ -9,22 +9,23 @@ NEEDS YOU · 7 · oldest first ◆ Add board view · from Editor · relay · 14m +4 more · Tab then j/k ──────────────────────────────────────────────────────────────────────────────── -atlas -├── › ○ Fix retry after recon… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 50m -├── ▶ Fix retry after recon… codex ░━━━░░━━━━━░░━━━━░░━━━━░░━━━━ 1h 26m -├── ○ Investigate cache inv… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 14m -├── ✓ Fix retry after recon… codex ░░░░░░░░░━━░░━━━━░✓━━━━ ━━━━ 1h 2m -└── ○ Investigate cache inv… codex ░░░░░░░░░░░░░░░░░░░░░░░░─ 50m - └── ‖ Investigate cache … codex ░░░░░░░━━━━░░━━━━░░━━━━░░━━━░ 5m -canon -├── ▶ Add board view · from… claude ░░━━░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 49m -├── ◆ 流れを整える 🌊 claude ░░░░░░━━━━━░░━━━━░░━━━◆◆◆◆◆◆◆ 13m -├── ▶ Add board view · from… claude ━━━━░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 25m -├── ▶ 流れを整える 🌊 claude ░░░░░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 13m -├── ▶ Add board view · from… claude ░░░░░░░░━━━░░━━━━░░━━━━░░━━━┿ 1h 1m -└── ▶ 流れを整える 🌊 claude ━━ ░━━━━━░░━━━━░░━━━━░░━━━┿ 49m -driga -├── ▶ 流れを整える 🌊 claude ━━━━░░━━━━━░░━━━━░░━━━━░░━━━┿ 1h 45m +echo-pro +├── ▶ Investigate cache inv… codex ░━━━░░━━━━━░░━━━━░░━━━━░░━━━━ 1h 46m +├── ✓ Investigate cache inv… codex ░░░░░░░░░━━░░━━━━░░━━✓━ ━━━━ 1h 22m +├── ○ Fix retry after recon… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 10m +├── ‖ Investigate cache inv… codex ░░░░░░░━━━━░░━━━━░░━━━━░░━━━░ 1m +└── ▶ Fix retry after recon… codex ━━━ ━━━━━░░━━━━░░━━━━░░━━━━ 46m + └── ○ Fix retry after re… codex ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 34m +inloop +├── ‖ An intentionally long… gemini ░░━━░░━━━━━░░━━━━░░━━━━░░━━━░ 3m +├── ○ An intentionally long… gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 24m +├── ✓ Polish onboarding copy gemini ░░░░░░━━━━━░░━━━━░░━━━━░░━✓━━ 1h 12m +├── ○ An intentionally long… gemini ░░░░░░░░░░░░░░░░░░░░░░░░░░░░─ 1h 0m +└── ‖ Polish onboarding copy gemini ━━ ░━━━━━░░━━━━░░━━━━░░━━━░ 3m + └── ▶ Polish onboarding … gemini ░░░░░░━━━━━░░━━━━░░━━━━░░━━━━ 1h 36m +monk +├── › ◆ Ship the release pipe… copilot ░━━━░░━━━━━░░━━━━◆◆◆◆◆◆◆◆◆◆◆◆ 23m ──────────────────────────────────────────────────────────────────────────────── -Fix retry after reconnect · codex · idle +Ship the release pipeline safely · copilot · waiting +cost $0.49 · retained window Enter Open session · J Terminal · K Stop · c Ask Chat · Tab Needs you · / Find diff --git a/internal/view/board_map.go b/internal/view/board_map.go index d2d8a6f0..04b488d5 100644 --- a/internal/view/board_map.go +++ b/internal/view/board_map.go @@ -28,7 +28,7 @@ type BoardMapNode struct { type BoardEdge struct { Path, Kind, File string A, B string // overlap endpoints, shown while either is selected - Region, X, Y, W int + Region int } type BoardBadge struct { Region, X, Y, TX, TY int @@ -214,7 +214,11 @@ func (b *BoardView) buildMap(remote map[string]bool) { if !ok || c.Region != a.Region { continue // across projects the "┄ file" tag on each session says it } - b.Map.Edges = append(b.Map.Edges, BoardEdge{Region: a.Region, Kind: "overlap", File: name, A: keys[centre], B: k, W: len([]rune(name))*7 + 16, X: (a.X+c.X)/2 - (len([]rune(name))*7+16)/2, Y: min(a.Y, c.Y) - boardMapRow/2 - 10 + abs(a.Y-c.Y)/4, Path: fmt.Sprintf("M %d %d Q %d %d %d %d", a.X, a.Y, (a.X+c.X)/2, min(a.Y, c.Y)-boardMapRow, c.X, c.Y)}) + // Sessions in a region share one column, so a link drawn between + // them crosses nodes and titles. A bracket in the region's left + // margin joins them instead; each node's meta already names the file. + b.Map.Edges = append(b.Map.Edges, BoardEdge{Region: a.Region, Kind: "overlap", File: name, A: keys[centre], B: k, + Path: fmt.Sprintf("M %d %d H %d V %d H %d", a.X-boardMapRadius-3, a.Y, boardMapGutter, c.Y, c.X-boardMapRadius-3)}) } } for i := range b.Needs { @@ -225,6 +229,7 @@ func (b *BoardView) buildMap(remote map[string]bool) { const ( boardMapBlock = 250 // region content width: families stack in one column boardMapPad = 20 // inside a region + boardMapGutter = 7 // overlap brackets run down the region's left margin boardMapHead = 44 // region title band boardMapRow = 48 // one session row boardMapRadius = 14 diff --git a/internal/view/board_test.go b/internal/view/board_test.go index d9abf142..00d9d5e6 100644 --- a/internal/view/board_test.go +++ b/internal/view/board_test.go @@ -1,6 +1,7 @@ package view_test import ( + "fmt" "reflect" "testing" "time" @@ -83,6 +84,17 @@ func TestBoardOverlapRequiresSameMachineAndFullPath(t *testing.T) { if e.File != "file.go" { t.Fatal(e) } + // The link stays in the region's left margin, clear of every node + // and title: x never reaches past the nodes' left edge. + var x0, y0, x1, y1, x2 int + if _, err := fmt.Sscanf(e.Path, "M %d %d H %d V %d H %d", &x0, &y0, &x1, &y1, &x2); err != nil { + t.Fatal(e.Path, err) + } + for _, n := range b.Map.Nodes { + if n.Region == e.Region && max(x0, x1, x2) >= n.X-14 { + t.Fatalf("overlap link %q crosses node %q at x=%d", e.Path, n.Key, n.X) + } + } } } if overlaps != 1 { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 46e6a7e6..46c72841 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -800,3 +800,8 @@ meter::-moz-meter-bar { .machine-app code { overflow-wrap:anywhere; } .reclaim-nav{display:flex;gap:18px;padding:10px 14px;border-bottom:1px solid var(--line)} + +/* Feedback that survives a refresh: outside #main, announced politely. */ +.app-notice{position:fixed;left:50%;bottom:24px;z-index:50;margin:0;padding:10px 16px;border:1px solid var(--line);border-radius:8px;background:var(--raise);color:var(--fg);box-shadow:0 8px 30px rgb(0 0 0 / .25);transform:translate(-50%,8px);opacity:0;pointer-events:none;transition:opacity .15s,transform .15s} +.app-notice.show{opacity:1;transform:translate(-50%,0)} +@media (prefers-reduced-motion:reduce){.app-notice{transition:none}} diff --git a/internal/web/static/flow-board.css b/internal/web/static/flow-board.css index 170f0594..e48fa899 100644 --- a/internal/web/static/flow-board.css +++ b/internal/web/static/flow-board.css @@ -1,10 +1,10 @@ .flow-board{background:var(--flow-canvas);color:var(--fg);min-width:0;font-family:"IBM Plex Sans",system-ui,sans-serif} .flow-board button,.flow-board a{font:inherit}.flow-board button{cursor:pointer;border:1px solid var(--flow-line);border-radius:7px;background:transparent;color:inherit;padding:6px 10px}.flow-board button:disabled{opacity:.45;cursor:default}.flow-board [hidden]{display:none!important}.flow-board :focus-visible{outline:2px solid var(--accent);outline-offset:3px} .fb-header{display:flex;align-items:center;gap:24px;padding:12px 22px;border-bottom:1px solid var(--flow-line);font:12px "IBM Plex Mono",monospace}.fb-views{display:flex;padding:3px;background:var(--flow-panel);border-radius:9px;gap:2px}.fb-views button{border:0}.fb-views [aria-selected=true]{background:var(--line)}.fb-counts{display:flex;gap:16px}.fb-counts b{font-weight:500}.fb-spark{color:var(--observation);font-size:17px;letter-spacing:1px}.fb-search{margin-left:auto}.fb-search input{width:130px;padding:7px 12px;background:var(--flow-panel);border:1px solid var(--flow-line);border-radius:7px;color:inherit;font:inherit}.fb-updated{color:var(--flow-muted);white-space:nowrap} -.fb-workspace{display:grid;grid-template-columns:minmax(0,1fr) 350px;min-height:620px;height:calc(100vh - 160px)}.fb-canvas,.fb-timeline,.fb-sessions{overflow:auto;min-width:0;min-height:0}.fb-canvas{padding:16px}.fb-map{columns:290px;column-gap:16px}.fb-region-map{display:block;break-inside:avoid;margin-bottom:16px;max-width:100%;height:auto;overflow:visible}.fb-region-map text{font:11px "IBM Plex Mono",monospace}.fb-node-glyph{font:600 11px "IBM Plex Mono",monospace!important}.fb-node-meta{fill:var(--flow-muted);font-size:10px!important}.fb-node.waiting .fb-node-meta{fill:var(--warn)}.fb-node.stalled .fb-node-meta{fill:var(--stalled)}.fb-node:hover .fb-node-title{text-decoration:underline}.fb-region{fill:var(--fg);fill-opacity:.02;stroke:var(--flow-line)}.fb-region-title{fill:var(--mid);font-weight:600!important;letter-spacing:1.2px}.fb-region-sub{fill:var(--flow-muted);font-size:9px!important}.fb-edge{fill:none;stroke-width:2;stroke-linejoin:round;stroke:var(--accent);opacity:.6}.fb-edge.overlap{stroke:var(--stalled);stroke-dasharray:3 5;opacity:.9}.fb-file-pill rect{fill:var(--flow-canvas);stroke:var(--stalled)}.fb-file-pill text{fill:var(--stalled);font-size:10px}.fb-node{cursor:pointer}.fb-selection{fill:none;stroke:var(--accent);stroke-width:2;visibility:hidden}.fb-node.selected .fb-selection{visibility:visible}.fb-wait-ring{fill:none;stroke:var(--warn);stroke-width:2}.fb-node-title{fill:var(--fg);font:12px "IBM Plex Sans",system-ui,sans-serif!important}.fb-fold{cursor:pointer;fill:var(--accent)}.fb-fold rect{fill:var(--flow-canvas);stroke:var(--accent)} +.fb-workspace{display:grid;grid-template-columns:minmax(0,1fr) 350px;min-height:620px;height:calc(100vh - 160px)}.fb-canvas,.fb-timeline,.fb-sessions{overflow:auto;min-width:0;min-height:0}.fb-canvas{padding:16px}.fb-map{columns:290px;column-gap:16px}.fb-region-map{display:block;break-inside:avoid;margin-bottom:16px;max-width:100%;height:auto;overflow:visible}.fb-region-map text{font:11px "IBM Plex Mono",monospace}.fb-node-glyph{font:600 11px "IBM Plex Mono",monospace!important}.fb-node-meta{fill:var(--flow-muted);font-size:10px!important}.fb-node.waiting .fb-node-meta{fill:var(--warn)}.fb-node.stalled .fb-node-meta{fill:var(--stalled)}.fb-node:hover .fb-node-title{text-decoration:underline}.fb-region{fill:var(--fg);fill-opacity:.02;stroke:var(--flow-line)}.fb-region-title{fill:var(--mid);font-weight:600!important;letter-spacing:1.2px}.fb-region-sub{fill:var(--flow-muted);font-size:9px!important}.fb-edge{fill:none;stroke-width:2;stroke-linejoin:round;stroke:var(--accent);opacity:.6}.fb-edge.overlap{stroke:var(--stalled);stroke-dasharray:3 5;opacity:.9}.fb-node{cursor:pointer}.fb-selection{fill:none;stroke:var(--accent);stroke-width:2;visibility:hidden}.fb-node.selected .fb-selection{visibility:visible}.fb-wait-ring{fill:none;stroke:var(--warn);stroke-width:2}.fb-node-title{fill:var(--fg);font:12px "IBM Plex Sans",system-ui,sans-serif!important}.fb-fold{cursor:pointer;fill:var(--accent)}.fb-fold rect{fill:var(--flow-canvas);stroke:var(--accent)} .fb-panel{border-left:1px solid var(--flow-line);padding:18px 20px;background:var(--flow-panel);overflow:auto;display:flex;flex-direction:column;gap:14px}.fb-panel h2{font:600 11px "IBM Plex Mono",monospace;letter-spacing:1px;text-transform:uppercase;margin:0}.fb-panel small{font:10px "IBM Plex Mono",monospace;color:var(--flow-muted)}.fb-panel-heading{display:flex;justify-content:space-between;align-items:baseline;gap:10px}.fb-needs{display:grid;gap:7px}.fb-need{border:1px solid var(--flow-line);border-radius:9px;padding:8px 10px;background:var(--flow-canvas)}.fb-need.primary{border-color:var(--warn);padding:13px}.fb-need-select{display:flex;justify-content:space-between;width:100%;border:0!important;padding:0!important;text-align:left;gap:10px}.fb-need b{font-size:12px;font-weight:600}.fb-need small{display:block;margin:5px 0}.fb-need:not(.primary) .fb-command{display:none}.fb-command code{display:block;white-space:pre-wrap;overflow-wrap:anywhere;background:var(--flow-canvas);padding:9px;border-radius:6px;color:var(--waiting);font:11px/1.6 "IBM Plex Mono",monospace}.fb-answer{display:flex;gap:8px;margin-top:9px}.fb-answer button{flex:1}.fb-answer [data-answer=allow]{background:var(--working);color:var(--flow-canvas);border:0}.fb-family{border-top:1px solid var(--flow-line);padding-top:18px}.fb-family h2{color:var(--accent);margin-bottom:12px}.fb-mini-row{width:100%;display:grid;grid-template-columns:45% 1fr;gap:8px;border:0!important;padding:6px!important;align-items:center}.fb-mini-row>span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;font:11px "IBM Plex Mono",monospace}.fb-mini-row.selected{background:var(--line)}.fb-detail h3{font-size:13px;margin:0 0 12px}.fb-detail dl{display:grid;grid-template-columns:auto minmax(0,1fr);gap:7px 12px;font:11px/1.6 "IBM Plex Mono",monospace}.fb-detail dt{color:var(--flow-muted)}.fb-detail dd{margin:0;overflow-wrap:anywhere}.fb-detail-actions{display:flex;gap:6px;margin-top:16px}.fb-detail-actions>*{flex:1;white-space:nowrap;padding-inline:4px!important;font-size:11px!important;text-align:center}.fb-detail-actions a{border:1px solid var(--flow-line);border-radius:7px;padding:6px 4px;text-decoration:none;color:inherit}.fb-status{font:11px "IBM Plex Mono",monospace;overflow-wrap:anywhere}.fb-empty{padding:90px 30px;text-align:center;color:var(--flow-muted)} .fb-timeline{padding:22px}.fb-axis,.fb-row{display:grid;grid-template-columns:minmax(190px,32%) minmax(70px,1fr) 58px;gap:16px;align-items:center;padding:0 8px}.fb-axis{color:var(--flow-muted);font:10px "IBM Plex Mono",monospace;border-bottom:1px solid var(--flow-line);padding-bottom:12px}.fb-axis>span:nth-child(2){display:flex;justify-content:space-between}.fb-axis i{font-style:normal}.fb-row{min-height:34px;border-radius:6px;cursor:pointer}.fb-row.selected{background:var(--line);box-shadow:inset 2px 0 var(--accent)}.fb-project{font:600 11px "IBM Plex Mono",monospace;letter-spacing:1px;margin:20px 8px 8px;color:var(--mid)}.fb-project small{font-weight:400;letter-spacing:0;color:var(--flow-muted);margin-left:10px}.fb-row-title{display:flex;align-items:center;gap:8px;min-width:0;color:var(--fg)}.fb-row-title b{font:12px "IBM Plex Sans",system-ui,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fb-row-title small{font:9px "IBM Plex Mono",monospace;color:var(--flow-muted)}.fb-branch{white-space:pre;color:var(--rail);font:12px "IBM Plex Mono",monospace}.fb-glyph{color:inherit}.fb-row-age{font:11px "IBM Plex Mono",monospace;text-align:right}.fb-strip{display:flex;position:relative;height:14px;align-items:center;color:var(--fg)}.fb-cell{display:block;flex:1;height:8px;background:transparent;position:relative}.fb-cell.working{background:var(--working);opacity:.55}.fb-cell.waiting{background:var(--warn);opacity:.8}.fb-cell.idle{background:var(--idle);opacity:.45}.fb-cell.stalled{background:repeating-linear-gradient(90deg,var(--stalled) 0 3px,transparent 3px 5px)}.fb-cell.finished{background:var(--flow-muted);opacity:.4}.fb-cell.unknown{background:var(--line);opacity:.4}.fb-cell.tool-tick:after{content:"";position:absolute;width:1px;height:14px;top:-3px;left:50%;background:var(--fg)}.fb-strip>.fb-overlap{position:absolute;right:0;top:-3px;font:9px "IBM Plex Mono",monospace;background:var(--flow-canvas)}.fb-foot{display:flex;flex-wrap:nowrap;align-items:center;gap:8px 24px;padding:10px 24px;border-top:1px solid var(--flow-line)}.fb-legend{display:flex;flex-wrap:wrap;gap:16px;color:var(--flow-muted);font:10px "IBM Plex Mono",monospace}.fb-spawn{color:var(--accent)}.fb-overlap{color:var(--stalled)}.flow-board .working{color:var(--working)}.flow-board .waiting{color:var(--warn)}.flow-board .stalled{color:var(--stalled)}.flow-board .idle{color:var(--idle)}.flow-board .finished{color:var(--flow-muted)}.fb-sessions{padding:20px}.fb-sessions table{width:100%;border-collapse:collapse;font-size:12px}.fb-sessions td,.fb-sessions th{padding:10px;text-align:left;border-bottom:1px solid var(--flow-line)}.fb-sessions tr{cursor:pointer}.fb-sessions tr.selected{background:var(--line)} -@media(max-width:1050px){.fb-header{flex-wrap:wrap;gap:12px}.fb-workspace{grid-template-columns:minmax(0,1fr) 310px}.fb-spark{display:none}.fb-row-title small{display:none}}@media(max-width:760px){.fb-workspace{display:flex;flex-direction:column;height:auto}.fb-canvas,.fb-timeline,.fb-sessions{max-height:65vh}.fb-panel{border-left:0;border-top:1px solid var(--flow-line)}.fb-header{padding:12px}.fb-counts{gap:9px}.fb-row{gap:8px;grid-template-columns:45% 1fr 40px}} +@media(max-width:1050px){.fb-header{flex-wrap:wrap;gap:12px}.fb-workspace{grid-template-columns:minmax(0,1fr) 310px}.fb-spark{display:none}.fb-row-title small{display:none}}@media(max-width:760px){.fb-workspace{display:flex;flex-direction:column;height:auto}.fb-canvas,.fb-timeline,.fb-sessions{max-height:none}.fb-map{columns:auto}.fb-region-map{width:100%}.fb-panel{border-left:0;border-top:1px solid var(--flow-line)}.fb-panel:has(.fb-need){order:-1;border-top:0;border-bottom:1px solid var(--flow-line)}.fb-header{padding:12px}.fb-counts{gap:9px}.fb-row{gap:8px;grid-template-columns:45% 1fr 40px}} @media(prefers-reduced-motion:reduce){.flow-board *{animation:none!important;transition:none!important}} .fb-activity{margin-left:auto;color:var(--flow-muted);font:11px "IBM Plex Mono",monospace}.fb-foot:has(.fb-activity[open]){flex-wrap:wrap}.fb-activity[open]{flex-basis:100%;margin-left:0}.fb-node-title,.fb-node-meta{paint-order:stroke;stroke:var(--flow-canvas);stroke-width:4px;stroke-linejoin:round}.fb-activity>summary{cursor:pointer}.fb-activity h2{font-size:12px}.fb-activity .tail{max-height:300px;overflow:auto} /* Flow owns its monitoring chrome; host diagnosis remains in Machines. */ @@ -25,3 +25,4 @@ body:has(.flow-board) .grid+footer{display:none} .fb-overlap-link{display:none}.fb-overlap-link.on{display:inline}.fb-node-meta .fb-overlap{fill:var(--stalled)} .fb-detail-actions .fb-stop{color:var(--stalled);border-color:color-mix(in srgb,var(--stalled) 45%,transparent)} @media(max-width:760px){.fb-header{flex-wrap:wrap;gap:10px 14px}.fb-search{margin-left:0}.fb-search input{width:110px}.fb-canvas{padding:10px}body:has(.flow-board) .wins{height:auto;min-height:48px;flex-wrap:wrap;padding:6px 10px}} + diff --git a/internal/web/static/flow-board.js b/internal/web/static/flow-board.js index 9d4747e7..8ae86e11 100644 --- a/internal/web/static/flow-board.js +++ b/internal/web/static/flow-board.js @@ -3,11 +3,11 @@ const duration=ms=>{const m=Math.max(0,Math.floor(ms/60000));return m>=60?Math.floor(m/60)+"h "+m%60+"m":m?m+"m":Math.max(0,Math.floor(ms/1000))+"s";}; // State lives outside the morphed subtree. Identity, never a row index, owns // selection; new waits cannot take focus or change the selected session. - const state={view:typeof location!=="undefined"&&["map","timeline","sessions"].includes(new URLSearchParams(location.search).get("view"))?new URLSearchParams(location.search).get("view"):"map",key:"",query:"",sort:"title",descending:false,needsExpanded:false,collapsed:new Set()}; + const state={view:typeof location!=="undefined"&&["map","timeline","sessions"].includes(new URLSearchParams(location.search).get("view"))?new URLSearchParams(location.search).get("view"):"map",key:"",query:"",sort:"title",descending:false,needsExpanded:false,collapsed:new Set(),touched:typeof location!=="undefined"&&(new URLSearchParams(location.search).has("id")||/^\/flow\/\d+$/.test(location.pathname))}; const nextIndex=(index,key,length)=>key==="Home"?0:key==="End"?length-1:Math.max(0,Math.min(length-1,index+(["ArrowUp","k"].includes(key)?-1:1))); globalThis.DrigaBoard=Object.freeze({duration,nextIndex}); if(typeof document==="undefined")return; - const armed=new Map(),inFlight=new Set();let scroll=[],focusId="",caret=null,opened=[],promptOrder=""; + const armed=new Map(),inFlight=new Set();let scroll=[],focusId="",caret=null,opened=[],promptOrder="",focusNeeds=false,noticeTimer=0; const root=()=>document.querySelector("[data-flow-board]"); const nodes=()=>Array.from(root()?.querySelectorAll('[data-flow-surface="'+state.view+'"] [data-board-key]')||[]).filter(n=>!n.hidden); const row=key=>root()?.querySelector('[data-board-key="'+CSS.escape(key)+'"]'); @@ -21,7 +21,9 @@ function filter(){const all=Array.from(root()?.querySelectorAll("[data-board-key]")||[]),timeline=all.filter(n=>n.getAttribute("role")==="treeitem");const parents=new Map(timeline.map(n=>[n.dataset.boardKey,n.dataset.parent]));for(const n of all){let parent=n.dataset.parent,folded=false;while(parent){if(state.collapsed.has(parent)){folded=true;break;}parent=parents.get(parent);}n.hidden=folded||!!state.query&&!(n.dataset.title+" "+n.dataset.project).toLocaleLowerCase().includes(state.query);if(timeline.some(c=>c.dataset.parent===n.dataset.boardKey))n.setAttribute("aria-expanded",String(!state.collapsed.has(n.dataset.boardKey)));}} function order(){const body=root()?.querySelector("tbody");if(!body)return;const rows=Array.from(body.children);rows.sort((a,b)=>{const x=a.dataset[state.sort]||"",y=b.dataset[state.sort]||"";const d=state.sort==="age"?Number(x)-Number(y):x.localeCompare(y);return(state.descending?-1:1)*d;});body.append(...rows);} function hydrate(){if(!root())return;const input=root().querySelector("[data-board-search]");input.value=state.query; - if(!state.key||!row(state.key)){const q=new URLSearchParams(location.search);state.key=Array.from(root().querySelectorAll("[data-board-key]")).find(n=>n.dataset.session===(q.get("id")||location.pathname.match(/^\/flow\/(\d+)$/)?.[1])&&(!q.get("machine")||n.dataset.machine===q.get("machine")))?.dataset.boardKey||root().querySelector("[data-board-key]")?.dataset.boardKey||"";} + // Until the user picks a session, the selection follows the oldest need. + const need=root().querySelector("[data-need-key]")?.dataset.needKey;if(need&&!state.touched&&state.key!==need&&row(need))state.key=need; + if(!state.key||!row(state.key)){const q=new URLSearchParams(location.search);state.key=Array.from(root().querySelectorAll("[data-board-key]")).find(n=>n.dataset.session===(q.get("id")||location.pathname.match(/^\/flow\/(\d+)$/)?.[1])&&(!q.get("machine")||n.dataset.machine===q.get("machine")))?.dataset.boardKey||root().querySelector("[data-need-key]")?.dataset.needKey||root().querySelector("[data-board-key]")?.dataset.boardKey||"";} show(state.view);for(const n of root().querySelectorAll("[data-need-overflow]"))n.hidden=!state.needsExpanded;order();tick(); } function tick(){const host=root();if(!host)return;const at=Date.parse(host.dataset.observed);host.querySelector("[data-board-updated]").textContent="updated "+duration(Date.now()-at)+" ago"; @@ -31,29 +33,35 @@ for(const n of host.querySelectorAll("[data-prompt]")){const id=n.dataset.prompt;if(!armed.has(id))armed.set(id,Date.now()+600);for(const b of n.querySelectorAll("button"))b.disabled=Date.now(){const n=root()?.querySelector("[data-board-status]");if(n)n.textContent=text;}; + // The refresh morphs #main, so feedback lives in a notice outside it. + const note=text=>{const n=document.getElementById("app-notice");if(!n)return;n.textContent=text;n.classList.add("show");clearTimeout(noticeTimer);noticeTimer=setTimeout(()=>n.classList.remove("show"),4000);}; document.addEventListener("click",async event=>{const host=root();if(!host||!host.contains(event.target))return;const t=event.target; const view=t.closest("[data-flow-view]");if(view){show(view.dataset.flowView);return;} const sort=t.closest("[data-sort]");if(sort){state.descending=state.sort===sort.dataset.sort?!state.descending:false;state.sort=sort.dataset.sort;order();return;} const more=t.closest("[data-show-needs]");if(more){state.needsExpanded=!state.needsExpanded;for(const n of host.querySelectorAll("[data-need-overflow]"))n.hidden=!state.needsExpanded;return;} const fold=t.closest("[data-fold]");if(fold){show("timeline");if(fold.dataset.fold)select(fold.dataset.fold);nodes().find(n=>n.dataset.boardKey===state.key)?.focus();return;} - const pick=t.closest("[data-board-key],[data-select]");if(pick){select(pick.dataset.boardKey||pick.dataset.select);const need=t.closest("[data-need-key]");if(need){for(const n of host.querySelectorAll(".fb-need"))n.classList.toggle("primary",n===need);}return;} - const answer=t.closest("[data-answer]");if(answer){const id=answer.closest("[data-prompt]").dataset.prompt;if(answer.disabled||inFlight.has(id)||Date.now(){if(event.target.matches("[data-board-search]")){state.query=event.target.value.toLocaleLowerCase();filter();}}); document.addEventListener("keydown",event=>{if(event.defaultPrevented||event.metaKey||event.ctrlKey||event.altKey||!root())return;if(event.target.closest("input,textarea,select,[contenteditable=true],#chat"))return; if(event.key==="/"){event.preventDefault();root().querySelector("[data-board-search]").focus();return;} + // y/n answer the prompt whose card has focus, as in the terminal; the same + // arming delay applies because the buttons stay disabled until armed. + const need=event.target.closest(".fb-need");if(need&&["y","n"].includes(event.key)){const b=need.querySelector('[data-answer="'+(event.key==="y"?"allow":"deny")+'"]');if(b){event.preventDefault();b.click();}return;} if(["m","t","s"].includes(event.key)){event.preventDefault();show({m:"map",t:"timeline",s:"sessions"}[event.key]);return;} const tab=event.target.closest("[data-flow-view]");if(tab&&["ArrowLeft","ArrowRight"].includes(event.key)){event.preventDefault();const views=["map","timeline","sessions"];show(views[(views.indexOf(state.view)+(event.key==="ArrowLeft"?2:1))%3]);root().querySelector('[data-flow-view="'+state.view+'"]').focus();return;} - const target=event.target.closest("[data-board-key]");if(["ArrowDown","ArrowUp","Home","End","j","k"].includes(event.key)){const rows=nodes();if(!rows.length)return;event.preventDefault();const index=Math.max(0,rows.findIndex(n=>n.dataset.boardKey===(target?.dataset.boardKey||state.key)));const next=rows[nextIndex(index,event.key,rows.length)];select(next.dataset.boardKey);next.focus();return;} + const target=event.target.closest("[data-board-key]");if(["ArrowDown","ArrowUp","Home","End","j","k"].includes(event.key)){const rows=nodes();if(!rows.length)return;event.preventDefault();const index=Math.max(0,rows.findIndex(n=>n.dataset.boardKey===(target?.dataset.boardKey||state.key)));const next=rows[nextIndex(index,event.key,rows.length)];state.touched=true;select(next.dataset.boardKey);next.focus();return;} if(target&&["ArrowLeft","ArrowRight"].includes(event.key)&&state.view==="timeline"){event.preventDefault();const rows=nodes(),child=rows.find(n=>n.dataset.parent===target.dataset.boardKey);if(event.key==="ArrowLeft"&&child){state.collapsed.add(target.dataset.boardKey);filter();return;}if(event.key==="ArrowRight"&&state.collapsed.has(target.dataset.boardKey)){state.collapsed.delete(target.dataset.boardKey);filter();return;}const next=event.key==="ArrowLeft"?rows.find(n=>n.dataset.boardKey===target.dataset.parent):child;if(next){select(next.dataset.boardKey);next.focus();}return;} if(["Enter"," "].includes(event.key)&&target){event.preventDefault();select(target.dataset.boardKey);if(state.view==="sessions"&&event.key==="Enter")window.location.assign("/agents/"+target.dataset.session+"?machine="+encodeURIComponent(target.dataset.machine));return;} const fold=event.target.closest("[data-fold]");if(fold&&["Enter"," "].includes(event.key)){event.preventDefault();fold.dispatchEvent(new MouseEvent("click",{bubbles:true}));} }); document.addEventListener("htmx:beforeSwap",()=>{const active=document.activeElement;focusId=root()?.contains(active)?active.id:"";caret=active?.matches("input")?[active.selectionStart,active.selectionEnd]:null;scroll=Array.from(root()?.querySelectorAll(".fb-canvas,.fb-timeline,.fb-sessions,.fb-panel")||[]).map(n=>[n.className,n.scrollTop,n.scrollLeft]);opened=Array.from(root()?.querySelectorAll("details[open][id]")||[],n=>n.id);}); - document.addEventListener("htmx:afterSwap",()=>{for(const id of opened)document.getElementById(id)?.setAttribute("open","");hydrate();for(const [cls,top,left]of scroll){const n=root()?.querySelector("."+cls);if(n){n.scrollTop=top;n.scrollLeft=left;}}if(focusId){const n=document.getElementById(focusId);if(n){n.focus({preventScroll:true});if(caret&&typeof n.setSelectionRange==="function"){try{n.setSelectionRange(...caret);}catch(_){}}}}}); + document.addEventListener("htmx:afterSwap",()=>{for(const id of opened)document.getElementById(id)?.setAttribute("open","");hydrate();for(const [cls,top,left]of scroll){const n=root()?.querySelector("."+cls);if(n){n.scrollTop=top;n.scrollLeft=left;}}if(focusId){const n=document.getElementById(focusId);if(n){n.focus({preventScroll:true});if(caret&&typeof n.setSelectionRange==="function"){try{n.setSelectionRange(...caret);}catch(_){}}}} + // After an answer, focus moves to the next need, or to the shelf heading. + if(focusNeeds&&document.activeElement===document.body){focusNeeds=false;(root()?.querySelector(".fb-need .fb-need-select")||root()?.querySelector("#fb-needs-heading"))?.focus({preventScroll:true});}}); document.addEventListener("driga:select-session",e=>{const n=Array.from(root()?.querySelectorAll("[data-board-key]")||[]).find(n=>Number(n.dataset.session)===Number(e.detail.id));if(n)select(n.dataset.boardKey);}); hydrate();setInterval(tick,1000); })(); diff --git a/internal/web/templates/board.html b/internal/web/templates/board.html index 322c5f4e..24ed67f3 100644 --- a/internal/web/templates/board.html +++ b/internal/web/templates/board.html @@ -10,7 +10,7 @@
{{if .Board.Map.Nodes}}
{{$m := .Board.Map}}{{range $i, $r := $m.Regions}} {{.Label}}{{.Sub}} - {{range $m.Edges}}{{if eq .Region $i}}{{if .File}}{{.File}}{{else}}{{end}}{{end}}{{end}} + {{range $m.Edges}}{{if eq .Region $i}}{{if .File}}Both edit {{.File}}{{else}}{{end}}{{end}}{{end}} {{range $m.Nodes}}{{if eq .Region $i}} {{.Title}} · {{.Node.Adapter}} · {{.State}}{{if eq .State "waiting"}}{{end}} {{.Glyph}} @@ -24,7 +24,7 @@