From 2c4964f8bf9f33ac67f1ea66328ea5ded83659fd Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 20:26:55 +0100 Subject: [PATCH 1/7] feat(app): labs can lay the window out another way, picked in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small registry of layouts (components/layouts/registry.ts): each says what replaces the sidebar, what bar goes across the top, what replaces the status bar, and what overlay it mounts. Settings › Labs › Layout picks one; prefs keep its id (?layout= in the mock). The sidebar stays the default and is untouched. Co-Authored-By: Claude Opus 5.5 --- app/src/App.tsx | 37 ++++++++++++++----- app/src/components/layouts/registry.ts | 47 +++++++++++++++++++++++++ app/src/lib/prefs.ts | 11 ++++-- app/src/views/settings/labs-section.tsx | 15 ++++++++ 4 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 app/src/components/layouts/registry.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 4428588e1..fe819dc7a 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,9 +100,19 @@ 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. @@ -158,21 +169,30 @@ export default function App() {
{fakeTrafficLights() && } + {!zen && Top && ( + + + + )}
- {!zen && ( + {!zen && Side && ( - + )} -
+
{zen && !onboarding ? ( ) : workspace && !onboarding ? ( - + {!onHome && } {onHome ? : } - ) : !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 +207,7 @@ export default function App() {
- {!zen && } + {!zen && Bottom && }
@@ -210,6 +230,7 @@ export default function App() { + {!zen && Overlay && } diff --git a/app/src/components/layouts/registry.ts b/app/src/components/layouts/registry.ts new file mode 100644 index 000000000..995955f32 --- /dev/null +++ b/app/src/components/layouts/registry.ts @@ -0,0 +1,47 @@ +import type { ComponentType } from "react"; + +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." }, +]; + +// 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/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/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 })} + /> + + ); } From 02becd4a59886a2ae260bbba6e1d2f6b3e4f2bb0 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 20:32:13 +0100 Subject: [PATCH 2/7] feat(app): labs top bar, a way around without the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Labs › Layout › Top bar: Home, the project you're in (a menu of every project's worktrees), the worktrees you have open as tabs, what needs you, and the places across the top. ⌃⇥ held shows every agent as a card with the last lines of its screen, ⌘-Tab style; ⌃1–⌃9 go to a worktree tab. Co-Authored-By: Claude Opus 5.5 --- app/e2e/layout-topbar.spec.ts | 89 ++++++ app/src/App.tsx | 5 +- app/src/components/layouts/model.ts | 160 +++++++++++ app/src/components/layouts/parts.tsx | 173 ++++++++++++ app/src/components/layouts/registry.ts | 10 + app/src/components/layouts/switcher.tsx | 180 ++++++++++++ app/src/components/layouts/topbar.tsx | 360 ++++++++++++++++++++++++ app/src/views/home/widgets/agents.tsx | 2 +- docs/changelog.mdx | 6 + 9 files changed, 983 insertions(+), 2 deletions(-) create mode 100644 app/e2e/layout-topbar.spec.ts create mode 100644 app/src/components/layouts/model.ts create mode 100644 app/src/components/layouts/parts.tsx create mode 100644 app/src/components/layouts/switcher.tsx create mode 100644 app/src/components/layouts/topbar.tsx diff --git a/app/e2e/layout-topbar.spec.ts b/app/e2e/layout-topbar.spec.ts new file mode 100644 index 000000000..372000b78 --- /dev/null +++ b/app/e2e/layout-topbar.spec.ts @@ -0,0 +1,89 @@ +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 menu opens 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-project-item][data-project=shop]").hover(); + await page.locator('[data-testid=topbar-worktree-item][data-worktree="devl/search-perf"]').click(); + const tab = page.locator("[data-testid=topbar-tab][aria-selected=true]"); + await expect(tab).toContainText("search-perf"); + await expect(page.getByTestId("topbar-project")).toContainText("shop"); + 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][aria-selected=true]")).toHaveCount(0); + await page.locator("[data-testid=topbar-tab]", { hasText: "search-perf" }).click(); + await expect(page.locator("[data-testid=topbar-tab][aria-selected=true]")).toContainText("search-perf"); +}); + +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][aria-selected=true]")).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); + await page.keyboard.down("Control"); + await page.keyboard.press("Tab"); + const switcher = page.getByTestId("agent-switcher"); + await expect(switcher).toBeVisible(); + await expect(switcher.getByTestId("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][aria-selected=true]")).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 tab = page.locator("[data-testid=topbar-tab][aria-selected=true]"); + await tab.getByRole("button", { name: /^Close/ }).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 fe819dc7a..328482ccc 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -119,6 +119,8 @@ export default function App() { 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. @@ -186,7 +188,8 @@ export default function App() { ) : workspace && !onboarding ? ( {!onHome && } - {onHome ? : } + {/* Under a top bar, Home's strip shows only with terminals in it. */} + {onHome ? !Top || homeTerminals ? : null : } ) : !workspace ? ( // Every other view's ViewHeader is the strip; bare, the 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..36c10a842 --- /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 index 995955f32..de2ad5e2a 100644 --- a/app/src/components/layouts/registry.ts +++ b/app/src/components/layouts/registry.ts @@ -1,5 +1,7 @@ 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 @@ -34,6 +36,14 @@ 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 diff --git a/app/src/components/layouts/switcher.tsx b/app/src/components/layouts/switcher.tsx new file mode 100644 index 000000000..b15394ead --- /dev/null +++ b/app/src/components/layouts/switcher.tsx @@ -0,0 +1,180 @@ +import { useEffect, useMemo, useRef } from "react"; +import { create } from "zustand"; + +import { AgentIcon } from "@/components/agent-glyph"; +import { type Item, 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 { 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 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. + const items = useMemo(() => { + const live = all.filter((i) => i.lane !== "away" && i.lane !== "recent"); + return [...live, ...all.filter((i) => i.lane === "recent").slice(0, Math.max(4, 12 - live.length))].slice(0, 16); + }, [all]); + 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 (!n) return; + if (!s.open) { + // The one after where you are, as ⌘-Tab picks the last app. + const here = list.current.findIndex((i) => i.selected); + useSwitcher.setState({ open: true, held: true, index: here === 0 && n > 1 ? 1 : 0 }); + } else 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) 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.

+ ) : ( +
+ {items.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, 4, working); + const lines = tail?.length ? tail : fallback ? [fallback] : []; + 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..982f137e5 --- /dev/null +++ b/app/src/components/layouts/topbar.tsx @@ -0,0 +1,360 @@ +import { ChevronDownIcon, ChevronRightIcon, FolderIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayoutGridIcon, PlusIcon, XIcon } from "lucide-react"; +import { type MouseEvent, useEffect, useMemo, useRef } from "react"; +import { create } from "zustand"; + +import { StateGlyph } from "@/components/agent-glyph"; +import { 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 { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger } from "@/components/ui/menu"; +import type { Location, Worktree } from "@/lib/api"; +import { agentOf, type SessionState, sessionState } from "@/lib/derive"; +import { useProjects } from "@/lib/project-groups"; +import { load, save } from "@/lib/storage"; +import { 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"; +import { platformKeys } from "@/lib/platform"; + +// 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] [judge-v2] + ● 2 ▦ Review Automations ⋯ ⌕ 🔔 ⚙ +// +// Home, then the project you're in, which switches to any worktree of any +// project; then the worktrees you have open, as tabs, each with its agents' +// state; then what needs you, the switcher (⌃⇥) and the places. Under it +// the worktree's own tabs, as ever, name the agent: project ▸ worktree ▸ +// agent, top to bottom. + +// ---- 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. +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]]!) out[key] = st; + } + } + 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(); + + return ( +
+ + + + / + + + +
+ + Every agent at a glance ⌃⇥}> + + + + + + + + + + + + + +
+
+ ); +} + +// NeedsYou says how many agents wait for you, and goes to the one that has +// waited longest. +function NeedsYou() { + const items = useItems(); + const waiting = items.filter((i) => i.lane === "waiting"); + if (!waiting.length) return null; + const next = waiting.find((i) => !i.selected) ?? waiting[0]; + return ( + + + + ); +} + +// ---- Project ▾ ---------------------------------------------------------- + +// ProjectCrumb names the project in front and switches to any worktree of +// any project, each project a submenu of its worktrees on every box. +function ProjectCrumb() { + const ref = useWorkspaces((s) => (s.current && !homeBox(s.current) ? s.spaces[s.current]?.ref : undefined)); + const view = useStore((s) => s.view); + const { projects } = useProjects(); + const boxes = useStore((s) => s.boxes); + const states = useWorktreeStates(); + const project = ref ? (projects.find((p) => p.members.some((m) => m.box.name === ref.box && m.loc.name === ref.location))?.name ?? ref.location) : undefined; + const placeName = view.kind === "workspace" ? undefined : view.kind === "settings" ? "Settings" : undefined; + const label = project ?? placeName ?? "Projects"; + + return ( + + + } + > + + {label} + + + + + Projects + {projects.map((p) => { + const online = p.members.filter((m) => m.box.state === "online"); + const wts = online.flatMap((m) => (boxes[m.box.name]?.locations?.find((l) => l.name === m.loc.name)?.worktrees ?? []).map((wt) => ({ box: m.box.name, loc: m.loc, wt }))); + const waiting = wts.filter((w) => states[wsKey(w.box, w.wt.path)] === "waiting").length; + const spans = new Set(p.members.map((m) => m.box.name)).size > 1; + return ( + + + + {p.name} + {waiting > 0 && {waiting}} + {online.length ? p.members.map((m) => m.box.name).join(" ") : "offline"} + + + {wts.map(({ box, loc, wt }) => ( + + ))} + + useStore.getState().openNewWorktree({ box: online[0]?.box.name, location: online[0]?.loc.name })}> + + New task in {p.name}… + + + + ); + })} + + + useStore.getState().openAddLocation()}> + + Add a project… + + + + ); +} + +function WorktreeItem({ box, loc, wt, state, spans }: { box: string; loc: Location; wt: Worktree; state?: SessionState; spans: boolean }) { + return ( + selectWorktree(refOf(box, loc, wt))}> + {state ? : wt.main ? : } + {wt.main ? "main" : worktreeLabel(wt)} + {spans && {box}} + + ); +} + +// ---- Worktree tabs ------------------------------------------------------ + +function WorktreeTabs() { + 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 states = useWorktreeStates(); + const { projects } = useProjects(); + 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); + const manyProjects = new Set(tabs.map((t) => t.ref.location)).size > 1; + const spansBox = (ref: WorktreeRef) => new Set(projects.find((p) => p.members.some((m) => m.box.name === ref.box && m.loc.name === ref.location))?.members.map((m) => m.box.name)).size > 1; + + // ⌃1–⌃9 go to worktree tab 1–9. + useEffect(() => { + const onKey = (e: 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); + }, []); + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + + // The one in front stays in sight. + useEffect(() => { + strip.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [current, tabs.length]); + + return ( +
+
+ {tabs.map((t, i) => { + const selected = workspace && current === t.key; + const state = states[t.key]; + const name = t.ref.main ? t.ref.location : worktreeLabel({ name: t.ref.worktree, title: useStore.getState().boxes[t.ref.box]?.locations?.find((l) => l.name === t.ref.location)?.worktrees?.find((w) => w.path === t.ref.path)?.title }); + const close = (e: MouseEvent) => { + e.stopPropagation(); + closeTab(t.key); + }; + return ( + {`${t.ref.location}${t.ref.main ? " · main checkout" : ` / ${name}`} · ${t.ref.box}`}{i < 9 && ⌃{i + 1}}}> +
selectWorktree(t.ref)} + onAuxClick={(e) => e.button === 1 && close(e)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectWorktree(t.ref); + } + }} + className={cn( + "group relative flex h-7 min-w-0 max-w-52 shrink-0 cursor-default items-center gap-1.5 rounded-md border pr-1 pl-2 text-[13px] outline-none focus-visible:ring-2 focus-visible:ring-ring", + selected ? "border-border bg-background text-foreground shadow-xs" : "border-transparent text-muted-foreground hover:bg-sidebar-accent hover:text-foreground", + )} + > + {state ? : t.ref.main ? : } + {manyProjects && !t.ref.main && {t.ref.location} /} + {name} + {spansBox(t.ref) && {t.ref.box}} + +
+
+ ); + })} +
+ New task {platformKeys("⌘N")}}> + + +
+
+ ); +} 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/docs/changelog.mdx b/docs/changelog.mdx index bae55bd91..7a751a0c8 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,12 @@ again. ## Unreleased +- **Labs: try the window without a sidebar.** Settings › Labs › Layout has + **Top bar**: the project you're in and the worktrees you have open as tabs + across the top, how many agents need you (a click goes to the one that has + waited longest), and ⌃⇥ 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. From 015711394aac340a49b23e85fa5179b433cb43de Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:22:09 +0100 Subject: [PATCH 3/7] feat(app): the top bar switches by typing and counts every agent After a critic's pass on screenshots: the project crumb opens a list of every worktree to filter by name or task (with each project's counts and the boxes, away ones too); the bar counts agents working and done beside what needs you; worktree tabs show just their name and shrink gracefully; the switcher groups agents by what they need with one ring for the pick; narrow windows fold places into More instead of bare icons; the tab strip drops its repeated breadcrumb under the top bar. Axe scenes for all three. Co-Authored-By: Claude Opus 5.5 --- app/e2e/a11y-scenes.ts | 27 ++ app/e2e/layout-topbar.spec.ts | 30 +- app/src/App.tsx | 2 +- app/src/components/layouts/parts.tsx | 2 +- app/src/components/layouts/switcher.tsx | 34 +- app/src/components/layouts/topbar.tsx | 458 +++++++++++++-------- app/src/components/workspace/tab-strip.tsx | 2 +- app/src/index.css | 6 + 8 files changed, 366 insertions(+), 195 deletions(-) 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 index 372000b78..244e45370 100644 --- a/app/e2e/layout-topbar.spec.ts +++ b/app/e2e/layout-topbar.spec.ts @@ -14,21 +14,31 @@ test("the top bar takes the sidebar's place", async ({ app }) => { await expect(page.getByTestId("sidebar")).toHaveCount(0); }); -test("the project menu opens any worktree, which gets a tab", async ({ app }) => { +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-project-item][data-project=shop]").hover(); await page.locator('[data-testid=topbar-worktree-item][data-worktree="devl/search-perf"]').click(); - const tab = page.locator("[data-testid=topbar-tab][aria-selected=true]"); + const tab = page.locator("[data-testid=topbar-tab][data-selected]"); await expect(tab).toContainText("search-perf"); await expect(page.getByTestId("topbar-project")).toContainText("shop"); 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][aria-selected=true]")).toHaveCount(0); + 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][aria-selected=true]")).toContainText("search-perf"); + await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toContainText("search-perf"); +}); + +test("typing in the project switcher finds a worktree by its task", async ({ app }) => { + const { page } = app; + await open(app); + await page.getByTestId("topbar-project").click(); + await page.getByRole("textbox", { 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"); + await expect(page.getByTestId("topbar-project")).toContainText("evals"); }); test("what needs you is one click away", async ({ app }) => { @@ -36,7 +46,7 @@ test("what needs you is one click away", async ({ 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][aria-selected=true]")).toContainText("checkout-fix"); + 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 }) => { @@ -46,13 +56,13 @@ test("holding ⌃ and pressing ⇥ shows every agent, and letting go opens the o await page.keyboard.press("Tab"); const switcher = page.getByTestId("agent-switcher"); await expect(switcher).toBeVisible(); - await expect(switcher.getByTestId("switcher-card").first()).toContainText("Fix checkout webhook retries"); + 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][aria-selected=true]")).toContainText("shop"); + 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 }) => { @@ -60,8 +70,8 @@ test("closing a tab leaves its agents running and shows the next one", async ({ await open(app); await page.getByTestId("topbar-needs-you").click(); await expect(page.locator("[data-testid=topbar-tab]")).toHaveCount(1); - const tab = page.locator("[data-testid=topbar-tab][aria-selected=true]"); - await tab.getByRole("button", { name: /^Close/ }).click(); + 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"); }); diff --git a/app/src/App.tsx b/app/src/App.tsx index 328482ccc..2f3962302 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -169,7 +169,7 @@ 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 && ( diff --git a/app/src/components/layouts/parts.tsx b/app/src/components/layouts/parts.tsx index 36c10a842..78a023a28 100644 --- a/app/src/components/layouts/parts.tsx +++ b/app/src/components/layouts/parts.tsx @@ -40,7 +40,7 @@ export function ItemGlyph({ i, className }: { i: Item; className?: string }) { // 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}; + return {i.box}; } // SearchButton opens ⌘K. diff --git a/app/src/components/layouts/switcher.tsx b/app/src/components/layouts/switcher.tsx index b15394ead..48afae7d0 100644 --- a/app/src/components/layouts/switcher.tsx +++ b/app/src/components/layouts/switcher.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { create } from "zustand"; import { AgentIcon } from "@/components/agent-glyph"; -import { type Item, LANE_WORDS, openItem, shortAgo, useItems } from "@/components/layouts/model"; +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"; @@ -21,6 +21,8 @@ export function openSwitcher() { useSwitcher.setState({ open: true, index: 0, held: false }); } +const LANES: Lane[] = ["waiting", "running", "finished", "recent"]; + const close = () => useSwitcher.setState({ open: false, held: false }); export function AgentSwitcher() { @@ -117,10 +119,23 @@ export function AgentSwitcher() { {items.length === 0 ? (

No agents yet. Start a task with ⌘N.

) : ( -
- {items.map((it, i) => ( - useSwitcher.setState({ index: i })} onOpen={() => (close(), openItem(it))} /> - ))} +
+ {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} +

+
+ {inLane.map(({ it, i }) => ( + useSwitcher.setState({ index: i })} onOpen={() => (close(), openItem(it))} /> + ))} +
+
+ ); + })}
)}
@@ -145,8 +160,8 @@ function Card({ it, picked, onPick, onOpen }: { it: Item; picked: boolean; onPic onClick={onOpen} className={cn( "flex min-w-0 flex-col gap-1 rounded-xl border bg-card p-2.5 text-left outline-none transition-colors", - picked ? "border-ring ring-2 ring-ring/40" : "hover:border-ring/40", - it.lane === "waiting" && !picked && "border-warning/45", + it.lane === "waiting" && "bg-warning/[0.06]", + picked ? "border-primary ring-2 ring-primary/35" : "hover:border-ring/50", )} > @@ -167,7 +182,10 @@ function Card({ it, picked, onPick, onOpen }: { it: Item; picked: boolean; onPic // 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, 4, working); - const lines = tail?.length ? tail : fallback ? [fallback] : []; + // 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) => ( diff --git a/app/src/components/layouts/topbar.tsx b/app/src/components/layouts/topbar.tsx index 982f137e5..299ca4b77 100644 --- a/app/src/components/layouts/topbar.tsx +++ b/app/src/components/layouts/topbar.tsx @@ -1,35 +1,37 @@ -import { ChevronDownIcon, ChevronRightIcon, FolderIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayoutGridIcon, PlusIcon, XIcon } from "lucide-react"; -import { type MouseEvent, useEffect, useMemo, useRef } from "react"; +import { ChevronsUpDownIcon, 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 { openItem, useItems } from "@/components/layouts/model"; +import { BoxStateDot, 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 { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger } from "@/components/ui/menu"; +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 { useStore } from "@/lib/store"; +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"; -import { platformKeys } from "@/lib/platform"; // 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] [judge-v2] + ● 2 ▦ Review Automations ⋯ ⌕ 🔔 ⚙ +// ⌂ shop ⇅ │ [● checkout-fix] [✓ search-perf] [◌ evals / judge-v2] + ● 2 need you ◌ 2 ✓ 5 ▦⌃⇥ │ Review 2 ⋯ │ ⌕ 🔔 ⚙ // -// Home, then the project you're in, which switches to any worktree of any -// project; then the worktrees you have open, as tabs, each with its agents' -// state; then what needs you, the switcher (⌃⇥) and the places. Under it -// the worktree's own tabs, as ever, name the agent: project ▸ worktree ▸ -// agent, top to bottom. +// 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 ------------------------------------------------- @@ -55,18 +57,19 @@ function closeTab(key: string) { const URGENCY: Partial> = { waiting: 0, running: 1, finished: 2, ready: 3 }; -// useWorktreeStates is each worktree's most urgent agent state. -function useWorktreeStates(): Record { +// 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 = {}; + 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]]!) out[key] = st; + if (out[key] === undefined || URGENCY[st]! < URGENCY[out[key].state]!) out[key] = { state: st, title: s.title?.trim() || out[key]?.title }; } } return out; @@ -86,14 +89,14 @@ export function TopBar() { }, [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. + const wide = useMediaQuery({ min: 1180 }); + const shown = wide ? pinned : pinned.filter((n) => n.id === "review"); + const folded = wide ? more : [...pinned.filter((n) => n.id !== "review"), ...more]; return ( -
+
- / - - + +
- - Every agent at a glance ⌃⇥}> - - + - - - - - - + {wide ? : }
@@ -151,105 +130,232 @@ export function TopBar() { ); } -// NeedsYou says how many agents wait for you, and goes to the one that has -// waited longest. -function NeedsYou() { +// ---- 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"); - if (!waiting.length) return null; const next = waiting.find((i) => !i.selected) ?? waiting[0]; return ( - - - +
+ {next && ( + + + + )} + Every agent at a glance. Hold ⌃ and press ⇥ anywhere}> + + +
); } -// ---- Project ▾ ---------------------------------------------------------- +// ---- Go to any worktree ------------------------------------------------- -// ProjectCrumb names the project in front and switches to any worktree of -// any project, each project a submenu of its worktrees on every box. -function ProjectCrumb() { - const ref = useWorkspaces((s) => (s.current && !homeBox(s.current) ? s.spaces[s.current]?.ref : undefined)); - const view = useStore((s) => s.view); +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(); - const project = ref ? (projects.find((p) => p.members.some((m) => m.box.name === ref.box && m.loc.name === ref.location))?.name ?? ref.location) : undefined; - const placeName = view.kind === "workspace" ? undefined : view.kind === "settings" ? "Settings" : undefined; - const label = project ?? placeName ?? "Projects"; + 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. +function ProjectSwitcher() { + const ref = useWorkspaces((s) => (s.current && !homeBox(s.current) ? s.spaces[s.current]?.ref : undefined)); + const workspace = useStore((s) => s.view.kind === "workspace"); + const { projects } = useProjects(); + const project = ref && workspace ? (projects.find((p) => p.members.some((m) => m.box.name === ref.box && m.loc.name === ref.location))?.name ?? ref.location) : undefined; + const [open, setOpen] = useState(false); return ( - - + } > - {label} - - - - - Projects - {projects.map((p) => { - const online = p.members.filter((m) => m.box.state === "online"); - const wts = online.flatMap((m) => (boxes[m.box.name]?.locations?.find((l) => l.name === m.loc.name)?.worktrees ?? []).map((wt) => ({ box: m.box.name, loc: m.loc, wt }))); - const waiting = wts.filter((w) => states[wsKey(w.box, w.wt.path)] === "waiting").length; - const spans = new Set(p.members.map((m) => m.box.name)).size > 1; - return ( - - - - {p.name} - {waiting > 0 && {waiting}} - {online.length ? p.members.map((m) => m.box.name).join(" ") : "offline"} - - - {wts.map(({ box, loc, wt }) => ( - - ))} - - useStore.getState().openNewWorktree({ box: online[0]?.box.name, location: online[0]?.loc.name })}> - - New task in {p.name}… - - - - ); - })} - - - useStore.getState().openAddLocation()}> - - Add a project… - - - + {project ?? "Projects"} + + + + {open && setOpen(false)} />} + + ); } -function WorktreeItem({ box, loc, wt, state, spans }: { box: string; loc: Location; wt: Worktree; state?: SessionState; spans: boolean }) { +function SwitcherList({ front, close }: { front?: string; close(): void }) { + const groups = useRows(); + 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)); + }; + const shown = groups.map((g) => ({ ...g, rows: g.rows.filter(match) })).filter((g) => g.rows.length); + const flat = shown.flatMap((g) => g.rows); + const go = (r: Row) => { + close(); + selectWorktree(refOf(r.box, r.loc, r.wt)); + }; + useEffect(() => setActive(0), [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 ( - selectWorktree(refOf(box, loc, wt))}> - {state ? : wt.main ? : } - {wt.main ? "main" : worktreeLabel(wt)} - {spans && {box}} - +
+
+ + setQ(e.target.value)} + onKeyDown={onKeyDown} + 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" + /> +
+
+ {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 ? : } + {r.wt.main ? "main" : worktreeLabel(r.wt)} + {r.title} + {r.spans && {r.box}} +
+ ); + })} +
+ ))} +
+
+ + + {/* The boxes, and which are away. */} + + {status.map((b) => ( + + + {b.name} + + ))} + +
+
); } @@ -260,17 +366,20 @@ function WorktreeTabs() { 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 { projects } = useProjects(); - const strip = useRef(null); + 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); - const manyProjects = new Set(tabs.map((t) => t.ref.location)).size > 1; - const spansBox = (ref: WorktreeRef) => new Set(projects.find((p) => p.members.some((m) => m.box.name === ref.box && m.loc.name === ref.location))?.members.map((m) => m.box.name)).size > 1; + // 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: KeyboardEvent) => { + 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; @@ -281,74 +390,74 @@ function WorktreeTabs() { window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, []); - const tabsRef = useRef(tabs); - tabsRef.current = tabs; // The one in front stays in sight. useEffect(() => { - strip.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest", inline: "nearest" }); + strip.current?.querySelector("[data-selected]")?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [current, tabs.length]); return (
-
+
+ New task {platformKeys("⌘N")}}>
); } + diff --git a/app/src/components/workspace/tab-strip.tsx b/app/src/components/workspace/tab-strip.tsx index 71b2f0b6d..661cd4c4c 100644 --- a/app/src/components/workspace/tab-strip.tsx +++ b/app/src/components/workspace/tab-strip.tsx @@ -178,7 +178,7 @@ export function TabStrip() { front; the breadcrumb only speaks up for a guest pane. */} {hereRef && !(grouped && hereKey === key) && ( - + {hereRef.location} diff --git a/app/src/index.css b/app/src/index.css index 8d1232568..623ab98b7 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -393,3 +393,9 @@ :root[data-berth-still] :is(.animate-spin, .animate-ping, .animate-pulse, .cv-shimmer)::after { animation-play-state: paused !important; } + +/* Labs › Layout › Top bar names the worktree in front in its tabs; the + tab strip under it leaves out its own breadcrumb (a guest pane's stays). */ +[data-layout="topbar"] [data-strip-place="own"] { + display: none; +} From 20af633878be809fb7a8e99e68403037d853d9ce Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:46:50 +0100 Subject: [PATCH 4/7] fix(app): the top bar says what its controls are and leads with what needs you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second and third critic passes: a stable Projects ⌘E button that opens on the worktree in front, with what needs you from every project at the top and open tabs marked; counts as one labelled chip (2 working · 4 done); Review keeps its label down to 1280px; New task in words when there's room; compact pill tabs whose × overlays on hover; the switcher starts on the worktree you were in before, in two columns with the newest two done and recent; the status bar leaves out what the bar already says. Co-Authored-By: Claude Opus 5.5 --- app/e2e/layout-topbar.spec.ts | 8 +- app/src/components/layouts/parts.tsx | 2 +- app/src/components/layouts/switcher.tsx | 39 ++++--- app/src/components/layouts/topbar.tsx | 120 +++++++++++++-------- app/src/components/status-bar.tsx | 4 +- app/src/components/workspace/tab-strip.tsx | 2 +- app/src/index.css | 7 +- docs/changelog.mdx | 10 +- 8 files changed, 120 insertions(+), 72 deletions(-) diff --git a/app/e2e/layout-topbar.spec.ts b/app/e2e/layout-topbar.spec.ts index 244e45370..e36cfaac7 100644 --- a/app/e2e/layout-topbar.spec.ts +++ b/app/e2e/layout-topbar.spec.ts @@ -21,7 +21,6 @@ test("the project switcher goes to any worktree, which gets a tab", async ({ app 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(page.getByTestId("topbar-project")).toContainText("shop"); await expect(app.panes.first()).toBeVisible(); // Home and back by its tab. await page.getByTestId("nav-home").click(); @@ -30,15 +29,14 @@ test("the project switcher goes to any worktree, which gets a tab", async ({ app await expect(page.locator("[data-testid=topbar-tab][data-selected]")).toContainText("search-perf"); }); -test("typing in the project switcher finds a worktree by its task", async ({ app }) => { +test("⌘E and a few letters of a task go to its worktree", async ({ app }) => { const { page } = app; await open(app); - await page.getByTestId("topbar-project").click(); + await page.keyboard.press("Meta+e"); await page.getByRole("textbox", { 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"); - await expect(page.getByTestId("topbar-project")).toContainText("evals"); }); test("what needs you is one click away", async ({ app }) => { @@ -52,6 +50,8 @@ test("what needs you is one click away", async ({ app }) => { 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"); diff --git a/app/src/components/layouts/parts.tsx b/app/src/components/layouts/parts.tsx index 78a023a28..65df62c9e 100644 --- a/app/src/components/layouts/parts.tsx +++ b/app/src/components/layouts/parts.tsx @@ -135,7 +135,7 @@ export function PlaceButton({ n, iconOnly, className }: { n: NavItem; iconOnly?: {!iconOnly && n.label} {n.badge && (iconOnly ? ( - {n.badge.count} + {n.badge.count} ) : ( {n.badge.count} ))} diff --git a/app/src/components/layouts/switcher.tsx b/app/src/components/layouts/switcher.tsx index 48afae7d0..9a19807a8 100644 --- a/app/src/components/layouts/switcher.tsx +++ b/app/src/components/layouts/switcher.tsx @@ -7,6 +7,7 @@ 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, @@ -22,16 +23,21 @@ export function openSwitcher() { } const LANES: Lane[] = ["waiting", "running", "finished", "recent"]; +const ROW = 2; 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 live = all.filter((i) => i.lane !== "away" && i.lane !== "recent"); - return [...live, ...all.filter((i) => i.lane === "recent").slice(0, Math.max(4, 12 - live.length))].slice(0, 16); + 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); @@ -54,12 +60,18 @@ export function AgentSwitcher() { if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === "Tab" && (s.open || !e.shiftKey)) { e.preventDefault(); e.stopPropagation(); - if (!n) return; if (!s.open) { - // The one after where you are, as ⌘-Tab picks the last app. - const here = list.current.findIndex((i) => i.selected); - useSwitcher.setState({ open: true, held: true, index: here === 0 && n > 1 ? 1 : 0 }); - } else useSwitcher.setState({ index: (s.index + (e.shiftKey ? -1 : 1) + n) % n }); + // 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; @@ -101,7 +113,7 @@ export function AgentSwitcher() { className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 p-6 backdrop-blur-[2px] motion-safe:animate-in motion-safe:fade-in-0" onClick={close} > -
e.stopPropagation()}> +
e.stopPropagation()}>
Switch to @@ -126,9 +138,10 @@ export function AgentSwitcher() { return (

- {LANE_WORDS[lane]} {inLane.length} + {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))} /> ))} @@ -174,20 +187,20 @@ function Card({ it, picked, onPick, onOpen }: { it: Item; picked: boolean; onPic {it.place} - {it.session ? : No agent here} + {it.session ? : No agent here} ); } // 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, 4, working); + 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 index 299ca4b77..dfa7537f9 100644 --- a/app/src/components/layouts/topbar.tsx +++ b/app/src/components/layouts/topbar.tsx @@ -1,8 +1,8 @@ -import { ChevronsUpDownIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayoutGridIcon, PlusIcon, SearchIcon, XIcon } from "lucide-react"; +import { FolderIcon, FolderPlusIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, PlusIcon, SearchIcon, XIcon } from "lucide-react"; import { type KeyboardEvent, type MouseEvent, useEffect, useMemo, useRef, useState } from "react"; import { create } from "zustand"; -import { BoxStateDot, StateGlyph } from "@/components/agent-glyph"; +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"; @@ -91,7 +91,13 @@ export function TopBar() { 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, keeps its + // label down to 1280px and the others to 1600px; under 1180px all but + // Review fold into ⋯. + const roomy = useMediaQuery({ min: 1600 }); + const labelled = useMediaQuery({ min: 1280 }); const wide = useMediaQuery({ min: 1180 }); + const hint = useMediaQuery({ min: 1440 }); const shown = wide ? pinned : pinned.filter((n) => n.id === "review"); const folded = wide ? more : [...pinned.filter((n) => n.id !== "review"), ...more]; @@ -113,16 +119,16 @@ export function TopBar() {
- + - {wide ? : } + {roomy ? : }
@@ -156,28 +162,20 @@ function Agents({ wide }: { wide: boolean }) { )} - Every agent at a glance. Hold ⌃ and press ⇥ anywhere
}> + Every agent at a glance: hold ⌃ and press ⇥ anywhere
}>
@@ -227,12 +225,23 @@ function useRows() { // 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 workspace = useStore((s) => s.view.kind === "workspace"); - const { projects } = useProjects(); - const project = ref && workspace ? (projects.find((p) => p.members.some((m) => m.box.name === ref.box && m.loc.name === ref.location))?.name ?? ref.location) : undefined; - const [open, setOpen] = useState(false); + 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 ( } > - - {project ?? "Projects"} - + + Projects + {platformKeys("⌘E")} {open && setOpen(false)} />} @@ -257,7 +266,10 @@ function ProjectSwitcher() { } function SwitcherList({ front, close }: { front?: string; close(): void }) { - const groups = useRows(); + 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); @@ -267,13 +279,25 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { 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)); }; - const shown = groups.map((g) => ({ ...g, rows: g.rows.filter(match) })).filter((g) => g.rows.length); + // 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)); }; - useEffect(() => setActive(0), [q]); + // Opens on the worktree in front; typing starts from the top. + useEffect(() => { + const i = q ? 0 : flat.findIndex((r) => r.key === front); + 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]); @@ -305,7 +329,7 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { {shown.length === 0 &&
No worktree matches.
} {shown.map((g) => (
-
+
{g.name} {g.waiting > 0 && {g.waiting}} {g.working > 0 && {g.working}} @@ -326,9 +350,10 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { 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 ? : } - {r.wt.main ? "main" : worktreeLabel(r.wt)} + {g.asks && !r.wt.main ? `${r.project} / ` : ""}{r.wt.main ? (g.asks ? r.project : "main") : worktreeLabel(r.wt)} + {r.box} {r.title} - {r.spans && {r.box}} + {open.has(r.key) && {r.key === front ? "in front" : "open"}}
); })} @@ -349,8 +374,9 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { {status.map((b) => ( - + {b.name} + {b.state !== "online" && offline} ))} @@ -362,6 +388,7 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { // ---- 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); @@ -412,7 +439,7 @@ function WorktreeTabs() {
@@ -425,12 +452,12 @@ function WorktreeTabs() { data-selected={selected || undefined} onClick={() => selectWorktree(t.ref)} onAuxClick={(e) => e.button === 1 && close(e)} - className="flex h-full min-w-0 flex-1 cursor-default items-center gap-1.5 rounded-md pl-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + className={cn("flex h-full min-w-0 flex-auto cursor-default items-center gap-1.5 rounded-full pl-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", !selected && "pr-2.5")} > {state ? : t.ref.main ? : } {/* Just the name: where it is shows on hover. Two of a name say their project. */} - + {twins.has(t.ref.main ? t.ref.location : t.ref.worktree) && !t.ref.main && {t.ref.location} / } {name} @@ -443,7 +470,12 @@ function WorktreeTabs() { data-ws={t.key} tabIndex={-1} onClick={close} - className={cn("mx-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground", selected ? "opacity-70" : "opacity-0 group-hover:opacity-70")} + className={cn( + "inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", + // In front it has its place; behind, it shows over the + // tab's end on hover, so tabs are as wide as their names. + selected ? "mr-0.5 opacity-70" : "absolute right-0.5 bg-sidebar-accent opacity-0 group-hover:opacity-100", + )} > @@ -457,10 +489,10 @@ function WorktreeTabs() { data-testid="layout-new-task" aria-label="New task" onClick={() => useStore.getState().openNewWorktree()} - className={cn("inline-flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent hover:text-foreground", !tabs.length && "px-2")} + className={cn("inline-flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent hover:text-foreground", (roomy || !tabs.length) && "px-2")} > - {!tabs.length && "New task"} + {(roomy || !tabs.length) && "New task"}
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/components/workspace/tab-strip.tsx b/app/src/components/workspace/tab-strip.tsx index 661cd4c4c..71b2f0b6d 100644 --- a/app/src/components/workspace/tab-strip.tsx +++ b/app/src/components/workspace/tab-strip.tsx @@ -178,7 +178,7 @@ export function TabStrip() { front; the breadcrumb only speaks up for a guest pane. */} {hereRef && !(grouped && hereKey === key) && ( - + {hereRef.location} diff --git a/app/src/index.css b/app/src/index.css index 623ab98b7..bde4f1071 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -394,8 +394,9 @@ animation-play-state: paused !important; } -/* Labs › Layout › Top bar names the worktree in front in its tabs; the - tab strip under it leaves out its own breadcrumb (a guest pane's stays). */ -[data-layout="topbar"] [data-strip-place="own"] { +/* 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/docs/changelog.mdx b/docs/changelog.mdx index 7a751a0c8..e32cbe7c3 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -12,10 +12,12 @@ again. ## Unreleased - **Labs: try the window without a sidebar.** Settings › Labs › Layout has - **Top bar**: the project you're in and the worktrees you have open as tabs - across the top, how many agents need you (a click goes to the one that has - waited longest), and ⌃⇥ 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. + **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. From 788d7bdbfd99c5ccf458355c1b58da7f0a5a1f87 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:52:01 +0100 Subject: [PATCH 5/7] fix(app): the switcher keeps the keyboard's pick until the pointer moves It opened under a resting pointer, which took the pick from the keyboard and showed the worktree you were in rather than the one before. Co-Authored-By: Claude Opus 5.5 --- app/src/components/layouts/switcher.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/components/layouts/switcher.tsx b/app/src/components/layouts/switcher.tsx index 9a19807a8..c96cada42 100644 --- a/app/src/components/layouts/switcher.tsx +++ b/app/src/components/layouts/switcher.tsx @@ -25,6 +25,17 @@ export function openSwitcher() { 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() { @@ -103,7 +114,10 @@ export function AgentSwitcher() { }; }, []); - if (!open) return null; + if (!open) { + rest = undefined; + return null; + } return (
pointerMoved(e) && onPick()} onClick={onOpen} className={cn( "flex min-w-0 flex-col gap-1 rounded-xl border bg-card p-2.5 text-left outline-none transition-colors", From 74a287b7a4047c4034cb24daaf475ceca480fe61 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:00:40 +0100 Subject: [PATCH 6/7] fix(app): the top bar names its places, finds what needs you next, and keeps tab names whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth critic pass: places other than Review fold into ⋯ until there's room for their names; the finder preselects the next agent that needs you (or the worktree before), names every row, marks open and here, has the boxes on a line of their own and teaches ⌃⇥; tab × shows on hover so names keep their room; the counts chip turns to glyphs when narrow; the finder is a proper combobox for screen readers. Co-Authored-By: Claude Opus 5.5 --- app/e2e/layout-topbar.spec.ts | 2 +- app/src/components/layouts/topbar.tsx | 81 ++++++++++++++++----------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/app/e2e/layout-topbar.spec.ts b/app/e2e/layout-topbar.spec.ts index e36cfaac7..8c8f5134e 100644 --- a/app/e2e/layout-topbar.spec.ts +++ b/app/e2e/layout-topbar.spec.ts @@ -33,7 +33,7 @@ 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("textbox", { name: "Go to a worktree or task" }).fill("judge"); + 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"); diff --git a/app/src/components/layouts/topbar.tsx b/app/src/components/layouts/topbar.tsx index dfa7537f9..02d089fb3 100644 --- a/app/src/components/layouts/topbar.tsx +++ b/app/src/components/layouts/topbar.tsx @@ -91,15 +91,13 @@ export function TopBar() { 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, keeps its - // label down to 1280px and the others to 1600px; under 1180px all but - // Review fold into ⋯. + // 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 labelled = useMediaQuery({ min: 1280 }); - const wide = useMediaQuery({ min: 1180 }); - const hint = useMediaQuery({ min: 1440 }); - const shown = wide ? pinned : pinned.filter((n) => n.id === "review"); - const folded = wide ? more : [...pinned.filter((n) => n.id !== "review"), ...more]; + 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 (
@@ -123,7 +121,7 @@ export function TopBar() { @@ -171,10 +169,12 @@ function Agents({ wide }: { wide: boolean }) { className="inline-flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-muted-foreground text-xs tabular-nums hover:bg-sidebar-accent hover:text-foreground" > - {count("running")} working - · + {count("running")} + {wide && " working"} + {wide && ·} - {count("finished")} done + {count("finished")} + {wide && " done"} {wide && ⌃⇥} @@ -294,7 +294,13 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { }; // Opens on the worktree in front; typing starts from the top. useEffect(() => { - const i = q ? 0 : flat.findIndex((r) => r.key === front); + // 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]); @@ -320,12 +326,18 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { value={q} onChange={(e) => 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) => (
@@ -343,6 +355,7 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { role="option" aria-selected={i === active} data-index={i} + id={`goto-${i}`} data-testid="topbar-worktree-item" data-worktree={`${r.box}/${r.wt.main ? r.loc.name : r.wt.name}`} onMouseMove={() => setActive(i)} @@ -350,16 +363,26 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { 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.wt.main ? `${r.project} / ` : ""}{r.wt.main ? (g.asks ? r.project : "main") : worktreeLabel(r.wt)} + {g.asks ? `${r.project} / ` : ""}{r.wt.main ? "main" : worktreeLabel(r.wt)} {r.box} {r.title} - {open.has(r.key) && {r.key === front ? "in front" : "open"}} + {open.has(r.key) && {r.key === front ? "here" : "open"}}
); })}
))}
+ {/* The boxes, and which are away. */} +
+ {status.map((b) => ( + + + {b.name} + {b.state !== "online" && offline} + + ))} +
- {/* The boxes, and which are away. */} - - {status.map((b) => ( - - - {b.name} - {b.state !== "online" && offline} - - ))} - +
); @@ -439,8 +456,8 @@ function WorktreeTabs() {
{`${t.ref.location}${t.ref.main ? " · main checkout" : ` / ${name}`} on ${t.ref.box}`}{i < 9 && ⌃{i + 1}}}> @@ -452,7 +469,7 @@ function WorktreeTabs() { data-selected={selected || undefined} onClick={() => selectWorktree(t.ref)} onAuxClick={(e) => e.button === 1 && close(e)} - className={cn("flex h-full min-w-0 flex-auto cursor-default items-center gap-1.5 rounded-full pl-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", !selected && "pr-2.5")} + className="flex h-full min-w-0 flex-auto cursor-default items-center gap-1.5 rounded-full px-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" > {state ? : t.ref.main ? : } {/* Just the name: where it is shows on hover. Two of a name @@ -472,9 +489,9 @@ function WorktreeTabs() { onClick={close} className={cn( "inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", - // In front it has its place; behind, it shows over the - // tab's end on hover, so tabs are as wide as their names. - selected ? "mr-0.5 opacity-70" : "absolute right-0.5 bg-sidebar-accent opacity-0 group-hover:opacity-100", + // It shows over the tab's end on hover, so tabs are as + // wide as their names. + "absolute right-0.5 bg-sidebar-accent opacity-0 group-hover:opacity-100 focus-visible:opacity-100", )} > From 030f74f5dc984ad1e8e73783399ef062be103aa0 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:04:47 +0100 Subject: [PATCH 7/7] fix(app): every agent has its icon in the worktree finder's foot Co-Authored-By: Claude Opus 5.5 --- app/src/components/layouts/topbar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/components/layouts/topbar.tsx b/app/src/components/layouts/topbar.tsx index 02d089fb3..8e970477a 100644 --- a/app/src/components/layouts/topbar.tsx +++ b/app/src/components/layouts/topbar.tsx @@ -1,4 +1,4 @@ -import { FolderIcon, FolderPlusIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, PlusIcon, SearchIcon, XIcon } from "lucide-react"; +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"; @@ -394,6 +394,7 @@ function SwitcherList({ front, close }: { front?: string; close(): void }) { Add a project