feat(workspace): VS Code-style split panes, edge-to-edge session view
The console splits into up to four panes: a binary split tree of tmux slots (client/src/lib/paneLayout.ts) drawn by PaneGrid.tsx, every pane an absolutely placed sibling keyed by slot so splitting, swapping, resizing or closing never remounts a live terminal. Ctrl/Cmd+click on a tab, a + item or a sidebar session row opens it beside the focused pane (router state, never the URL); a pane header drags onto another pane's edge to split there or onto its middle to swap, picks another live session, splits right/down (a not-shown session or a new terminal), or closes the pane only. Dividers drag or take arrow keys. ?run= is the focused pane: every layout change goes through change(), which pushes the URL with focusOnly instead of an effect watching the focus (that ping-ponged with open requests). The layout is kept per workspace in localStorage. Only the focused pane's terminal takes the keyboard when it appears, and the keyboard follows a focus change. A pane closes when its terminal reports the exit; the run list prunes only a remembered layout on a workspace's first read and a just-opened slot that never got listed, so one empty read cannot wipe a split. A run ending on the home or a replay closes its pane without leaving the page. The session view now fills the page edge to edge: no page padding, the tab bar and the right panel's activity bar one h-10 strip, and the tab bar, pane headers and right panel on the sidebar/status bar colour (surface-1) around terminals on the page colour.
This commit is contained in:
@@ -259,8 +259,23 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
// `exit-<runId>` stands in for the server's exit frame.
|
||||
TerminalView: ({
|
||||
runId,
|
||||
onExit,
|
||||
autoFocus,
|
||||
}: {
|
||||
runId: string;
|
||||
onExit?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="terminal-view"
|
||||
data-run-id={runId}
|
||||
data-autofocus={autoFocus === false ? "false" : "true"}
|
||||
>
|
||||
<button data-testid={`exit-${runId}`} onClick={() => onExit?.()} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -598,6 +613,261 @@ describe("console tabs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split panes", () => {
|
||||
const live = (slot: number, tool = "claude") => ({
|
||||
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot,
|
||||
status: "running",
|
||||
tool,
|
||||
model: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
const original = vi.mocked(api.run.list).getMockImplementation();
|
||||
afterEach(() => {
|
||||
vi.mocked(api.run.list).mockImplementation(original!);
|
||||
});
|
||||
const withRuns = (items: ReturnType<typeof live>[]) =>
|
||||
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
|
||||
/** The URL's `?run=` — the focused pane's run. */
|
||||
function RunParam() {
|
||||
const [params] = useSearchParams();
|
||||
return <span data-testid="run-param">{params.get("run")}</span>;
|
||||
}
|
||||
const open = async (entry: string | { pathname: string; search: string; state?: unknown }) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<RunParam />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
};
|
||||
/** The slots a pane is drawn for (the rest are mounted and hidden). */
|
||||
const shownPanes = () =>
|
||||
screen
|
||||
.getAllByTestId(/^pane-\d$/)
|
||||
.filter((p) => !p.classList.contains("hidden"))
|
||||
.map((p) => p.dataset.testid);
|
||||
const ctrlClick = async (el: HTMLElement) => {
|
||||
const user = userEvent.setup();
|
||||
await user.keyboard("{Control>}");
|
||||
await user.click(el);
|
||||
await user.keyboard("{/Control}");
|
||||
await settle();
|
||||
};
|
||||
const stored = () =>
|
||||
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
|
||||
|
||||
it("Ctrl+click on a tab opens it in a split beside the focused pane, and remembers it", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.queryByTestId("pane-header-0")).toBeNull();
|
||||
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
|
||||
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
|
||||
// The other pane's tab is marked as shown; the focused one is the active tab.
|
||||
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("console-tab-0").parentElement).toHaveAttribute("data-shown", "true");
|
||||
expect(stored().root).toMatchObject({ type: "split", dir: "row" });
|
||||
});
|
||||
|
||||
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-1", "pane-2"]);
|
||||
expect(screen.getByTestId("pane-2")).toHaveAttribute("data-focused", "true");
|
||||
});
|
||||
|
||||
it("closing a pane keeps its session as a tab; closing a tab closes its pane", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await ctrlClick(screen.getByTestId("console-tab-2"));
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2"]);
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId("pane-close-1"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
|
||||
|
||||
withRuns([live(0), live(1)]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-close-2"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("drops a remembered pane whose session is gone", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 3 },
|
||||
},
|
||||
focus: 3,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
});
|
||||
|
||||
it("a sidebar Ctrl/Cmd+click (router state) splits; a plain one goes into the focused pane", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({ 1: { root: { type: "pane", slot: 0 }, focus: 0 } })
|
||||
);
|
||||
await open({
|
||||
pathname: "/workspace",
|
||||
search: "?lane=1&run=agenthub-lane-1-1",
|
||||
state: { split: true },
|
||||
});
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
|
||||
it("a pane's picker swaps two shown panes", async () => {
|
||||
withRuns([live(0), live(1, "shell")]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("0%");
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-pick-0"));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /Terminal/ }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
||||
});
|
||||
|
||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-bottom-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-bottom-1"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||
});
|
||||
|
||||
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
withRuns([live(0)]);
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("a run ending while the home is shown closes its pane but never leaves the home", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 1 },
|
||||
},
|
||||
focus: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1");
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
|
||||
await settle();
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
expect(stored().root).toEqual({ type: "pane", slot: 0 });
|
||||
// The last one too: still the home.
|
||||
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
|
||||
await settle();
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("an empty run-list read after the first does not wipe a split", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
withRuns([]);
|
||||
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
});
|
||||
|
||||
it("only the focused pane's terminal takes the keyboard when it appears", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({
|
||||
1: {
|
||||
root: {
|
||||
type: "split",
|
||||
dir: "row",
|
||||
ratio: 0.5,
|
||||
a: { type: "pane", slot: 0 },
|
||||
b: { type: "pane", slot: 1 },
|
||||
},
|
||||
focus: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1-1");
|
||||
const term = (id: string) =>
|
||||
screen.getAllByTestId("terminal-view").find((t) => t.dataset.runId === id);
|
||||
expect(term("agenthub-lane-1-1")).toHaveAttribute("data-autofocus", "true");
|
||||
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
|
||||
});
|
||||
|
||||
it("offers no new terminal in a split once every slot is busy", async () => {
|
||||
withRuns([live(0), live(1), live(2), live(3)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
|
||||
expect(screen.getByTestId("pane-split-new-right-1")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("a divider resizes by keyboard", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const sep = screen.getByRole("separator");
|
||||
sep.focus();
|
||||
await userEvent.setup().keyboard("{ArrowRight}");
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-0").style.width).toBe("55.00000000000001%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — home and session view", () => {
|
||||
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
Reference in New Issue
Block a user