From bbbd45931beea9bdcc31ab6e328a67fb2f98bdf8 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 09:50:13 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(THI-243):=20grouping=20mode=20toggle?= =?UTF-8?q?=20=E2=80=94=20discovery=20(repos)=20view,=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a global grouping-mode toggle that flips between two ways of organizing the dashboard: 1. "sessions" — the historical behavior, group by tmux session. 2. "repos" — discovery view, group panes under their git repo, sessions atomic to one repo via first-seen-wins. Lands all the infrastructure (backend field, setting, pure helper, toggle UI) plus ListView re-rendering in discovery mode. Kanban / Grid follow in separate PRs once the simpler List discovery feel is validated. Backend: - Window schema gains `repo_key` + `repo_label`. Derived per pane via `git rev-parse --show-toplevel`, TTL-cached 60 s. None for non-git cwds; basename of toplevel for the label. Frontend: - types.Window: new repoKey/repoLabel fields, also added to every local test factory. - Setting `groupingMode: "sessions" | "repos"`, default "sessions". - Segmented chip pair in Subhead (next to the layout switcher). - New pure helper lib/groupByRepo.ts: session→repo first-seen-wins assignment, sessions atomic, "Other" pinned to bottom for non-git-resolvable sessions. 8 unit tests. - ListView accepts groupingMode prop; in "repos" mode renders a repo header row before each group's windows. 3 new tests for the discovery rendering. Explicitly deferred (separate tickets): - Kanban swim-lane axis (status × repo grid). - Grid section headers per repo. - Drag-reorder persistence (`repo_order`, `sessions_by_repo`). Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/switchboard/schemas.py | 10 ++ .../src/switchboard/services/claude_parser.py | 39 +++++ backend/src/switchboard/services/tmux.py | 11 ++ backend/tests/test_claude_parser.py | 94 ++++++++++++ frontend/src/App.tsx | 1 + frontend/src/api/client.test.ts | 2 + .../src/components/CommandPalette.test.tsx | 2 + frontend/src/components/GridView.test.tsx | 2 + frontend/src/components/ListView.test.tsx | 66 ++++++++ frontend/src/components/ListView.tsx | 66 ++++++-- frontend/src/components/NeedsStrip.test.tsx | 2 + frontend/src/components/Subhead.test.tsx | 32 ++++ frontend/src/components/Subhead.tsx | 33 ++++ frontend/src/components/WindowCard.test.tsx | 2 + frontend/src/lib/groupByRepo.test.ts | 144 ++++++++++++++++++ frontend/src/lib/groupByRepo.ts | 84 ++++++++++ frontend/src/lib/pollTier.test.ts | 2 + frontend/src/lib/quickActions.test.ts | 2 + frontend/src/lib/settings.ts | 9 ++ frontend/src/lib/useNeedsStripDismiss.test.ts | 2 + frontend/src/styles/styles.css | 53 +++++++ frontend/src/test/factories.ts | 2 + frontend/src/types.ts | 8 + 23 files changed, 652 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/groupByRepo.test.ts create mode 100644 frontend/src/lib/groupByRepo.ts diff --git a/backend/src/switchboard/schemas.py b/backend/src/switchboard/schemas.py index 0fb7c13..7db3506 100644 --- a/backend/src/switchboard/schemas.py +++ b/backend/src/switchboard/schemas.py @@ -113,6 +113,16 @@ class Window(_CamelModel): # the cwd isn't inside a github repo. Drives the in-pane `PR #N` linkifier # so a non-current PR mention in an agent footer is still clickable. repo_url: str | None = None + # THI-243: git toplevel path (`git rev-parse --show-toplevel`) for the + # pane's cwd, or None when the cwd isn't inside a git repo. The frontend + # groups panes by `repoKey` in discovery mode. Independent of `repo_url`: + # `repo_key` is the local filesystem identity; `repo_url` is the github + # remote URL. + repo_key: str | None = None + # THI-243: human-readable label for the repo, derived from the toplevel's + # basename. Two repos with the same basename render the same label; the + # frontend disambiguates via tooltip with the full `repo_key`. + repo_label: str | None = None agent: Agent | None = None preview: list[str] = [] diff --git a/backend/src/switchboard/services/claude_parser.py b/backend/src/switchboard/services/claude_parser.py index e3176a7..7d0eb4b 100644 --- a/backend/src/switchboard/services/claude_parser.py +++ b/backend/src/switchboard/services/claude_parser.py @@ -441,6 +441,10 @@ def _scan_open_question(lines: list[str]) -> str | None: tuple[float, tuple[int | None, CIState | None, str | None]], ] = {} _REPO_URL_CACHE: dict[str, tuple[float, str | None]] = {} +# THI-243: repo-root cache, used by the grouping-mode toggle to map each pane +# to its containing repo. Long TTL because repo roots are extremely stable — +# users don't routinely move git checkouts around. +_REPO_ROOT_CACHE: dict[str, tuple[float, str | None]] = {} # Branch resolution caches per-cwd; the key doesn't change when the user runs # `git checkout` from inside the pane, so a long TTL freezes the dashboard # branch chip until expiry (THI-126). With N agent panes polled at ~500 ms @@ -467,6 +471,10 @@ def _scan_open_question(lines: list[str]) -> str | None: # from `_PR_CACHE` so panes on a branch with no PR still get the URL for the # in-pane `PR #N` linkifier (THI-146 PR 2). _REPO_URL_TTL_SECONDS = 300.0 +# THI-243: repo root rarely changes. Long TTL is fine — even a `git checkout` +# in the pane doesn't move the toplevel. A 60s window keeps the discovery +# view responsive when the user opens a new pane in a new repo. +_REPO_ROOT_TTL_SECONDS = 60.0 def _git_branch(cwd: str | None) -> str | None: @@ -498,6 +506,37 @@ def _git_branch(cwd: str | None) -> str | None: return branch +def _git_repo_root(cwd: str | None) -> str | None: + """THI-243: resolve `cwd` to its git toplevel via `git rev-parse + --show-toplevel`. None for non-git cwds, missing tools, or timeouts. The + resulting path feeds the grouping-mode toggle's discovery view. + + Cached per cwd at `_REPO_ROOT_TTL_SECONDS` — see comments next to the TTL + constant for the rationale (toplevel rarely moves).""" + if not cwd: + return None + now = time.monotonic() + cached = _REPO_ROOT_CACHE.get(cwd) + if cached and now - cached[0] < _REPO_ROOT_TTL_SECONDS: + return cached[1] + try: + out = subprocess.run( + ["git", "-C", cwd, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=0.5, + ) + root = out.stdout.strip() if out.returncode == 0 else None + # Empty string is meaningless; coerce to None so callers don't have to + # handle two empty-y values. + if not root: + root = None + except (subprocess.TimeoutExpired, FileNotFoundError): + root = None + _REPO_ROOT_CACHE[cwd] = (now, root) + return root + + def _gh_pr(cwd: str | None, branch: str | None) -> tuple[int | None, CIState | None, str | None]: if not cwd or not branch: return None, None, None diff --git a/backend/src/switchboard/services/tmux.py b/backend/src/switchboard/services/tmux.py index 3530f06..d0aca03 100644 --- a/backend/src/switchboard/services/tmux.py +++ b/backend/src/switchboard/services/tmux.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +import os import subprocess import threading import time @@ -246,6 +247,14 @@ def collect_state() -> StateResponse: # `PR #N` linkifier still has a base URL on branches with no open # PR. Pure local git, cached 5 min. repo_url = claude_parser._git_repo_url(cwd) if cwd else None + # THI-243: repo toplevel + display label, used by the grouping-mode + # toggle to bucket panes in the discovery view. Cached 60 s — long + # enough to absorb noise, short enough that a freshly-opened pane + # in a new repo appears quickly. + repo_key = claude_parser._git_repo_root(cwd) if cwd else None + repo_label = ( + os.path.basename(repo_key.rstrip("/")) if repo_key else None + ) idx = _to_int(w.window_index) windows.append( @@ -266,6 +275,8 @@ def collect_state() -> StateResponse: pr_url=pr_url, ci=ci, repo_url=repo_url, + repo_key=repo_key, + repo_label=repo_label, agent=agent, preview=capture[-8:] if capture else [], ) diff --git a/backend/tests/test_claude_parser.py b/backend/tests/test_claude_parser.py index c2ff472..471b9f4 100644 --- a/backend/tests/test_claude_parser.py +++ b/backend/tests/test_claude_parser.py @@ -944,3 +944,97 @@ def test_parse_pane_distinguishes_different_captures(monkeypatch) -> None: assert agent1 is not None and agent1.spinner is not None assert agent2 is not None assert agent2.spinner is None + + +# THI-243: _git_repo_root maps a cwd to its git toplevel. The grouping-mode +# toggle uses this to bucket panes in the discovery view. +def test_git_repo_root_returns_toplevel_for_git_cwd(monkeypatch) -> None: + from types import SimpleNamespace + + def fake_run(args, **kwargs): + # Expect: git -C rev-parse --show-toplevel + assert args[:2] == ["git", "-C"] + assert args[3:] == ["rev-parse", "--show-toplevel"] + return SimpleNamespace(returncode=0, stdout="/Users/me/dev/foo\n", stderr="") + + monkeypatch.setattr(claude_parser.subprocess, "run", fake_run) + claude_parser._REPO_ROOT_CACHE.clear() + assert claude_parser._git_repo_root("/Users/me/dev/foo/sub") == "/Users/me/dev/foo" + + +def test_git_repo_root_returns_none_for_non_git_cwd(monkeypatch) -> None: + from types import SimpleNamespace + + monkeypatch.setattr( + claude_parser.subprocess, + "run", + lambda *a, **k: SimpleNamespace(returncode=128, stdout="", stderr="fatal"), + ) + claude_parser._REPO_ROOT_CACHE.clear() + assert claude_parser._git_repo_root("/tmp") is None + + +def test_git_repo_root_returns_none_for_empty_cwd() -> None: + # No syscall for empty / None — short-circuit guard. + assert claude_parser._git_repo_root(None) is None + assert claude_parser._git_repo_root("") is None + + +def test_git_repo_root_cache_re_queries_after_ttl(monkeypatch) -> None: + from types import SimpleNamespace + + calls: list[list[str]] = [] + + def fake_run(args, **kwargs): + calls.append(list(args)) + return SimpleNamespace(returncode=0, stdout=fake_run.root + "\n", stderr="") + + fake_run.root = "/repo/a" + monkeypatch.setattr(claude_parser.subprocess, "run", fake_run) + fake_clock = {"t": 0.0} + monkeypatch.setattr(claude_parser.time, "monotonic", lambda: fake_clock["t"]) + claude_parser._REPO_ROOT_CACHE.clear() + + assert claude_parser._git_repo_root("/repo/a/sub") == "/repo/a" + assert len(calls) == 1 + # Within TTL. + fake_clock["t"] = claude_parser._REPO_ROOT_TTL_SECONDS - 0.01 + assert claude_parser._git_repo_root("/repo/a/sub") == "/repo/a" + assert len(calls) == 1 + # Past TTL: re-query, observe the change. + fake_run.root = "/repo/b" + fake_clock["t"] = claude_parser._REPO_ROOT_TTL_SECONDS + 0.01 + assert claude_parser._git_repo_root("/repo/a/sub") == "/repo/b" + assert len(calls) == 2 + + +def test_window_repo_key_and_label_can_carry_independently() -> None: + """THI-243: the discovery view reads repoKey/repoLabel off every Window. + Schema must let the fields carry as their own values (not derived).""" + from switchboard.schemas import Window + + w = Window( + id="main:0", + session="main", + index=0, + name="zsh", + kind="shell", + status="idle", + last_activity=0, + repo_key="/Users/me/dev/foo", + repo_label="foo", + ) + assert w.repo_key == "/Users/me/dev/foo" + assert w.repo_label == "foo" + # Default None when the cwd isn't inside a git repo. + w2 = Window( + id="main:1", + session="main", + index=1, + name="zsh", + kind="shell", + status="idle", + last_activity=0, + ) + assert w2.repo_key is None + assert w2.repo_label is None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e08bf09..c738471 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1015,6 +1015,7 @@ export function App() { onQuickAction={handleQuickAction} pinnedPaneIds={pinnedIds} onTogglePin={onTogglePinWindow} + groupingMode={settings.groupingMode} /> ) : settings.layout === "grid" ? ( ): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...overrides, diff --git a/frontend/src/components/CommandPalette.test.tsx b/frontend/src/components/CommandPalette.test.tsx index d78fe8b..12dceb3 100644 --- a/frontend/src/components/CommandPalette.test.tsx +++ b/frontend/src/components/CommandPalette.test.tsx @@ -28,6 +28,8 @@ const TARGET: Window = { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], }; diff --git a/frontend/src/components/GridView.test.tsx b/frontend/src/components/GridView.test.tsx index d8dcf9e..81f36c1 100644 --- a/frontend/src/components/GridView.test.tsx +++ b/frontend/src/components/GridView.test.tsx @@ -37,6 +37,8 @@ function mkWindow(over: Partial = {}): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...over, diff --git a/frontend/src/components/ListView.test.tsx b/frontend/src/components/ListView.test.tsx index ff7c84b..4551bc6 100644 --- a/frontend/src/components/ListView.test.tsx +++ b/frontend/src/components/ListView.test.tsx @@ -26,6 +26,8 @@ function mkWindow(over: Partial = {}): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...over, @@ -123,4 +125,68 @@ describe("ListView (THI-60)", () => { const { container } = renderList([]); expect(container.textContent).toMatch(/no matching/i); }); + + // ─── THI-243: discovery (repos) mode ───────────────────────────────────── + it("renders repo header rows and groups windows under them when groupingMode=repos", () => { + const { container } = renderList( + [ + mkWindow({ + paneId: "%a", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }), + mkWindow({ + paneId: "%b", + session: "beta", + repoKey: "/r/beta", + repoLabel: "beta", + }), + ], + { groupingMode: "repos" }, + ); + const heads = container.querySelectorAll(".list-repo-head"); + expect(heads).toHaveLength(2); + expect(heads[0]!.textContent).toContain("alpha"); + expect(heads[1]!.textContent).toContain("beta"); + }); + + it("pins non-git sessions under Other (bottom of the list)", () => { + const { container } = renderList( + [ + mkWindow({ + paneId: "%o", + session: "lonely", + repoKey: null, + repoLabel: null, + }), + mkWindow({ + paneId: "%a", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }), + ], + { groupingMode: "repos" }, + ); + const heads = Array.from( + container.querySelectorAll(".list-repo-head .list-repo-label"), + ).map((el) => el.textContent); + expect(heads).toEqual(["alpha", "Other"]); + }); + + it("renders flat (no headers) when groupingMode=sessions", () => { + const { container } = renderList( + [ + mkWindow({ + paneId: "%a", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }), + ], + { groupingMode: "sessions" }, + ); + expect(container.querySelector(".list-repo-head")).toBeNull(); + }); }); diff --git a/frontend/src/components/ListView.tsx b/frontend/src/components/ListView.tsx index 3d1d00d..3295d01 100644 --- a/frontend/src/components/ListView.tsx +++ b/frontend/src/components/ListView.tsx @@ -3,7 +3,9 @@ import { memo } from "react"; import type { Window } from "../types"; import { sortPendingFirst } from "../lib/filter"; import { formatMem } from "../lib/format"; +import { groupByRepo } from "../lib/groupByRepo"; import { quickActionsFor, type QuickAction } from "../lib/quickActions"; +import type { GroupingMode } from "../lib/settings"; import { cpuLevel, kindIcon, memLevel, STATUS_META } from "../lib/status"; import { AgoSpan } from "./AgoSpan"; import { Chip } from "./Chip"; @@ -23,6 +25,10 @@ interface Props { onQuickAction?: (w: Window, action: QuickAction) => void; pinnedPaneIds?: Set; onTogglePin?: (w: Window) => void; + /** THI-243: when "repos", rows are interleaved with repo header rows + * derived from each pane's `repoKey`. Default "sessions" preserves the + * flat-list legacy behavior. */ + groupingMode?: GroupingMode; } /** @@ -46,6 +52,7 @@ export function ListView({ onQuickAction, pinnedPaneIds, onTogglePin, + groupingMode, }: Props) { const sorted = sortPendingFirst( windows, @@ -60,24 +67,51 @@ export function ListView({ ); } + const rowFor = (w: Window) => ( + + ); + + // THI-243: discovery mode inserts a repo header row before each group. + // Sessions-mode rendering is unchanged. groupByRepo preserves the + // sortPendingFirst ordering within each bucket. + if (groupingMode === "repos") { + const groups = groupByRepo(sorted); + return ( +
+ {groups.map((g) => ( +
+
+ + {g.label} + {g.windows.length} +
+ {g.windows.map(rowFor)} +
+ ))} +
+ ); + } + return (
- {sorted.map((w) => ( - - ))} + {sorted.map(rowFor)}
); } diff --git a/frontend/src/components/NeedsStrip.test.tsx b/frontend/src/components/NeedsStrip.test.tsx index cb7f3ea..1ac9593 100644 --- a/frontend/src/components/NeedsStrip.test.tsx +++ b/frontend/src/components/NeedsStrip.test.tsx @@ -26,6 +26,8 @@ function mkWindow(over: Partial = {}): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...over, diff --git a/frontend/src/components/Subhead.test.tsx b/frontend/src/components/Subhead.test.tsx index ddc65fb..8b03ebe 100644 --- a/frontend/src/components/Subhead.test.tsx +++ b/frontend/src/components/Subhead.test.tsx @@ -219,6 +219,38 @@ describe("Subhead memoization (THI-217)", () => { }); }); +describe("Subhead grouping switcher (THI-243)", () => { + it("renders both Sessions and Repos buttons, with Sessions active by default", () => { + localStorage.clear(); + const { container } = render(); + const buttons = container.querySelectorAll( + ".grouping-switcher button", + ); + expect(buttons).toHaveLength(2); + expect(buttons[0]!.textContent).toBe("Sessions"); + expect(buttons[1]!.textContent).toBe("Repos"); + expect(buttons[0]!.className).toContain("is-active"); + expect(buttons[1]!.className).not.toContain("is-active"); + }); + + it("clicking Repos flips the active state and persists groupingMode", () => { + localStorage.clear(); + const { container } = render(); + const buttons = container.querySelectorAll( + ".grouping-switcher button", + ); + fireEvent.click(buttons[1]!); + const after = container.querySelectorAll( + ".grouping-switcher button", + ); + expect(after[0]!.className).not.toContain("is-active"); + expect(after[1]!.className).toContain("is-active"); + expect( + JSON.parse(localStorage.getItem("switchboard:settings")!).groupingMode, + ).toBe("repos"); + }); +}); + describe("Subhead layout switcher (THI-59)", () => { it("kanban and grid buttons are both enabled; only the active one has is-active", () => { localStorage.clear(); diff --git a/frontend/src/components/Subhead.tsx b/frontend/src/components/Subhead.tsx index 369634a..ddb57c5 100644 --- a/frontend/src/components/Subhead.tsx +++ b/frontend/src/components/Subhead.tsx @@ -225,6 +225,7 @@ function SubheadInner({ + ); @@ -302,3 +303,35 @@ function LayoutSwitcher() { ); } + +function GroupingSwitcher() { + // THI-243: choose how the dashboard groups panes — by tmux session + // (historical) or by discovered repo. MVP affects ListView only; Kanban + + // Grid land in follow-up PRs (the toggle is global, but those views still + // render in session mode until they're wired through). + const mode = useSetting("groupingMode"); + return ( + + + + + + + + + ); +} diff --git a/frontend/src/components/WindowCard.test.tsx b/frontend/src/components/WindowCard.test.tsx index cecba21..ccdb436 100644 --- a/frontend/src/components/WindowCard.test.tsx +++ b/frontend/src/components/WindowCard.test.tsx @@ -26,6 +26,8 @@ function makeWindow(overrides: Partial = {}): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...overrides, diff --git a/frontend/src/lib/groupByRepo.test.ts b/frontend/src/lib/groupByRepo.test.ts new file mode 100644 index 0000000..bc20a44 --- /dev/null +++ b/frontend/src/lib/groupByRepo.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { OTHER_REPO_KEY, OTHER_REPO_LABEL, groupByRepo } from "./groupByRepo"; +import { mkWindow } from "../test/factories"; + +describe("groupByRepo", () => { + it("buckets windows by their session's first-seen repo", () => { + const a1 = mkWindow({ + paneId: "%a1", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const a2 = mkWindow({ + paneId: "%a2", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const b1 = mkWindow({ + paneId: "%b1", + session: "beta", + repoKey: "/r/beta", + repoLabel: "beta", + }); + const groups = groupByRepo([a1, a2, b1]); + expect(groups.map((g) => g.key)).toEqual(["/r/alpha", "/r/beta"]); + expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%a1", "%a2"]); + expect(groups[1]!.windows.map((w) => w.paneId)).toEqual(["%b1"]); + }); + + it("pins a session to the first git-backed window's repo (sessions atomic)", () => { + // Same session, two windows: first is non-git, second is in /r/alpha. + // The session must land under /r/alpha — first GIT-BACKED window wins — + // and BOTH windows render under that bucket. + const noGit = mkWindow({ + paneId: "%n1", + session: "mixed", + repoKey: null, + repoLabel: null, + }); + const inAlpha = mkWindow({ + paneId: "%n2", + session: "mixed", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const groups = groupByRepo([noGit, inAlpha]); + expect(groups.map((g) => g.key)).toEqual(["/r/alpha"]); + expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%n1", "%n2"]); + }); + + it("a session whose windows span repos lands under the first-seen repo", () => { + const a = mkWindow({ + paneId: "%a", + session: "x", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const b = mkWindow({ + paneId: "%b", + session: "x", + repoKey: "/r/beta", + repoLabel: "beta", + }); + const groups = groupByRepo([a, b]); + // /r/alpha was seen first → session "x" lives entirely there. + expect(groups).toHaveLength(1); + expect(groups[0]!.key).toBe("/r/alpha"); + expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%a", "%b"]); + }); + + it("sessions with no git-backed window land in Other (pinned to bottom)", () => { + const a = mkWindow({ + paneId: "%a", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const o1 = mkWindow({ + paneId: "%o1", + session: "lonely", + repoKey: null, + }); + const o2 = mkWindow({ + paneId: "%o2", + session: "wandering", + repoKey: null, + }); + const groups = groupByRepo([o1, a, o2]); + expect(groups.map((g) => g.key)).toEqual(["/r/alpha", OTHER_REPO_KEY]); + expect(groups[1]!.label).toBe(OTHER_REPO_LABEL); + expect(groups[1]!.windows.map((w) => w.paneId)).toEqual(["%o1", "%o2"]); + }); + + it("preserves input order within each bucket", () => { + const w1 = mkWindow({ + paneId: "%w1", + session: "s", + index: 3, + repoKey: "/r/x", + repoLabel: "x", + }); + const w2 = mkWindow({ + paneId: "%w2", + session: "s", + index: 1, + repoKey: "/r/x", + repoLabel: "x", + }); + // Input order matters; tie-breaks are the caller's job. + expect( + groupByRepo([w1, w2])[0]!.windows.map((w) => w.paneId), + ).toEqual(["%w1", "%w2"]); + }); + + it("returns empty when the input is empty", () => { + expect(groupByRepo([])).toEqual([]); + }); + + it("Other is absent when every session resolves to a repo", () => { + const a = mkWindow({ + paneId: "%a", + session: "alpha", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const groups = groupByRepo([a]); + expect(groups.map((g) => g.key)).toEqual(["/r/alpha"]); + }); + + it("falls back to basename when repoLabel is missing", () => { + // Mimics a window where the backend resolved a repoKey but somehow lacks + // a label (defensive: the live API populates both, but the helper should + // not crash if a fixture omits one). + const a = mkWindow({ + paneId: "%a", + session: "alpha", + repoKey: "/r/with-trailing/", + repoLabel: null, + }); + expect(groupByRepo([a])[0]!.label).toBe("with-trailing"); + }); +}); diff --git a/frontend/src/lib/groupByRepo.ts b/frontend/src/lib/groupByRepo.ts new file mode 100644 index 0000000..7081639 --- /dev/null +++ b/frontend/src/lib/groupByRepo.ts @@ -0,0 +1,84 @@ +import type { Window } from "../types"; + +/** Synthetic key for the catch-all bucket of sessions that don't resolve to a + * git repo. Pinned to the bottom of every render. */ +export const OTHER_REPO_KEY = "__other__"; + +/** Display label for the "Other" bucket. Kept here so consumers don't + * individually hard-code the string. */ +export const OTHER_REPO_LABEL = "Other"; + +/** One row of the discovery view: a repo plus its sessions' panes, in render + * order. `label` is the basename of `key` (or "Other" for the catch-all); + * the full `key` is the tooltip on basename collisions. */ +export interface RepoGroup { + key: string; + label: string; + windows: Window[]; +} + +/** Group a flat list of windows into the THI-243 discovery view shape: + * Repo → Session → Pane. + * + * Rules (from the spec): + * + * - **Sessions are atomic.** Each session is assigned to exactly one repo + * via FIRST-SEEN-WINS across its windows iterated in `(session, index)` + * order. A session whose windows span multiple repos lands under the + * repo of its first git-backed window. A session with zero git-backed + * windows lands in "Other". + * - **"Other" pinned to bottom.** Always rendered last, never reorderable. + * - **Membership from live state only.** Repos with zero live windows are + * absent from the result. + * + * Within a repo bucket, windows render in `(session, index)` order — same + * natural order the per-view sort uses as its tie-breaker. */ +export function groupByRepo(windows: readonly Window[]): RepoGroup[] { + // First pass: pin every session to its first-seen repo. Iterating in the + // input order means the caller's pre-sort (typically tmux index ascending) + // determines which window is "first" for each session — predictable and + // stable across polls. + const sessionToRepo = new Map(); + const sessionToLabel = new Map(); + for (const w of windows) { + if (sessionToRepo.has(w.session)) continue; + if (w.repoKey) { + sessionToRepo.set(w.session, w.repoKey); + sessionToLabel.set(w.session, w.repoLabel ?? basename(w.repoKey)); + } + } + + // Second pass: bucket every window by its session's pinned repo (or Other). + // Preserves input order within each bucket. + const buckets = new Map(); + const labels = new Map(); + labels.set(OTHER_REPO_KEY, OTHER_REPO_LABEL); + for (const w of windows) { + const repo = sessionToRepo.get(w.session) ?? OTHER_REPO_KEY; + const bucket = buckets.get(repo); + if (bucket) bucket.push(w); + else buckets.set(repo, [w]); + if (repo !== OTHER_REPO_KEY && !labels.has(repo)) { + labels.set(repo, sessionToLabel.get(w.session) ?? basename(repo)); + } + } + + // Build the ordered result: real repos in first-seen order, then Other. + const result: RepoGroup[] = []; + for (const [key, ws] of buckets) { + if (key === OTHER_REPO_KEY) continue; + result.push({ key, label: labels.get(key) ?? basename(key), windows: ws }); + } + const other = buckets.get(OTHER_REPO_KEY); + if (other && other.length > 0) { + result.push({ key: OTHER_REPO_KEY, label: OTHER_REPO_LABEL, windows: other }); + } + return result; +} + +function basename(path: string): string { + // Match the backend's `os.path.basename(repo_key.rstrip("/"))` derivation. + const trimmed = path.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; +} diff --git a/frontend/src/lib/pollTier.test.ts b/frontend/src/lib/pollTier.test.ts index 353bfd3..5569432 100644 --- a/frontend/src/lib/pollTier.test.ts +++ b/frontend/src/lib/pollTier.test.ts @@ -25,6 +25,8 @@ function makeWindow(status: Status, paneId = "%1"): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], }; diff --git a/frontend/src/lib/quickActions.test.ts b/frontend/src/lib/quickActions.test.ts index 7ecd28a..ad124a7 100644 --- a/frontend/src/lib/quickActions.test.ts +++ b/frontend/src/lib/quickActions.test.ts @@ -23,6 +23,8 @@ function makeWindow(overrides: Partial = {}): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...overrides, diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 77efb86..ed18060 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -17,6 +17,10 @@ export type Layout = "kanban" | "grid" | "list"; export type ColumnSize = "narrow" | "normal" | "wide"; /** Ordered narrow → normal → wide so +/- controls can step linearly (THI-128). */ export const COLUMN_SIZE_ORDER: readonly ColumnSize[] = ["narrow", "normal", "wide"]; +/** THI-243: grouping axis for the dashboard. "sessions" is the historical + * behavior (group by tmux session); "repos" is the discovery view that + * buckets sessions under their git repo. Independent of `Layout`. */ +export type GroupingMode = "sessions" | "repos"; export interface Settings { theme: Theme; @@ -39,6 +43,10 @@ export interface Settings { /** Threshold for "Clean up idle panes…" in days. 0 hides the action. * Default 7. Stored as a number; clamped at the UI layer (0–365). */ idleCleanupDays: number; + /** THI-243: discovery vs sessions grouping. Default "sessions" preserves + * legacy behavior for existing users; new MVP affects ListView only — + * Kanban + Grid land in follow-up PRs. */ + groupingMode: GroupingMode; } // OKLCH lightness/chroma/hue for each accent preset. @@ -117,6 +125,7 @@ export const DEFAULT_SETTINGS: Settings = { terminalFontSize: 13, selectedIde: "", idleCleanupDays: 7, + groupingMode: "sessions", }; export const POLL_MIN_S = 1; diff --git a/frontend/src/lib/useNeedsStripDismiss.test.ts b/frontend/src/lib/useNeedsStripDismiss.test.ts index dca170c..d54e6c1 100644 --- a/frontend/src/lib/useNeedsStripDismiss.test.ts +++ b/frontend/src/lib/useNeedsStripDismiss.test.ts @@ -26,6 +26,8 @@ function pendingWindow(paneId: string): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], }; diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 63895b5..5085bf2 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -578,6 +578,34 @@ input, textarea, select { font: inherit; color: inherit; } padding: 24px 0; text-align: center; } + +/* THI-243: repo-grouped list view (discovery mode). The .list-repo-group + * wraps a header row + its windows so the gap rule above doesn't bleed + * between repos. */ +.list-repo-group { display: flex; flex-direction: column; gap: 2px; } +.list-repo-group + .list-repo-group { margin-top: 12px; } +.list-repo-head { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 4px 4px; + color: var(--text-mute); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} +.list-repo-label { color: var(--text); } +.list-repo-count { + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + background: var(--hairline); + color: var(--text-mute); + font-size: 10px; + font-weight: 500; + text-transform: none; +} .list-row { display: grid; grid-template-columns: @@ -2572,6 +2600,31 @@ input, textarea, select { font: inherit; color: inherit; } box-shadow: 0 1px 0 rgba(0,0,0,.2); } +/* ─── Grouping switcher (THI-243 — sessions / repos segmented pair) ─── */ +.grouping-switcher { + display: inline-flex; + border: 1px solid var(--hairline); + border-radius: 8px; + padding: 2px; + background: var(--panel); + margin-right: 6px; +} +.grouping-switcher button { + height: 22px; + padding: 0 8px; + display: grid; place-items: center; + border-radius: 5px; + color: var(--text-mute); + font-size: 11.5px; +} +.grouping-switcher button:hover { color: var(--text); } +.grouping-switcher button:focus-visible { outline: 1.5px solid var(--accent-edge); outline-offset: 1px; } +.grouping-switcher button.is-active { + background: var(--bg-elev); + color: var(--text); + box-shadow: 0 1px 0 rgba(0,0,0,.2); +} + /* ─── Kind glyph (icon-based) ──────────────────────────────── */ .card-kind { width: 20px; height: 20px; flex: 0 0 auto; diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts index cb5837c..676ab1e 100644 --- a/frontend/src/test/factories.ts +++ b/frontend/src/test/factories.ts @@ -34,6 +34,8 @@ export function mkWindow(overrides: Partial = {}): Window { prUrl: null, ci: null, repoUrl: null, + repoKey: null, + repoLabel: null, agent: null, preview: [], ...overrides, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dea37aa..17a6319 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -62,6 +62,14 @@ export interface Window { // the cwd isn't inside a github repo. The xterm linkProvider appends // `/pull/N` to this base when linkifying `PR #N` mentions in the pane. repoUrl: string | null; + // THI-243: git toplevel path for the pane's cwd, or null when the cwd + // isn't inside a git repo. The grouping-mode toggle buckets panes by + // `repoKey` in discovery mode. + repoKey: string | null; + // THI-243: basename(repoKey) — display label for the repo header. Two + // repos with the same basename render the same label; the tooltip shows + // the full `repoKey` for disambiguation. + repoLabel: string | null; agent: Agent | null; preview: string[]; } From 69be2042e7e19d6508cc6215d20615fda6f8773b Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 10:27:42 -0400 Subject: [PATCH 2/7] chore(THI-243): ruff format Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/switchboard/services/tmux.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/src/switchboard/services/tmux.py b/backend/src/switchboard/services/tmux.py index d0aca03..12f262d 100644 --- a/backend/src/switchboard/services/tmux.py +++ b/backend/src/switchboard/services/tmux.py @@ -252,9 +252,7 @@ def collect_state() -> StateResponse: # enough to absorb noise, short enough that a freshly-opened pane # in a new repo appears quickly. repo_key = claude_parser._git_repo_root(cwd) if cwd else None - repo_label = ( - os.path.basename(repo_key.rstrip("/")) if repo_key else None - ) + repo_label = os.path.basename(repo_key.rstrip("/")) if repo_key else None idx = _to_int(w.window_index) windows.append( From 62de823802deb11b18ce9e41da3d3b212836975a Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 18:35:11 -0400 Subject: [PATCH 3/7] feat(THI-243): drop sessions-atomic rule; bucket each window by its own repoKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original spec pinned every tmux session to a single repo (first-seen wins), reasoning that "the user thinks in sessions; splitting fragments the UX." In practice that breaks down for catch-all daily-driver sessions that mix projects — every window in the session gets misattributed to whatever repo its first window happened to open in. Drop the atomic rule. Each window goes to the bucket of its own `repoKey`. A session that spans multiple repos now appears under each repo it has a window in. Non-git windows still go to "Other"; "Other" still pins to the bottom. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/groupByRepo.test.ts | 29 +++++++++------- frontend/src/lib/groupByRepo.ts | 51 +++++++++++----------------- 2 files changed, 35 insertions(+), 45 deletions(-) diff --git a/frontend/src/lib/groupByRepo.test.ts b/frontend/src/lib/groupByRepo.test.ts index bc20a44..617d5f4 100644 --- a/frontend/src/lib/groupByRepo.test.ts +++ b/frontend/src/lib/groupByRepo.test.ts @@ -4,7 +4,7 @@ import { OTHER_REPO_KEY, OTHER_REPO_LABEL, groupByRepo } from "./groupByRepo"; import { mkWindow } from "../test/factories"; describe("groupByRepo", () => { - it("buckets windows by their session's first-seen repo", () => { + it("buckets each window by its own repoKey", () => { const a1 = mkWindow({ paneId: "%a1", session: "alpha", @@ -29,10 +29,10 @@ describe("groupByRepo", () => { expect(groups[1]!.windows.map((w) => w.paneId)).toEqual(["%b1"]); }); - it("pins a session to the first git-backed window's repo (sessions atomic)", () => { + it("within one session, non-git windows go to Other and git ones to their own repo", () => { // Same session, two windows: first is non-git, second is in /r/alpha. - // The session must land under /r/alpha — first GIT-BACKED window wins — - // and BOTH windows render under that bucket. + // Per-window bucketing puts each in its own group — the non-git one + // lands in Other and the git one in /r/alpha. const noGit = mkWindow({ paneId: "%n1", session: "mixed", @@ -46,11 +46,15 @@ describe("groupByRepo", () => { repoLabel: "alpha", }); const groups = groupByRepo([noGit, inAlpha]); - expect(groups.map((g) => g.key)).toEqual(["/r/alpha"]); - expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%n1", "%n2"]); + // /r/alpha real bucket first, Other pinned to bottom. + expect(groups.map((g) => g.key)).toEqual(["/r/alpha", OTHER_REPO_KEY]); + expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%n2"]); + expect(groups[1]!.windows.map((w) => w.paneId)).toEqual(["%n1"]); }); - it("a session whose windows span repos lands under the first-seen repo", () => { + it("a session whose windows span repos appears under each repo it touches", () => { + // Sessions are NOT atomic — a daily-driver session that mixes projects + // fragments across groups, which is the point of the discovery view. const a = mkWindow({ paneId: "%a", session: "x", @@ -64,13 +68,12 @@ describe("groupByRepo", () => { repoLabel: "beta", }); const groups = groupByRepo([a, b]); - // /r/alpha was seen first → session "x" lives entirely there. - expect(groups).toHaveLength(1); - expect(groups[0]!.key).toBe("/r/alpha"); - expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%a", "%b"]); + expect(groups.map((g) => g.key)).toEqual(["/r/alpha", "/r/beta"]); + expect(groups[0]!.windows.map((w) => w.paneId)).toEqual(["%a"]); + expect(groups[1]!.windows.map((w) => w.paneId)).toEqual(["%b"]); }); - it("sessions with no git-backed window land in Other (pinned to bottom)", () => { + it("windows with no git-backed cwd land in Other (pinned to bottom)", () => { const a = mkWindow({ paneId: "%a", session: "alpha", @@ -118,7 +121,7 @@ describe("groupByRepo", () => { expect(groupByRepo([])).toEqual([]); }); - it("Other is absent when every session resolves to a repo", () => { + it("Other is absent when every window resolves to a repo", () => { const a = mkWindow({ paneId: "%a", session: "alpha", diff --git a/frontend/src/lib/groupByRepo.ts b/frontend/src/lib/groupByRepo.ts index 7081639..e22e82f 100644 --- a/frontend/src/lib/groupByRepo.ts +++ b/frontend/src/lib/groupByRepo.ts @@ -1,6 +1,6 @@ import type { Window } from "../types"; -/** Synthetic key for the catch-all bucket of sessions that don't resolve to a +/** Synthetic key for the catch-all bucket of windows that don't resolve to a * git repo. Pinned to the bottom of every render. */ export const OTHER_REPO_KEY = "__other__"; @@ -8,58 +8,45 @@ export const OTHER_REPO_KEY = "__other__"; * individually hard-code the string. */ export const OTHER_REPO_LABEL = "Other"; -/** One row of the discovery view: a repo plus its sessions' panes, in render - * order. `label` is the basename of `key` (or "Other" for the catch-all); - * the full `key` is the tooltip on basename collisions. */ +/** One row of the discovery view: a repo plus the windows whose own cwd + * resolves to it, in render order. `label` is the basename of `key` (or + * "Other" for the catch-all); the full `key` is the tooltip on basename + * collisions. */ export interface RepoGroup { key: string; label: string; windows: Window[]; } -/** Group a flat list of windows into the THI-243 discovery view shape: - * Repo → Session → Pane. +/** Group a flat list of windows into the discovery view's repo buckets. * - * Rules (from the spec): + * Rules: * - * - **Sessions are atomic.** Each session is assigned to exactly one repo - * via FIRST-SEEN-WINS across its windows iterated in `(session, index)` - * order. A session whose windows span multiple repos lands under the - * repo of its first git-backed window. A session with zero git-backed - * windows lands in "Other". + * - **Per-window bucketing.** Every window is placed in the bucket of its + * own `repoKey` — the git toplevel of that window's cwd. A tmux session + * that spans multiple repos shows up under each repo it has a window in; + * sessions are NOT atomic. This means a daily-driver session that mixes + * projects is fragmented across groups, which is the point of the + * discovery view (the user opted into seeing repo as the primary unit). + * - **Non-git windows land in "Other".** Windows whose cwd doesn't resolve + * to a git repo all go to a single synthetic "Other" bucket. * - **"Other" pinned to bottom.** Always rendered last, never reorderable. * - **Membership from live state only.** Repos with zero live windows are * absent from the result. * - * Within a repo bucket, windows render in `(session, index)` order — same - * natural order the per-view sort uses as its tie-breaker. */ + * Within a repo bucket, windows render in input order — the caller's + * pre-sort (typically pending-first, then `(session, index)`) is preserved. */ export function groupByRepo(windows: readonly Window[]): RepoGroup[] { - // First pass: pin every session to its first-seen repo. Iterating in the - // input order means the caller's pre-sort (typically tmux index ascending) - // determines which window is "first" for each session — predictable and - // stable across polls. - const sessionToRepo = new Map(); - const sessionToLabel = new Map(); - for (const w of windows) { - if (sessionToRepo.has(w.session)) continue; - if (w.repoKey) { - sessionToRepo.set(w.session, w.repoKey); - sessionToLabel.set(w.session, w.repoLabel ?? basename(w.repoKey)); - } - } - - // Second pass: bucket every window by its session's pinned repo (or Other). - // Preserves input order within each bucket. const buckets = new Map(); const labels = new Map(); labels.set(OTHER_REPO_KEY, OTHER_REPO_LABEL); for (const w of windows) { - const repo = sessionToRepo.get(w.session) ?? OTHER_REPO_KEY; + const repo = w.repoKey ?? OTHER_REPO_KEY; const bucket = buckets.get(repo); if (bucket) bucket.push(w); else buckets.set(repo, [w]); if (repo !== OTHER_REPO_KEY && !labels.has(repo)) { - labels.set(repo, sessionToLabel.get(w.session) ?? basename(repo)); + labels.set(repo, w.repoLabel ?? basename(repo)); } } From d08ca0a68dd91da089a364f10fe26b85cabbd835 Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 18:44:38 -0400 Subject: [PATCH 4/7] feat(THI-243): group list rows by tmux session in sessions mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions mode previously rendered a flat list with the session as a small prefix on each row. Mirror the repos-mode treatment: one header per session with the count of windows below it, sharing the .list-group DOM structure so both modes use the same CSS. The repo-grouped classes are renamed `.list-repo-*` → `.list-group-*` so the shared structure is no longer named for one of its two consumers. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/ListView.test.tsx | 24 +++++++------ frontend/src/components/ListView.tsx | 44 +++++++++++++++++------ frontend/src/styles/styles.css | 16 ++++----- 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/ListView.test.tsx b/frontend/src/components/ListView.test.tsx index 4551bc6..52da757 100644 --- a/frontend/src/components/ListView.test.tsx +++ b/frontend/src/components/ListView.test.tsx @@ -145,7 +145,7 @@ describe("ListView (THI-60)", () => { ], { groupingMode: "repos" }, ); - const heads = container.querySelectorAll(".list-repo-head"); + const heads = container.querySelectorAll(".list-group-head"); expect(heads).toHaveLength(2); expect(heads[0]!.textContent).toContain("alpha"); expect(heads[1]!.textContent).toContain("beta"); @@ -170,23 +170,27 @@ describe("ListView (THI-60)", () => { { groupingMode: "repos" }, ); const heads = Array.from( - container.querySelectorAll(".list-repo-head .list-repo-label"), + container.querySelectorAll(".list-group-head .list-group-label"), ).map((el) => el.textContent); expect(heads).toEqual(["alpha", "Other"]); }); - it("renders flat (no headers) when groupingMode=sessions", () => { + it("renders one session header per tmux session when groupingMode=sessions", () => { const { container } = renderList( [ - mkWindow({ - paneId: "%a", - session: "alpha", - repoKey: "/r/alpha", - repoLabel: "alpha", - }), + mkWindow({ paneId: "%a1", session: "alpha", name: "a1" }), + mkWindow({ paneId: "%a2", session: "alpha", name: "a2", index: 1 }), + mkWindow({ paneId: "%b1", session: "beta", name: "b1" }), ], { groupingMode: "sessions" }, ); - expect(container.querySelector(".list-repo-head")).toBeNull(); + const heads = Array.from( + container.querySelectorAll(".list-group-head .list-group-label"), + ).map((el) => el.textContent); + expect(heads).toEqual(["alpha", "beta"]); + const counts = Array.from( + container.querySelectorAll(".list-group-head .list-group-count"), + ).map((el) => el.textContent); + expect(counts).toEqual(["2", "1"]); }); }); diff --git a/frontend/src/components/ListView.tsx b/frontend/src/components/ListView.tsx index 3295d01..e7cc28e 100644 --- a/frontend/src/components/ListView.tsx +++ b/frontend/src/components/ListView.tsx @@ -34,9 +34,11 @@ interface Props { /** * THI-60: dense, single-line tabular layout. One per visible * window. Pending panes float to the top (same rule as Kanban/Grid), then - * pinned (THI-98), then natural tmux index. No session grouping — the - * session name is just a small dim prefix on each row so users can still - * tell what's where. + * pinned (THI-98), then natural tmux index. + * + * Sessions mode groups rows under session headers; repos mode (THI-243) + * groups under repo headers via groupByRepo(). Both share the .list-group + * DOM structure so CSS stays one ruleset. * * Best for many small panes where the Kanban/Grid card footprint is too big. */ @@ -85,22 +87,21 @@ export function ListView({ ); // THI-243: discovery mode inserts a repo header row before each group. - // Sessions-mode rendering is unchanged. groupByRepo preserves the - // sortPendingFirst ordering within each bucket. + // groupByRepo preserves the sortPendingFirst ordering within each bucket. if (groupingMode === "repos") { const groups = groupByRepo(sorted); return (
{groups.map((g) => ( -
+
- {g.label} - {g.windows.length} + {g.label} + {g.windows.length}
{g.windows.map(rowFor)}
@@ -109,9 +110,32 @@ export function ListView({ ); } + // Sessions mode: group rows under one header per tmux session. Sessions + // appear in first-seen order from the post-sort window list, so a session + // containing a pending window floats to the top of the page along with + // the row inside it. + const sessionBuckets = new Map(); + for (const w of sorted) { + const bucket = sessionBuckets.get(w.session); + if (bucket) bucket.push(w); + else sessionBuckets.set(w.session, [w]); + } return (
- {sorted.map(rowFor)} + {[...sessionBuckets].map(([session, ws]) => ( +
+
+ + {session} + {ws.length} +
+ {ws.map(rowFor)} +
+ ))}
); } diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 5085bf2..f851b20 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -579,12 +579,12 @@ input, textarea, select { font: inherit; color: inherit; } text-align: center; } -/* THI-243: repo-grouped list view (discovery mode). The .list-repo-group - * wraps a header row + its windows so the gap rule above doesn't bleed - * between repos. */ -.list-repo-group { display: flex; flex-direction: column; gap: 2px; } -.list-repo-group + .list-repo-group { margin-top: 12px; } -.list-repo-head { +/* Grouped list view: shared structure used by both sessions-mode (group by + * tmux session) and repos-mode (group by git repo, THI-243). The wrapper + * keeps the per-row gap from bleeding between groups. */ +.list-group { display: flex; flex-direction: column; gap: 2px; } +.list-group + .list-group { margin-top: 12px; } +.list-group-head { display: inline-flex; align-items: center; gap: 6px; @@ -595,8 +595,8 @@ input, textarea, select { font: inherit; color: inherit; } letter-spacing: 0.02em; text-transform: uppercase; } -.list-repo-label { color: var(--text); } -.list-repo-count { +.list-group-label { color: var(--text); } +.list-group-count { margin-left: 4px; padding: 0 5px; border-radius: 8px; From cff9db258ea94aad5e8e0c91004f933cc41baf9f Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 18:50:29 -0400 Subject: [PATCH 5/7] feat(THI-243): hide grouping switcher in kanban + grid until they honor it Both views currently ignore groupingMode (the switcher's only consumers are ListView and the Split rail). Hiding the toggle when the active layout is kanban or grid avoids the silent-no-op UX. The persisted groupingMode value is preserved, so flipping back to List restores the user's prior choice. Tracked as v0.4 follow-ups: THI-247 (Kanban swim-lane) and THI-248 (Grid section headers). When those land, drop the gate in GroupingSwitcher. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/Subhead.test.tsx | 18 ++++++++++++++++++ frontend/src/components/Subhead.tsx | 11 ++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Subhead.test.tsx b/frontend/src/components/Subhead.test.tsx index 8b03ebe..32d54aa 100644 --- a/frontend/src/components/Subhead.test.tsx +++ b/frontend/src/components/Subhead.test.tsx @@ -222,6 +222,7 @@ describe("Subhead memoization (THI-217)", () => { describe("Subhead grouping switcher (THI-243)", () => { it("renders both Sessions and Repos buttons, with Sessions active by default", () => { localStorage.clear(); + updateSettings({ layout: "list" }); const { container } = render(); const buttons = container.querySelectorAll( ".grouping-switcher button", @@ -235,6 +236,7 @@ describe("Subhead grouping switcher (THI-243)", () => { it("clicking Repos flips the active state and persists groupingMode", () => { localStorage.clear(); + updateSettings({ layout: "list" }); const { container } = render(); const buttons = container.querySelectorAll( ".grouping-switcher button", @@ -249,6 +251,22 @@ describe("Subhead grouping switcher (THI-243)", () => { JSON.parse(localStorage.getItem("switchboard:settings")!).groupingMode, ).toBe("repos"); }); + + it("is hidden in kanban and grid layouts (those views don't honor it yet)", () => { + localStorage.clear(); + updateSettings({ layout: "kanban" }); + const { container, rerender } = render(); + expect(container.querySelector(".grouping-switcher")).toBeNull(); + + updateSettings({ layout: "grid" }); + rerender(); + expect(container.querySelector(".grouping-switcher")).toBeNull(); + + // Sanity: comes back in list. + updateSettings({ layout: "list" }); + rerender(); + expect(container.querySelector(".grouping-switcher")).not.toBeNull(); + }); }); describe("Subhead layout switcher (THI-59)", () => { diff --git a/frontend/src/components/Subhead.tsx b/frontend/src/components/Subhead.tsx index ddb57c5..dd10e45 100644 --- a/frontend/src/components/Subhead.tsx +++ b/frontend/src/components/Subhead.tsx @@ -306,10 +306,15 @@ function LayoutSwitcher() { function GroupingSwitcher() { // THI-243: choose how the dashboard groups panes — by tmux session - // (historical) or by discovered repo. MVP affects ListView only; Kanban + - // Grid land in follow-up PRs (the toggle is global, but those views still - // render in session mode until they're wired through). + // (historical) or by discovered repo. ListView (and the Split rail, when + // it ships) honor the toggle; Kanban + Grid don't yet — until they do, + // hide the switcher in those layouts so the toggle is never a no-op. + // Tracked as v0.4 follow-ups; the persisted `groupingMode` is preserved + // across layouts so the user's preference comes back when they return to + // a layout that honors it. + const layout = useSetting("layout"); const mode = useSetting("groupingMode"); + if (layout === "kanban" || layout === "grid") return null; return ( From e167e5e5887fd0432519eced41d8594a8e04723d Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 18:54:15 -0400 Subject: [PATCH 6/7] fix(test): poll for send_keys / send_signal calls before WS close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sibling tests in test_ws.py — non_resize_json_falls_through, signal_control_message_routes, plain_text_is_pasted — assert on a list populated by the recv loop, then exit the `with` block. Since THI-184 offloaded the recv-loop tmux calls onto asyncio.to_thread, the WS close at the end of the block can race the thread hop and the assertion sees an empty list. Observed in CI run 27107086903 on the THI-243 branch (test_ws_plain_text_is_pasted_as_keys, [] vs ['abc']). Apply the same poll-until-condition pattern already used in the resize tests (08865e2) and the recv-loop offload regression test (64559df). Local runs unaffected; CI runners stop flaking. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/tests/test_ws.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index fba129f..6f3ed58 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -150,6 +150,8 @@ def test_ws_resize_with_bogus_dims_is_ignored(monkeypatch, ws_client: TestClient def test_ws_non_resize_json_falls_through_to_send_keys(monkeypatch, ws_client: TestClient) -> None: + import time + send_calls: list[tuple] = [] monkeypatch.setattr( tmux, @@ -164,6 +166,12 @@ def test_ws_non_resize_json_falls_through_to_send_keys(monkeypatch, ws_client: T # verbatim as a paste — otherwise legitimate keystrokes that happen # to start with `{` (e.g. typing JSON into a REPL) get swallowed. ws.send_text('{"foo": 1}') + # send_keys now runs via asyncio.to_thread; closing the WS at the end + # of the `with` block can race the offload on slow CI runners. Poll + # until the call lands (or time out) so the assertion sees it. + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and not send_calls: + time.sleep(0.01) assert send_calls == [("dev", 2, '{"foo": 1}')] @@ -171,6 +179,8 @@ def test_ws_non_resize_json_falls_through_to_send_keys(monkeypatch, ws_client: T def test_ws_signal_control_message_routes_to_send_signal( monkeypatch, ws_client: TestClient ) -> None: + import time + signals: list[tuple] = [] monkeypatch.setattr( tmux, @@ -180,11 +190,19 @@ def test_ws_signal_control_message_routes_to_send_signal( with ws_client.websocket_connect("/ws/pane?session=dev&index=2", headers=_HOST) as ws: ws.send_text('{"signal":"C-c"}') + # send_signal runs via asyncio.to_thread; poll for the call before + # the WS close races the offload (same flake source as the resize + # tests). + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and not signals: + time.sleep(0.01) assert signals == [("dev", 2, "C-c")] def test_ws_plain_text_is_pasted_as_keys(monkeypatch, ws_client: TestClient) -> None: + import time + pastes: list[str] = [] def _send_keys(session, index, *, keys=None, paste=None, bracketed=False): @@ -195,6 +213,12 @@ def _send_keys(session, index, *, keys=None, paste=None, bracketed=False): with ws_client.websocket_connect("/ws/pane?session=dev&index=2", headers=_HOST) as ws: ws.send_text("abc") + # send_keys runs via asyncio.to_thread; poll for the call before + # the WS close races the offload (CI failure observed 2026-06-07 + # on the THI-243 branch). + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and not pastes: + time.sleep(0.01) assert pastes == ["abc"] From daf061f7df2ad2deea30fe7204e5aedfc7d09aff Mon Sep 17 00:00:00 2001 From: Thibault Dody Date: Sun, 7 Jun 2026 18:59:02 -0400 Subject: [PATCH 7/7] chore(hooks): make pre-push more diagnosable and use git's actual push range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small improvements after a CI failure slipped through on the THI-243 branch (the hook hadn't run because core.hooksPath had been clobbered to .git/hooks; nothing in the hook itself caught that). - **Path banner.** Echo the resolved hook path on every invocation so it's obvious from terminal output that the version-controlled hook ran (and which copy of it). If the banner is missing, the hook isn't installed. - **Prerequisite check.** Bail early with a clear message if `uv` or `npx` is missing, instead of failing mid-run on the first command. - **Read git's stdin for the actual push range.** git feeds the hook ` ` per ref. Use that range instead of `@{push}` so first pushes (remote_sha=zeros) still narrow against `origin/HEAD`'s merge-base instead of falling back to run-everything. Manual invocations (stdin empty) keep the old `@{push}` fallback. - **Keep logs on failure.** A failing step's full log path is now printed and the temp dir is preserved so you can `cat $log_dir/…log` for more than the last 40 lines. - **CI-flake reminder on success.** Add one dim-text line noting that a green pre-push does NOT prove CI will pass — timing-sensitive tests can still flake on slower CI runners, and the right response is to fix the flake, not retry the job. Install (idempotent — either suffices): git config core.hooksPath scripts/hooks ln -sf ../../scripts/hooks/pre-push .git/hooks/pre-push Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/hooks/pre-push | 105 ++++++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 676d2d8..cdc8c17 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -2,23 +2,54 @@ # Pre-push hook: runs the same checks as CI before allowing `git push`. # Skip with `git push --no-verify` for emergencies. # -# Enable for this clone: `git config core.hooksPath scripts/hooks` +# Install for this clone (idempotent): +# git config core.hooksPath scripts/hooks +# # — or, symlink-based, which survives an IDE clobbering core.hooksPath: +# ln -sf ../../scripts/hooks/pre-push .git/hooks/pre-push # # Fast path: only the side(s) (backend/ frontend/) with changes in the push # range are checked. Changes to scripts/ or .github/ — or an undeterminable # range — fall back to running everything. set -uo pipefail -cd "$(git rev-parse --show-toplevel)" +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" GREEN='\033[0;32m' +YELLOW='\033[0;33m' RED='\033[0;31m' DIM='\033[2m' RST='\033[0m' failed=0 log_dir="$(mktemp -d)" -trap 'rm -rf "$log_dir"' EXIT +# Preserve logs on failure so the user can `cat $log_dir/.log` for the +# full output; cleaned on success. +keep_logs=0 +trap '[[ $keep_logs -eq 0 ]] && rm -rf "$log_dir" || echo "(full logs kept in $log_dir)"' EXIT + +# --- Diagnostics: announce which hook actually ran ------------------------- +# Helps when something resets `core.hooksPath`; if you see this banner you +# know the version-controlled script ran. If you DON'T see it, the hook +# isn't installed in this clone. +hook_path="$0" +[[ -L "$hook_path" ]] && hook_path="$(readlink "$hook_path") (via symlink $0)" +printf "${DIM}pre-push: %s${RST}\n" "$hook_path" + +# --- Prerequisites: fail fast with a clear message ------------------------- +missing=() +if [[ -d backend ]]; then + command -v uv >/dev/null 2>&1 || missing+=("uv (backend lint/test runner)") +fi +if [[ -d frontend ]]; then + command -v npx >/dev/null 2>&1 || missing+=("npx (frontend lint/test runner)") +fi +if (( ${#missing[@]} > 0 )); then + printf "${RED}pre-push: missing prerequisites:${RST}\n" + printf ' - %s\n' "${missing[@]}" + printf "Install them, or push with ${DIM}--no-verify${RST} to bypass.\n" + exit 1 +fi # run_step — runs in , records pass/fail. # The subshell scopes only the `cd`; `failed` is set in the parent shell. @@ -33,27 +64,63 @@ run_step() { printf "${GREEN}✓${RST}\n" else printf "${RED}✗${RST}\n" - echo "${DIM}--- ${name} output ---${RST}" + echo "${DIM}--- ${name} (last 40 lines; full at $log) ---${RST}" tail -40 "$log" echo "${DIM}--- end ---${RST}" + keep_logs=1 failed=1 fi } -# Decide which sides to check. Default: everything (safe). Narrow only when -# the push range is determinable and touches just one side. +# --- Determine push range -------------------------------------------------- +# Prefer the actual push range git hands us on stdin (one line per ref): +# +# - A remote_sha of all zeros means a brand-new branch — compare against the +# merge-base with origin/main (or whatever default branch resolves) so we +# still get a meaningful diff instead of running everything. +# - Multiple refs in one push aggregate into the same change set. +# Fall back to `@{push}` (current behavior) if stdin is empty (script was +# invoked manually, not through `git push`). +ZERO_SHA='0000000000000000000000000000000000000000' +changed="" +stdin_seen=0 + +if [[ ! -t 0 ]]; then + while read -r local_ref local_sha remote_ref remote_sha; do + stdin_seen=1 + # `git push --delete` sends a zero local_sha; nothing to check. + [[ "$local_sha" == "$ZERO_SHA" ]] && continue + if [[ "$remote_sha" == "$ZERO_SHA" ]]; then + # New branch — diff against the default-branch merge-base. + default_remote="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || echo origin/main)" + if base="$(git merge-base "$default_remote" "$local_sha" 2>/dev/null)"; then + changed+=$'\n'"$(git diff --name-only "$base" "$local_sha" 2>/dev/null || true)" + else + changed="" # Couldn't resolve a base — force run-everything. + break + fi + else + changed+=$'\n'"$(git diff --name-only "$remote_sha" "$local_sha" 2>/dev/null || true)" + fi + done +fi + +if [[ $stdin_seen -eq 0 ]]; then + # Manual invocation: fall back to the upstream-tracking heuristic. + if base="$(git rev-parse --verify --quiet '@{push}' 2>/dev/null)"; then + changed="$(git diff --name-only "$base" HEAD 2>/dev/null || true)" + fi +fi + run_backend=1 run_frontend=1 -if base=$(git rev-parse --verify --quiet '@{push}' 2>/dev/null); then - changed="$(git diff --name-only "$base" HEAD 2>/dev/null || true)" - if [[ -n "$changed" ]]; then - grep -qE '^backend/' <<<"$changed" && run_backend=1 || run_backend=0 - grep -qE '^frontend/' <<<"$changed" && run_frontend=1 || run_frontend=0 - # A change to the hook / CI config itself → re-check everything. - if grep -qE '^(scripts/|\.github/)' <<<"$changed"; then - run_backend=1 - run_frontend=1 - fi +if [[ -n "$changed" ]]; then + grep -qE '^backend/' <<<"$changed" && run_backend=1 || run_backend=0 + grep -qE '^frontend/' <<<"$changed" && run_frontend=1 || run_frontend=0 + # A change to the hook / CI config itself → re-check everything. + if grep -qE '^(scripts/|\.github/)' <<<"$changed"; then + run_backend=1 + run_frontend=1 fi fi @@ -84,6 +151,10 @@ if [[ $failed -ne 0 ]]; then exit 1 fi +# Remind: a clean pre-push doesn't prove CI will pass. CI-only conditions +# (timing-sensitive tests under a slow runner, env-specific config, parallel +# job interactions) can still surface there. If a flake hits CI but not +# local, fix the flake — don't just retry. echo -printf "${GREEN}pre-push: all checks passed.${RST}\n" +printf "${GREEN}pre-push: all checks passed.${RST} ${DIM}(CI may still surface timing-sensitive flakes; don't just retry.)${RST}\n" exit 0