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. */} -
+
{fakeTrafficLights() && } + {!zen && Top && ( + + + + )}
- {!zen && ( + {!zen && Side && ( - + )} -
+
{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 + + )} + +
+ {items.length === 0 ? ( +

No agents yet. Start a task with ⌘N.

+ ) : ( +
+ {LANES.map((lane) => { + const inLane = items.map((it, i) => ({ it, i })).filter(({ it }) => it.lane === lane); + if (!inLane.length) return null; + return ( +
+

+ {LANE_WORDS[lane]} {inLane.length + Math.max(0, more[lane] ?? 0)} + {(more[lane] ?? 0) > 0 && the newest {ROW}; ⌘E finds the rest} +

+
+ {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 &&
No worktree matches.
} + {shown.map((g) => ( +
+
+ {g.name} + {g.waiting > 0 && {g.waiting}} + {g.working > 0 && {g.working}} +
+ {g.rows.map((r) => { + index++; + const i = index; + return ( +
setActive(i)} + onClick={() => go(r)} + className={cn("flex h-8 cursor-default items-center gap-2 rounded-md px-2 text-[13px]", i === active && "bg-accent", r.key === front && "font-medium")} + > + {r.state ? : r.wt.main ? : } + {g.asks ? `${r.project} / ` : ""}{r.wt.main ? "main" : worktreeLabel(r.wt)} + {r.box} + {r.title} + {open.has(r.key) && {r.key === front ? "here" : "open"}} +
+ ); + })} +
+ ))} +
+ {/* The boxes, and which are away. */} +
+ {status.map((b) => ( + + + {b.name} + {b.state !== "online" && offline} + + ))} +
+
+ + + +
+
+ ); +} + +// ---- Worktree tabs ------------------------------------------------------ + +function WorktreeTabs() { + const roomy = useMediaQuery({ min: 1280 }); + const keys = useTopTabs((s) => s.keys); + const spaces = useWorkspaces((s) => s.spaces); + const current = useWorkspaces((s) => s.current); + const workspace = useStore((s) => s.view.kind === "workspace"); + const boxes = useStore((s) => s.boxes); + const states = useWorktreeStates(); + const strip = useRef(null); + // Tabs whose worktree is still known. + const tabs = keys.map((k) => ({ key: k, ref: spaces[k]?.ref })).filter((t): t is { key: string; ref: WorktreeRef } => !!t.ref); + // Names open more than once (two projects' "main"s, say). + const names = tabs.map((t) => (t.ref.main ? t.ref.location : t.ref.worktree)); + const twins = new Set(names.filter((n, i) => names.indexOf(n) !== i)); + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + + // ⌃1–⌃9 go to worktree tab 1–9. + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => { + if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || !/^Digit[1-9]$/.test(e.code)) return; + const t = tabsRef.current[Number(e.code.slice(5)) - 1]; + if (!t) return; + e.preventDefault(); + e.stopPropagation(); + selectWorktree(t.ref); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, []); + + // The one in front stays in sight. + useEffect(() => { + strip.current?.querySelector("[data-selected]")?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [current, tabs.length]); + + return ( +
+ + New task {platformKeys("⌘N")}}> + + +
+
+ ); +} + 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.