From 32a060eaf313b120393204415b4fca539a370773 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:00:42 +0100 Subject: [PATCH 1/4] feat(app): labs layout with a rail of projects and a breadcrumb that switches worktrees and agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Labs › Layout › Icon rail swaps the sidebar for a 60px rail of project marks (needs-you counts, a dot per agent at work, a peek of each project's worktrees by state) and a title bar breadcrumb whose parts are switchers. The sidebar stays the default. Co-Authored-By: Claude Opus 5.5 --- app/e2e/rail-layout.spec.ts | 137 +++++++++ app/src/App.tsx | 32 +- app/src/components/rail-layout/model.ts | 201 +++++++++++++ .../components/rail-layout/project-rail.tsx | 281 +++++++++++++++++ app/src/components/rail-layout/title-bar.tsx | 284 ++++++++++++++++++ .../components/rail-layout/worktree-items.tsx | 152 ++++++++++ app/src/components/ui/menu.tsx | 3 + app/src/components/workspace/tab-strip.tsx | 8 +- app/src/lib/prefs.ts | 5 + app/src/views/settings/labs-section.tsx | 13 + docs/changelog.mdx | 7 + 11 files changed, 1110 insertions(+), 13 deletions(-) create mode 100644 app/e2e/rail-layout.spec.ts create mode 100644 app/src/components/rail-layout/model.ts create mode 100644 app/src/components/rail-layout/project-rail.tsx create mode 100644 app/src/components/rail-layout/title-bar.tsx create mode 100644 app/src/components/rail-layout/worktree-items.tsx diff --git a/app/e2e/rail-layout.spec.ts b/app/e2e/rail-layout.spec.ts new file mode 100644 index 00000000..719e5a64 --- /dev/null +++ b/app/e2e/rail-layout.spec.ts @@ -0,0 +1,137 @@ +import { expect, mockOnly, test } from "./fixtures"; + +// Labs › Layout › Icon rail (components/rail-layout): a rail of projects +// whose peeks list their worktrees and agents, and a title bar whose +// breadcrumb (Project / Worktree / Agent) switches at every step. + +const RAIL = { prefs: { labs: true, labsChosen: true, layout: "rail" } }; + +test("the rail peeks at a project's worktrees by state, and a click goes there", async ({ app }) => { + mockOnly(); + await app.open(RAIL); + const { page } = app; + await expect(page.getByTestId("sidebar")).toHaveCount(0); + const rail = page.getByTestId("project-rail"); + await expect(rail).toBeVisible(); + + // shop's mark says two agents need you and one works, without a word. + const shop = rail.locator('[data-testid=rail-project][data-project="shop"]'); + await expect(shop).toHaveAccessibleName(/shop, 2 need you, 1 working/); + + // Pointing at it peeks: its worktrees by what they need, needs-you first. + await shop.hover(); + const peek = page.getByTestId("rail-peek"); + await expect(peek).toBeVisible(); + const lanes = await peek.getByTestId("rail-lane").evaluateAll((els) => els.map((e) => e.getAttribute("data-lane"))); + expect(lanes).toEqual(["waiting", "running", "finished", "quiet"]); + const fix = peek.locator('[data-testid=rail-wt][data-worktree="devl/checkout-fix"]'); + await expect(fix).toContainText("Fix checkout webhook retries"); + await expect(fix).toContainText("pnpm prisma migrate dev"); + + // One click: the worktree opens with its agent in front, and the + // breadcrumb and the rail say where you are. + await fix.click(); + await expect(peek).toBeHidden(); + await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Fix checkout webhook retries/); + await expect(page.getByTestId("crumb-project")).toContainText("shop"); + await expect(page.getByTestId("crumb-worktree")).toContainText("checkout-fix"); + // shop is on two boxes, so the box shows. + await expect(page.getByTestId("crumb-worktree")).toContainText("devl"); + // One agent: its name is on its tab, so the breadcrumb stops at the worktree. + await expect(page.getByTestId("crumb-agent")).toHaveCount(0); + await expect(shop).toHaveAttribute("aria-current", "true"); + + // A dot per agent at work, the ones that need you first. + const pips = await shop.getByTestId("rail-pips").locator("[data-state]").evaluateAll((els) => els.map((e) => e.getAttribute("data-state"))); + expect(pips.slice(0, 3)).toEqual(["waiting", "waiting", "running"]); + + // A click on a mark goes to the project, where its agent works; so does ⌃n. + await page.mouse.move(700, 500); + await rail.locator('[data-testid=rail-project][data-project="evals"]').click(); + await expect(page.getByTestId("crumb-worktree")).toContainText("judge-v2"); + await page.keyboard.press("Control+3"); + await expect(page.getByTestId("crumb-project")).toContainText("shop"); + + // A box that is away is said at the rail's foot. + await expect(rail.getByTestId("rail-away")).toHaveAccessibleName(/old-vps is offline/); +}); + +test("the breadcrumb switches worktree, agent and project", async ({ app }) => { + mockOnly(); + await app.open(RAIL); + const { page } = app; + + // From Home, the first crumb lists the projects: evals opens where its + // agent works. + await expect(page.getByTestId("crumb-project")).toContainText("Home"); + await page.getByTestId("crumb-project").click(); + await page.locator('[data-testid=crumb-project-item][data-project="evals"]').click(); + await expect(page.getByTestId("crumb-worktree")).toContainText("judge-v2"); + await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Tune the judge prompt/); + + // Back to shop, then across its worktrees by the worktree crumb. + await page.getByTestId("crumb-project").click(); + await page.locator('[data-testid=crumb-project-item][data-project="shop"]').click(); + await expect(page.getByTestId("crumb-project")).toContainText("shop"); + await page.getByTestId("crumb-worktree").click(); + await page.getByRole("menu").locator('[data-testid=rail-wt][data-worktree="devl/qa-deck"]').click(); + await expect(page.getByTestId("crumb-worktree")).toContainText("qa-deck"); + await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Build the QA deck/); + + // A worktree with several agents: the agent crumb goes between them. + await page.getByTestId("crumb-worktree").click(); + await page.getByRole("menu").locator('[data-testid=rail-wt][data-worktree="devl/order-export"]').click(); + await expect(page.getByTestId("crumb-worktree")).toContainText("order-export"); + await page.getByTestId("crumb-agent").click(); + await page.getByTestId("crumb-agent-item").filter({ hasText: "Add tests for the export job" }).click(); + await expect(page.getByTestId("crumb-agent")).toContainText("Add tests for the export job"); + await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Add tests for the export job/); +}); + +test("needs you goes round the agents that wait, and new work starts from the title bar or a peek", async ({ app }) => { + mockOnly(); + await app.open(RAIL); + const { page } = app; + + const needs = page.getByTestId("title-needs-you"); + await expect(needs).toContainText("2 need you"); + const tab = page.locator("[role=tab][aria-selected=true]"); + await needs.click(); + await expect(tab).toHaveAccessibleName(/Plan the checkout release|Fix checkout webhook retries/); + const plan = /Plan the checkout release/.test((await tab.textContent()) ?? ""); + await needs.click(); + await expect(tab).toHaveAccessibleName(plan ? /Fix checkout webhook retries/ : /Plan the checkout release/); + + // New task from the title bar opens the composer. + await page.getByTestId("title-new-task").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + + // From a project's peek, the composer starts in that project, and the + // new task opens. + await page.locator('[data-testid=rail-project][data-project="notes"]').hover(); + await page.getByTestId("rail-peek").getByTestId("rail-new-task").click(); + const composer = page.getByRole("dialog"); + await expect(composer).toContainText("notes"); + await composer.getByRole("textbox").first().fill("say hello"); + await composer.getByRole("button", { name: /^Start/ }).click(); + await expect(composer).toBeHidden(); + await expect(page.getByTestId("crumb-project")).toContainText("notes"); +}); + +test("Settings is at the rail's foot, and Labs puts the sidebar back", async ({ app }) => { + mockOnly(); + const body = await (async () => { + await app.open(RAIL); + return app.openSettings("labs"); + })(); + const { page } = app; + await expect(page.getByTestId("nav-settings")).toHaveAttribute("aria-current", "page"); + await expect(page.getByTestId("crumb-project")).toContainText("Settings"); + await body.getByRole("button", { name: "Sidebar", exact: true }).click(); + await expect(page.getByTestId("sidebar")).toBeVisible(); + await expect(page.getByTestId("project-rail")).toHaveCount(0); + await expect(page.getByTestId("title-bar")).toHaveCount(0); + expect(((await app.stored("berth.prefs")) as { layout?: string }).layout).toBe("sidebar"); +}); diff --git a/app/src/App.tsx b/app/src/App.tsx index 4428588e..6d7fb63a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,6 +4,8 @@ import { lazy, Suspense, useEffect } from "react"; import { AddLocationDialog } from "@/components/add-location-dialog"; import { Connecting } from "@/components/agent-offline"; import { AppSidebar } from "@/components/app-sidebar"; +import { ProjectRail } from "@/components/rail-layout/project-rail"; +import { TitleBar } from "@/components/rail-layout/title-bar"; import { CommandPalette } from "@/components/command-palette"; import { FilePicker } from "@/components/files/file-picker"; import { TreeDockFrame } from "@/components/files/tree-dock"; @@ -99,6 +101,13 @@ 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 › Layout › Icon rail: a rail of projects and a title bar whose + // breadcrumb switches worktrees and agents, in place of the sidebar + // (components/rail-layout). Zen puts both away as it does the sidebar. + const rail = usePrefs((p) => p.labs && p.layout === "rail") && !zen; + // Home's strip holds box homes' terminals; with none, the rail layout + // has no use for it (its title bar drags the window). + const homeTabs = useWorkspaces((s) => Object.keys(s.spaces).some((k) => !!homeBox(k) && s.spaces[k].tabs.length > 0)); // 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]); @@ -158,21 +167,24 @@ export default function App() {
{fakeTrafficLights() && } + {rail && ( + + + + )}
- {!zen && ( - - - - )} + {!zen && {rail ? : }}
{zen && !onboarding ? ( ) : workspace && !onboarding ? ( - - {!onHome && } - {onHome ? : } - - ) : !workspace ? null /* every other view's ViewHeader is the strip */ : ( + rail && onHome && !homeTabs ? null : ( + + {!onHome && } + {onHome ? : } + + ) + ) : !workspace || rail ? null /* every other view's ViewHeader is the strip */ : ( // Onboarding names itself; the strip only drags the window.
)} diff --git a/app/src/components/rail-layout/model.ts b/app/src/components/rail-layout/model.ts new file mode 100644 index 00000000..1cae5929 --- /dev/null +++ b/app/src/components/rail-layout/model.ts @@ -0,0 +1,201 @@ +import { useMemo } from "react"; + +import type { Location, Session, Worktree } from "@/lib/api"; +import { agentLabel, agentOf, type SessionState, sessionState, worktreeSessions } from "@/lib/derive"; +import { leaves } from "@/lib/layout"; +import { type Project, useProjects } from "@/lib/project-groups"; +import { BOX_WORDS, boxState } from "@/lib/state-model"; +import { NONE, useStore } from "@/lib/store"; +import { openSession, refOf, selectWorktree, useWorkspaces, type Workspace, wsKey } from "@/lib/workspaces"; +import { worktreeLabel } from "@/lib/worktree-names"; + +// The rail layout's model (Labs › Layout › Icon rail): every project with +// its worktrees, each worktree with its agents and the one line that says +// what it is doing, and the counts the rail's badges and rings draw. The +// rail, its peeks and the title bar's breadcrumb all read it, so they agree. + +export type Lane = "waiting" | "running" | "finished" | "quiet"; +export const LANES: Lane[] = ["waiting", "running", "finished", "quiet"]; +export const LANE_WORDS: Record = { waiting: "Needs you", running: "Working", finished: "Done", quiet: "Quiet" }; + +const urgency: Record = { waiting: 0, running: 1, finished: 2, ready: 3, idle: 4, exited: 5 }; + +export interface AgentEntry { + box: string; + session: Session; + state: SessionState; + agent: string; +} + +export interface WtEntry { + key: string; + box: string; + loc: Location; + wt: Worktree; + // Its agents, the one that needs you most first; then its shells and + // dev servers. + agents: AgentEntry[]; + others: Session[]; + // Its most urgent agent's state, when one runs. + state?: SessionState; + lane: Lane; + name: string; + // What it is doing, in one line: its lead agent's work. + doing?: string; + // What its lead agent waits for, when it needs you. + ask?: string; + selected: boolean; + // The box's state when it is not online. + away?: string; + visitedAt: number; +} + +export interface ProjectEntry { + project: Project; + id: string; + name: string; + initials: string; + worktrees: WtEntry[]; + waiting: number; + running: number; + finished: number; + // The worktree in front is one of its own. + current: boolean; + multiBox: boolean; + online: boolean; + section?: string; +} + +// initialsFor names each project in one or two letters: its words' +// initials, or its first letter, or two when another project starts alike. +export function initialsFor(names: string[]): Record { + const words = (n: string) => n.split(/[\s\-_./]+/).filter(Boolean); + const first = (n: string) => (words(n)[0]?.[0] ?? n[0] ?? "?").toUpperCase(); + const out: Record = {}; + for (const n of names) { + const w = words(n); + if (w.length > 1) out[n] = (w[0][0] + w[1][0]).toUpperCase(); + else if (names.filter((o) => o !== n && first(o) === first(n)).length) out[n] = first(n) + (w[0]?.[1] ?? "").toLowerCase(); + else out[n] = first(n); + } + return out; +} + +const laneOf = (s?: SessionState): Lane => (s === "waiting" || s === "running" || s === "finished" ? s : "quiet"); + +function askText(s: Session): string | undefined { + const a = s.ask; + if (!a) return undefined; + const what = a.input ?? a.message; + if (!what) return undefined; + return a.tool && a.tool !== "AskUserQuestion" ? `${a.tool}: ${what}` : what; +} + +export function useRailProjects(): ProjectEntry[] { + const status = useStore((s) => s.status?.boxes ?? NONE); + const data = useStore((s) => s.boxes); + const current = useWorkspaces((s) => s.current); + const spaces = useWorkspaces((s) => s.spaces); + const inWorkspace = useStore((s) => s.view.kind === "workspace"); + const { projects, sections } = useProjects(); + return useMemo(() => { + const initials = initialsFor(projects.map((p) => p.name)); + const list = projects.map((p): ProjectEntry => { + const worktrees: WtEntry[] = []; + for (const m of p.members) { + const d = data[m.box.name]; + const away = m.box.state === "online" ? undefined : BOX_WORDS[boxState(m.box, d)].lower; + for (const wt of m.loc.worktrees ?? []) { + const sessions = worktreeSessions(d?.sessions, wt).filter((s) => !s.exited); + const agents = sessions + .filter((s) => agentOf(s)) + .map((s) => ({ box: m.box.name, session: s, state: sessionState(s, d?.stats), agent: agentOf(s)! })) + .sort((a, b) => urgency[a.state] - urgency[b.state] || a.session.created.localeCompare(b.session.created)); + const lead = away ? undefined : agents[0]; + const key = wsKey(m.box.name, wt.path); + worktrees.push({ + key, + box: m.box.name, + loc: m.loc, + wt, + agents, + others: sessions.filter((s) => !agentOf(s)), + state: lead?.state, + lane: away ? "quiet" : laneOf(lead?.state), + name: wt.main ? (p.members.length > 1 || (m.loc.worktrees?.length ?? 0) > 1 ? "main" : p.name) : worktreeLabel(wt), + doing: lead ? lead.session.title?.trim() || agentLabel(lead.agent) : away ? `${m.box.name} is ${away}` : undefined, + ask: lead?.state === "waiting" ? askText(lead.session) : undefined, + selected: inWorkspace && current === key, + away, + visitedAt: spaces[key]?.visitedAt ?? 0, + }); + } + } + // By lane, then the main checkout first, then by name: a row only + // moves when its agents change state. + worktrees.sort((a, b) => LANES.indexOf(a.lane) - LANES.indexOf(b.lane) || Number(!!b.wt.main) - Number(!!a.wt.main) || a.name.localeCompare(b.name) || a.box.localeCompare(b.box)); + const count = (st: SessionState) => worktrees.reduce((n, w) => n + (w.away ? 0 : w.agents.filter((a) => a.state === st).length), 0); + return { + project: p, + id: p.id, + name: p.name, + initials: initials[p.name] ?? p.name[0]?.toUpperCase() ?? "?", + worktrees, + waiting: count("waiting"), + running: count("running"), + finished: count("finished"), + current: worktrees.some((w) => w.selected), + multiBox: p.members.length > 1, + online: p.members.some((m) => m.box.state === "online"), + section: p.section, + }; + }); + // The sidebar's order: projects in no section, then each section's. + const order = (e: ProjectEntry) => (e.section ? sections.indexOf(e.section) + 1 : 0); + return list.sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name)); + }, [projects, sections, data, current, spaces, inWorkspace, status]); +} + +// bestWorktree is where a project opens: a worktree with an agent that +// needs you, else the one last visited, else one where an agent works, +// else its main checkout. +export function bestWorktree(p: ProjectEntry): WtEntry | undefined { + const live = p.worktrees.filter((w) => !w.away); + return ( + live.find((w) => w.lane === "waiting") ?? + [...live].filter((w) => w.visitedAt).sort((a, b) => b.visitedAt - a.visitedAt)[0] ?? + live.find((w) => w.lane === "running") ?? + live.find((w) => w.wt.main && w.box === p.project.defaultBox) ?? + live[0] + ); +} + +// openWt shows a worktree: with one agent in it, that agent's tab in front. +export function openWt(w: WtEntry) { + if (w.away) return; + if (w.agents.length === 1) return openSession(w.box, w.agents[0].session); + selectWorktree(refOf(w.box, w.loc, w.wt)); +} + +export function openProject(p: ProjectEntry) { + const w = bestWorktree(p); + if (w) openWt(w); +} + +// useFocused is the session in the focused pane, if it is one, and what +// else the pane is when not. +export function useFocused(): { box: string; session?: Session; kind?: string; title?: string } | undefined { + const ws: Workspace | undefined = useWorkspaces((s) => (s.current ? s.spaces[s.current] : undefined)); + const tab = ws?.tabs.find((t) => t.id === ws.active); + const c = tab ? leaves(tab.root).find((l) => l.id === tab.focus)?.content : undefined; + const session = useStore((s) => (c?.kind === "terminal" ? s.boxes[c.box]?.sessions?.find((x) => x.name === c.session) : undefined)); + if (!ws || !c) return undefined; + if (c.kind === "terminal") return { box: c.box, session, kind: "terminal", title: c.title }; + return { box: ws.ref.box, kind: c.kind }; +} + +// waitingAgents is every agent that needs you, in the rail's order, for +// the title bar's "needs you" button to go through. +export function waitingAgents(projects: ProjectEntry[]): AgentEntry[] { + return projects.flatMap((p) => p.worktrees.filter((w) => !w.away).flatMap((w) => w.agents.filter((a) => a.state === "waiting"))); +} diff --git a/app/src/components/rail-layout/project-rail.tsx b/app/src/components/rail-layout/project-rail.tsx new file mode 100644 index 00000000..a3b38b3e --- /dev/null +++ b/app/src/components/rail-layout/project-rail.tsx @@ -0,0 +1,281 @@ +import { EllipsisIcon, FolderPlusIcon, PlusIcon, ServerIcon, ServerOffIcon, SettingsIcon } from "lucide-react"; +import { Fragment, type ReactNode, useEffect, useState } from "react"; + +import { MoreItems, useArrangedNav } from "@/components/sidebar/nav"; +import { Tip } from "@/components/tip"; +import { Kbd } from "@/components/ui/kbd"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "@/components/ui/menu"; +import { keysFor } from "@/lib/shortcuts"; +import { BOX_WORDS, boxState } from "@/lib/state-model"; +import { NONE, useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { openAddBox } from "@/views/onboarding/add-box-dialog"; +import { type AgentEntry, openProject, type ProjectEntry, useRailProjects } from "@/components/rail-layout/model"; +import { ProjectFooter, ProjectHead, WorktreeItems } from "@/components/rail-layout/worktree-items"; + +export const SHORT = "(max-height: 820px)"; + +// The peek keeps clear of the title bar and the status bar. +export const PEEK_COLLISION = { top: 52, bottom: 34, left: 8, right: 8 }; + +// ProjectRail is the rail layout's left edge (Labs › Layout › Icon rail), +// in the spirit of a chat app's server rail: Home and Review at the top, one +// mark per project in the middle, and Settings at the foot. Under each mark +// is a dot per agent at work in it, in the state's colour (amber needs you, +// blue works, green is done), and an amber count when some need you, so the +// rail is an overview of every agent without a hover. A click on a mark goes +// to the project (where an agent needs you first); pointing at it, or Enter +// on it, peeks at its worktrees and agents by what they need. ⌃1–9 go to the +// first nine projects. +export function ProjectRail() { + const projects = useRailProjects(); + const { pinned, more } = useArrangedNav(); + const view = useStore((s) => s.view); + const top = pinned.filter((n) => n.id === "home" || n.id === "review"); + const bottom = pinned.filter((n) => n.id !== "home" && n.id !== "review"); + const empty = useStore((s) => !!s.status && s.status.boxes.length === 0); + + // ⌃1–9: the nth project, from anywhere (terminals included). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || !/^Digit[1-9]$/.test(e.code)) return; + const p = projects[Number(e.code.slice(5)) - 1]; + if (!p) return; + e.preventDefault(); + e.stopPropagation(); + openProject(p); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [projects]); + + return ( + + ); +} + +const placeClass = + "relative inline-flex size-9 items-center justify-center rounded-xl text-muted-foreground outline-none transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-sidebar-accent [&_svg]:size-[18px]"; + +// Edge is the bar at the rail's left edge beside where you are. +function Edge({ on, top = "50%" }: { on: boolean; top?: string }) { + return ( + + ); +} + +function PlaceButton({ id, label, icon, active, onClick, badge }: { id: string; label: string; icon: ReactNode; active: boolean; onClick(e: React.MouseEvent): void; badge?: { count: number; loud?: boolean; title: string } }) { + return ( +
+ + + + +
+ ); +} + +// CountBadge sits on a mark's corner, cut out of the rail: solid amber only +// when something needs you, as everywhere in the app. +function CountBadge({ count, loud }: { count: number; loud?: boolean }) { + return ( + + + {count > 9 ? "9+" : count} + + + ); +} + +// label is a mark's name for screen readers: the project and its agents. +function markLabel(p: ProjectEntry) { + const parts = [p.waiting && `${p.waiting} need${p.waiting === 1 ? "s" : ""} you`, p.running && `${p.running} working`, p.finished && `${p.finished} done`].filter(Boolean); + return `${p.name}${parts.length ? `, ${parts.join(", ")}` : ""}${p.online ? "" : ", box away"}`; +} + +const PIPS = 4; +const LIVE = ["waiting", "running", "finished"]; +const pipColor: Record = { waiting: "bg-warning", running: "bg-info", finished: "bg-success/80" }; + +// Pips are a dot per agent at work in a project, the ones that need you +// first: how much is going on, and of what kind, at a glance. +function Pips({ p }: { p: ProjectEntry }) { + const live: AgentEntry[] = p.worktrees + .filter((w) => !w.away) + .flatMap((w) => w.agents) + .filter((a) => LIVE.includes(a.state)) + .sort((a, b) => LIVE.indexOf(a.state) - LIVE.indexOf(b.state)); + return ( + + {live.slice(0, PIPS).map((a) => ( + + ))} + {live.length > PIPS && +{live.length - PIPS}} + + ); +} + +// ProjectMark is a project's place in the rail, and its peek. +function ProjectMark({ p, n }: { p: ProjectEntry; n: number }) { + const [open, setOpen] = useState(false); + // A short window gets a shorter peek, so its ways onward stay in sight. + const short = useMediaQuery(SHORT); + return ( + { + // A click goes there; the peek is for pointing and the keyboard. + if (d.reason === "trigger-press" && !(d.event instanceof KeyboardEvent)) { + setOpen(false); + openProject(p); + return; + } + setOpen(next); + }} + > + + + + {p.initials} + {p.waiting > 0 && } + + + {p.name} + + + ⌃{n} : undefined} /> + + + + + ); +} + +// AwayBoxes is the boxes that are not online, when any is, as the sidebar +// lists them: a mark that says how many, and Settings › Boxes to see why. +function AwayBoxes() { + const boxes = useStore((s) => s.status?.boxes ?? NONE); + const data = useStore((s) => s.boxes); + const away = boxes.filter((b) => b.state !== "online"); + if (!away.length) return null; + const text = away.map((b) => `${b.name} is ${BOX_WORDS[boxState(b, data[b.name])].lower}`).join(", "); + return ( + + + + ); +} + +// NewMenu is the "+" under the projects: new work first, then the rarer +// adding of a project or a box. +function NewMenu({ empty }: { empty: boolean }) { + return ( + + + + } + > + + + + + {!empty && ( + useStore.getState().openNewWorktree()}> + + New task… + {keysFor("new-worktree")} + + )} + useStore.getState().openAddLocation()}> + + Add a project… + + + + + Add a box… + + + + ); +} diff --git a/app/src/components/rail-layout/title-bar.tsx b/app/src/components/rail-layout/title-bar.tsx new file mode 100644 index 00000000..accaa00f --- /dev/null +++ b/app/src/components/rail-layout/title-bar.tsx @@ -0,0 +1,284 @@ +import { ChevronDownIcon, FolderPlusIcon, GitBranchIcon, HomeIcon, PlusIcon, SearchIcon, SettingsIcon, SquareTerminalIcon } from "lucide-react"; +import { type ReactNode, useMemo } from "react"; + +import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; +import { NotificationBell } from "@/components/notifications/notification-center"; +import { useNavItems } from "@/components/sidebar/nav"; +import { Tip } from "@/components/tip"; +import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "@/components/ui/menu"; +import { useNarrow, useTiny } from "@/components/workspace/worktree-tone"; +import { agentPresets, startSession } from "@/lib/actions"; +import { hasTrafficLights } from "@/lib/api"; +import { agentLabel, sessionName, sessionState } from "@/lib/derive"; +import { ago } from "@/lib/format"; +import { keysFor } from "@/lib/shortcuts"; +import { sessionWord } from "@/lib/state-model"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { homeBox, openSession, useHereKey, useWorkspaces } from "@/lib/workspaces"; +import { openProject, type ProjectEntry, useFocused, useRailProjects, waitingAgents, type WtEntry } from "@/components/rail-layout/model"; +import { PEEK_COLLISION, SHORT } from "@/components/rail-layout/project-rail"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { BoxTag, ProjectFooter, ProjectHead, WorktreeItems } from "@/components/rail-layout/worktree-items"; + +// TitleBar is the rail layout's top edge, across the whole window: clear of +// the window's buttons, the breadcrumb that says where you are and switches +// at every step (Project / Worktree / Agent), then what needs you, search, +// notifications and new work. It drags the window. +export function TitleBar() { + const narrow = useNarrow(); + const tiny = useTiny(); + return ( +
+ + + + + New task {keysFor("new-worktree")}} side="bottom"> + + +
+ ); +} + +// NeedsYou goes to the next agent that needs you, round them all; quiet +// (nothing at all) when none does. +function NeedsYou({ compact }: { compact: boolean }) { + const projects = useRailProjects(); + const waiting = useMemo(() => waitingAgents(projects), [projects]); + const focused = useFocused(); + if (!waiting.length) return null; + const at = waiting.findIndex((a) => focused?.session && a.box === focused.box && a.session.name === focused.session.name); + const next = waiting[(at + 1) % waiting.length]; + const label = `${waiting.length} need${waiting.length === 1 ? "s" : ""} you`; + return ( + 1 ? ", then the next" : ""}`} side="bottom"> + + + ); +} + +const crumbClass = + "flex h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-[13px] text-foreground outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-sidebar-accent"; + +function Slash() { + return ( + + / + + ); +} + +function Chevron() { + return ; +} + +// ProjectGlyph is a project's mark, small, as the rail draws it. +function ProjectGlyph({ p, className }: { p: ProjectEntry; className?: string }) { + return {p.initials}; +} + +// Crumbs is the breadcrumb. Where no worktree is open it names the page +// you are on; in a worktree it is its project, then the worktree, then the +// agent (or other tab) in the focused pane, each a switcher. +function Crumbs({ narrow }: { narrow: boolean }) { + const view = useStore((s) => s.view); + const key = useHereKey(); + const current = useWorkspaces((s) => s.current); + const projects = useRailProjects(); + const nav = useNavItems(); + const here = useMemo(() => { + for (const p of projects) { + const w = p.worktrees.find((x) => x.key === key); + if (w) return { p, w }; + } + return undefined; + }, [projects, key]); + + if (view.kind !== "workspace" || !current || homeBox(current) || !here) { + const item = nav.find((n) => n.active); + const label = view.kind === "settings" ? "Settings" : view.kind === "project" ? `${view.location} settings` : view.kind === "team" ? "Team setup" : (item?.label ?? "Home"); + const icon = view.kind === "settings" || view.kind === "project" ? : (item?.icon ?? ); + return ( + + ); + } + return ( + + ); +} + +// ProjectsCrumb is the first step: the project you are in (or the page), +// and every project to go to instead. +function ProjectsCrumb({ projects, here, label, icon, narrow }: { projects: ProjectEntry[]; here?: ProjectEntry; label?: string; icon?: ReactNode; narrow: boolean }) { + return ( + + + {here ? : {icon}} + {(!here || !narrow) && {here ? here.name : label}} + + + + + Projects + {projects.map((p) => ( + openProject(p)} className={cn(p.id === here?.id && "bg-accent/50")}> + + {p.name} + + {p.waiting > 0 && ( + + + {p.waiting} + + )} + {p.running > 0 && ( + + + {p.running} + + )} + {!p.online && "away"} + + + ))} + + + useStore.getState().openAddLocation()}> + + Add a project… + + + + ); +} + +// WorktreeCrumb is the worktree you are in, by its state; its menu is the +// project's worktrees by what they need, as the rail's peek lists them. +function WorktreeCrumb({ p, w, narrow }: { p: ProjectEntry; w: WtEntry; narrow: boolean }) { + const short = useMediaQuery(SHORT); + return ( + + + {w.state === "waiting" || w.state === "running" || w.state === "finished" ? ( + + ) : ( + {w.wt.main ? : } + )} + {w.name} + {/* The box only where it matters: a project on several, or away. */} + {(p.multiBox || w.away) && } + + + + + + + + + ); +} + +// AgentCrumb is what the focused pane shows: an agent by its work, or a +// shell, a page or a file; its menu is the worktree's agents and sessions, +// and new ones. +function AgentCrumb({ w }: { w: WtEntry }) { + const focused = useFocused(); + const stats = useStore((s) => s.boxes[w.box]?.stats); + const presets = useMemo(() => agentPresets(w.box, w.loc), [w.box, w.loc]); + const sessions = [...w.agents.map((a) => a.session), ...w.others]; + const s = focused?.session; + const state = s ? sessionState(s, stats) : undefined; + const agent = s ? w.agents.find((a) => a.session.name === s.name)?.agent : undefined; + const name = s ? sessionName(s, { sessions }) : focused?.kind === "browser" ? "Browser" : focused?.kind === "preview" ? "Preview" : focused?.kind === "file" ? "File" : focused?.title || (w.agents.length ? `${w.agents.length} agent${w.agents.length === 1 ? "" : "s"}` : "No agent yet"); + return ( + + + {s ? agent ? : : null} + {name} + {state && (state === "waiting" || state === "running" || state === "finished") && {sessionWord(state)}} + + + + {w.agents.length > 0 && ( + + Agents in {w.name} + {w.agents.map((a) => ( + openSession(a.box, a.session)} className={cn(s?.name === a.session.name && "bg-accent/50")}> + + {sessionName(a.session, { sessions })} + + + {ago(a.session.state_since ?? a.session.created).replace(" ago", "")} + + + ))} + + )} + {w.others.length > 0 && ( + + Terminals and servers + {w.others.map((o) => ( + openSession(w.box, o)} className={cn(s?.name === o.name && "bg-accent/50")}> + + {sessionName(o, { sessions })} + {o.service && server} + + ))} + + )} + {(w.agents.length > 0 || w.others.length > 0) && } + + Start here + {presets.map((p) => ( + void startSession(p.command, { kind: "tab" }, p.name)}> + + + + {p.name} + + ))} + void startSession("")}> + + Terminal + {keysFor("new-terminal")} + + + + + ); +} diff --git a/app/src/components/rail-layout/worktree-items.tsx b/app/src/components/rail-layout/worktree-items.tsx new file mode 100644 index 00000000..5c597db8 --- /dev/null +++ b/app/src/components/rail-layout/worktree-items.tsx @@ -0,0 +1,152 @@ +import { GitBranchIcon, GitBranchPlusIcon, HomeIcon, ListIcon, ServerOffIcon, SettingsIcon } from "lucide-react"; + +import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; +import { MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator, MenuShortcut } from "@/components/ui/menu"; +import { sessionName } from "@/lib/derive"; +import { ago } from "@/lib/format"; +import { keysFor } from "@/lib/shortcuts"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { openSession } from "@/lib/workspaces"; +import { LANE_WORDS, LANES, type ProjectEntry, openWt, type WtEntry } from "@/components/rail-layout/model"; + +// How many quiet worktrees a list shows before "All worktrees". +const QUIET_MAX = 5; + +// BoxTag names a worktree's box, only where a project lives on more than +// one, or the box is away. +export function BoxTag({ box, away }: { box: string; away?: string }) { + return ( + + {away && } + {box} + + ); +} + +function Lead({ w }: { w: WtEntry }) { + if (w.away) return ; + if (w.state === "waiting" || w.state === "running" || w.state === "finished") return ; + return {w.wt.main ? : }; +} + +// WorktreeItem is one worktree as a peek or the breadcrumb lists it: its +// state, its name (and box, when that matters), and under it what it is +// doing; a second line in mono for what its agent waits on. +function WorktreeItem({ w, chip }: { w: WtEntry; chip: boolean }) { + const since = w.agents[0]?.session.state_since; + return ( + openWt(w)} + className={cn("items-start gap-2.5 py-1.5", w.selected && "bg-accent/50")} + > + + + + + + {w.name} + {(chip || w.away) && } + + {w.agents.slice(0, 3).map((a) => ( + + ))} + {w.agents.length > 3 && +{w.agents.length - 3}} + {since && w.lane !== "quiet" && {ago(since).replace(" ago", "")}} + + + {w.doing && {w.doing}} + {w.ask && {w.ask}} + + + ); +} + +// AgentSubItems are a worktree's agents, when it has more than one, each +// its own way in. +function AgentSubItems({ w }: { w: WtEntry }) { + if (w.away || w.agents.length < 2) return null; + const sessions = w.agents.map((a) => a.session); + return w.agents.map((a) => ( + openSession(a.box, a.session)} className="min-h-6 gap-2 py-0.5 ps-9 text-xs sm:min-h-6 sm:text-xs"> + + {sessionName(a.session, { sessions })} + + + )); +} + +// WorktreeItems lists a project's worktrees by what they need from you. +export function WorktreeItems({ p, compact }: { p: ProjectEntry; compact?: boolean }) { + const groups = LANES.map((lane) => ({ lane, list: p.worktrees.filter((w) => w.lane === lane) })).filter((g) => g.list.length); + return ( + <> + {groups.map(({ lane, list }) => { + const shown = lane === "quiet" ? list.slice(0, compact ? 2 : QUIET_MAX) : list; + const more = list.length - shown.length; + return ( + + {LANE_WORDS[lane]} + {shown.map((w) => ( +
+ + {!compact && } +
+ ))} + {more > 0 && ( + useStore.getState().setView({ kind: "worktrees" })} className="ps-8.5 text-muted-foreground text-xs"> + {more} more quiet worktree{more === 1 ? "" : "s"}… + + )} +
+ ); + })} + + ); +} + +// ProjectFooter is a peek's ways onward: new work in the project, its +// worktrees in full, its settings. +export function ProjectFooter({ p }: { p: ProjectEntry }) { + const def = p.project.members.find((m) => m.box.name === p.project.defaultBox) ?? p.project.members[0]; + return ( + <> + + useStore.getState().openNewWorktree({ box: def?.box.name, location: def?.loc.name })}> + + New task in {p.name}…{keysFor("new-worktree")} + + useStore.getState().setView({ kind: "worktrees" })}> + + All worktrees + + {def && ( + useStore.getState().setView({ kind: "project", box: def.box.name, location: def.loc.name })}> + + Project settings + + )} + + ); +} + +// ProjectHead heads a peek: the project, its boxes, and how its agents are. +// Its counts are of agents (the badge's and the pips'), where the lists +// under it are of worktrees. +export function ProjectHead({ p, hint }: { p: ProjectEntry; hint?: React.ReactNode }) { + const agents = p.waiting + p.running + p.finished; + return ( +
+ {p.name} + {p.project.members.map((m) => m.box.name).join(" · ")} + {agents ? `${agents} agent${agents === 1 ? "" : "s"} at work` : "Nothing running"} + {hint} +
+ ); +} + diff --git a/app/src/components/ui/menu.tsx b/app/src/components/ui/menu.tsx index 6d9aa12c..32139839 100644 --- a/app/src/components/ui/menu.tsx +++ b/app/src/components/ui/menu.tsx @@ -38,8 +38,10 @@ export function MenuPopup({ side = "bottom", anchor, portalProps, + collisionPadding, ...props }: MenuPrimitive.Popup.Props & { + collisionPadding?: MenuPrimitive.Positioner.Props["collisionPadding"]; align?: MenuPrimitive.Positioner.Props["align"]; sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; @@ -54,6 +56,7 @@ export function MenuPopup({ alignOffset={alignOffset} anchor={anchor} className="z-50" + collisionPadding={collisionPadding} data-slot="menu-positioner" side={side} sideOffset={sideOffset} diff --git a/app/src/components/workspace/tab-strip.tsx b/app/src/components/workspace/tab-strip.tsx index 71b2f0b6..06d89f04 100644 --- a/app/src/components/workspace/tab-strip.tsx +++ b/app/src/components/workspace/tab-strip.tsx @@ -34,8 +34,10 @@ import { isContextMenuKey, openContextMenu } from "@/lib/context-menu-key"; // TabStrip is the current worktree's tabs across the top, as in Orca. It is // also the window's drag handle. A tab that is not split has no pane header, // so its pane's actions sit at the strip's right. A tab drags (tab-drag.tsx) -// to another place in the strip or into a split beside a pane. -export function TabStrip() { +// to another place in the strip or into a split beside a pane. Without +// place, it leaves naming the worktree to a breadcrumb elsewhere (the rail +// layout's title bar). +export function TabStrip({ place = true }: { place?: boolean } = {}) { const key = useWorkspaces((s) => s.current); const ws = useWorkspaces((s) => (s.current ? s.spaces[s.current] : undefined)); // Tab groups (Labs): with more than one worktree in the strip, each one's @@ -176,7 +178,7 @@ export function TabStrip() { )} {/* With groups, the solid label already names the worktree in front; the breadcrumb only speaks up for a guest pane. */} - {hereRef && !(grouped && hereKey === key) && ( + {place && hereRef && !(grouped && hereKey === key) && ( diff --git a/app/src/lib/prefs.ts b/app/src/lib/prefs.ts index ff81ef9d..651b4178 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 gets around. "sidebar" is the sidebar as always; + // "rail" is a narrow rail of projects with a breadcrumb in the title bar + // (components/rail-layout). + layout: "sidebar" | "rail"; // 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", diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx index adb84e1b..9599e9e5 100644 --- a/app/src/views/settings/labs-section.tsx +++ b/app/src/views/settings/labs-section.tsx @@ -7,6 +7,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 = usePrefs((p) => p.layout); return ( @@ -25,6 +26,18 @@ export function LabsSection() { /> )} + {labs && ( + + setPrefs({ layout: v })} + /> + + )} ); diff --git a/docs/changelog.mdx b/docs/changelog.mdx index bae55bd9..65e0b6b2 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,13 @@ again. ## Unreleased +- **Labs: an icon rail and a breadcrumb instead of the sidebar.** Settings › + Labs › Layout › Icon rail puts your projects in a narrow rail. Each one + shows how many agents need you and a dot for every agent at work. Click a + project to go where it needs you, or press ⌃1–9. Point at it to see its + worktrees by what they need. The title bar reads Project / Worktree, and + each part switches. A "need you" button goes to each waiting agent in + turn. 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 c7c477bd2f48e0a924e082785c9d43f99c7c92dd Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:08:41 +0100 Subject: [PATCH 2/4] feat(app): the rail says each project's agents as dots, and the peek stays short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A click on a project goes where it needs you, ⌃1–9 jump to projects, quiet worktrees fold into one row, the "need you" button names the next agent, and the agent crumb only shows when a worktree has several. Co-Authored-By: Claude Opus 5.5 --- app/e2e/rail-layout.spec.ts | 5 +- .../components/rail-layout/project-rail.tsx | 73 +++++++-------- app/src/components/rail-layout/title-bar.tsx | 60 ++++++++----- .../components/rail-layout/worktree-items.tsx | 88 ++++++++++--------- 4 files changed, 120 insertions(+), 106 deletions(-) diff --git a/app/e2e/rail-layout.spec.ts b/app/e2e/rail-layout.spec.ts index 719e5a64..a9a6c476 100644 --- a/app/e2e/rail-layout.spec.ts +++ b/app/e2e/rail-layout.spec.ts @@ -27,6 +27,8 @@ test("the rail peeks at a project's worktrees by state, and a click goes there", const fix = peek.locator('[data-testid=rail-wt][data-worktree="devl/checkout-fix"]'); await expect(fix).toContainText("Fix checkout webhook retries"); await expect(fix).toContainText("pnpm prisma migrate dev"); + // Quiet worktrees fold into one row, so the peek stays short. + await expect(peek.getByTestId("rail-quiet")).toContainText("2 quiet worktrees"); // One click: the worktree opens with its agent in front, and the // breadcrumb and the rail say where you are. @@ -51,9 +53,6 @@ test("the rail peeks at a project's worktrees by state, and a click goes there", await expect(page.getByTestId("crumb-worktree")).toContainText("judge-v2"); await page.keyboard.press("Control+3"); await expect(page.getByTestId("crumb-project")).toContainText("shop"); - - // A box that is away is said at the rail's foot. - await expect(rail.getByTestId("rail-away")).toHaveAccessibleName(/old-vps is offline/); }); test("the breadcrumb switches worktree, agent and project", async ({ app }) => { diff --git a/app/src/components/rail-layout/project-rail.tsx b/app/src/components/rail-layout/project-rail.tsx index a3b38b3e..ca119cfe 100644 --- a/app/src/components/rail-layout/project-rail.tsx +++ b/app/src/components/rail-layout/project-rail.tsx @@ -1,4 +1,4 @@ -import { EllipsisIcon, FolderPlusIcon, PlusIcon, ServerIcon, ServerOffIcon, SettingsIcon } from "lucide-react"; +import { EllipsisIcon, FolderPlusIcon, PlusIcon, ServerIcon, SettingsIcon } from "lucide-react"; import { Fragment, type ReactNode, useEffect, useState } from "react"; import { MoreItems, useArrangedNav } from "@/components/sidebar/nav"; @@ -7,8 +7,7 @@ import { Kbd } from "@/components/ui/kbd"; import { useMediaQuery } from "@/hooks/use-media-query"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "@/components/ui/menu"; import { keysFor } from "@/lib/shortcuts"; -import { BOX_WORDS, boxState } from "@/lib/state-model"; -import { NONE, useStore } from "@/lib/store"; +import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import { openAddBox } from "@/views/onboarding/add-box-dialog"; import { type AgentEntry, openProject, type ProjectEntry, useRailProjects } from "@/components/rail-layout/model"; @@ -53,8 +52,10 @@ export function ProjectRail() { return (