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
27 changes: 27 additions & 0 deletions app/e2e/a11y-scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,33 @@ export const scenes: Scene[] = [
await app.open({ theme });
},
},
{
// Labs › Layout › Top bar, a worktree open in it.
id: "layout-topbar",
async run(app, theme) {
await app.open({ theme, prefs: { layout: "topbar" } });
await app.page.getByTestId("topbar-needs-you").click();
await expect(app.page.locator("[data-testid=topbar-tab][data-selected]")).toBeVisible();
},
},
{
id: "layout-topbar-switcher",
extra: true,
async run(app, theme) {
await app.open({ theme, prefs: { layout: "topbar" } });
await app.page.getByTestId("topbar-switcher").click();
await expect(app.page.getByTestId("agent-switcher")).toBeVisible();
},
},
{
id: "layout-topbar-projects",
extra: true,
async run(app, theme) {
await app.open({ theme, prefs: { layout: "topbar" } });
await app.page.getByTestId("topbar-project").click();
await expect(app.page.getByRole("listbox", { name: "Worktrees" })).toBeVisible();
},
},
{
id: "palette",
key: true,
Expand Down
99 changes: 99 additions & 0 deletions app/e2e/layout-topbar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { type App, expect, mockOnly, test } from "./fixtures";

// Labs › Layout › Top bar: no sidebar; the project you're in, your open
// worktrees as tabs and the places across the top, and ⌃⇥ for every agent.

test.beforeEach(() => mockOnly("opens mock worktrees and starts a task"));

const open = (app: App) => app.open({ prefs: { layout: "topbar" } });

test("the top bar takes the sidebar's place", async ({ app }) => {
const { page } = app;
await open(app);
await expect(page.getByTestId("topbar")).toBeVisible();
await expect(page.getByTestId("sidebar")).toHaveCount(0);
});

test("the project switcher goes to any worktree, which gets a tab", async ({ app }) => {
const { page } = app;
await open(app);
await page.getByTestId("topbar-project").click();
await page.locator('[data-testid=topbar-worktree-item][data-worktree="devl/search-perf"]').click();
const tab = page.locator("[data-testid=topbar-tab][data-selected]");
await expect(tab).toContainText("search-perf");
await expect(app.panes.first()).toBeVisible();
// Home and back by its tab.
await page.getByTestId("nav-home").click();
await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toHaveCount(0);
await page.locator("[data-testid=topbar-tab]", { hasText: "search-perf" }).click();
await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toContainText("search-perf");
});

test("⌘E and a few letters of a task go to its worktree", async ({ app }) => {
const { page } = app;
await open(app);
await page.keyboard.press("Meta+e");
await page.getByRole("combobox", { name: "Go to a worktree or task" }).fill("judge");
await expect(page.getByTestId("topbar-worktree-item")).toHaveCount(1);
await page.keyboard.press("Enter");
await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toContainText("judge-v2");
});

test("what needs you is one click away", async ({ app }) => {
const { page } = app;
await open(app);
await expect(page.getByTestId("topbar-needs-you")).toContainText("2");
await page.getByTestId("topbar-needs-you").click();
await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toContainText("checkout-fix");
});

test("holding ⌃ and pressing ⇥ shows every agent, and letting go opens the one picked", async ({ app }) => {
const { page } = app;
await open(app);
// The agents are in: the bar counts them.
await expect(page.getByTestId("topbar-needs-you")).toBeVisible();
await page.keyboard.down("Control");
await page.keyboard.press("Tab");
const switcher = page.getByTestId("agent-switcher");
await expect(switcher).toBeVisible();
await expect(switcher.locator("[data-testid=switcher-lane][data-lane=waiting] [data-testid=switcher-card]").first()).toContainText("Fix checkout webhook retries");
await expect(switcher.locator("[data-testid=switcher-card][data-picked]")).toContainText("Fix checkout webhook retries");
await page.keyboard.press("Tab");
await expect(switcher.locator("[data-testid=switcher-card][data-picked]")).toContainText("Plan the checkout release");
await page.keyboard.up("Control");
await expect(switcher).toBeHidden();
await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toContainText("shop");
});

test("closing a tab leaves its agents running and shows the next one", async ({ app }) => {
const { page } = app;
await open(app);
await page.getByTestId("topbar-needs-you").click();
await expect(page.locator("[data-testid=topbar-tab]")).toHaveCount(1);
const key = await page.locator("[data-testid=topbar-tab][data-selected]").getAttribute("data-ws");
await page.locator(`[data-testid=topbar-tab-close][data-ws="${key}"]`).click();
await expect(page.locator("[data-testid=topbar-tab]")).toHaveCount(0);
await expect(page.getByTestId("topbar-needs-you")).toContainText("2");
});

test("New task starts work from the top bar", async ({ app }) => {
const { page } = app;
await open(app);
await page.getByTestId("layout-new-task").click();
const composer = page.getByRole("dialog");
await expect(composer).toBeVisible();
await composer.getByRole("textbox").first().fill("say hello");
await composer.getByRole("button", { name: /^Start/ }).click();
await expect(composer).toBeHidden();
});

test("Settings is in the top bar, and Labs picks the layout", async ({ app }) => {
const { page } = app;
await open(app);
const labs = await app.openSettings("labs");
await expect(labs.getByRole("combobox")).toContainText("Top bar");
await labs.getByRole("combobox").click();
await page.getByRole("option", { name: "Sidebar (current)" }).click();
await expect(page.getByTestId("sidebar")).toBeVisible();
await expect(page.getByTestId("topbar")).toHaveCount(0);
});
44 changes: 34 additions & 10 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ 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 { fakeTrafficLights } from "@/lib/api";
import { fakeTrafficLights, hasTrafficLights } from "@/lib/api";
import { useLayout } from "@/components/layouts/registry";
import { useBerthConnection } from "@/hooks/use-berth-connection";
import { useShortcuts } from "@/hooks/use-shortcuts";
import { useWindowTitle } from "@/hooks/use-window-title";
Expand Down Expand Up @@ -99,15 +100,27 @@ 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: the window's layout (components/layouts/registry.ts). Zen, when
// on, puts away whatever it has.
const layout = useLayout();
const Side = layout.side === undefined ? AppSidebar : layout.side;
const Bottom = layout.bottom === undefined ? StatusBar : layout.bottom;
const Top = layout.top;
const Overlay = layout.overlay;
// Nothing at the left or top: the strip leaves the window's buttons room.
const bare = !zen && !Side && !Top && hasTrafficLights();
// 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]);
const statusH = zen ? "0px" : Bottom === StatusBar ? "26px" : Bottom ? "var(--berth-bottom-h, 26px)" : "0px";
useEffect(() => document.documentElement.style.setProperty("--berth-status-h", statusH), [statusH]);
// 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();
// Home (no worktree, or a box's home terminals over it) has its own strip.
const onHome = useWorkspaces((s) => !s.current || !!homeBox(s.current));
// Terminals open in a box's home, which Home's strip holds.
const homeTerminals = useWorkspaces((s) => Object.keys(s.spaces).some((k) => !!homeBox(k) && s.spaces[k].tabs.length > 0));
const connected = useStore((s) => !!s.client);
// Until onboarding is done it is the whole window: no sidebar, status
// bar, palette or shortcuts to wander off through.
Expand Down Expand Up @@ -156,23 +169,33 @@ export default function App() {
or sheet open they move to a corner clear of it; see
components/ui/toast.tsx. The stack is as wide as the loops panel. */}
<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">
<div data-layout={zen ? "zen" : layout.id} className="flex h-svh flex-col overflow-hidden bg-background text-foreground">
{fakeTrafficLights() && <FakeTrafficLights />}
{!zen && Top && (
<Disconnectable className="shrink-0 flex-col">
<Top />
</Disconnectable>
)}
<div className="flex min-h-0 flex-1">
{!zen && (
{!zen && Side && (
<Disconnectable>
<AppSidebar />
<Side />
</Disconnectable>
)}
<div className="relative flex min-w-0 flex-1 flex-col">
<div className="relative flex min-w-0 flex-1 flex-col" data-bare={bare || undefined}>
{zen && !onboarding ? (
<ZenBar />
) : workspace && !onboarding ? (
<Disconnectable className="shrink-0 flex-col" label="Tab bar">
<Disconnectable className={cn("shrink-0 flex-col", bare && "bg-sidebar pl-[84px] shadow-[inset_0_-1px_0_var(--border)]")} label="Tab bar">
{!onHome && <WorkspaceHeading />}
{onHome ? <HomeTabs /> : <TabStrip />}
{/* Under a top bar, Home's strip shows only with terminals in it. */}
{onHome ? !Top || homeTerminals ? <HomeTabs /> : null : <TabStrip />}
</Disconnectable>
) : !workspace ? null /* every other view's ViewHeader is the strip */ : (
) : !workspace ? (
// Every other view's ViewHeader is the strip; bare, the
// window's buttons get a strip of their own over it.
bare ? <div data-tauri-drag-region className="h-8 shrink-0 bg-sidebar/40" /> : null
) : (
// Onboarding names itself; the strip only drags the window.
<div data-tauri-drag-region className="h-10 shrink-0 bg-background" />
)}
Expand All @@ -187,7 +210,7 @@ export default function App() {
</main>
</div>
</div>
{!zen && <StatusBar />}
{!zen && Bottom && <Bottom />}
</div>
<ErrorBoundary scope="a dialog">
<CommandPalette />
Expand All @@ -210,6 +233,7 @@ export default function App() {
<PrReviewSheet />
<NotificationCenter />
<WhatsNewDialog />
{!zen && Overlay && <Overlay />}
</ErrorBoundary>
<FileDropGuard />
<Announcer />
Expand Down
160 changes: 160 additions & 0 deletions app/src/components/layouts/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useMemo } from "react";

import { useRailAgents } from "@/components/sidebar/rail";
import type { Location, Session, Worktree } from "@/lib/api";
import { agentOf, type SessionState, sessionState } from "@/lib/derive";
import { useProjects } from "@/lib/project-groups";
import { NONE, useStore } from "@/lib/store";
import { openSession, refOf, selectWorktree, useWorkspaces, wsKey } from "@/lib/workspaces";
import { worktreeLabel } from "@/lib/worktree-names";

// The Labs layouts (Settings › Labs › Layout) all list the same thing: the
// agents, by what they need from you, and the worktrees you were last in.
// The sidebar's tree is organised by where work lives; these are organised
// by what to do next.

export type Lane = "waiting" | "running" | "finished" | "recent" | "away";
export const LANE_ORDER: Lane[] = ["waiting", "running", "finished", "recent", "away"];
export const LANE_WORDS: Record<Lane, string> = { waiting: "Needs you", running: "Working", finished: "Done", recent: "Recent", away: "Box away" };

export interface Item {
// box/session for an agent, wt:<key> for a worktree with no agent to show.
id: string;
lane: Lane;
box: string;
project: string;
loc: Location;
wt: Worktree;
// The worktree's workspace key (lib/workspaces).
key: string;
session?: Session;
agent?: string;
state?: SessionState;
// The work, when it has a name ("Fix checkout webhook retries"), else
// the place.
title: string;
// "shop / checkout-fix", or "shop" for a main checkout.
place: string;
since?: string;
// On screen now.
selected: boolean;
// The project lives on more than one box, so the box is worth naming.
spansBoxes: boolean;
}

const placeOf = (project: string, wt: Worktree) => (wt.main ? project : `${project} / ${worktreeLabel(wt)}`);

// useItems is every agent worth a look, in lanes, then the worktrees you
// were in last (and agents sitting ready) as Recent.
export function useItems(): Item[] {
const agents = useRailAgents();
const { projects } = useProjects();
const status = useStore((s) => s.status?.boxes ?? NONE);
const data = useStore((s) => s.boxes);
const spaces = useWorkspaces((s) => s.spaces);
const current = useWorkspaces((s) => s.current);
const inWorkspace = useStore((s) => s.view.kind === "workspace");
return useMemo(() => {
const spans = new Map<string, boolean>();
const projectOf = new Map<string, string>();
for (const p of projects) {
const many = new Set(p.members.map((m) => m.box.name)).size > 1;
for (const m of p.members) {
projectOf.set(`${m.box.name}/${m.loc.name}`, p.name);
spans.set(`${m.box.name}/${m.loc.name}`, many);
}
}
const out: Item[] = agents.map((e) => ({
id: e.id,
lane: e.lane,
box: e.box,
project: e.project,
loc: e.loc,
wt: e.wt,
key: e.key,
session: e.session,
agent: e.agent,
state: e.state,
title: e.session.title?.trim() || placeOf(e.project, e.wt),
place: placeOf(e.project, e.wt),
since: e.session.state_since ?? e.session.created,
selected: e.selected,
spansBoxes: spans.get(`${e.box}/${e.loc.name}`) ?? false,
}));
const listed = new Set(out.map((i) => i.key));
// Recent: worktrees visited, and agents sitting ready with nothing to do.
const recent: (Item & { at: number })[] = [];
const online = new Set(status.filter((b) => b.state === "online").map((b) => b.name));
for (const b of online) {
const d = data[b];
for (const loc of d?.locations ?? []) {
for (const wt of loc.worktrees ?? []) {
const key = wsKey(b, wt.path);
if (listed.has(key)) continue;
const visited = spaces[key]?.visitedAt;
const ready = (d?.sessions ?? []).find((s) => s.dir === wt.path && !s.service && !s.exited && agentOf(s) && sessionState(s, d?.stats) === "ready");
if (!visited && !ready) continue;
const project = projectOf.get(`${b}/${loc.name}`) ?? loc.name;
listed.add(key);
recent.push({
id: ready ? `${b}/${ready.name}` : `wt:${key}`,
lane: "recent",
box: b,
project,
loc,
wt,
key,
session: ready,
agent: ready ? agentOf(ready) : undefined,
state: ready ? "ready" : undefined,
title: ready?.title?.trim() || placeOf(project, wt),
place: placeOf(project, wt),
since: ready?.state_since ?? ready?.created,
selected: inWorkspace && current === key,
spansBoxes: spans.get(`${b}/${loc.name}`) ?? false,
at: visited ?? Date.parse(ready?.state_since ?? ready?.created ?? "") ?? 0,
});
}
}
}
recent.sort((a, b) => b.at - a.at);
// Done: the newest first, since what just finished is what you look at.
const time = (i: Item) => Date.parse(i.since ?? "") || 0;
const lanes = LANE_ORDER.map((lane) => {
if (lane === "recent") return recent.map(({ at: _at, ...i }) => i);
const list = out.filter((i) => i.lane === lane);
// What has waited longest needs you first; work by place, so rows
// only move when their state changes.
if (lane === "waiting") return list.sort((a, b) => time(a) - time(b));
if (lane === "finished") return list.sort((a, b) => time(b) - time(a));
return list;
});
return lanes.flat();
}, [agents, projects, status, data, spaces, current, inWorkspace]);
}

// openItem goes to an item: its agent's pane, or its worktree.
export function openItem(i: Item) {
if (i.session) openSession(i.box, i.session);
else selectWorktree(refOf(i.box, i.loc, i.wt));
}

// askText is what a waiting agent asks, in a few words.
export function askText(s?: Session): string | undefined {
const ask = s?.ask;
if (!ask) return undefined;
if (ask.tool === "Bash" && ask.input) return `Run ${ask.input}`;
if (ask.message) return ask.message;
if (ask.input) return ask.tool ? `${ask.tool}: ${ask.input}` : ask.input;
return ask.tool ? `Allow ${ask.tool}?` : undefined;
}

// shortAgo is "4m", "2h", "3d": a time for a narrow row.
export function shortAgo(iso?: string, now = Date.now()): string {
if (!iso) return "";
const s = Math.max(0, (now - Date.parse(iso)) / 1000);
if (s < 60) return "now";
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
Loading
Loading