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

// The workspace layout (Labs › Layout › Workspaces): no sidebar; named
// workspaces of agent panes across the top, every other agent in the strip
// along the bottom.

test.beforeEach(async ({ app }) => {
mockOnly();
await app.open({ params: { layout: "workspace", view: "conversation" } });
});

// Panes are in the DOM by id, not where they show: find one by its place.
const paneAt = (app: { panes: import("@playwright/test").Locator; page: import("@playwright/test").Page }, place: string) => app.panes.filter({ has: app.page.getByTestId("pane-place").filter({ hasText: place }) });

const chip = (app: { page: import("@playwright/test").Page }, session: string) => app.page.locator(`[data-deck-strip] [data-testid=strip-agent][data-session="${session}"]`);

test("two agents side by side: focus moves between them, and one zooms to fill the workspace", async ({ app }) => {
const { page } = app;
await expect(page.getByText("Main is empty")).toBeVisible();
// What needs you leads the strip.
await expect(page.locator("[data-deck-strip] [data-testid=strip-agent]").first()).toHaveAttribute("data-state", "waiting");

await chip(app, "devl/checkout-fix-claude").click();
await expect(app.panes).toHaveCount(1);
await chip(app, "gpu/judge-v2-claude").click();
await expect(app.panes).toHaveCount(2);
// Side by side, each naming its worktree and box.
const left = paneAt(app, "checkout-fix");
const right = paneAt(app, "judge-v2");
const [a, b] = await Promise.all([left.boundingBox(), right.boundingBox()]);
expect(Math.abs(a!.y - b!.y)).toBeLessThan(2);
expect(b!.x - a!.x).toBeGreaterThan(400);
// The one that needs you says so.
await expect(left.locator("[data-needs-you]")).toBeVisible();
await expect(right.locator("[data-needs-you]")).toHaveCount(0);
// On screen, they leave the strip; the workspace's tab counts who needs you.
await expect(chip(app, "devl/checkout-fix-claude")).toHaveCount(0);
await expect(page.locator("[data-deck='Main'] [aria-label='1 needs you']")).toBeVisible();

// The new one has the keyboard; ⌘⌥← moves it to the left pane.
await expect(right).toHaveAttribute("data-pane-focused", "");
await page.keyboard.press("Meta+Alt+ArrowLeft");
await expect(left).toHaveAttribute("data-pane-focused", "");

// ⌘⇧↵ zooms the focused pane; the other waits in the strip, behind it.
await page.keyboard.press("Meta+Shift+Enter");
await expect(app.panes).toHaveCount(1);
await expect(app.panes.first()).toHaveAttribute("data-zoomed", "");
await expect(app.panes.first().getByTestId("pane-place")).toContainText("checkout-fix");
await expect(chip(app, "gpu/judge-v2-claude")).toBeVisible();
await page.keyboard.press("Meta+Shift+Enter");
await expect(app.panes).toHaveCount(2);

// Grid and back: the same two panes, rearranged, nothing reloaded.
await page.getByRole("radio", { name: "Grid" }).or(page.getByRole("button", { name: "Grid" })).first().click();
await expect(app.panes).toHaveCount(2);
});

test("a worktree opens from the strip or ⌘E, beside the others or in the focused pane's place", async ({ app }) => {
const { page } = app;
await chip(app, "gpu/shop-claude").click();
await expect(app.panes).toHaveCount(1);
await expect(app.panes.first().getByTestId("pane-place")).toContainText("shop");
// ⌥-click: in the focused pane's place, which goes back to the strip.
await chip(app, "devl/qa-deck-codex").click({ modifiers: ["Alt"] });
await expect(app.panes).toHaveCount(1);
await expect(app.panes.first().getByTestId("pane-place")).toContainText("qa-deck");
await expect(chip(app, "gpu/shop-claude")).toBeVisible();

// ⌘E: every agent and worktree, to type into.
await page.keyboard.press("Meta+e");
const dialog = page.getByRole("dialog", { name: "Open an agent or worktree" });
await expect(dialog).toBeVisible();
await dialog.getByRole("combobox").or(dialog.getByRole("textbox")).first().fill("search-perf");
await dialog.getByRole("option", { name: /search-perf/ }).first().click();
await expect(dialog).toBeHidden();
await expect(app.panes).toHaveCount(2);
await expect(paneAt(app, "search-perf")).toBeVisible();
await expect(paneAt(app, "qa-deck")).toBeVisible();

// The strip's label opens every box, project and worktree, offline boxes too.
await page.getByTestId("deck-tray").click();
const tray = page.getByTestId("deck-tray-panel");
await expect(tray).toContainText("old-vps");
// Projects fold to a line of counts; one that needs you starts open.
await expect(tray.getByRole("button", { name: /Fix checkout webhook retries/ })).toBeVisible();
await tray.getByRole("button", { name: /^evals/ }).click();
await tray.getByRole("button", { name: /Tune the judge prompt/ }).click();
await expect(tray).toBeHidden();
await expect(app.panes).toHaveCount(3);
await expect(paneAt(app, "judge-v2")).toBeVisible();
});

test("a new task lands in a new pane, another workspace starts empty, and Settings is a click away", async ({ app }) => {
const { page } = app;
await chip(app, "devl/qa-deck-codex").click();
await expect(app.panes).toHaveCount(1);

await page.locator("[data-deck-bar]").getByRole("button", { name: /New task/ }).click();
// The composer, not a toast that an agent needs you (also a dialog).
const composer = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: /^Start/ }) });
await composer.getByRole("textbox").first().fill("say hello");
await composer.getByRole("button", { name: /^Start/ }).click();
await expect(composer).toBeHidden();
// Beside the agent already there, not instead of it.
await expect(app.panes).toHaveCount(2);

// A second workspace starts empty; ⌘1 comes back to the first, as it was.
await page.getByRole("button", { name: "New workspace" }).click();
await expect(page.getByText("Workspace 2 is empty")).toBeVisible();
await expect(page.getByTestId("deck-how")).toContainText("finds any agent or worktree");
await expect(app.panes).toHaveCount(0);
await page.keyboard.press("Meta+1");
await expect(app.panes).toHaveCount(2);

// Settings, and back to the workspace.
const labs = await app.openSettings("labs");
await expect(labs.getByRole("radio", { name: "Workspaces" }).or(labs.getByRole("button", { name: "Workspaces" })).first()).toBeVisible();
await page.locator("[data-deck-bar]").getByRole("button", { name: "Close" }).click();
await expect(app.panes).toHaveCount(2);
});

test("⌘J brings in who has waited longest, and the strip's card says what the next one asks", async ({ app }) => {
const { page } = app;
await expect(chip(app, "devl/checkout-fix-claude")).toBeVisible();
await page.keyboard.press("Meta+j");
await expect(app.panes).toHaveCount(1);
await expect(paneAt(app, "checkout-fix")).toBeVisible();
// The other one that needs you is pinned in the strip; its card answers it.
await chip(app, "gpu/shop-claude").hover();
const card = page.getByTestId("ask-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Plan the checkout release");
await expect(card.getByRole("button", { name: "Answer here" })).toBeVisible();
await card.getByRole("button", { name: "Answer here" }).click();
await expect(app.panes).toHaveCount(2);
});
40 changes: 38 additions & 2 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import { HomeTabs } from "@/components/workspace/home-tabs";
import { BoxPicker } from "@/components/box-picker";
import { FakeTrafficLights, ZenBar } from "@/components/workspace/zen";
import { Announcer } from "@/components/announcer";
import { DeckBar } from "@/components/deck/deck-bar";
import { DeckEmpty, DeckStrip, DeckSwitcher } from "@/components/deck/deck-strip";
import { deckTab, useDeckOn, useDecks } from "@/lib/deck";
import { fakeTrafficLights } from "@/lib/api";
import { useBerthConnection } from "@/hooks/use-berth-connection";
import { useShortcuts } from "@/hooks/use-shortcuts";
Expand Down Expand Up @@ -97,15 +100,20 @@ export default function App() {
useTeamWatch();
const view = useStore((s) => s.view);
const workspace = view.kind === "workspace";
// Labs: the workspace layout (lib/deck.ts) has no sidebar or tab strip:
// named workspaces of panes across the top, other agents in a strip below.
const deckOn = useDeckOn();
// Labs: zen (⌘.) puts away the sidebar, the tab strip and the status bar.
const zen = usePrefs((p) => p.labs && p.zen);
// The workspace layout has its own way, so zen stays out of it.
const zen = usePrefs((p) => p.labs && p.zen) && !deckOn;
// 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]);
// The keyboard is never dropped on <body> by what had it going away.
useRescueRemovedFocus();
// Onboarding has no tabs yet, so it gets the plain strip, not the tab strip.
const onboarding = useOnboardingActive();
const deck = deckOn && !onboarding;
// Home (no worktree, or a box's home terminals over it) has its own strip.
const onHome = useWorkspaces((s) => !s.current || !!homeBox(s.current));
const connected = useStore((s) => !!s.client);
Expand Down Expand Up @@ -158,6 +166,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 />}
{deck ? (
<>
<Disconnectable className="shrink-0 flex-col" label="Workspaces">
<DeckBar />
</Disconnectable>
<main className="relative min-h-0 flex-1">
<TreeDockFrame showing={workspace}>
<PaneLayer showing={workspace} />
</TreeDockFrame>
<ErrorBoundary key={view.kind} scope={viewTitles[view.kind as keyof typeof viewTitles] || (view.kind === "workspace" ? "the workspace" : undefined)} onLeave={view.kind === "workspace" ? undefined : () => useStore.getState().setView({ kind: "workspace" })}>
<MainView />
</ErrorBoundary>
</main>
<Disconnectable className="shrink-0 flex-col" label="Agents not on screen">
<DeckStrip />
</Disconnectable>
</>
) : (
<div className="flex min-h-0 flex-1">
{!zen && (
<Disconnectable>
Expand Down Expand Up @@ -187,6 +213,7 @@ export default function App() {
</main>
</div>
</div>
)}
{!zen && <StatusBar />}
</div>
<ErrorBoundary scope="a dialog">
Expand All @@ -210,6 +237,7 @@ export default function App() {
<PrReviewSheet />
<NotificationCenter />
<WhatsNewDialog />
{deck && <DeckSwitcher />}
</ErrorBoundary>
<FileDropGuard />
<Announcer />
Expand All @@ -230,7 +258,14 @@ function MainView() {
const ws = useWorkspaces((s) => (s.current ? s.spaces[s.current] : undefined));
const home = useWorkspaces((s) => !!homeBox(s.current));
const onboarding = useOnboardingActive();
const zen = usePrefs((p) => p.labs && p.zen);
const deckOn = useDeckOn();
const zen = usePrefs((p) => p.labs && p.zen) && !deckOn;
// The workspace in front with no panes yet (the layout's own empty page).
const emptyDeck = useDecks((d) => {
if (!deckOn || d.home) return undefined;
const a = d.decks.find((x) => x.id === d.active);
return a && !deckTab(a) ? a.name : undefined;
});

if (!client) return <Connecting state={connection.state} error={connection.error} />;
// A new account starts here; Settings and the other views stay reachable.
Expand All @@ -243,6 +278,7 @@ function MainView() {
}
if (view.kind === "workspace") {
// A box's home terminals show over Home, which shows again without them.
if (!ws && emptyDeck) return <DeckEmpty name={emptyDeck} />;
if (!ws || home) return ws?.tabs.length ? null : <NoWorktree />;
return ws.tabs.length ? null : <Launcher worktree={ws.ref} />;
}
Expand Down
Loading
Loading