diff --git a/app/e2e/rail-layout.spec.ts b/app/e2e/rail-layout.spec.ts new file mode 100644 index 00000000..f5c3c50a --- /dev/null +++ b/app/e2e/rail-layout.spec.ts @@ -0,0 +1,155 @@ +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"); + // 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. + 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 working, then per agent done; the badge counts the rest. + const pips = await shop.getByTestId("rail-pips").locator("[data-state]").evaluateAll((els) => els.map((e) => e.getAttribute("data-state"))); + expect(pips).toEqual(["running", "finished", "finished", "finished"]); + + // 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"); +}); + +test("Home's peek is every agent at work, and a waiting one is answered in place", async ({ app }) => { + mockOnly(); + await app.open(RAIL); + const { page } = app; + await page.getByTestId("nav-home").hover(); + const peek = page.getByTestId("home-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"]); + // Across projects, each named. + await expect(peek.locator('[data-testid=rail-wt][data-worktree="gpu/judge-v2"]')).toContainText("evals / judge-v2"); + const fix = peek.locator('[data-testid=rail-wt][data-worktree="devl/checkout-fix"]'); + await expect(fix).toContainText("shop / checkout-fix"); + // A permission is answered from the list, without going to the agent. + await fix.getByTestId("peek-allow").click(); + await expect(fix).toContainText("Allowed · resuming"); + await expect(page.getByTestId("crumb-project")).toContainText("Home"); +}); + +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..1c868b94 --- /dev/null +++ b/app/src/components/rail-layout/model.ts @@ -0,0 +1,214 @@ +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; + // Its project's name, and whether its box is worth naming (the project + // is on several). + project: string; + chip: boolean; + // 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; +} + +// Done stays news for a few hours; after that a worktree is quiet again. +const DONE_FOR = 3 * 60 * 60 * 1000; +const stale = (s?: Session) => !!s?.state_since && Date.now() - Date.parse(s.state_since) > DONE_FOR; +const laneOf = (s?: SessionState, session?: Session): Lane => (s === "finished" && stale(session) ? "quiet" : 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)! })) + // The most urgent first, and of those the latest to get there. + .sort((a, b) => urgency[a.state] - urgency[b.state] || (b.session.state_since ?? "").localeCompare(a.session.state_since ?? "") || 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, lead?.session), + project: p.name, + chip: p.members.length > 1, + 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 && !(st === "finished" && stale(a.session))).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. +// fresh is an agent still worth a dot: not done hours ago. +export const fresh = (a: AgentEntry) => !(a.state === "finished" && stale(a.session)); + +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..02499326 --- /dev/null +++ b/app/src/components/rail-layout/project-rail.tsx @@ -0,0 +1,326 @@ +import { EllipsisIcon, FolderPlusIcon, HouseIcon, PlusIcon, ServerIcon, 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 { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { openAddBox } from "@/views/onboarding/add-box-dialog"; +import { type AgentEntry, fresh, 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 ( + + ); +} + +// A place in the rail: its icon over its name, the whole of it one target. +const itemClass = "group/item flex w-15 flex-col items-center gap-0.5 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"; +const iconClass = + "relative inline-flex h-8 w-10 items-center justify-center rounded-lg text-muted-foreground transition-colors group-hover/item:bg-sidebar-accent group-hover/item:text-foreground group-data-popup-open/item:bg-sidebar-accent [&_svg]:size-[17px]"; + +function Caption({ children, on }: { children: ReactNode; on?: boolean }) { + return {children}; +} + +// 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 ( +
+ + + + +
+ ); +} + +// HomeMark is Home, and its peek is every agent at work, in every project, +// by what it needs: the whole fleet one point away. +function HomeMark({ label, icon, active, onClick, projects }: { label: string; icon: ReactNode; active: boolean; onClick(): void; projects: ProjectEntry[] }) { + const [open, setOpen] = useState(false); + const short = useMediaQuery(SHORT); + const all = projects.flatMap((p) => p.worktrees).filter((w) => !w.away && w.lane !== "quiet"); + const count = (k: "waiting" | "running" | "finished") => projects.reduce((n, p) => n + p[k], 0); + const parts = [count("waiting") && `${count("waiting")} need you`, count("running") && `${count("running")} working`, count("finished") && `${count("finished")} done`].filter(Boolean); + return ( +
+ + { + if (d.reason === "trigger-press" && !(d.event instanceof KeyboardEvent)) { + setOpen(false); + onClick(); + return; + } + setOpen(next); + }} + > + + {icon} + {label} + + +
+ Everywhere + {parts.length ? parts.join(" · ") : "Nothing running"} +
+ + + + + Open Home + +
+
+
+ ); +} + +// 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; +// Waiting agents are the badge's; the dots are the rest at work. +const LIVE = ["running", "finished"]; +const pipColor: Record = { running: "bg-info", finished: "bg-success" }; + +// Pips are a dot per agent working (blue) or done (green) in a project, so +// how much is going on shows at a glance; those that need you are the +// badge's count. +function Pips({ p }: { p: ProjectEntry }) { + const live: AgentEntry[] = p.worktrees + .filter((w) => !w.away) + .flatMap((w) => w.agents) + .filter((a) => LIVE.includes(a.state) && fresh(a)) + .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} /> + + + + + ); +} + +// 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..8d41aaf2 --- /dev/null +++ b/app/src/components/rail-layout/title-bar.tsx @@ -0,0 +1,305 @@ +import { ChevronDownIcon, FolderPlusIcon, GitBranchIcon, HomeIcon, PlusIcon, SearchIcon, SettingsIcon, SquareTerminalIcon } from "lucide-react"; +import { Fragment, 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, MenuSub, MenuSubPopup, MenuSubTrigger, 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`; + const where = projects.flatMap((p) => p.worktrees.filter((w) => w.agents.includes(next)).map((w) => `${p.name} / ${w.name}`))[0]; + const what = next.session.title?.trim() || agentLabel(next.agent); + const ask = next.session.ask?.input ?? next.session.ask?.message; + return ( + + Next{where ? ` · ${where}` : ""} + {what} + {ask && {ask}} +
+ } + > + + + ); +} + +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.map((p, i) => ( + + {(i === 0 || p.section !== projects[i - 1].section) && {p.section ?? "Projects"}} + 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"} + {i < 9 && ⌃{i + 1}} + + + + ))} + + + 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) && } + + + + New agent here + + + {presets.map((p) => ( + void startSession(p.command, { kind: "tab" }, p.name)}> + + + + {p.name} + + ))} + + + void startSession("")}> + + New 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..1555dd09 --- /dev/null +++ b/app/src/components/rail-layout/worktree-items.tsx @@ -0,0 +1,216 @@ +import { GitBranchIcon, GitBranchPlusIcon, HomeIcon, ListIcon, ServerOffIcon, SettingsIcon } from "lucide-react"; + +import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; +import { MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator, MenuShortcut, MenuSub, MenuSubPopup, MenuSubTrigger } from "@/components/ui/menu"; +import { useState } from "react"; + +import { toastError } from "@/components/error-note"; +import { Button } from "@/components/ui/button"; +import { boxApi } from "@/lib/api"; +import { ago } from "@/lib/format"; +import { permissionChoices } from "@/lib/screen"; +import { useAsk } from "@/lib/transcript-feed"; +import { keysFor } from "@/lib/shortcuts"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +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 ? : }; +} + +const QUESTION_TOOLS = /^(AskUserQuestion|request_user_input|ExitPlanMode)$/; + +// AskLine is what a waiting agent asks, and, for a permission, Deny and +// Allow once right there, as Home's Needs you card has: answered without +// going to it. Its choices are read off the agent's screen while the list +// is open. +function AskLine({ w }: { w: WtEntry }) { + const client = useStore((s) => s.client); + const a = w.agents[0]; + const s = a.session; + const tool = s.ask?.tool; + const question = !tool || QUESTION_TOOLS.test(tool); + const ask = useAsk(a.box, s.name, !question, s.state_since); + const choices = ask && !ask.form ? permissionChoices(ask.choices) : undefined; + const allow = choices?.find((c) => c.label === "Allow"); + const deny = choices?.find((c) => c.label === "Deny"); + const [sent, setSent] = useState<{ at?: string; label: string }>(); + const answered = sent && sent.at === s.state_since ? sent.label : undefined; + if (!w.ask && !allow) return null; + const answer = (e: React.MouseEvent, key: string, label: string) => { + e.stopPropagation(); + e.preventDefault(); + if (!client) return; + setSent({ at: s.state_since, label }); + // The person answered, so the box may type into a waiting agent. + boxApi.send(client, a.box, s.name, key, false, { when: "now", force: true }).catch((err) => { + setSent(undefined); + toastError(err, { title: "Couldn't answer", box: a.box }); + }); + }; + return ( + + {w.ask && {w.ask}} + {answered ? ( + {answered === "Deny" ? "Denied" : "Allowed"} · resuming + ) : allow && deny ? ( + + + + + ) : null} + + ); +} + +// 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, everywhere }: { w: WtEntry; everywhere?: boolean }) { + const since = w.agents[0]?.session.state_since; + const chip = w.chip; + return ( + openWt(w)} + className={cn("items-start gap-2.5 py-1.5", w.selected && "bg-accent/50")} + > + + + + + + + {everywhere && {w.project} / } + {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.lane === "waiting" && w.agents[0] && } + + + ); +} + +// WorktreeItems lists a project's worktrees by what they need from you: +// those that need you, work or are done in full, the quiet ones folded +// into one row, so a peek stays short. A worktree's several agents are +// the breadcrumb's to tell apart, not the peek's. +export function WorktreeItems({ worktrees, compact, everywhere }: { worktrees: WtEntry[]; compact?: boolean; everywhere?: boolean }) { + const groups = LANES.filter((l) => l !== "quiet").map((lane) => ({ lane, list: worktrees.filter((w) => w.lane === lane) })).filter((g) => g.list.length); + // Everywhere (Home's peek) is what is going on; the quiet ones are each + // project's. + const quiet = everywhere ? [] : worktrees.filter((w) => w.lane === "quiet"); + const shownQuiet = quiet.slice(0, compact ? 6 : QUIET_MAX * 2); + return ( + <> + {groups.map(({ lane, list }) => ( + + {LANE_WORDS[lane]} + {list.map((w) => ( + + ))} + + ))} + {quiet.length > 0 && ( + + {groups.length > 0 && } + + + + + + + {quiet.length} quiet worktree{quiet.length === 1 ? "" : "s"} + + + + {shownQuiet.map((w) => ( + + ))} + {quiet.length > shownQuiet.length && ( + useStore.getState().setView({ kind: "worktrees" })} className="text-muted-foreground text-xs"> + {quiet.length - shownQuiet.length} more… + + )} + + + + )} + + ); +} + +// 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, as the badge and the dots are; the lists under +// it are of worktrees. +export function ProjectHead({ p, hint }: { p: ProjectEntry; hint?: React.ReactNode }) { + 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} + {p.project.members.map((m) => m.box.name).join(" · ")} + {parts.length ? parts.join(" · ") : "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.