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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/src/switchboard/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand Down
39 changes: 39 additions & 0 deletions backend/src/switchboard/services/claude_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions backend/src/switchboard/services/tmux.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import logging
import os
import subprocess
import threading
import time
Expand Down Expand Up @@ -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(
Expand All @@ -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 [],
)
Expand Down
94 changes: 94 additions & 0 deletions backend/tests/test_claude_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cwd> 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
24 changes: 24 additions & 0 deletions backend/tests/test_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -164,13 +166,21 @@ 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}')]


def test_ws_signal_control_message_routes_to_send_signal(
monkeypatch, ws_client: TestClient
) -> None:
import time

signals: list[tuple] = []
monkeypatch.setattr(
tmux,
Expand All @@ -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):
Expand All @@ -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"]

Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,7 @@ export function App() {
onQuickAction={handleQuickAction}
pinnedPaneIds={pinnedIds}
onTogglePin={onTogglePinWindow}
groupingMode={settings.groupingMode}
/>
) : settings.layout === "grid" ? (
<GridView
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ function makeWindow(overrides: Partial<Window>): Window {
prUrl: null,
ci: null,
repoUrl: null,
repoKey: null,
repoLabel: null,
agent: null,
preview: [],
...overrides,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const TARGET: Window = {
prUrl: null,
ci: null,
repoUrl: null,
repoKey: null,
repoLabel: null,
agent: null,
preview: [],
};
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/GridView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function mkWindow(over: Partial<Window> = {}): Window {
prUrl: null,
ci: null,
repoUrl: null,
repoKey: null,
repoLabel: null,
agent: null,
preview: [],
...over,
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/components/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ function mkWindow(over: Partial<Window> = {}): Window {
prUrl: null,
ci: null,
repoUrl: null,
repoKey: null,
repoLabel: null,
agent: null,
preview: [],
...over,
Expand Down Expand Up @@ -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<HTMLDivElement>(".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<HTMLDivElement>(".list-group-head .list-group-label"),
).map((el) => el.textContent);
expect(heads).toEqual(["alpha", "beta"]);
const counts = Array.from(
container.querySelectorAll<HTMLDivElement>(".list-group-head .list-group-count"),
).map((el) => el.textContent);
expect(counts).toEqual(["2", "1"]);
});
});
Loading
Loading