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..12f262d 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,12 @@ 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 +273,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/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"] 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..52da757 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,72 @@ 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-group-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-group-head .list-group-label"), + ).map((el) => el.textContent); + expect(heads).toEqual(["alpha", "Other"]); + }); + + it("renders one session header per tmux session when groupingMode=sessions", () => { + const { container } = renderList( + [ + 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" }, + ); + 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 3d1d00d..e7cc28e 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,14 +25,20 @@ 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; } /** * 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. */ @@ -46,6 +54,7 @@ export function ListView({ onQuickAction, pinnedPaneIds, onTogglePin, + groupingMode, }: Props) { const sorted = sortPendingFirst( windows, @@ -60,23 +69,72 @@ export function ListView({ ); } + const rowFor = (w: Window) => ( + + ); + + // THI-243: discovery mode inserts a repo header row before each group. + // 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)} +
+ ))} +
+ ); + } + + // 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((w) => ( - + {[...sessionBuckets].map(([session, ws]) => ( +
+
+ + {session} + {ws.length} +
+ {ws.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..32d54aa 100644 --- a/frontend/src/components/Subhead.test.tsx +++ b/frontend/src/components/Subhead.test.tsx @@ -219,6 +219,56 @@ 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", + ); + 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(); + updateSettings({ layout: "list" }); + 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"); + }); + + 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)", () => { 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..dd10e45 100644 --- a/frontend/src/components/Subhead.tsx +++ b/frontend/src/components/Subhead.tsx @@ -225,6 +225,7 @@ function SubheadInner({ + ); @@ -302,3 +303,40 @@ function LayoutSwitcher() { ); } + +function GroupingSwitcher() { + // THI-243: choose how the dashboard groups panes — by tmux session + // (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 ( + + + + + + + + + ); +} 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..617d5f4 --- /dev/null +++ b/frontend/src/lib/groupByRepo.test.ts @@ -0,0 +1,147 @@ +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 each window by its own repoKey", () => { + 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("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. + // 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", + repoKey: null, + repoLabel: null, + }); + const inAlpha = mkWindow({ + paneId: "%n2", + session: "mixed", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const groups = groupByRepo([noGit, inAlpha]); + // /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 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", + repoKey: "/r/alpha", + repoLabel: "alpha", + }); + const b = mkWindow({ + paneId: "%b", + session: "x", + repoKey: "/r/beta", + repoLabel: "beta", + }); + const groups = groupByRepo([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("windows with no git-backed cwd 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 window 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..e22e82f --- /dev/null +++ b/frontend/src/lib/groupByRepo.ts @@ -0,0 +1,71 @@ +import type { Window } from "../types"; + +/** 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__"; + +/** 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 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 discovery view's repo buckets. + * + * Rules: + * + * - **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 input order — the caller's + * pre-sort (typically pending-first, then `(session, index)`) is preserved. */ +export function groupByRepo(windows: readonly Window[]): RepoGroup[] { + const buckets = new Map(); + const labels = new Map(); + labels.set(OTHER_REPO_KEY, OTHER_REPO_LABEL); + for (const w of windows) { + 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, w.repoLabel ?? 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..f851b20 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; } + +/* 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; + padding: 6px 4px 4px; + color: var(--text-mute); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} +.list-group-label { color: var(--text); } +.list-group-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[]; } 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