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
9 changes: 8 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Header, type HeaderCounts } from "./components/Header";
import { GridView } from "./components/GridView";
import { Kanban } from "./components/Kanban";
import { ListView } from "./components/ListView";
import { SplitView } from "./components/split/SplitView";
import { NeedsStrip } from "./components/NeedsStrip";
import { NewSessionOverlay } from "./components/NewSessionOverlay";
import { NewWindowOverlay } from "./components/NewWindowOverlay";
Expand Down Expand Up @@ -1002,7 +1003,13 @@ export function App() {
visibleCount={visible.length}
/>
<main className="main">
{settings.layout === "list" ? (
{settings.layout === "split" ? (
<SplitView
windows={visible}
sessions={orderedSessions}
onFocus={handleFocus}
/>
) : settings.layout === "list" ? (
<ListView
windows={visible}
focusedId={focusedId}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type IconName =
| "grid"
| "kanban"
| "list"
| "split"
| "play"
| "pause"
| "filter"
Expand Down Expand Up @@ -193,6 +194,16 @@ export function Icon({ name, size = 14, style, className }: IconProps) {
<path d="M2.5 4h11M2.5 8h11M2.5 12h11" />
</svg>
);
case "split":
// THI-246: rail-on-left, large detail panel on right. The vertical
// divider hints at the resizable column split. Stroke style matches
// the other layout glyphs.
return (
<svg {...props}>
<rect x="2" y="2.5" width="4" height="11" rx="0.6" />
<rect x="7.5" y="2.5" width="6.5" height="11" rx="0.6" />
</svg>
);
case "play":
return (
<svg {...props}>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Subhead.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ describe("Subhead layout switcher (THI-59)", () => {
const buttons = container.querySelectorAll<HTMLButtonElement>(
".layout-switcher button",
);
expect(buttons).toHaveLength(3); // kanban, grid, list
expect(buttons).toHaveLength(4); // kanban, grid, list, split (THI-246)
const [kanban, grid, list] = buttons;
expect(kanban.disabled).toBe(false);
expect(grid.disabled).toBe(false);
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/Subhead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,18 @@ function LayoutSwitcher() {
<Icon name="list" size={13} />
</button>
</Tooltip>
{/* THI-246: Split view — persistent rail + detail. PR 1 ships the
* switcher + skeleton; rail features (PR 2) and inline xterm (PR 3)
* follow. */}
<Tooltip content="Split">
<button
className={layout === "split" ? "is-active" : ""}
onClick={() => updateSettings({ layout: "split" })}
aria-label="Split layout"
>
<Icon name="split" size={13} />
</button>
</Tooltip>
</span>
);
}
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/components/split/SplitView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";

import { SplitView } from "./SplitView";
import { DEFAULT_SETTINGS, updateSettings } from "../../lib/settings";
import { mkSession, mkWindow } from "../../test/factories";

beforeEach(() => {
localStorage.clear();
updateSettings(DEFAULT_SETTINGS);
});

afterEach(() => {
cleanup();
});

const noop = vi.fn();

describe("SplitView (THI-246 PR 1)", () => {
it("renders an empty-state hint when no panes match", () => {
const { container } = render(
<SplitView windows={[]} sessions={[]} onFocus={noop} />,
);
expect(container.querySelector(".sb-rail-empty")).not.toBeNull();
expect(container.textContent).toMatch(/select a pane/i);
});

it("renders one rail row per visible window, grouped under its session", () => {
const { container } = render(
<SplitView
windows={[
mkWindow({ paneId: "%a", session: "alpha", name: "shell" }),
mkWindow({ paneId: "%b", session: "alpha", name: "claude" }),
mkWindow({ paneId: "%c", session: "beta", name: "logs" }),
]}
sessions={[mkSession({ id: "alpha" }), mkSession({ id: "beta" })]}
onFocus={noop}
/>,
);
const paneRows = container.querySelectorAll(".sb-row.pane");
expect(paneRows).toHaveLength(3);
// Session headers are present too.
const heads = container.querySelectorAll(".sb-row.sb-row-head");
expect(heads).toHaveLength(2);
});

it("clicking a rail row persists the selection and swaps the detail pane", () => {
const { container } = render(
<SplitView
windows={[
mkWindow({ paneId: "%a", session: "alpha", name: "shell" }),
]}
sessions={[mkSession({ id: "alpha" })]}
onFocus={noop}
/>,
);
// Before click: empty-state hint visible.
expect(container.querySelector(".sb-detail-empty")).not.toBeNull();
fireEvent.click(container.querySelector<HTMLButtonElement>(".sb-row.pane")!);
expect(container.querySelector(".sb-detail-empty")).toBeNull();
expect(container.querySelector(".sb-pane-hd")).not.toBeNull();
// And the setting is persisted.
expect(
JSON.parse(localStorage.getItem("switchboard:settings")!).selectedPaneId,
).toBe("%a");
});

it("restores the persisted selection on mount", () => {
updateSettings({ selectedPaneId: "%a" });
const { container } = render(
<SplitView
windows={[mkWindow({ paneId: "%a", session: "alpha", name: "shell" })]}
sessions={[mkSession({ id: "alpha" })]}
onFocus={noop}
/>,
);
// Detail header rendered = the pane was matched and selected on mount.
expect(container.querySelector(".sb-pane-hd")).not.toBeNull();
expect(container.querySelector(".sb-row.pane.sel")).not.toBeNull();
});

it("uses the rail-width setting for the grid template", () => {
updateSettings({ splitRailWidth: 360 });
const { container } = render(
<SplitView
windows={[mkWindow({ paneId: "%a", session: "alpha" })]}
sessions={[mkSession({ id: "alpha" })]}
onFocus={noop}
/>,
);
const split = container.querySelector<HTMLDivElement>(".sb-split")!;
expect(split.style.gridTemplateColumns).toContain("360px");
});

it("focus button calls onFocus when a pane is selected", () => {
updateSettings({ selectedPaneId: "%a" });
const onFocus = vi.fn();
const { container } = render(
<SplitView
windows={[mkWindow({ paneId: "%a", session: "alpha" })]}
sessions={[mkSession({ id: "alpha" })]}
onFocus={onFocus}
/>,
);
fireEvent.click(
container.querySelector<HTMLButtonElement>(
".sb-pane-hd button[aria-label='Focus in tmux']",
)!,
);
expect(onFocus).toHaveBeenCalledTimes(1);
});
});
176 changes: 176 additions & 0 deletions frontend/src/components/split/SplitView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useMemo } from "react";

import { sortPendingFirst } from "../../lib/filter";
import { updateSettings, useSetting } from "../../lib/settings";
import type { Session, Window } from "../../types";
import { Icon } from "../Icon";
import { StatusPill } from "../StatusPill";

interface Props {
windows: Window[];
sessions: Session[];
/** Forwarded so the "Open in tmux" header button still focuses the
* selected pane in the user's real terminal (THI-88). */
onFocus: (w: Window) => void;
}

/** THI-246 PR 1 — Split view foundation.
*
* Two-pane workspace: a rail on the left listing every visible window in a
* flat tree, a detail pane on the right that shows the selected pane's
* metadata. The inline xterm + rail features (Repo→Worktree→Pane tree,
* collapse, divider resize, drag-reorder) ship in follow-up PRs.
*
* Selection persists in `settings.selectedPaneId` so a reload or a layout
* swap restores the last viewed pane. Rail width persists in
* `settings.splitRailWidth` (used in PR 2 once the divider lands).
*/
export function SplitView({ windows, sessions, onFocus }: Props) {
const selectedPaneId = useSetting("selectedPaneId");
const railWidth = useSetting("splitRailWidth");

// Sort each session bucket like Kanban does so the rail order matches what
// the user sees elsewhere. PR 2 will swap in the Repo→Worktree→Pane tree
// derived from THI-243's discovery feed.
const rows = useMemo<Array<{ session: Session; windows: Window[] }>>(() => {
const bySession = new Map<string, Window[]>();
for (const w of windows) {
const bucket = bySession.get(w.session);
if (bucket) bucket.push(w);
else bySession.set(w.session, [w]);
}
return sessions
.map((s) => ({ session: s, windows: sortPendingFirst(bySession.get(s.id) ?? []) }))
.filter((row) => row.windows.length > 0);
}, [sessions, windows]);

const selected = selectedPaneId
? windows.find((w) => w.paneId === selectedPaneId) ?? null
: null;

return (
<div
className="sb-split"
style={{ gridTemplateColumns: `${railWidth}px 7px 1fr` }}
>
<aside className="sb-rail" role="navigation" aria-label="Panes">
<header className="sb-rail-hd">
<span className="ttl">Projects</span>
<span className="grow" />
</header>
<div className="sb-rail-body">
{rows.length === 0 ? (
<div className="sb-rail-empty">No matching windows.</div>
) : (
rows.map(({ session, windows: ws }) => (
<SessionGroup
key={session.id}
session={session}
windows={ws}
selectedPaneId={selectedPaneId}
/>
))
)}
</div>
</aside>
{/* Divider placeholder — drag-to-resize lands in PR 2. The 7px gutter
* preserves the grid-template-columns shape so PR 2 just wires up the
* pointer handlers without re-laying out the surface. */}
<div className="sb-divider" aria-hidden="true" />
<section className="sb-detail" role="main" aria-label="Detail pane">
{selected ? (
<DetailPlaceholder window={selected} onFocus={onFocus} />
) : (
<div className="sb-detail-empty">
<p>Select a pane from the rail to see its live terminal.</p>
<p className="sb-detail-empty-sub">
Inline xterm lands in a follow-up PR.
</p>
</div>
)}
</section>
</div>
);
}

interface SessionGroupProps {
session: Session;
windows: Window[];
selectedPaneId: string;
}

function SessionGroup({ session, windows, selectedPaneId }: SessionGroupProps) {
return (
<div className="sb-group">
<div className="sb-row sb-row-head" aria-label={`Session ${session.name}`}>
<span className="ic">
<Icon name="kanban" size={11} />
</span>
<span className="lbl">{session.name}</span>
</div>
{windows.map((w) => (
<PaneRow key={w.paneId} w={w} selected={w.paneId === selectedPaneId} />
))}
</div>
);
}

function PaneRow({ w, selected }: { w: Window; selected: boolean }) {
return (
<button
type="button"
className={`sb-row pane${selected ? " sel" : ""}`}
onClick={() => updateSettings({ selectedPaneId: w.paneId })}
aria-pressed={selected}
data-pane-id={w.paneId}
>
<span className={`ic ${w.kind === "agent" ? "agent" : ""}`}>
<Icon name={w.kind === "agent" ? "agent" : "shell"} size={12} />
</span>
<span className="lbl">{w.name}</span>
{w.pendingInput && <span className="count">!</span>}
</button>
);
}

function DetailPlaceholder({
window: w,
onFocus,
}: {
window: Window;
onFocus: (w: Window) => void;
}) {
return (
<>
<header className="sb-pane-hd">
<span className="pid">
<span className="ic">
<Icon name={w.kind === "agent" ? "agent" : "shell"} size={13} />
</span>
{w.name}
</span>
<span className="meta">
{w.session}:{w.index}
</span>
{w.branch && <span className="meta">{w.branch}</span>}
<span className="grow" />
<StatusPill status={w.status} />
<button
className="btn btn-icon btn-ghost"
onClick={() => onFocus(w)}
title="Focus this window in tmux"
aria-label="Focus in tmux"
>
<Icon name="focus" />
</button>
</header>
<div className="sb-detail-stub">
<p>Inline terminal coming in a follow-up PR.</p>
<p className="sb-detail-stub-sub">
For now, use the Focus-in-tmux button above or switch to
Kanban/List/Grid to open the existing terminal modal.
</p>
</div>
</>
);
}
16 changes: 16 additions & 0 deletions frontend/src/lib/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ afterEach(() => {
updateSettings(DEFAULT_SETTINGS);
});

describe("settings.selectedPaneId + splitRailWidth (THI-246)", () => {
it("defaults the selected pane to empty string and the rail to 280px", () => {
expect(DEFAULT_SETTINGS.selectedPaneId).toBe("");
expect(DEFAULT_SETTINGS.splitRailWidth).toBe(280);
});

it("round-trips both through localStorage", () => {
updateSettings({ selectedPaneId: "%42", splitRailWidth: 320 });
const parsed = JSON.parse(
localStorage.getItem(STORAGE_KEY)!,
) as { selectedPaneId: string; splitRailWidth: number };
expect(parsed.selectedPaneId).toBe("%42");
expect(parsed.splitRailWidth).toBe(320);
});
});

describe("settings.idleCleanupDays", () => {
it("is present in DEFAULT_SETTINGS with value 7", () => {
expect(DEFAULT_SETTINGS.idleCleanupDays).toBe(7);
Expand Down
Loading
Loading