Skip to content
Closed
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
155 changes: 155 additions & 0 deletions app/e2e/rail-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { expect, mockOnly, test } from "./fixtures";

// Labs › Layout › Icon rail (components/rail-layout): a rail of projects
// whose peeks list their worktrees and agents, and a title bar whose
// breadcrumb (Project / Worktree / Agent) switches at every step.

const RAIL = { prefs: { labs: true, labsChosen: true, layout: "rail" } };

test("the rail peeks at a project's worktrees by state, and a click goes there", async ({ app }) => {
mockOnly();
await app.open(RAIL);
const { page } = app;
await expect(page.getByTestId("sidebar")).toHaveCount(0);
const rail = page.getByTestId("project-rail");
await expect(rail).toBeVisible();

// shop's mark says two agents need you and one works, without a word.
const shop = rail.locator('[data-testid=rail-project][data-project="shop"]');
await expect(shop).toHaveAccessibleName(/shop, 2 need you, 1 working/);

// Pointing at it peeks: its worktrees by what they need, needs-you first.
await shop.hover();
const peek = page.getByTestId("rail-peek");
await expect(peek).toBeVisible();
const lanes = await peek.getByTestId("rail-lane").evaluateAll((els) => els.map((e) => e.getAttribute("data-lane")));
expect(lanes).toEqual(["waiting", "running", "finished", "quiet"]);
const fix = peek.locator('[data-testid=rail-wt][data-worktree="devl/checkout-fix"]');
await expect(fix).toContainText("Fix checkout webhook retries");
await expect(fix).toContainText("pnpm prisma migrate dev");
// Quiet worktrees fold into one row, so the peek stays short.
await expect(peek.getByTestId("rail-quiet")).toContainText("2 quiet worktrees");

// One click: the worktree opens with its agent in front, and the
// breadcrumb and the rail say where you are.
await fix.click();
await expect(peek).toBeHidden();
await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Fix checkout webhook retries/);
await expect(page.getByTestId("crumb-project")).toContainText("shop");
await expect(page.getByTestId("crumb-worktree")).toContainText("checkout-fix");
// shop is on two boxes, so the box shows.
await expect(page.getByTestId("crumb-worktree")).toContainText("devl");
// One agent: its name is on its tab, so the breadcrumb stops at the worktree.
await expect(page.getByTestId("crumb-agent")).toHaveCount(0);
await expect(shop).toHaveAttribute("aria-current", "true");

// A dot per agent working, then per agent done; the badge counts the rest.
const pips = await shop.getByTestId("rail-pips").locator("[data-state]").evaluateAll((els) => els.map((e) => e.getAttribute("data-state")));
expect(pips).toEqual(["running", "finished", "finished", "finished"]);

// A click on a mark goes to the project, where its agent works; so does ⌃n.
await page.mouse.move(700, 500);
await rail.locator('[data-testid=rail-project][data-project="evals"]').click();
await expect(page.getByTestId("crumb-worktree")).toContainText("judge-v2");
await page.keyboard.press("Control+3");
await expect(page.getByTestId("crumb-project")).toContainText("shop");
});

test("Home's peek is every agent at work, and a waiting one is answered in place", async ({ app }) => {
mockOnly();
await app.open(RAIL);
const { page } = app;
await page.getByTestId("nav-home").hover();
const peek = page.getByTestId("home-peek");
await expect(peek).toBeVisible();
const lanes = await peek.getByTestId("rail-lane").evaluateAll((els) => els.map((e) => e.getAttribute("data-lane")));
expect(lanes).toEqual(["waiting", "running", "finished"]);
// Across projects, each named.
await expect(peek.locator('[data-testid=rail-wt][data-worktree="gpu/judge-v2"]')).toContainText("evals / judge-v2");
const fix = peek.locator('[data-testid=rail-wt][data-worktree="devl/checkout-fix"]');
await expect(fix).toContainText("shop / checkout-fix");
// A permission is answered from the list, without going to the agent.
await fix.getByTestId("peek-allow").click();
await expect(fix).toContainText("Allowed · resuming");
await expect(page.getByTestId("crumb-project")).toContainText("Home");
});

test("the breadcrumb switches worktree, agent and project", async ({ app }) => {
mockOnly();
await app.open(RAIL);
const { page } = app;

// From Home, the first crumb lists the projects: evals opens where its
// agent works.
await expect(page.getByTestId("crumb-project")).toContainText("Home");
await page.getByTestId("crumb-project").click();
await page.locator('[data-testid=crumb-project-item][data-project="evals"]').click();
await expect(page.getByTestId("crumb-worktree")).toContainText("judge-v2");
await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Tune the judge prompt/);

// Back to shop, then across its worktrees by the worktree crumb.
await page.getByTestId("crumb-project").click();
await page.locator('[data-testid=crumb-project-item][data-project="shop"]').click();
await expect(page.getByTestId("crumb-project")).toContainText("shop");
await page.getByTestId("crumb-worktree").click();
await page.getByRole("menu").locator('[data-testid=rail-wt][data-worktree="devl/qa-deck"]').click();
await expect(page.getByTestId("crumb-worktree")).toContainText("qa-deck");
await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Build the QA deck/);

// A worktree with several agents: the agent crumb goes between them.
await page.getByTestId("crumb-worktree").click();
await page.getByRole("menu").locator('[data-testid=rail-wt][data-worktree="devl/order-export"]').click();
await expect(page.getByTestId("crumb-worktree")).toContainText("order-export");
await page.getByTestId("crumb-agent").click();
await page.getByTestId("crumb-agent-item").filter({ hasText: "Add tests for the export job" }).click();
await expect(page.getByTestId("crumb-agent")).toContainText("Add tests for the export job");
await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Add tests for the export job/);
});

test("needs you goes round the agents that wait, and new work starts from the title bar or a peek", async ({ app }) => {
mockOnly();
await app.open(RAIL);
const { page } = app;

const needs = page.getByTestId("title-needs-you");
await expect(needs).toContainText("2 need you");
const tab = page.locator("[role=tab][aria-selected=true]");
await needs.click();
await expect(tab).toHaveAccessibleName(/Plan the checkout release|Fix checkout webhook retries/);
const plan = /Plan the checkout release/.test((await tab.textContent()) ?? "");
await needs.click();
await expect(tab).toHaveAccessibleName(plan ? /Fix checkout webhook retries/ : /Plan the checkout release/);

// New task from the title bar opens the composer.
await page.getByTestId("title-new-task").click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();

// From a project's peek, the composer starts in that project, and the
// new task opens.
await page.locator('[data-testid=rail-project][data-project="notes"]').hover();
await page.getByTestId("rail-peek").getByTestId("rail-new-task").click();
const composer = page.getByRole("dialog");
await expect(composer).toContainText("notes");
await composer.getByRole("textbox").first().fill("say hello");
await composer.getByRole("button", { name: /^Start/ }).click();
await expect(composer).toBeHidden();
await expect(page.getByTestId("crumb-project")).toContainText("notes");
});

test("Settings is at the rail's foot, and Labs puts the sidebar back", async ({ app }) => {
mockOnly();
const body = await (async () => {
await app.open(RAIL);
return app.openSettings("labs");
})();
const { page } = app;
await expect(page.getByTestId("nav-settings")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("crumb-project")).toContainText("Settings");
await body.getByRole("button", { name: "Sidebar", exact: true }).click();
await expect(page.getByTestId("sidebar")).toBeVisible();
await expect(page.getByTestId("project-rail")).toHaveCount(0);
await expect(page.getByTestId("title-bar")).toHaveCount(0);
expect(((await app.stored("berth.prefs")) as { layout?: string }).layout).toBe("sidebar");
});
32 changes: 22 additions & 10 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { lazy, Suspense, useEffect } from "react";
import { AddLocationDialog } from "@/components/add-location-dialog";
import { Connecting } from "@/components/agent-offline";
import { AppSidebar } from "@/components/app-sidebar";
import { ProjectRail } from "@/components/rail-layout/project-rail";
import { TitleBar } from "@/components/rail-layout/title-bar";
import { CommandPalette } from "@/components/command-palette";
import { FilePicker } from "@/components/files/file-picker";
import { TreeDockFrame } from "@/components/files/tree-dock";
Expand Down Expand Up @@ -99,6 +101,13 @@ export default function App() {
const workspace = view.kind === "workspace";
// Labs: zen (⌘.) puts away the sidebar, the tab strip and the status bar.
const zen = usePrefs((p) => p.labs && p.zen);
// Labs › Layout › Icon rail: a rail of projects and a title bar whose
// breadcrumb switches worktrees and agents, in place of the sidebar
// (components/rail-layout). Zen puts both away as it does the sidebar.
const rail = usePrefs((p) => p.labs && p.layout === "rail") && !zen;
// Home's strip holds box homes' terminals; with none, the rail layout
// has no use for it (its title bar drags the window).
const homeTabs = useWorkspaces((s) => Object.keys(s.spaces).some((k) => !!homeBox(k) && s.spaces[k].tabs.length > 0));
// Without the status bar, what floats over its corner (toasts, the loops
// panel) comes down to the window's edge.
useEffect(() => document.documentElement.style.setProperty("--berth-status-h", zen ? "0px" : "26px"), [zen]);
Expand Down Expand Up @@ -158,21 +167,24 @@ export default function App() {
<ToastProvider position="bottom-right" viewportClassName="max-w-88 data-[position=bottom-right]:bottom-[max(calc(var(--berth-status-h,26px)+12px+var(--berth-loops-h,0px)),var(--berth-bar-lift,0px))] data-[position=bottom-right]:right-3 data-[position=bottom-left]:bottom-[calc(var(--berth-status-h,26px)+12px)] data-[position=bottom-left]:left-3 data-[position=top-left]:top-3 data-[position=top-left]:left-3 data-[position=top-right]:top-3 data-[position=top-right]:right-3">
<div className="flex h-svh flex-col overflow-hidden bg-background text-foreground">
{fakeTrafficLights() && <FakeTrafficLights />}
{rail && (
<Disconnectable className="shrink-0 flex-col" label="Title bar">
<TitleBar />
</Disconnectable>
)}
<div className="flex min-h-0 flex-1">
{!zen && (
<Disconnectable>
<AppSidebar />
</Disconnectable>
)}
{!zen && <Disconnectable>{rail ? <ProjectRail /> : <AppSidebar />}</Disconnectable>}
<div className="relative flex min-w-0 flex-1 flex-col">
{zen && !onboarding ? (
<ZenBar />
) : workspace && !onboarding ? (
<Disconnectable className="shrink-0 flex-col" label="Tab bar">
{!onHome && <WorkspaceHeading />}
{onHome ? <HomeTabs /> : <TabStrip />}
</Disconnectable>
) : !workspace ? null /* every other view's ViewHeader is the strip */ : (
rail && onHome && !homeTabs ? null : (
<Disconnectable className="shrink-0 flex-col" label="Tab bar">
{!onHome && <WorkspaceHeading />}
{onHome ? <HomeTabs /> : <TabStrip place={!rail} />}
</Disconnectable>
)
) : !workspace || rail ? null /* every other view's ViewHeader is the strip */ : (
// Onboarding names itself; the strip only drags the window.
<div data-tauri-drag-region className="h-10 shrink-0 bg-background" />
)}
Expand Down
Loading
Loading