diff --git a/app/e2e/a11y-scenes.ts b/app/e2e/a11y-scenes.ts
index 7aa964321..5ba7a63f3 100644
--- a/app/e2e/a11y-scenes.ts
+++ b/app/e2e/a11y-scenes.ts
@@ -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,
diff --git a/app/e2e/layout-topbar.spec.ts b/app/e2e/layout-topbar.spec.ts
new file mode 100644
index 000000000..8c8f5134e
--- /dev/null
+++ b/app/e2e/layout-topbar.spec.ts
@@ -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);
+});
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 4428588e1..2f3962302 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -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";
@@ -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
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.
@@ -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. */}
-
{zen && !onboarding ? (
) : workspace && !onboarding ? (
-
+
{!onHome && }
- {onHome ? : }
+ {/* Under a top bar, Home's strip shows only with terminals in it. */}
+ {onHome ? !Top || homeTerminals ? : null : }
- ) : !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 ? : null
+ ) : (
// Onboarding names itself; the strip only drags the window.
)}
@@ -187,7 +210,7 @@ export default function App() {
- {!zen && }
+ {!zen && Bottom && }
@@ -210,6 +233,7 @@ export default function App() {
+ {!zen && Overlay && }
diff --git a/app/src/components/layouts/model.ts b/app/src/components/layouts/model.ts
new file mode 100644
index 000000000..5651155ea
--- /dev/null
+++ b/app/src/components/layouts/model.ts
@@ -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 = { waiting: "Needs you", running: "Working", finished: "Done", recent: "Recent", away: "Box away" };
+
+export interface Item {
+ // box/session for an agent, wt: 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();
+ const projectOf = new Map();
+ 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`;
+}
diff --git a/app/src/components/layouts/parts.tsx b/app/src/components/layouts/parts.tsx
new file mode 100644
index 000000000..65df62c9e
--- /dev/null
+++ b/app/src/components/layouts/parts.tsx
@@ -0,0 +1,173 @@
+import { EllipsisIcon, SearchIcon, SettingsIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { StateGlyph } from "@/components/agent-glyph";
+import { type Item, askText } from "@/components/layouts/model";
+import { MoreItems, type NavItem, useArrangedNav } from "@/components/sidebar/nav";
+import { Tip } from "@/components/tip";
+import { Kbd } from "@/components/ui/kbd";
+import { Menu, MenuPopup, MenuTrigger } from "@/components/ui/menu";
+import { hasTrafficLights } from "@/lib/api";
+import { useStore } from "@/lib/store";
+import { cn } from "@/lib/utils";
+import { platformKeys } from "@/lib/platform";
+import { useLiveStep } from "@/views/home/widgets/agents";
+
+// The pieces the Labs layouts share: what an agent is doing, the places,
+// and the buttons kept in reach (search, Settings).
+
+// trafficPad leaves the macOS window buttons room at a bar's left.
+export const trafficPad = () => (hasTrafficLights() ? "pl-[84px]" : "pl-2");
+
+// useDoing is what an item is doing, in a few words: what a waiting agent
+// asks, the step a working one is on (read from its screen while shown),
+// or nothing to add.
+export function useDoing(i: Item, live = true): string | undefined {
+ const step = useLiveStep(i.box, i.session?.name ?? "", i.agent ?? "", live && i.lane === "running" && !!i.session);
+ if (i.lane === "away") return `${i.box} is away`;
+ if (i.lane === "waiting") return askText(i.session) ?? "Waiting for you";
+ if (i.lane === "running") return step?.now ?? "Working…";
+ return undefined;
+}
+
+// ItemGlyph is the state, or a plain dot for a worktree with no agent.
+export function ItemGlyph({ i, className }: { i: Item; className?: string }) {
+ if (i.lane === "away") return ;
+ if (i.state) return ;
+ return ;
+}
+
+// BoxTag names the box quietly, and only where it tells you something.
+export function BoxTag({ i, always }: { i: Item; always?: boolean }) {
+ if (!always && !i.spansBoxes) return null;
+ return {i.box};
+}
+
+// SearchButton opens ⌘K.
+export function SearchButton({ wide, className }: { wide?: boolean; className?: string }) {
+ const open = () => useStore.getState().setPaletteOpen(true);
+ if (!wide)
+ return (
+ Search {platformKeys("⌘K")}}>
+
+
+
+
+ );
+ return (
+
+ );
+}
+
+export function SettingsButton({ withLabel, className }: { withLabel?: boolean; className?: string }) {
+ const active = useStore((s) => s.view.kind === "settings");
+ const go = (e: React.MouseEvent) => useStore.getState().setView({ kind: "settings", section: e.shiftKey ? "developer" : undefined });
+ if (withLabel)
+ return (
+
+ );
+ return (
+
+
+
+
+
+ );
+}
+
+export function IconButton({ label, children, onClick, active, className, testid }: { label: string; children: ReactNode; onClick(e: React.MouseEvent): void; active?: boolean; className?: string; testid?: string }) {
+ return (
+
+ );
+}
+
+// usePlaces is the person's places (lib/nav) less Home, which each layout
+// has its own way to.
+export function usePlaces() {
+ const { pinned, more } = useArrangedNav();
+ const home = [...pinned, ...more].find((n) => n.id === "home");
+ return { home, pinned: pinned.filter((n) => n.id !== "home"), more: more.filter((n) => n.id !== "home") };
+}
+
+// PlaceButton is a place as a small labelled button with its count.
+export function PlaceButton({ n, iconOnly, className }: { n: NavItem; iconOnly?: boolean; className?: string }) {
+ const btn = (
+
+ );
+ return iconOnly ? {btn} : btn;
+}
+
+// MorePlaces is the places that aren't pinned, as a menu.
+export function MorePlaces({ more, side = "bottom", align = "end" }: { more: NavItem[]; side?: "bottom" | "top" | "right"; align?: "start" | "end" }) {
+ if (!more.length) return null;
+ const active = more.some((n) => n.active);
+ return (
+
+ );
+}
+
diff --git a/app/src/components/layouts/registry.ts b/app/src/components/layouts/registry.ts
new file mode 100644
index 000000000..de2ad5e2a
--- /dev/null
+++ b/app/src/components/layouts/registry.ts
@@ -0,0 +1,57 @@
+import type { ComponentType } from "react";
+
+import { AgentSwitcher } from "@/components/layouts/switcher";
+import { TopBar } from "@/components/layouts/topbar";
+import { usePrefs } from "@/lib/prefs";
+
+// The window's layouts (Labs). Settings › Labs › Layout lists them, prefs
+// keep the one picked by its id (prefs.layout, ?layout= in the mock),
+// and App.tsx draws it. "sidebar" is the app as it is and the default.
+//
+// A layout is one entry here. It says what goes where around the window's
+// main area; App keeps everything else (the tab strip, the panes, the
+// views, the dialogs):
+//
+// side replaces the sidebar at the window's left. null: nothing there.
+// top a bar across the window's top, over the tab strip and every
+// view. It leaves the macOS window buttons room itself.
+// bottom replaces the status bar. null: no status bar.
+// overlay mounted once beside the dialogs: a switcher, its own keys.
+//
+// Omit side or bottom to keep the sidebar or the status bar. With no side
+// and no top, App leaves the window buttons room over the strip itself.
+
+export interface Layout {
+ id: string;
+ label: string;
+ // One line for Settings: what it puts first.
+ description: string;
+ side?: ComponentType | null;
+ top?: ComponentType;
+ bottom?: ComponentType | null;
+ overlay?: ComponentType;
+}
+
+export const DEFAULT_LAYOUT = "sidebar";
+
+export const LAYOUTS: Layout[] = [
+ { id: "sidebar", label: "Sidebar", description: "Places, then every project and its worktrees down the left. The app as it is." },
+ {
+ id: "topbar",
+ label: "Top bar",
+ description: "No sidebar: the project you're in and your open worktrees as tabs across the top, what needs you beside them, and ⌃⇥ to see every agent at once.",
+ side: null,
+ top: TopBar,
+ overlay: AgentSwitcher,
+ },
+];
+
+// layoutById is a layout by its id; one this Shipyard doesn't know (a
+// newer one's, or one taken away) is the sidebar.
+export function layoutById(id: string | undefined): Layout {
+ return LAYOUTS.find((l) => l.id === id) ?? LAYOUTS.find((l) => l.id === DEFAULT_LAYOUT)!;
+}
+
+export function useLayout(): Layout {
+ return layoutById(usePrefs((p) => p.layout));
+}
diff --git a/app/src/components/layouts/switcher.tsx b/app/src/components/layouts/switcher.tsx
new file mode 100644
index 000000000..c96cada42
--- /dev/null
+++ b/app/src/components/layouts/switcher.tsx
@@ -0,0 +1,225 @@
+import { useEffect, useMemo, useRef } from "react";
+import { create } from "zustand";
+
+import { AgentIcon } from "@/components/agent-glyph";
+import { type Item, type Lane, LANE_WORDS, openItem, shortAgo, useItems } from "@/components/layouts/model";
+import { BoxTag, ItemGlyph, useDoing } from "@/components/layouts/parts";
+import { Kbd } from "@/components/ui/kbd";
+import { type Session } from "@/lib/api";
+import { cn } from "@/lib/utils";
+import { useWorkspaces } from "@/lib/workspaces";
+import { useScreenTail } from "@/views/dashboard/use-screen-tail";
+
+// The switcher (Labs layouts): hold ⌃ and press ⇥, as ⌘-Tab switches apps,
+// and every agent shows as a card with the last lines of its screen, the
+// ones that need you first. ⇥ and the arrows move, letting go of ⌃ opens
+// the one picked, esc puts it away. Opened by a click instead, it stays
+// until a card is picked.
+
+const useSwitcher = create<{ open: boolean; index: number; held: boolean }>()(() => ({ open: false, index: 0, held: false }));
+
+export function openSwitcher() {
+ useSwitcher.setState({ open: true, index: 0, held: false });
+}
+
+const LANES: Lane[] = ["waiting", "running", "finished", "recent"];
+const ROW = 2;
+
+// The pointer picks a card only once it moves: the switcher opening under a
+// resting pointer mustn't take the pick from the keyboard.
+let rest: { x: number; y: number } | undefined;
+function pointerMoved(e: React.MouseEvent) {
+ if (!rest) {
+ rest = { x: e.clientX, y: e.clientY };
+ return false;
+ }
+ return Math.abs(e.clientX - rest.x) + Math.abs(e.clientY - rest.y) > 4;
+}
+
+const close = () => useSwitcher.setState({ open: false, held: false });
+
+export function AgentSwitcher() {
+ const all = useItems();
+ // Away boxes can't be opened; a long tail of Recent isn't switching.
+ // Everything that needs you or works; a row each of the newest done and
+ // recent, so the picture fits one screen. The rest are a type away in
+ // the project switcher (⌘E).
+ const items = useMemo(() => {
+ const lane = (l: Lane) => all.filter((i) => i.lane === l);
+ return [...lane("waiting"), ...lane("running"), ...lane("finished").slice(0, ROW), ...lane("recent").slice(0, ROW)];
+ }, [all]);
+ const more = { finished: all.filter((i) => i.lane === "finished").length - ROW, recent: all.filter((i) => i.lane === "recent").length - ROW } as Partial>;
+ const { open, index, held } = useSwitcher();
+ const grid = useRef(null);
+ const list = useRef(items);
+ list.current = items;
+
+ useEffect(() => {
+ const cols = () => {
+ const el = grid.current;
+ if (!el) return 4;
+ return getComputedStyle(el).gridTemplateColumns.split(" ").length || 4;
+ };
+ const go = (i: number) => {
+ const it = list.current[i];
+ close();
+ if (it) openItem(it);
+ };
+ const onDown = (e: KeyboardEvent) => {
+ const s = useSwitcher.getState();
+ const n = list.current.length;
+ if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === "Tab" && (s.open || !e.shiftKey)) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!s.open) {
+ // As ⌘-Tab picks the app you were in last: the worktree before
+ // this one, else the first that isn't on screen.
+ const spaces = useWorkspaces.getState().spaces;
+ const here = useWorkspaces.getState().current;
+ const last = Object.entries(spaces)
+ .filter(([k, w]) => k !== here && w.visitedAt)
+ .sort((a, b) => (b[1].visitedAt ?? 0) - (a[1].visitedAt ?? 0))[0]?.[0];
+ const prev = last ? list.current.findIndex((i) => i.key === last) : -1;
+ const other = list.current.findIndex((i) => !i.selected);
+ useSwitcher.setState({ open: true, held: true, index: prev >= 0 ? prev : Math.max(0, other) });
+ } else if (n) useSwitcher.setState({ index: (s.index + (e.shiftKey ? -1 : 1) + n) % n });
+ return;
+ }
+ if (!s.open) return;
+ const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols(), ArrowUp: -cols() }[e.key];
+ if (step !== undefined) {
+ e.preventDefault();
+ e.stopPropagation();
+ useSwitcher.setState({ index: Math.max(0, Math.min(n - 1, s.index + step)) });
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ go(s.index);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ close();
+ }
+ };
+ const onUp = (e: KeyboardEvent) => {
+ const s = useSwitcher.getState();
+ if (s.open && s.held && e.key === "Control") go(s.index);
+ };
+ // Capture, so a focused terminal doesn't take ⌃⇥ first.
+ window.addEventListener("keydown", onDown, true);
+ window.addEventListener("keyup", onUp, true);
+ return () => {
+ window.removeEventListener("keydown", onDown, true);
+ window.removeEventListener("keyup", onUp, true);
+ };
+ }, []);
+
+ if (!open) {
+ rest = undefined;
+ return null;
+ }
+ return (
+
+
e.stopPropagation()}>
+
+ Switch to
+
+ {held ? (
+ <>
+ ⇥ next · ⇧⇥ back · let go of ⌃ to open
+ >
+ ) : (
+ <>
+ ←→ to move · ↵ to open · ⌃⇥ any time
+ >
+ )}
+
+
+ {inLane.map(({ it, i }) => (
+ useSwitcher.setState({ index: i })} onOpen={() => (close(), openItem(it))} />
+ ))}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+function Card({ it, picked, onPick, onOpen }: { it: Item; picked: boolean; onPick(): void; onOpen(): void }) {
+ const ref = useRef(null);
+ useEffect(() => {
+ if (picked) ref.current?.scrollIntoView({ block: "nearest" });
+ }, [picked]);
+ const doing = useDoing(it, false);
+ return (
+
+ );
+}
+
+// Tail is the last lines of the agent's screen: a live thumbnail in words.
+function Tail({ box, session, working, fallback }: { box: string; session: Session; working: boolean; fallback?: string }) {
+ const { tail } = useScreenTail(box, session, 3, working);
+ // An agent that has said nothing yet shows only its banner: say what it
+ // is for instead.
+ const banner = !tail?.length || tail.some((l) => /[▐▛▜▙▟█]/.test(l));
+ const lines = !banner ? tail! : [fallback ?? (session.title ? `Ready: ${session.title}` : "Ready for a prompt")];
+ return (
+
+ {lines.map((l, i) => (
+
+ {l}
+
+ ))}
+
+ );
+}
diff --git a/app/src/components/layouts/topbar.tsx b/app/src/components/layouts/topbar.tsx
new file mode 100644
index 000000000..8e970477a
--- /dev/null
+++ b/app/src/components/layouts/topbar.tsx
@@ -0,0 +1,520 @@
+import { FolderIcon, FolderPlusIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayoutGridIcon, PlusIcon, SearchIcon, XIcon } from "lucide-react";
+import { type KeyboardEvent, type MouseEvent, useEffect, useMemo, useRef, useState } from "react";
+import { create } from "zustand";
+
+import { StateGlyph } from "@/components/agent-glyph";
+import { type Lane, openItem, useItems } from "@/components/layouts/model";
+import { MorePlaces, PlaceButton, SearchButton, SettingsButton, trafficPad, usePlaces } from "@/components/layouts/parts";
+import { openSwitcher } from "@/components/layouts/switcher";
+import { NotificationBell } from "@/components/notifications/notification-center";
+import { Tip } from "@/components/tip";
+import { Kbd } from "@/components/ui/kbd";
+import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import type { Location, Worktree } from "@/lib/api";
+import { agentOf, type SessionState, sessionState } from "@/lib/derive";
+import { platformKeys } from "@/lib/platform";
+import { useProjects } from "@/lib/project-groups";
+import { load, save } from "@/lib/storage";
+import { NONE, useStore } from "@/lib/store";
+import { cn } from "@/lib/utils";
+import { goHome, homeBox, refOf, selectWorktree, useWorkspaces, type WorktreeRef, wsKey } from "@/lib/workspaces";
+import { worktreeLabel } from "@/lib/worktree-names";
+
+// The top bar (Labs › Layout › Top bar) puts the way around across the top
+// and gives the work the window's whole width, as a browser does:
+//
+// ⌂ shop ⇅ │ [● checkout-fix] [✓ search-perf] [◌ evals / judge-v2] + ● 2 need you ◌ 2 ✓ 5 ▦⌃⇥ │ Review 2 ⋯ │ ⌕ 🔔 ⚙
+//
+// Home; the project in front, which opens a switcher to any worktree of
+// any project, by typing; the worktrees you have open, as tabs, each with
+// its agents' state; how many agents need you, work and are done (a click
+// goes to the one that has waited longest, or shows them); and the places.
+// Under it the worktree's own tabs name the agent: project ▸ worktree ▸
+// agent, top to bottom. ⌃⇥ shows every agent at once (switcher.tsx).
+
+// ---- The open worktrees -------------------------------------------------
+
+// The worktrees open as tabs, in the order they were opened, kept on this
+// computer. A worktree joins when you go to it, however you got there, and
+// leaves when its tab is closed (its agents carry on).
+const KEY = "berth.layouts.topbar.tabs";
+export const useTopTabs = create<{ keys: string[] }>()(() => ({ keys: load(KEY, []) }));
+useTopTabs.subscribe((s) => save(KEY, s.keys));
+
+function closeTab(key: string) {
+ const { keys } = useTopTabs.getState();
+ const i = keys.indexOf(key);
+ const rest = keys.filter((k) => k !== key);
+ useTopTabs.setState({ keys: rest });
+ if (useWorkspaces.getState().current !== key) return;
+ // Closing the one in front shows its neighbour, as a browser does.
+ const next = rest[Math.min(i, rest.length - 1)];
+ const ref = next ? useWorkspaces.getState().spaces[next]?.ref : undefined;
+ if (ref) selectWorktree(ref);
+ else goHome();
+}
+
+const URGENCY: Partial> = { waiting: 0, running: 1, finished: 2, ready: 3 };
+
+// useWorktreeStates is each worktree's most urgent agent state, and the
+// work of the agent that has it.
+function useWorktreeStates(): Record {
+ const boxes = useStore((s) => s.boxes);
+ return useMemo(() => {
+ const out: Record = {};
+ for (const [box, d] of Object.entries(boxes)) {
+ for (const s of d.sessions ?? []) {
+ if (s.service || s.exited || !agentOf(s)) continue;
+ const st = sessionState(s, d.stats);
+ const key = wsKey(box, s.dir);
+ if (URGENCY[st] === undefined) continue;
+ if (out[key] === undefined || URGENCY[st]! < URGENCY[out[key].state]!) out[key] = { state: st, title: s.title?.trim() || out[key]?.title };
+ }
+ }
+ return out;
+ }, [boxes]);
+}
+
+// ---- The bar ------------------------------------------------------------
+
+export function TopBar() {
+ const current = useWorkspaces((s) => s.current);
+ const workspace = useStore((s) => s.view.kind === "workspace");
+ // Whatever brought a worktree to the front, it gets a tab.
+ useEffect(() => {
+ if (!current || homeBox(current)) return;
+ const { keys } = useTopTabs.getState();
+ if (!keys.includes(current)) useTopTabs.setState({ keys: [...keys, current] });
+ }, [current]);
+ const onHome = workspace && (!current || !!homeBox(current));
+ const { home, pinned, more } = usePlaces();
+ // Narrower, the places past Review fold into ⋯ rather than turn into
+ // icons to guess at.
+ // The tabs get the room. Review, the place people visit most, always
+ // shows with its name and count; the other places do from 1600px, and
+ // fold into ⋯ under it rather than turn into icons to guess at.
+ const roomy = useMediaQuery({ min: 1600 });
+ const hint = useMediaQuery({ min: 1280 });
+ const shown = roomy ? pinned : pinned.filter((n) => n.id === "review");
+ const folded = roomy ? more : [...pinned.filter((n) => n.id !== "review"), ...more];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {roomy ? : }
+
+
+
+
+ );
+}
+
+// ---- What the agents are doing -----------------------------------------
+
+// Agents counts the agents by what they need: those waiting on you (a
+// click goes to the one that has waited longest), working and done (a
+// click shows them all, as ⌃⇥ does).
+function Agents({ wide }: { wide: boolean }) {
+ const items = useItems();
+ const count = (l: Lane) => items.filter((i) => i.lane === l).length;
+ const waiting = items.filter((i) => i.lane === "waiting");
+ const next = waiting.find((i) => !i.selected) ?? waiting[0];
+ return (
+
+ {next && (
+
+
+
+ )}
+ Every agent at a glance: hold ⌃ and press ⇥ anywhere}>
+
+
+
+ );
+}
+
+// ---- Go to any worktree -------------------------------------------------
+
+interface Row {
+ key: string;
+ box: string;
+ loc: Location;
+ wt: Worktree;
+ project: string;
+ state?: SessionState;
+ title?: string;
+ spans: boolean;
+}
+
+// useRows is every worktree on every online box, by project, the ones
+// with agents to see to first.
+function useRows() {
+ const { projects } = useProjects();
+ const boxes = useStore((s) => s.boxes);
+ const states = useWorktreeStates();
+ return useMemo(() => {
+ const groups: { name: string; rows: Row[]; waiting: number; working: number }[] = [];
+ for (const p of projects) {
+ const spans = new Set(p.members.map((m) => m.box.name)).size > 1;
+ const rows: Row[] = [];
+ for (const m of p.members) {
+ if (m.box.state !== "online") continue;
+ const loc = boxes[m.box.name]?.locations?.find((l) => l.name === m.loc.name) ?? m.loc;
+ for (const wt of loc.worktrees ?? []) {
+ const key = wsKey(m.box.name, wt.path);
+ rows.push({ key, box: m.box.name, loc, wt, project: p.name, state: states[key]?.state, title: states[key]?.title, spans });
+ }
+ }
+ const rank = (r: Row) => (r.state ? URGENCY[r.state]! : r.wt.main ? 4 : 5);
+ rows.sort((a, b) => rank(a) - rank(b));
+ if (rows.length) groups.push({ name: p.name, rows, waiting: rows.filter((r) => r.state === "waiting").length, working: rows.filter((r) => r.state === "running").length });
+ }
+ return groups;
+ }, [projects, boxes, states]);
+}
+
+// ProjectSwitcher names the project in front and opens a list of every
+// worktree of every project to go to, filtered as you type: one click and
+// a few letters to anywhere. The boxes, online or not, are along its foot.
+const useGoTo = create<{ open: boolean }>()(() => ({ open: false }));
+
+function ProjectSwitcher() {
+ const ref = useWorkspaces((s) => (s.current && !homeBox(s.current) ? s.spaces[s.current]?.ref : undefined));
+ const open = useGoTo((s) => s.open);
+ const setOpen = (o: boolean) => useGoTo.setState({ open: o });
+ // ⌘E opens it from anywhere, a terminal included.
+ useEffect(() => {
+ const onKey = (e: globalThis.KeyboardEvent) => {
+ if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.key.toLowerCase() !== "e") return;
+ e.preventDefault();
+ e.stopPropagation();
+ useGoTo.setState((s) => ({ open: !s.open }));
+ };
+ window.addEventListener("keydown", onKey, true);
+ return () => window.removeEventListener("keydown", onKey, true);
+ }, []);
+ return (
+
+
+ }
+ >
+
+ Projects
+ {platformKeys("⌘E")}
+
+
+ {open && setOpen(false)} />}
+
+
+ );
+}
+
+function SwitcherList({ front, close }: { front?: string; close(): void }) {
+ const all = useRows();
+ // The project in front first, the rest as they come.
+ const frontProject = all.find((g) => g.rows.some((r) => r.key === front))?.name;
+ const groups = frontProject ? [...all.filter((g) => g.name === frontProject), ...all.filter((g) => g.name !== frontProject)] : all;
+ const [q, setQ] = useState("");
+ const [active, setActive] = useState(0);
+ const list = useRef(null);
+ const status = useStore((s) => s.status?.boxes ?? NONE);
+ const words = q.trim().toLowerCase().split(/\s+/).filter(Boolean);
+ const match = (r: Row) => {
+ const hay = `${r.project} ${r.wt.main ? "main" : worktreeLabel(r.wt)} ${r.wt.branch ?? ""} ${r.title ?? ""} ${r.box}`.toLowerCase();
+ return words.every((w) => hay.includes(w));
+ };
+ // What needs you, from every project, heads the list; then the projects.
+ const asks = groups.flatMap((g) => g.rows.filter((r) => r.state === "waiting"));
+ const lists = [
+ ...(asks.length ? [{ name: "Needs you", rows: asks, waiting: 0, working: 0, asks: true }] : []),
+ ...groups.map((g) => ({ ...g, rows: g.rows.filter((r) => r.state !== "waiting"), asks: false })),
+ ];
+ const shown = lists.map((g) => ({ ...g, rows: g.rows.filter(match) })).filter((g) => g.rows.length);
+ const open = new Set(useTopTabs((s) => s.keys));
+ const flat = shown.flatMap((g) => g.rows);
+ const go = (r: Row) => {
+ close();
+ selectWorktree(refOf(r.box, r.loc, r.wt));
+ };
+ // Opens on the worktree in front; typing starts from the top.
+ useEffect(() => {
+ // Enter goes somewhere: what needs you next, else the worktree before.
+ const spaces = useWorkspaces.getState().spaces;
+ const last = Object.entries(spaces)
+ .filter(([k, w]) => k !== front && w.visitedAt && !homeBox(k))
+ .sort((a, b) => (b[1].visitedAt ?? 0) - (a[1].visitedAt ?? 0))[0]?.[0];
+ const ask = flat.findIndex((r) => r.state === "waiting" && r.key !== front);
+ const i = q ? 0 : ask >= 0 ? ask : flat.findIndex((r) => r.key === last);
+ setActive(Math.max(0, i));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [q]);
+ useEffect(() => {
+ list.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
+ }, [active]);
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ setActive((a) => Math.max(0, Math.min(flat.length - 1, a + (e.key === "ArrowDown" ? 1 : -1))));
+ } else if (e.key === "Enter" && flat[active]) {
+ e.preventDefault();
+ go(flat[active]);
+ }
+ };
+ let index = -1;
+ return (
+
+
+
+ setQ(e.target.value)}
+ onKeyDown={onKeyDown}
+ role="combobox"
+ aria-expanded
+ aria-controls="goto-list"
+ aria-activedescendant={flat[active] ? `goto-${active}` : undefined}
+ placeholder="Go to a worktree or task…"
+ aria-label="Go to a worktree or task"
+ className="h-10 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+ />
+
+ {/* The input keeps the keyboard; the list follows it (↑↓, ↵). The
+ list takes focus too, so it scrolls from the keyboard. */}
+
onKeyDown(e as unknown as KeyboardEvent)} className="min-h-0 flex-1 overflow-y-auto p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset">
+ {shown.length === 0 &&
+ );
+}
+
diff --git a/app/src/components/status-bar.tsx b/app/src/components/status-bar.tsx
index 5d2a6c31d..61518cc07 100644
--- a/app/src/components/status-bar.tsx
+++ b/app/src/components/status-bar.tsx
@@ -69,12 +69,12 @@ export function StatusBar() {
) : (
<>
{counts.waiting > 0 && (
- go({ kind: "dashboard" })} tip="Agents waiting for your answer or permission">
+ go({ kind: "dashboard" })} tip="Agents waiting for your answer or permission">
{counts.waiting} {AGENT_WORDS["needs-you"].lower}
)}
- go({ kind: "dashboard" })} tip="Open the agent dashboard">
+ go({ kind: "dashboard" })} tip="Open the agent dashboard">
{counts.running} {AGENT_WORDS.working.lower}
>
diff --git a/app/src/index.css b/app/src/index.css
index 8d1232568..bde4f1071 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -393,3 +393,10 @@
:root[data-berth-still] :is(.animate-spin, .animate-ping, .animate-pulse, .cv-shimmer)::after {
animation-play-state: paused !important;
}
+
+/* Labs › Layout › Top bar counts the agents and names the worktree in
+ its tabs; the status bar leaves both out rather than say them twice. */
+[data-layout="topbar"] [data-status-agents],
+[data-layout="topbar"] [data-testid="status-worktree"] {
+ display: none;
+}
diff --git a/app/src/lib/prefs.ts b/app/src/lib/prefs.ts
index ff81ef9d4..117a8b6d2 100644
--- a/app/src/lib/prefs.ts
+++ b/app/src/lib/prefs.ts
@@ -46,6 +46,10 @@ export interface Prefs {
// Labs: zen (⌘.): no sidebar or status bar, a switcher for a tab strip,
// agents as conversations.
zen: boolean;
+ // Labs: how the window is laid out, by its id in the layouts registry
+ // (components/layouts/registry.ts). "sidebar" is the default; an id this
+ // Shipyard doesn't know shows the sidebar.
+ layout: string;
// Update a box's berthd as soon as Shipyard ships a newer one
// (lib/outdated.ts). Off: the status bar offers it instead.
autoUpdateBoxes: boolean;
@@ -94,6 +98,7 @@ const DEFAULTS: Prefs = {
labsChosen: false,
agentView: "terminal",
zen: false,
+ layout: "sidebar",
autoUpdateBoxes: false,
chatBackground: DEFAULT_CHAT_BACKGROUND,
chatWidth: "default",
@@ -149,12 +154,14 @@ usePrefs.subscribe((p) => save("berth.prefs", { ...p, version: PREFS_VERSION }))
// The migration is kept at once, not only on the next change.
save("berth.prefs", { ...usePrefs.getState(), version: PREFS_VERSION });
-// ?labs=1 turns Labs on, ?zen=1 zen, and ?view=conversation opens agents
-// as conversations, for the demo.
+// ?labs=1 turns Labs on, ?zen=1 zen, ?layout=inbox a layout, and
+// ?view=conversation opens agents as conversations, for the demo.
{
const q = new URLSearchParams(location.search);
if (q.has("labs")) usePrefs.setState({ labs: q.get("labs") !== "0" });
if (q.has("zen")) usePrefs.setState({ zen: q.get("zen") !== "0" });
+ const l = q.get("layout");
+ if (l) usePrefs.setState({ layout: l });
const v = q.get("view");
if (v === "terminal" || v === "conversation") usePrefs.setState({ agentView: v });
}
diff --git a/app/src/views/home/widgets/agents.tsx b/app/src/views/home/widgets/agents.tsx
index 649196360..16b03ea32 100644
--- a/app/src/views/home/widgets/agents.tsx
+++ b/app/src/views/home/widgets/agents.tsx
@@ -208,7 +208,7 @@ export function WorkingWidget() {
// useLiveStep is what a working agent's screen says it is doing: its step
// ("Running pnpm test…"), what that step printed last, and its status
// line's clock. Read every 12s while the row is on screen.
-function useLiveStep(box: string, session: string, agent: string, enabled: boolean) {
+export function useLiveStep(box: string, session: string, agent: string, enabled: boolean) {
const client = useStore((s) => s.client);
const [step, setStep] = useState<{ now?: string; sub?: string; elapsed?: string }>();
useEffect(() => {
diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx
index adb84e1b7..a2913052c 100644
--- a/app/src/views/settings/labs-section.tsx
+++ b/app/src/views/settings/labs-section.tsx
@@ -1,3 +1,5 @@
+import { layoutById, LAYOUTS } from "@/components/layouts/registry";
+import { SimpleSelect } from "@/components/simple-select";
import { Switch } from "@/components/ui/switch";
import { setPrefs, usePrefs } from "@/lib/prefs";
import { Segmented } from "@/views/settings/controls";
@@ -7,6 +9,7 @@ import { SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"
export function LabsSection() {
const labs = usePrefs((p) => p.labs);
const agentView = usePrefs((p) => p.agentView);
+ const layout = layoutById(usePrefs((p) => p.layout));
return (
@@ -26,6 +29,18 @@ export function LabsSection() {
)}
+ {/* The window's layouts being tried (components/layouts/registry.ts). */}
+
+
+ ({ value: l.id, label: l.id === "sidebar" ? `${l.label} (current)` : l.label }))}
+ onChange={(v) => setPrefs({ layout: v })}
+ />
+
+
);
}
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index bae55bd91..e32cbe7c3 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -11,6 +11,14 @@ again.
## Unreleased
+- **Labs: try the window without a sidebar.** Settings › Labs › Layout has
+ **Top bar**: the worktrees you have open as tabs across the top, and the
+ project button opens every worktree of every project to pick by typing
+ its name or its task. Beside them, how many agents need you (a click goes
+ to the one that has waited longest) and how many work or are done; hold
+ ⌃ and press ⇥ to see every agent at once with the last lines of its
+ screen. ⌃1–⌃9 go to a worktree tab. The sidebar stays the default.
+
- **A home widget's ⋯ menu opens again.** It broke the home screen with
"Base UI error #31" instead of showing its Size and Refresh options.