diff --git a/app/e2e/workspace-layout.spec.ts b/app/e2e/workspace-layout.spec.ts new file mode 100644 index 00000000..25b1421e --- /dev/null +++ b/app/e2e/workspace-layout.spec.ts @@ -0,0 +1,137 @@ +import { expect, mockOnly, test } from "./fixtures"; + +// The workspace layout (Labs › Layout › Workspaces): no sidebar; named +// workspaces of agent panes across the top, every other agent in the strip +// along the bottom. + +test.beforeEach(async ({ app }) => { + mockOnly(); + await app.open({ params: { layout: "workspace", view: "conversation" } }); +}); + +// Panes are in the DOM by id, not where they show: find one by its place. +const paneAt = (app: { panes: import("@playwright/test").Locator; page: import("@playwright/test").Page }, place: string) => app.panes.filter({ has: app.page.getByTestId("pane-place").filter({ hasText: place }) }); + +const chip = (app: { page: import("@playwright/test").Page }, session: string) => app.page.locator(`[data-deck-strip] [data-testid=strip-agent][data-session="${session}"]`); + +test("two agents side by side: focus moves between them, and one zooms to fill the workspace", async ({ app }) => { + const { page } = app; + await expect(page.getByText("Main is empty")).toBeVisible(); + // What needs you leads the strip. + await expect(page.locator("[data-deck-strip] [data-testid=strip-agent]").first()).toHaveAttribute("data-state", "waiting"); + + await chip(app, "devl/checkout-fix-claude").click(); + await expect(app.panes).toHaveCount(1); + await chip(app, "gpu/judge-v2-claude").click(); + await expect(app.panes).toHaveCount(2); + // Side by side, each naming its worktree and box. + const left = paneAt(app, "checkout-fix"); + const right = paneAt(app, "judge-v2"); + const [a, b] = await Promise.all([left.boundingBox(), right.boundingBox()]); + expect(Math.abs(a!.y - b!.y)).toBeLessThan(2); + expect(b!.x - a!.x).toBeGreaterThan(400); + // The one that needs you says so. + await expect(left.locator("[data-needs-you]")).toBeVisible(); + await expect(right.locator("[data-needs-you]")).toHaveCount(0); + // On screen, they leave the strip; the workspace's tab counts who needs you. + await expect(chip(app, "devl/checkout-fix-claude")).toHaveCount(0); + await expect(page.locator("[data-deck='Main'] [aria-label='1 needs you']")).toBeVisible(); + + // The new one has the keyboard; ⌘⌥← moves it to the left pane. + await expect(right).toHaveAttribute("data-pane-focused", ""); + await page.keyboard.press("Meta+Alt+ArrowLeft"); + await expect(left).toHaveAttribute("data-pane-focused", ""); + + // ⌘⇧↵ zooms the focused pane; the other waits in the strip, behind it. + await page.keyboard.press("Meta+Shift+Enter"); + await expect(app.panes).toHaveCount(1); + await expect(app.panes.first()).toHaveAttribute("data-zoomed", ""); + await expect(app.panes.first().getByTestId("pane-place")).toContainText("checkout-fix"); + await expect(chip(app, "gpu/judge-v2-claude")).toBeVisible(); + await page.keyboard.press("Meta+Shift+Enter"); + await expect(app.panes).toHaveCount(2); + + // Grid and back: the same two panes, rearranged, nothing reloaded. + await page.getByRole("radio", { name: "Grid" }).or(page.getByRole("button", { name: "Grid" })).first().click(); + await expect(app.panes).toHaveCount(2); +}); + +test("a worktree opens from the strip or ⌘E, beside the others or in the focused pane's place", async ({ app }) => { + const { page } = app; + await chip(app, "gpu/shop-claude").click(); + await expect(app.panes).toHaveCount(1); + await expect(app.panes.first().getByTestId("pane-place")).toContainText("shop"); + // ⌥-click: in the focused pane's place, which goes back to the strip. + await chip(app, "devl/qa-deck-codex").click({ modifiers: ["Alt"] }); + await expect(app.panes).toHaveCount(1); + await expect(app.panes.first().getByTestId("pane-place")).toContainText("qa-deck"); + await expect(chip(app, "gpu/shop-claude")).toBeVisible(); + + // ⌘E: every agent and worktree, to type into. + await page.keyboard.press("Meta+e"); + const dialog = page.getByRole("dialog", { name: "Open an agent or worktree" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("combobox").or(dialog.getByRole("textbox")).first().fill("search-perf"); + await dialog.getByRole("option", { name: /search-perf/ }).first().click(); + await expect(dialog).toBeHidden(); + await expect(app.panes).toHaveCount(2); + await expect(paneAt(app, "search-perf")).toBeVisible(); + await expect(paneAt(app, "qa-deck")).toBeVisible(); + + // The strip's label opens every box, project and worktree, offline boxes too. + await page.getByTestId("deck-tray").click(); + const tray = page.getByTestId("deck-tray-panel"); + await expect(tray).toContainText("old-vps"); + // Projects fold to a line of counts; one that needs you starts open. + await expect(tray.getByRole("button", { name: /Fix checkout webhook retries/ })).toBeVisible(); + await tray.getByRole("button", { name: /^evals/ }).click(); + await tray.getByRole("button", { name: /Tune the judge prompt/ }).click(); + await expect(tray).toBeHidden(); + await expect(app.panes).toHaveCount(3); + await expect(paneAt(app, "judge-v2")).toBeVisible(); +}); + +test("a new task lands in a new pane, another workspace starts empty, and Settings is a click away", async ({ app }) => { + const { page } = app; + await chip(app, "devl/qa-deck-codex").click(); + await expect(app.panes).toHaveCount(1); + + await page.locator("[data-deck-bar]").getByRole("button", { name: /New task/ }).click(); + // The composer, not a toast that an agent needs you (also a dialog). + const composer = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: /^Start/ }) }); + await composer.getByRole("textbox").first().fill("say hello"); + await composer.getByRole("button", { name: /^Start/ }).click(); + await expect(composer).toBeHidden(); + // Beside the agent already there, not instead of it. + await expect(app.panes).toHaveCount(2); + + // A second workspace starts empty; ⌘1 comes back to the first, as it was. + await page.getByRole("button", { name: "New workspace" }).click(); + await expect(page.getByText("Workspace 2 is empty")).toBeVisible(); + await expect(page.getByTestId("deck-how")).toContainText("finds any agent or worktree"); + await expect(app.panes).toHaveCount(0); + await page.keyboard.press("Meta+1"); + await expect(app.panes).toHaveCount(2); + + // Settings, and back to the workspace. + const labs = await app.openSettings("labs"); + await expect(labs.getByRole("radio", { name: "Workspaces" }).or(labs.getByRole("button", { name: "Workspaces" })).first()).toBeVisible(); + await page.locator("[data-deck-bar]").getByRole("button", { name: "Close" }).click(); + await expect(app.panes).toHaveCount(2); +}); + +test("⌘J brings in who has waited longest, and the strip's card says what the next one asks", async ({ app }) => { + const { page } = app; + await expect(chip(app, "devl/checkout-fix-claude")).toBeVisible(); + await page.keyboard.press("Meta+j"); + await expect(app.panes).toHaveCount(1); + await expect(paneAt(app, "checkout-fix")).toBeVisible(); + // The other one that needs you is pinned in the strip; its card answers it. + await chip(app, "gpu/shop-claude").hover(); + const card = page.getByTestId("ask-card"); + await expect(card).toBeVisible(); + await expect(card).toContainText("Plan the checkout release"); + await expect(card.getByRole("button", { name: "Answer here" })).toBeVisible(); + await card.getByRole("button", { name: "Answer here" }).click(); + await expect(app.panes).toHaveCount(2); +}); diff --git a/app/src/App.tsx b/app/src/App.tsx index 4428588e..8eaad2c1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -31,6 +31,9 @@ import { HomeTabs } from "@/components/workspace/home-tabs"; import { BoxPicker } from "@/components/box-picker"; import { FakeTrafficLights, ZenBar } from "@/components/workspace/zen"; import { Announcer } from "@/components/announcer"; +import { DeckBar } from "@/components/deck/deck-bar"; +import { DeckEmpty, DeckStrip, DeckSwitcher } from "@/components/deck/deck-strip"; +import { deckTab, useDeckOn, useDecks } from "@/lib/deck"; import { fakeTrafficLights } from "@/lib/api"; import { useBerthConnection } from "@/hooks/use-berth-connection"; import { useShortcuts } from "@/hooks/use-shortcuts"; @@ -97,8 +100,12 @@ export default function App() { useTeamWatch(); const view = useStore((s) => s.view); const workspace = view.kind === "workspace"; + // Labs: the workspace layout (lib/deck.ts) has no sidebar or tab strip: + // named workspaces of panes across the top, other agents in a strip below. + const deckOn = useDeckOn(); // Labs: zen (⌘.) puts away the sidebar, the tab strip and the status bar. - const zen = usePrefs((p) => p.labs && p.zen); + // The workspace layout has its own way, so zen stays out of it. + const zen = usePrefs((p) => p.labs && p.zen) && !deckOn; // 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]); @@ -106,6 +113,7 @@ export default function App() { useRescueRemovedFocus(); // Onboarding has no tabs yet, so it gets the plain strip, not the tab strip. const onboarding = useOnboardingActive(); + const deck = deckOn && !onboarding; // Home (no worktree, or a box's home terminals over it) has its own strip. const onHome = useWorkspaces((s) => !s.current || !!homeBox(s.current)); const connected = useStore((s) => !!s.client); @@ -158,6 +166,24 @@ export default function App() {
{fakeTrafficLights() && } + {deck ? ( + <> + + + +
+ + + + useStore.getState().setView({ kind: "workspace" })}> + + +
+ + + + + ) : (
{!zen && ( @@ -187,6 +213,7 @@ export default function App() {
+ )} {!zen && } @@ -210,6 +237,7 @@ export default function App() { + {deck && } @@ -230,7 +258,14 @@ function MainView() { const ws = useWorkspaces((s) => (s.current ? s.spaces[s.current] : undefined)); const home = useWorkspaces((s) => !!homeBox(s.current)); const onboarding = useOnboardingActive(); - const zen = usePrefs((p) => p.labs && p.zen); + const deckOn = useDeckOn(); + const zen = usePrefs((p) => p.labs && p.zen) && !deckOn; + // The workspace in front with no panes yet (the layout's own empty page). + const emptyDeck = useDecks((d) => { + if (!deckOn || d.home) return undefined; + const a = d.decks.find((x) => x.id === d.active); + return a && !deckTab(a) ? a.name : undefined; + }); if (!client) return ; // A new account starts here; Settings and the other views stay reachable. @@ -243,6 +278,7 @@ function MainView() { } if (view.kind === "workspace") { // A box's home terminals show over Home, which shows again without them. + if (!ws && emptyDeck) return ; if (!ws || home) return ws?.tabs.length ? null : ; return ws.tabs.length ? null : ; } diff --git a/app/src/components/deck/deck-bar.tsx b/app/src/components/deck/deck-bar.tsx new file mode 100644 index 00000000..3c1a3d86 --- /dev/null +++ b/app/src/components/deck/deck-bar.tsx @@ -0,0 +1,317 @@ +import { Columns3Icon, EllipsisIcon, GitBranchPlusIcon, HouseIcon, InboxIcon, LayoutGridIcon, Maximize2Icon, PencilIcon, PinIcon, PlusIcon, SearchIcon, SettingsIcon, WorkflowIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { NotificationBell } from "@/components/notifications/notification-center"; +import { MoreItems, useNavItems } from "@/components/sidebar/nav"; +import { Tip } from "@/components/tip"; +import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { ContextMenu, ContextMenuItem, ContextMenuPopup, ContextMenuSeparator, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { Menu, MenuPopup, MenuTrigger } from "@/components/ui/menu"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { runShortcut } from "@/hooks/use-shortcuts"; +import { hasTrafficLights } from "@/lib/api"; +import { closeDeck, type Deck, deckTab, frontDeck, keepAsDeck, newDeck, renameDeck, seedDecks, setArrange, showDeck, showDeckHome, toggleZoom, useDecks } from "@/lib/deck"; +import { sessionState } from "@/lib/derive"; +import { arranged, leaves } from "@/lib/layout"; +import { platformKeys } from "@/lib/platform"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { useWorkspaces } from "@/lib/workspaces"; +import { shortLabel } from "@/lib/worktree-names"; + +// DeckBar is the workspace layout's one bar across the top, in place of the +// sidebar and the tab strip: home, your named workspaces as tabs (⌘1–9), +// how the one in front is tiled, then starting work, search and the places +// (Review, Automations, Settings). It drags the window. +export function DeckBar() { + useEffect(seedDecks, []); + const decks = useDecks((s) => s.decks); + const view = useStore((s) => s.view); + const home = useDecks((s) => !!s.home); + // Read so the bar redraws as what shows changes. + const current = useWorkspaces((s) => s.current); + const spaces = useWorkspaces((s) => s.spaces); + const front = useMemo(() => frontDeck({ current, spaces }, useDecks.getState(), view.kind), [current, spaces, view.kind, decks, home]); + const t = deckTab(front, spaces); + const arrange = arranged(front?.arrange ?? "auto", t ? leaves(t.root).length : 0); + const nav = useNavItems(); + const place = useMemo(() => nav.find((n) => n.active && n.id !== "home"), [nav]); + const settings = view.kind === "settings" || view.kind === "project"; + // A worktree's tab opened some other way (⌘K): not a workspace yet. + const stray = view.kind === "workspace" && !front && current && spaces[current] ? spaces[current] : undefined; + const boxes = useStore((s) => s.boxes); + + return ( +
+ + + + +
+ {decks.map((d, i) => ( + + ))} + {stray && ( + + {shortLabel(stray.ref, boxes)} + + + + + )} + {(place || settings) && ( + + {settings ? : place?.icon} + {settings ? "Settings" : place?.label} + + + + + )} + + + +
+
+ {front && t && leaves(t.root).length > 1 && ( +
+ (v[0] === "zoom" ? !t.zoomed && toggleZoom() : v[0] && setArrange(v[0] as "columns" | "grid"))}> + + + + + + + + + + + + Zoom the focused pane {platformKeys("⌘⇧↵")} + + } + > + + + + + + +
+ )} + + + Search everything ⌘K + + } + > + + + + + + + +
+ ); +} + +// Places are Review and Automations as buttons (Review with how many wait +// for it), and every other place under ⋯, as the sidebar's More. +function Places() { + const nav = useNavItems(); + const review = nav.find((n) => n.id === "review"); + const automations = nav.find((n) => n.id === "automations"); + const rest = nav.filter((n) => n.id !== "home" && n.id !== "review" && n.id !== "automations"); + return ( + <> + {review && ( + + + + )} + {automations && ( + + + + )} + + + }> + + + + + + + + + ); +} + +// DeckTab is one workspace in the bar: its name, and a quiet dot with how +// many of its agents need you. Double-click renames it; its menu renames +// or closes it (nothing in it stops). +function DeckTab({ deck, index, on }: { deck: Deck; index: number; on: boolean }) { + const [editing, setEditing] = useState(false); + const waiting = useStore((s) => { + const t = deckTab(deck); + if (!t) return 0; + return leaves(t.root).filter((l) => { + if (l.content.kind !== "terminal") return false; + const c = l.content; + const x = s.boxes[c.box]?.sessions?.find((y) => y.name === c.session); + return !!x && sessionState(x, s.boxes[c.box]?.stats) === "waiting"; + }).length; + }); + // Read so the count of panes follows the tab. + const panes = useWorkspaces((s) => { + const t = deckTab(deck, s.spaces); + return t ? leaves(t.root).length : 0; + }); + if (editing) + return ( + { + setEditing(false); + if (v !== undefined) renameDeck(deck.id, v); + }} + /> + ); + return ( + + }> + + {panes ? `${panes} pane${panes === 1 ? "" : "s"}` : "Empty"} + {index < 9 && {platformKeys(`⌘${index + 1}`)}} +
+ } + > + + + + + setEditing(true)}> + + Rename… + + + closeDeck(deck.id)}> + + Close workspace + + + + ); +} + +function RenameInput({ initial, onDone }: { initial: string; onDone(v?: string): void }) { + const [v, setV] = useState(initial); + const ref = useRef(null); + const done = useRef(false); + useEffect(() => { + ref.current?.focus(); + ref.current?.select(); + }, []); + const finish = (next?: string) => { + if (done.current) return; + done.current = true; + onDone(next); + }; + return ( + setV(e.target.value)} + onBlur={() => finish(v)} + onKeyDown={(e) => { + if (e.key === "Enter") finish(v); + if (e.key === "Escape") finish(); + }} + className="h-7 w-36 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + ); +} diff --git a/app/src/components/deck/deck-pane.tsx b/app/src/components/deck/deck-pane.tsx new file mode 100644 index 00000000..502f506b --- /dev/null +++ b/app/src/components/deck/deck-pane.tsx @@ -0,0 +1,68 @@ +import { ArrowDownToLineIcon, Maximize2Icon, Minimize2Icon } from "lucide-react"; +import { useContext } from "react"; + +import { Tip } from "@/components/tip"; +import { park, toggleZoom, useDeckOn } from "@/lib/deck"; +import { PaneContext } from "@/lib/pane-context"; +import { platformKeys } from "@/lib/platform"; +import { useStore } from "@/lib/store"; +import { splitKey, useWorkspaces, useWorktreeRef } from "@/lib/workspaces"; +import { shortLabel } from "@/lib/worktree-names"; + +export { useDeckOn }; + +// DeckPaneBits is what a pane's header adds in the workspace layout: where +// the pane is (its worktree and box), a quiet "needs you", and, with the +// actions, zoom and put away. +export function DeckPaneBits({ owner, needsYou, pane, focused, actions }: { owner: string; needsYou?: boolean; pane?: string; focused?: boolean; actions?: boolean }) { + const info = useContext(PaneContext); + const zoomed = useWorkspaces((s) => !!(info && s.spaces[info.wsKey]?.tabs.find((t) => t.id === info.tab)?.zoomed)); + const ref = useWorktreeRef(owner); + const boxes = useStore((s) => s.boxes); + if (actions) { + if (!pane) return null; + return ( + <> + }> + + + }> + + + + ); + } + const place = ref ? shortLabel(ref, boxes) : undefined; + const box = splitKey(owner).box; + return ( + <> + {place && ( + + {place} + {box} + + )} + {needsYou && Needs you} + + ); +} + +function Keys({ label, keys }: { label: string; keys?: string }) { + return ( + + {label} + {keys && {platformKeys(keys)}} + + ); +} diff --git a/app/src/components/deck/deck-strip.tsx b/app/src/components/deck/deck-strip.tsx new file mode 100644 index 00000000..03db88e2 --- /dev/null +++ b/app/src/components/deck/deck-strip.tsx @@ -0,0 +1,703 @@ +import { ChevronRightIcon, ChevronUpIcon, FolderIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayersIcon, SearchIcon } from "lucide-react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { create } from "zustand"; + +import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; +import { Tip } from "@/components/tip"; +import { Button } from "@/components/ui/button"; +import { Command, CommandCollection, CommandDialog, CommandDialogPopup, CommandEmpty, CommandFooter, CommandGroup, CommandGroupLabel, CommandInput, CommandItem, CommandList, CommandPanel } from "@/components/ui/command"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; +import { Kbd } from "@/components/ui/kbd"; +import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; +import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu"; +import { DECK_NARROW } from "@/components/workspace/pane-layer"; +import { type SessionEntry, useAllSessions } from "@/hooks/use-agent-counts"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { runShortcut } from "@/hooks/use-shortcuts"; +import { toastError } from "@/components/error-note"; +import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "@/components/ui/preview-card"; +import { boxApi } from "@/lib/api"; +import { frontDeck, openInDeck, openWorktreeInDeck, type Placement, useDecks } from "@/lib/deck"; +import { permissionChoices } from "@/lib/screen"; +import { useAsk } from "@/lib/transcript-feed"; +import { agentOf, type SessionState, sessionName, sortedWorktrees, worktreeOf } from "@/lib/derive"; +import { ago } from "@/lib/format"; +import { leaves } from "@/lib/layout"; +import { platformKeys } from "@/lib/platform"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { focusPane, refOf, selectWorktree, useWorkspaces, wsKey } from "@/lib/workspaces"; +import { worktreeLabel } from "@/lib/worktree-names"; +import { armDrag } from "@/components/workspace/tab-drag"; + +// The strip along the bottom of the workspace layout holds every agent not +// on screen, the ones that need you first. Click one to bring it in beside +// the others (or, with the workspace full, in the focused pane's place), +// ⌥-click to put it in the focused pane's place, or drag it onto a pane. +// ⌘E opens the same list, and every worktree, to type into. + +const RANK: Partial> = { waiting: 0, running: 1, finished: 2, ready: 3, idle: 4 }; + +export interface StripAgent { + e: SessionEntry; + key: string; + title: string; + place: string; +} + +// useAgents is every live agent, the ones that need you first (the longest +// waiting first), then working, then done, newest first. +function useAgents(): StripAgent[] { + const all = useAllSessions(); + const boxes = useStore((s) => s.boxes); + return useMemo( + () => + all + .filter((e) => agentOf(e.session) && !e.session.exited && RANK[e.state] !== undefined) + .map((e) => { + const wt = worktreeOf(boxes[e.box]?.locations, e.session); + return { + e, + key: wsKey(e.box, e.session.dir ?? ""), + title: sessionName(e.session, { sessions: boxes[e.box]?.sessions }), + place: wt ? worktreeLabel(wt.worktree, wt.location) : (e.session.location ?? e.box), + }; + }) + .sort((a, b) => { + const r = RANK[a.e.state]! - RANK[b.e.state]!; + if (r) return r; + const x = a.e.session.state_since ?? a.e.session.created ?? ""; + const y = b.e.session.state_since ?? b.e.session.created ?? ""; + return a.e.state === "waiting" ? x.localeCompare(y) : y.localeCompare(x); + }), + [all, boxes], + ); +} + +// useShowing is what shows now: the tab in front and its panes that show +// (one, while it is zoomed or the window narrow). +function useShowing() { + const current = useWorkspaces((s) => s.current); + const tab = useWorkspaces((s) => (s.current ? s.spaces[s.current]?.tabs.find((t) => t.id === s.spaces[s.current!].active) : undefined)); + const workspace = useStore((s) => s.view.kind === "workspace"); + const narrow = useMediaQuery(DECK_NARROW); + return useMemo(() => { + if (!workspace || !tab || !current) return { key: current, tab: undefined, visible: new Set(), hidden: [] as { id: string; box: string; session: string }[] }; + const ls = leaves(tab.root); + const one = ls.length > 1 && (tab.zoomed || narrow); + const shownLeaves = one ? ls.filter((l) => l.id === tab.focus) : ls; + const id = (l: (typeof ls)[number]) => (l.content.kind === "terminal" ? `${l.content.box}/${l.content.session}` : ""); + return { + key: current, + tab, + visible: new Set(shownLeaves.map(id).filter(Boolean)), + // In the workspace but behind the zoomed pane. + hidden: (one ? ls.filter((l) => l.id !== tab.focus) : []).flatMap((l) => (l.content.kind === "terminal" ? [{ id: l.id, box: l.content.box, session: l.content.session }] : [])), + }; + }, [workspace, tab, current, narrow]); +} + +export function DeckStrip() { + const agents = useAgents(); + const showing = useShowing(); + const away = agents.filter((a) => !showing.visible.has(`${a.e.box}/${a.e.session.name}`)); + const behind = showing.hidden.map((h) => agents.find((a) => a.e.box === h.box && a.e.session.name === h.session)).filter((a): a is StripAgent => !!a); + const elsewhere = away.filter((a) => !behind.includes(a)); + // Agents with nothing going on fold into one chip, so the ones that need + // you, work or are done stay in sight. + const quiet = elsewhere.filter((a) => a.e.state === "ready" || a.e.state === "idle"); + const rest = elsewhere.filter((a) => !quiet.includes(a)); + // The first few that need you never scroll out of sight. + const pinned = rest.filter((a) => a.e.state === "waiting").slice(0, 3); + const lead = rest.filter((a) => !pinned.includes(a)); + const row = useRef(null); + const shape = `${lead.map((a) => a.e.session.name).join(" ")}|${behind.length}`; + // As many chips as fit whole; the rest are counted in "+N", which opens + // the tray. Nothing is ever cut off mid-word. + const [fits, setFits] = useState(Number.POSITIVE_INFINITY); + useLayoutEffect(() => { + const el = row.current; + if (!el) return; + const measure = () => { + const w = el.clientWidth; + let n = 0; + for (const k of el.querySelectorAll("[data-lead]")) { + if (k.offsetLeft + k.offsetWidth > w + 1) break; + n++; + } + setFits(n); + }; + measure(); + // The row and each chip: a chip grows once its font has loaded. + const ro = new ResizeObserver(measure); + ro.observe(el); + for (const k of el.querySelectorAll("[data-lead]")) ro.observe(k); + void document.fonts?.ready.then(measure); + return () => ro.disconnect(); + }, [shape]); + const more = Math.max(0, lead.length - fits); + const waiting = elsewhere.filter((a) => a.e.state === "waiting").length; + const deckName = useDecks((d) => frontDeck(undefined, d)?.name) ?? "this workspace"; + return ( +
+ + {behind.length > 0 && ( + <> + {behind.map((a) => ( + focusBehind(showing, a)} /> + ))} + + + )} + {pinned.map((a) => ( + + ))} +
+ {lead.map((a, i) => ( + = fits && "invisible")} aria-hidden={i >= fits || undefined}> + openInDeck(a.e.box, a.e.session.name, how)} /> + + ))} + {!elsewhere.length && !behind.length && !pinned.length && Every agent is on screen.} +
+ {more > 0 && ( + + )} + {quiet.length > 0 && } + + + +
+ ); +} + +// Quiet is the agents waiting for nothing (ready, idle), as one chip whose +// menu lists them. +function Quiet({ agents }: { agents: StripAgent[] }) { + return ( + + + } + > + + {agents.length} ready + + + + Ready for a prompt + {agents.map((a) => ( + openInDeck(a.e.box, a.e.session.name, e.altKey ? "replace" : "auto")}> + + {a.title} + + {a.place} · {a.e.box} + + + ))} + + + + ); +} + +// The tray's open state: its label opens it, and so does the strip's "+N". +const useTray = create<{ open: boolean }>(() => ({ open: false })); + +const QUESTION_TOOLS = /^(AskUserQuestion|request_user_input|ExitPlanMode)$/; + +// AskCard is a chip of an agent that needs you, whose hover card says what +// it asks and, for a permission, answers it there (Deny, Allow once), as +// Home's Needs you does: no need to bring it in to say yes. +function AskCard({ a }: { a: StripAgent }) { + const [open, setOpen] = useState(false); + const client = useStore((st) => st.client); + const s = a.e.session; + const tool = s.ask?.tool; + const question = !tool || QUESTION_TOOLS.test(tool); + const ask = useAsk(a.e.box, s.name, open && !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; + const detail = tool && !question ? [tool === "Bash" ? "" : tool, s.ask?.input].filter(Boolean).join(" ") : s.ask?.message || ask?.detail || (question ? "Asks you a question" : "Waiting for you"); + const answer = (key: string, label: string) => { + if (!client) return; + setSent({ at: s.state_since, label }); + boxApi.send(client, a.e.box, s.name, key, false, { when: "now", force: true }).catch((err) => { + setSent(undefined); + toastError(err, { title: "Couldn't answer", box: a.e.box }); + }); + }; + const bring = (how: Placement) => { + setOpen(false); + openInDeck(a.e.box, s.name, how); + }; + return ( + + }> + + + +
+
+ + {a.title} + + {a.place} · {a.e.box} + +
+

{detail}

+
+ {answered ? ( + {answered === "Deny" ? "Denied" : "Allowed"} · resuming + ) : ( + allow && + deny && ( + <> + + + + ) + )} + +
+

Click the chip to bring it in · ⌥-click for the focused pane's place · or drag it onto a pane

+
+
+
+ ); +} + +function focusBehind(showing: ReturnType, a: StripAgent) { + const l = showing.tab && leaves(showing.tab.root).find((x) => x.content.kind === "terminal" && x.content.box === a.e.box && x.content.session === a.e.session.name); + if (l && showing.key && showing.tab) focusPane(showing.key, showing.tab.id, l.id); +} + +// Tray is the strip's label, and opens the whole picture: every box, its +// projects, their worktrees and the agents in each, as the sidebar's tree +// had them, with the offline boxes said so. Click an agent, or a worktree, +// to bring it in. +function Tray({ agents, visible, count, waiting }: { agents: StripAgent[]; visible: Set; count: number; waiting: number }) { + const open = useTray((t) => t.open); + const setOpen = (o: boolean) => useTray.setState({ open: o }); + // Projects show folded to a line of counts; one with an agent that needs + // you starts unfolded. + const [unfolded, setUnfolded] = useState>(new Set()); + const [folded, setFolded] = useState>(new Set()); + const boxes = useStore((s) => s.boxes); + const status = useStore((s) => s.status); + const all = status?.boxes ?? []; + const waitingAll = agents.filter((a) => a.e.state === "waiting").length; + const workingAll = agents.filter((a) => a.e.state === "running").length; + const bring = (fn: () => void) => () => { + setOpen(false); + fn(); + }; + return ( + + + + } + > + + Elsewhere + {count} + {waiting > 0 && {waiting} need you} + + + + +
+ Every box and project + + {waitingAll > 0 && ( + + + {waitingAll} need{waitingAll === 1 ? "s" : ""} you + + )} + {workingAll} working + {agents.length} agents + +
+
+ {[...all] + .sort((x, y) => Number(x.state !== "online") - Number(y.state !== "online")) + .map((b) => { + const online = b.state === "online"; + const locs = (boxes[b.name]?.locations ?? []).filter((l) => l.worktrees?.length); + return ( +
+

+ + {b.name} + {!online && offline} +

+ {online && + locs.map((loc) => { + const pk = `${b.name}/${loc.name}`; + const paths = new Set((loc.worktrees ?? []).map((w) => w.path)); + const pa = agents.filter((a) => a.e.box === b.name && paths.has(a.e.session.dir ?? "")); + const n = (st: string) => pa.filter((a) => a.e.state === st).length; + const isOpen = unfolded.has(pk) || (n("waiting") > 0 && !folded.has(pk)); + const toggle = () => { + const u = new Set(unfolded); + const f = new Set(folded); + if (isOpen) { + u.delete(pk); + f.add(pk); + } else { + u.add(pk); + f.delete(pk); + } + setUnfolded(u); + setFolded(f); + }; + return ( +
+ + {isOpen && + sortedWorktrees(loc).map((wt) => { + const here = agents.filter((a) => a.e.box === b.name && a.e.session.dir === wt.path); + const key = wsKey(b.name, wt.path); + return ( +
+ + {here.map((a) => { + const on = visible.has(`${a.e.box}/${a.e.session.name}`); + return ( + + ); + })} +
+ ); + })} +
+ ); + })} +
+ ); + })} +
+

Click brings it in · ⌥-click swaps it with the focused pane

+
+
+ ); +} + +function Chip({ a, here, onOpen, bare }: { a: StripAgent; here?: string; onOpen(how: Placement): void; bare?: boolean }) { + const needs = a.e.state === "waiting"; + const button = ; + if (bare) return button; + return ( + + + {a.title} · {a.place} on {a.e.box} + + {here ? `In ${here}, behind the one showing` : "Click to bring in · ⌥-click for the focused pane's place · or drag onto a pane"} + + } + side="top" + > + {button} + + ); +} + +function ChipButton({ a, here, onOpen, needs, ...rest }: { a: StripAgent; here?: string; onOpen(how: Placement): void; needs: boolean } & React.HTMLAttributes) { + return ( + + ); +} + +// ---- The switcher (⌘E) ---- + +const useSwitcher = create<{ open: boolean }>(() => ({ open: false })); +export const openSwitcher = () => useSwitcher.setState({ open: true }); +export const toggleSwitcher = () => useSwitcher.setState((s) => ({ open: !s.open })); + +interface Item { + value: string; + label: string; + detail?: string; + icon: React.ReactNode; + trailing?: React.ReactNode; + run(how: Placement): void; +} + +const HEAD: Partial> = { waiting: "Needs you", running: "Working", finished: "Done", ready: "Ready", idle: "Ready" }; + +// DeckSwitcher is ⌘E: every agent (who needs you first) and every worktree. +// ↵ brings one in, ⌥↵ puts it in the focused pane's place. +export function DeckSwitcher() { + const open = useSwitcher((s) => s.open); + const agents = useAgents(); + const boxes = useStore((s) => s.boxes); + const status = useStore((s) => s.status); + const visible = useShowing().visible; + const [query, setQuery] = useState(""); + const alt = useRef(false); + const close = () => { + useSwitcher.setState({ open: false }); + setQuery(""); + }; + const groups = useMemo(() => { + const byHead = new Map(); + // On screen already: last in its group, so ↵ brings in one that isn't. + const ordered = [...agents.filter((a) => !visible.has(`${a.e.box}/${a.e.session.name}`)), ...agents.filter((a) => visible.has(`${a.e.box}/${a.e.session.name}`))]; + for (const a of ordered) { + const head = HEAD[a.e.state] ?? "Ready"; + const list = byHead.get(head) ?? []; + list.push({ + value: `agent:${a.e.box}/${a.e.session.name}`, + label: a.title, + detail: `${a.place} · ${a.e.box}`, + icon: , + trailing: ( + + {visible.has(`${a.e.box}/${a.e.session.name}`) && on screen} + + + ), + run: (how) => openInDeck(a.e.box, a.e.session.name, how), + }); + byHead.set(head, list); + } + const online = status?.boxes.filter((b) => b.state === "online").map((b) => b.name) ?? []; + const worktrees: Item[] = online.flatMap((box) => + (boxes[box]?.locations ?? []).flatMap((loc) => + (loc.worktrees ?? []).map((wt) => ({ + value: `wt:${box}:${wt.path}`, + label: worktreeLabel(wt, loc), + detail: `${loc.name} · ${box}`, + icon: wt.main ? : , + run: (how: Placement) => { + // Its agent comes in; with none, the worktree shows as it would + // from the sidebar, to start one. + if (!openWorktreeInDeck(wsKey(box, wt.path), how)) selectWorktree(refOf(box, loc, wt)); + }, + })), + ), + ); + return [ + ...["Needs you", "Working", "Done", "Ready"].filter((h) => byHead.has(h)).map((h) => ({ value: h, label: h, items: byHead.get(h)! })), + { value: "worktrees", label: "Worktrees", items: worktrees }, + { value: "start", label: "Start", items: [{ value: "new-task", label: "New task…", icon: , run: () => runShortcut("new-worktree", "menu") }] as Item[] }, + ].filter((g) => g.items.length); + }, [agents, boxes, status, visible]); + + return ( + !o && close()}> + + `${(i as Item).label} ${(i as Item).detail ?? ""}`}> + { + alt.current = e.key === "Enter" && e.altKey; + }} + /> + + Nothing matches. + + {(group: { value: string; label: string; items: Item[] }) => ( + + {group.label} + + {(item: Item) => ( + { + const how: Placement = e.altKey || alt.current ? "replace" : "auto"; + close(); + item.run(how); + }} + > + {item.icon} + {item.label} + {item.detail && {item.detail}} + {item.trailing} + + )} + + + )} + + + + + ↵ bring in ⌥↵ in the focused pane's place + + + esc + + + + + + ); +} + +// DeckEmpty is a workspace with no panes yet: how a workspace works, in a +// few lines, since the agents to bring in are in the strip just below. +const HOW: { keys: string; what: string }[] = [ + { keys: "Click", what: "an agent in the strip brings it in, beside the others" }, + { keys: "⌥-click", what: "puts it in the focused pane's place" }, + { keys: "Drag", what: "it onto a pane's edge to go beside, its middle to swap" }, + { keys: "⌘E", what: "finds any agent or worktree" }, + { keys: "⌘⌥ ←→", what: "moves between panes; with ⇧, swaps them" }, + { keys: "⌘⇧↵", what: "zooms the focused pane" }, + { keys: "⌘1–9", what: "goes to a workspace" }, +]; + +export function DeckEmpty({ name }: { name: string }) { + const waiting = useAgents().filter((a) => a.e.state === "waiting").length; + return ( +
+ + + + + + {name} is empty + + A workspace holds up to four agents side by side, from any project or box. Bring them in from the strip below{waiting > 0 ? `, where ${waiting} need${waiting === 1 ? "s" : ""} you` : ""}, or start something new. + + +
+ {HOW.map((h) => ( +
+
{platformKeys(h.keys)}
+
{h.what}
+
+ ))} +
+
+ + +
+
+
+ ); +} diff --git a/app/src/components/status-bar.tsx b/app/src/components/status-bar.tsx index 5d2a6c31..17c09d79 100644 --- a/app/src/components/status-bar.tsx +++ b/app/src/components/status-bar.tsx @@ -1,3 +1,4 @@ +import { useDeckOn } from "@/lib/deck"; import { CircleArrowUpIcon, GitBranchIcon, RefreshCwIcon } from "lucide-react"; import { useState } from "react"; @@ -19,7 +20,7 @@ import { PluginBoundary, pluginContexts } from "@/plugins/plugin-boundary"; import { TeamStatusItem } from "@/views/team/team-entry"; import { useRegistry } from "@/plugins/registry"; import { openRenameWorktree } from "@/components/sidebar/rename-worktree"; -import { homeBox, useWorkspaces } from "@/lib/workspaces"; +import { homeBox, useHereRef, useWorkspaces } from "@/lib/workspaces"; import { findWorktree } from "@/lib/worktree-names"; // StatusBar is the strip along the bottom: what agents are doing on the @@ -31,6 +32,8 @@ export function StatusBar() { // The agent restarting after an update (lib/updater.ts) is away a moment. const restarting = useAgentRestart((s) => s.restarting); const counts = useAgentCounts(); + // The workspace layout's strip and tabs count agents already. + const deck = useDeckOn(); const items = useRegistry((s) => s.statusBarItems); const [syncing, setSyncing] = useState(false); const go = useStore((s) => s.setView); @@ -66,7 +69,7 @@ export function StatusBar() { Agent unreachable - ) : ( + ) : deck ? null : ( <> {counts.waiting > 0 && ( go({ kind: "dashboard" })} tip="Agents waiting for your answer or permission"> @@ -153,7 +156,11 @@ export function StatusBar() { // WorktreeItem names the worktree in front, by its display name when it // has one, and renames it when clicked. function WorktreeItem() { - const at = useWorkspaces((s) => (s.current && !homeBox(s.current) ? s.spaces[s.current]?.ref : undefined)); + // The workspace layout mixes worktrees in one tab: the focused pane's. + const deck = useDeckOn(); + const here = useHereRef(); + const front = useWorkspaces((s) => (s.current && !homeBox(s.current) ? s.spaces[s.current]?.ref : undefined)); + const at = deck ? here : front; const inWorkspace = useStore((s) => s.view.kind === "workspace"); const boxes = useStore((s) => s.boxes); const found = at?.path ? findWorktree(at.box, at.path, boxes) : undefined; diff --git a/app/src/components/workspace/pane-layer.tsx b/app/src/components/workspace/pane-layer.tsx index c0ed9140..40b6f2d8 100644 --- a/app/src/components/workspace/pane-layer.tsx +++ b/app/src/components/workspace/pane-layer.tsx @@ -6,11 +6,17 @@ import { Pane } from "@/components/workspace/pane"; import { DragGhost, DropOverlay } from "@/components/workspace/tab-drag"; import { shownSides, sides } from "@/lib/compare"; import { type Divider, layout, mixed } from "@/lib/layout"; +import { useDeckOn } from "@/lib/deck"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; import { resizeSplit, useWorkspaces } from "@/lib/workspaces"; const pct = (n: number) => `${n * 100}%`; +// Below this the workspace layout shows one pane at a time, the strip +// holding the rest. +export const DECK_NARROW = "(max-width: 899px)"; + // PaneLayer draws every pane of every workspace opened since launch, in one // flat list positioned from each tab's split tree. Keeping them in one list, // keyed by pane id, is what lets a terminal survive its tab being split, @@ -28,6 +34,10 @@ export function PaneLayer({ showing }: { showing: boolean }) { const stateOf = (k: string) => states[compared.indexOf(k)] ?? "ok"; // In a tiny window a Compare tab's sides stack, each the tab's full width. const tiny = useTiny(); + // The workspace layout (lib/deck.ts): every pane has its header, and a + // zoomed tab, or any tab in a narrow window, shows its focused pane alone. + const deck = useDeckOn(); + const narrow = useMediaQuery(DECK_NARROW); const items = mounted.flatMap((key) => { const ws = spaces[key]; @@ -72,23 +82,35 @@ export function PaneLayer({ showing }: { showing: boolean }) { ]; } const { leaves, dividers } = layout(tab.root); - const split = leaves.length > 1; + const split = leaves.length > 1 || deck; const several = mixed(tab.root, key); + const zoomed = deck && leaves.length > 1 && (!!tab.zoomed || narrow); + const whole = { x: 0, y: 0, w: 1, h: 1 }; return [ - ...leaves.map(({ leaf, rect }) => ( -
0 && "border-l", rect.y > 0 && "border-t")} - style={{ left: pct(rect.x), top: pct(rect.y), width: pct(rect.w), height: pct(rect.h), display: visible ? "block" : "none" }} - > - -
- )), - ...(visible ? dividers.map((d) => resizeSplit(key, tab.id, d.id, r)} />) : []), + ...leaves.map(({ leaf, rect: at }) => { + const hidden = zoomed && tab.focus !== leaf.id; + const rect = zoomed ? whole : at; + const on = visible && !hidden; + return ( +
1 && tab.focus !== leaf.id ? "" : undefined} + className={cn("absolute overflow-hidden", rect.x > 0 && "border-l", rect.y > 0 && "border-t")} + style={{ left: pct(rect.x), top: pct(rect.y), width: pct(rect.w), height: pct(rect.h), display: on ? "block" : "none" }} + > + + {/* The workspace layout: a quiet ring says which pane has the keyboard. */} + {deck && !zoomed && leaves.length > 1 && tab.focus === leaf.id && } +
+ ); + }), + ...(visible && !zoomed ? dividers.map((d) => resizeSplit(key, tab.id, d.id, r)} />) : []), ]; }); }); diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 830b248b..5112f5cb 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -39,6 +39,7 @@ import { memory, memoryNote } from "@/lib/processes"; import { cn } from "@/lib/utils"; import { focusPane, paneBeside, paneToTab, setPaneContent, splitKey, useWorkspaces, useWorktreeRef } from "@/lib/workspaces"; import { platformKeys } from "@/lib/platform"; +import { DeckPaneBits, useDeckOn } from "@/components/deck/deck-pane"; export { agentLabel }; @@ -73,7 +74,10 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare // worktrees, else its tab's. const owner = paneWorktree(wsKey, pane); const info = useMemo(() => ({ wsKey, tab, pane: pane.id, worktree: owner }), [wsKey, tab, pane.id, owner]); - const tone = useTone(mixed ? owner : undefined); + // The workspace layout (components/deck): every pane names its place, and + // worktrees wear no colours; the one accent is for an agent that needs you. + const deck = useDeckOn(); + const tone = useTone(mixed && !deck ? owner : undefined); // Named with its worktree for screen readers: "Claude Code, search-perf". const { label: worktreeName } = useLabel(owner); // A guest pane whose worktree was archived or removed: say so, and let it @@ -89,6 +93,7 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare // Remember what runs here, so the pane can name it and start it again // once the session itself is gone. const agent = session ? agentOf(session) : undefined; + const needsYou = useStore((s) => deck && !!session && sessionState(session, s.boxes[(c as { box: string }).box]?.stats) === "waiting"); const view = usePaneView(pane); // Lifted: being dragged by its header, so it fades while it moves. const lifted = useTabDrag((s) => s.source?.kind === "pane" && s.source.pane === pane.id); @@ -111,18 +116,30 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare // the pane's worktree, and the focus line is in that one's colour.
armDrag(e, { kind: "pane", key: wsKey, tab, pane: pane.id }, (c.kind === "terminal" && (session?.title?.trim() || c.title)) || paneLabel(c, agent), )} - className={cn("group/header flex h-7 shrink-0 items-center gap-1.5 border-b px-2 text-xs", focused ? "bg-accent/50 text-foreground shadow-[inset_0_2px_0_var(--ring)]" : "text-muted-foreground")} + data-needs-you={needsYou ? "" : undefined} + className={cn( + "group/header flex h-7 shrink-0 items-center gap-1.5 border-b px-2 text-xs", + focused ? "bg-accent/50 text-foreground shadow-[inset_0_2px_0_var(--ring)]" : "text-muted-foreground", + deck && "@container h-8", + needsYou && "bg-warning/8 shadow-[inset_0_2px_0_color-mix(in_oklab,var(--warning)_70%,transparent)]", + )} style={tone ? { boxShadow: focused ? `inset 0 2px 0 ${tone}` : `inset 0 1px 0 color-mix(in oklab, ${tone} 50%, transparent)` } : undefined} > {tone && } - -
- + + {deck && } + {/* In the workspace layout the actions show on hover, so a + narrow pane's header is all name. */} +
+ {deck && } +
)} -
+
{gone && } + {/* The workspace layout: what scrolls up fades under the header rather than being cut. */} + {deck && view === "conversation" && } {/* Under a chat the terminal is out of reach: Tab never lands in its hidden input, where it would type a tab and keep the focus. */} {c.kind === "terminal" && ( @@ -299,7 +316,7 @@ export function PaneIcon({ content, agent, className }: { content: Leaf["content return ; } -function PaneTitle({ pane }: { pane: Leaf }) { +function PaneTitle({ pane, bare }: { pane: Leaf; bare?: boolean }) { const c = pane.content; const session = useStore((s) => (c.kind === "terminal" ? s.boxes[c.box]?.sessions?.find((x) => x.name === c.session) : undefined)); const stats = useStore((s) => (c.kind === "terminal" ? s.boxes[c.box]?.stats : undefined)); @@ -308,7 +325,8 @@ function PaneTitle({ pane }: { pane: Leaf }) { const named = useStore((s) => (c.kind === "terminal" && session ? sessionName(session, { sessions: s.boxes[c.box]?.sessions }) : undefined)); const agent = session ? agentOf(session) : undefined; const label = named ?? paneLabel(c, agent); - const secondary = session ? sessionAgent(session) : ""; + // Bare (the workspace layout): the agent's icon says which it is. + const secondary = session && !bare ? sessionAgent(session) : ""; // Honest about what it can't know: nothing while the box is away, ended // once the box no longer lists the session. const away = useStore((s) => c.kind === "terminal" && !!s.status && s.status.boxes.find((b) => b.name === c.box)?.state !== "online"); @@ -323,7 +341,8 @@ function PaneTitle({ pane }: { pane: Leaf }) { {label} {secondary && {secondary}} - {state && } + {/* Bare: "Needs you" beside it says waiting, once. */} + {state && !(bare && state === "waiting") && } {near && session?.usage && ( diff --git a/app/src/components/workspace/tab-drag.tsx b/app/src/components/workspace/tab-drag.tsx index 01558828..c7b82bd8 100644 --- a/app/src/components/workspace/tab-drag.tsx +++ b/app/src/components/workspace/tab-drag.tsx @@ -6,6 +6,7 @@ import { startSession } from "@/lib/actions"; import { bounds, layout, leaf, leaves, movePane, place, type Rect, type Side, sideAt, swap } from "@/lib/layout"; import { usePrefs } from "@/lib/prefs"; import { cn } from "@/lib/utils"; +import { dropSession } from "@/lib/deck"; import { addGroup, bringSession, leadAgent, moveInto, moveTab, paneBeside, paneToTab, splitKey, tabIntoPane, useWorkspaces } from "@/lib/workspaces"; // Dragging a tab from the strip, or a pane by its header, as in VS Code. Over @@ -27,7 +28,13 @@ import { addGroup, bringSession, leadAgent, moveInto, moveTab, paneBeside, paneT // only becomes a drag after a few pixels, so clicks still click. Escape, or // the window losing focus, cancels. -export type DragSource = { kind: "tab"; key: string; tab: string } | { kind: "pane"; key: string; tab: string; pane: string } | { kind: "worktree"; key: string }; +// session: an agent from the workspace layout's strip (components/deck): on +// a pane's edge it comes in beside it, on its middle in its place. +export type DragSource = + | { kind: "tab"; key: string; tab: string } + | { kind: "pane"; key: string; tab: string; pane: string } + | { kind: "worktree"; key: string } + | { kind: "session"; key: string; box: string; session: string }; type Target = | { kind: "pane"; tab: string; pane: string; side: Side | "center"; rect: Rect; label: string } @@ -125,6 +132,7 @@ const inside = (r: DOMRect, x: number) => x >= r.left && x <= r.right; // the tab showing, or nothing a drop would change. function hit(source: DragSource, x: number, y: number): Target | undefined { const { current, spaces } = useWorkspaces.getState(); + if (source.kind === "session") return hitSession(source, x, y); const own = source.kind === "worktree" ? undefined : spaces[source.key]; if (source.kind !== "worktree" && !own) return undefined; if (source.kind === "worktree" && !usePrefs.getState().labs) return undefined; @@ -206,8 +214,37 @@ function hit(source: DragSource, x: number, y: number): Target | undefined { return rect && { kind: "pane", tab: tab.id, pane: over.leaf.id, side, rect, label }; } +// hitSession is where an agent from the strip would land: beside a pane of +// the workspace in front, in its place (its middle), or alone in an empty +// one. +function hitSession(_source: Extract, x: number, y: number): Target | undefined { + const { current, spaces } = useWorkspaces.getState(); + const area = document.querySelector("[data-pane-area]")?.getBoundingClientRect(); + if (!area || x < area.left || x > area.right || y < area.top || y > area.bottom) return undefined; + const shown = current ? spaces[current] : undefined; + const tab = shown?.tabs.find((t) => t.id === shown.active); + const whole = { x: 0, y: 0, w: 1, h: 1 }; + if (!tab) return { kind: "pane", tab: "", pane: "", side: "center", rect: whole, label: "Open here" }; + if (tab.compare) return undefined; + const fx = (x - area.left) / area.width; + const fy = (y - area.top) / area.height; + const zoomed = tab.zoomed ? layout(tab.root).leaves.find((l) => l.leaf.id === tab.focus) : undefined; + const over = zoomed ? { leaf: zoomed.leaf, rect: whole } : layout(tab.root).leaves.find(({ rect: r }) => fx >= r.x && fx <= r.x + r.w && fy >= r.y && fy <= r.y + r.h); + if (!over) return undefined; + const r = over.rect; + const side = sideAt((fx - r.x) / r.w, (fy - r.y) / r.h, true); + if (side === "center") return { kind: "pane", tab: tab.id, pane: over.leaf.id, side, rect: r, label: "In its place" }; + const ghost = leaf({ kind: "starting", label: "" }); + const rect = zoomed ? { ...whole, ...(side === "left" ? { w: 0.5 } : side === "right" ? { x: 0.5, w: 0.5 } : side === "top" ? { h: 0.5 } : { y: 0.5, h: 0.5 }) } : bounds(place(tab.root, over.leaf.id, side, ghost), [ghost.id]); + return rect && { kind: "pane", tab: tab.id, pane: over.leaf.id, side, rect, label: LABELS[side] }; +} + function drop(source: DragSource, target: Target) { const { current } = useWorkspaces.getState(); + if (source.kind === "session") { + if (target.kind === "pane") dropSession(source, target); + return; + } if (source.kind === "worktree") { if (target.kind === "group") addGroup(source.key, target.index); else if (target.kind === "pane" && target.side !== "center") splitWorktreeIn(source.key, target.tab, target.pane, target.side); diff --git a/app/src/hooks/use-shortcuts.ts b/app/src/hooks/use-shortcuts.ts index 130295ac..d9fa15bc 100644 --- a/app/src/hooks/use-shortcuts.ts +++ b/app/src/hooks/use-shortcuts.ts @@ -24,6 +24,8 @@ import { findLeaf, leaves, paneWorktree } from "@/lib/layout"; import { paneKey, toggleDrawer } from "@/lib/devtools"; import { isOnboardingActive } from "@/views/onboarding/onboarding-state"; import { firstFocusable, rescueFocus } from "@/lib/focus-home"; +import { deckOn, jumpToWaiting, showDeck, swapFocused, toggleZoom, useDecks } from "@/lib/deck"; +import { toggleSwitcher } from "@/components/deck/deck-strip"; // The app's shortcuts (lib/shortcuts.json) come two ways: as keys, caught on // the window before a terminal sees them, and, in the Mac app, from the menu @@ -128,6 +130,8 @@ function run(id: string, from: "key" | "menu", arg?: number | Dir): boolean { usePrefs.setState((p) => ({ sidebarCollapsed: !p.sidebarCollapsed })); return true; case "dashboard": + // The workspace layout: the agent that has waited longest comes in. + if (deckOn() && jumpToWaiting()) return true; s.setView({ kind: "dashboard" }); return true; case "notifications": @@ -167,6 +171,12 @@ function run(id: string, from: "key" | "menu", arg?: number | Dir): boolean { return true; } case "tab": { + // The workspace layout: its workspaces, in the bar's order. + if (deckOn()) { + const d = useDecks.getState().decks[Number(arg) - 1]; + if (d) showDeck(d.id); + return !!d; + } // Counted across the whole strip, every unfolded group's tabs. const t = stripTab(Number(arg)); if (!t) return false; @@ -190,6 +200,16 @@ function run(id: string, from: "key" | "menu", arg?: number | Dir): boolean { case "shortcuts": toggleShortcuts(); return true; + case "deck-switcher": + if (!deckOn()) return false; + toggleSwitcher(); + return true; + case "deck-zoom": + return deckOn() && toggleZoom(); + case "deck-swap": + // Outside the workspace layout ⌘⌥⇧ and an arrow moves, as ⌘⌥ does. + if (!deckOn()) return run("focus", from, arg); + return swapFocused(arg as Dir); } return false; } @@ -232,13 +252,15 @@ export function runShortcut(id: string, from: "key" | "menu", arg?: number | Dir function fromKey(e: KeyboardEvent): [string, (number | Dir)?] | undefined { // With ⌥ the key is the character it types (⌥D is ∂), so go by its code. if (e.altKey) { - if (e.key in arrows) return ["focus", arrows[e.key]]; + if (e.key in arrows) return [e.shiftKey ? "deck-swap" : "focus", arrows[e.key]]; if (e.shiftKey) return undefined; return e.code === "KeyD" ? ["split-worktree"] : e.code === "KeyC" ? ["compare"] : e.code === "KeyS" ? ["compare-swap"] : e.code === "KeyI" ? ["devtools"] : undefined; } const key = e.key.toLowerCase(); const shift = e.shiftKey; if (key === ".") return ["zen"]; + if (key === "enter" && shift) return ["deck-zoom"]; + if (key === "e" && !shift) return ["deck-switcher"]; if (key === "\\") return ["sidebar"]; if (key === "/" && !shift) return ["shortcuts"]; if (key === "k") return ["palette"]; diff --git a/app/src/index.css b/app/src/index.css index 8d123256..665c9282 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -393,3 +393,9 @@ :root[data-berth-still] :is(.animate-spin, .animate-ping, .animate-pulse, .cv-shimmer)::after { animation-play-state: paused !important; } + +/* The workspace layout (components/deck): a pane without the keyboard keeps + its chat and reply box but not the row of controls under it. */ +[data-deck-quiet] [data-chat-controls] { + display: none; +} diff --git a/app/src/lib/deck.ts b/app/src/lib/deck.ts new file mode 100644 index 00000000..8b49f21d --- /dev/null +++ b/app/src/lib/deck.ts @@ -0,0 +1,342 @@ +import { create } from "zustand"; + +import type { Session } from "@/lib/api"; +import { agentOf, sessionState } from "@/lib/derive"; +import { type Arrangement, leaf, leaves, neighbor, type Side, tile } from "@/lib/layout"; +import { usePrefs } from "@/lib/prefs"; +import { load, save } from "@/lib/storage"; +import { useStore } from "@/lib/store"; +import { bringSession, ensure, findSession, focusPane, leadAgent, paneBeside, paneToTab, parkPane, refFor, setSessionOpener, setTabRoot, setTabZoom, splitKey, useWorkspaces, type WsTab, wsKey } from "@/lib/workspaces"; + +// The workspace layout (Labs, Settings › Labs › Layout): instead of a +// sidebar, the window is named workspaces of live panes you arrange, as in +// tmux or Zellij. Each workspace is one tab of the pane machinery +// (lib/workspaces.ts), whose panes may belong to any worktree on any box +// (guests): this file only remembers which tab each workspace is, its name +// and how its panes are tiled. Agents not on screen wait in the strip along +// the bottom; opening one (from the strip, a notification, ⌘K, a new task) +// brings it into the workspace in front rather than switching worktree. + +export interface Deck { + id: string; + name: string; + // The tab it is: key is the workspace (worktree) that holds it. Unset + // while it is empty. + key?: string; + tab?: string; + arrange: Arrangement; +} + +interface DeckState { + decks: Deck[]; + // The workspace in front, or last in front. + active?: string; + // Home is showing instead (the harbour and its composer). + home?: boolean; +} + +// More than this many panes and a new one takes the focused one's place. +export const MAX_PANES = 4; + +const saved = load("berth.decks", { decks: [] }); +export const useDecks = create()(() => ({ decks: saved.decks ?? [], active: saved.active })); +useDecks.subscribe((s) => save("berth.decks", { decks: s.decks, active: s.active })); + +export const deckOn = () => { + const p = usePrefs.getState(); + return p.labs && p.layout === "workspace"; +}; +export const useDeckOn = () => usePrefs((p) => p.labs && p.layout === "workspace"); + +const newDeckId = () => Math.random().toString(36).slice(2, 10); + +// deckTab is a workspace's tab, while it has one. +export function deckTab(d: Deck | undefined, spaces = useWorkspaces.getState().spaces): WsTab | undefined { + return d?.key && d.tab ? spaces[d.key]?.tabs.find((t) => t.id === d.tab) : undefined; +} + +export const activeDeck = (): Deck | undefined => { + const s = useDecks.getState(); + return s.decks.find((d) => d.id === s.active) ?? s.decks[0]; +}; + +function patch(id: string, p: Partial) { + useDecks.setState((s) => ({ decks: s.decks.map((d) => (d.id === id ? { ...d, ...p } : d)) })); +} + +// frontDeck is the workspace showing now, if one is: its tab in front, or +// it is empty and nothing else shows. +export function frontDeck(s: { current?: string; spaces: ReturnType["spaces"] } = useWorkspaces.getState(), d: DeckState = useDecks.getState(), view = useStore.getState().view.kind): Deck | undefined { + if (view !== "workspace" || d.home) return undefined; + const ws = s.current ? s.spaces[s.current] : undefined; + if (ws) return d.decks.find((x) => x.key === s.current && x.tab === ws.active); + const a = d.decks.find((x) => x.id === d.active); + return a && !deckTab(a, s.spaces) ? a : undefined; +} + +// showDeck puts a workspace in front. +export function showDeck(id: string) { + const d = useDecks.getState().decks.find((x) => x.id === id); + if (!d) return; + useDecks.setState({ active: id, home: false }); + const t = deckTab(d); + if (d.key && t) { + const key = d.key; + useWorkspaces.setState((s) => ({ + current: key, + shown: [key], + spaces: { ...s.spaces, [key]: { ...s.spaces[key], active: t.id, visitedAt: Date.now() } }, + mounted: s.mounted.includes(key) ? s.mounted : [...s.mounted, key], + })); + } else useWorkspaces.setState({ current: undefined, shown: [] }); + useStore.getState().setView({ kind: "workspace" }); +} + +// showHome shows the harbour (the composer and your agents) in place of a +// workspace. +export function showDeckHome() { + useDecks.setState({ home: true }); + useWorkspaces.setState({ current: undefined, shown: [] }); + useStore.getState().setView({ kind: "workspace" }); +} + +// newDeck makes an empty workspace and puts it in front. +export function newDeck(name?: string): string { + const taken = new Set(useDecks.getState().decks.map((d) => d.name)); + let n = useDecks.getState().decks.length + 1; + while (!name && taken.has(`Workspace ${n}`)) n++; + const d: Deck = { id: newDeckId(), name: name ?? `Workspace ${n}`, arrange: "auto" }; + useDecks.setState((s) => ({ decks: [...s.decks, d] })); + showDeck(d.id); + return d.id; +} + +export function renameDeck(id: string, name: string) { + if (name.trim()) patch(id, { name: name.trim() }); +} + +// closeDeck forgets a workspace. Nothing in it stops: its panes stay a tab +// of their worktree, and its agents wait in the strip. +export function closeDeck(id: string) { + const s = useDecks.getState(); + const i = s.decks.findIndex((d) => d.id === id); + if (i < 0) return; + const decks = s.decks.filter((d) => d.id !== id); + const wasFront = frontDeck()?.id === id; + useDecks.setState({ decks, active: s.active === id ? decks[Math.min(i, decks.length - 1)]?.id : s.active }); + if (wasFront) { + const next = useDecks.getState().active; + if (next) showDeck(next); + else showDeckHome(); + } +} + +// keepAsDeck makes the tab showing (opened some other way: a worktree from +// ⌘K) a workspace of its own, named after its worktree. +export function keepAsDeck(name: string) { + const { current, spaces } = useWorkspaces.getState(); + const tab = current ? spaces[current]?.active : undefined; + if (!current || !tab) return; + const d: Deck = { id: newDeckId(), name, key: current, tab, arrange: "auto" }; + useDecks.setState((s) => ({ decks: [...s.decks, d], active: d.id, home: false })); +} + +// setArrange tiles the workspace in front's panes again, as how says. +export function setArrange(how: Arrangement) { + const d = frontDeck() ?? activeDeck(); + if (!d) return; + patch(d.id, { arrange: how }); + retile(d.id); + const t = deckTab(d); + if (d.key && t?.zoomed) setTabZoom(d.key, t.id, false); +} + +function retile(id: string) { + const d = useDecks.getState().decks.find((x) => x.id === id); + const t = deckTab(d); + if (!d?.key || !t) return; + const root = tile(leaves(t.root), d.arrange); + if (root) setTabRoot(d.key, t.id, root); +} + +// toggleZoom fills the workspace with its focused pane, or lets the others +// back. +export function toggleZoom(): boolean { + const d = frontDeck(); + const t = deckTab(d); + if (!d?.key || !t) return false; + setTabZoom(d.key, t.id, !t.zoomed); + return true; +} + +// swapFocused trades the focused pane with its neighbour in dir. +export function swapFocused(dir: "left" | "right" | "up" | "down"): boolean { + const d = frontDeck(); + const t = deckTab(d); + if (!d?.key || !t) return false; + const other = neighbor(t.root, t.focus, dir); + if (!other) return false; + paneBeside(d.key, t.id, t.focus, other, "center"); + return true; +} + +// park takes a pane out of the workspace in front: it keeps running, in the +// strip. The last pane leaves the workspace empty. +export function park(paneId: string) { + const d = frontDeck(); + const t = deckTab(d); + if (!d?.key || !t) return; + if (!parkPane(d.key, t.id, paneId)) { + patch(d.id, { key: undefined, tab: undefined }); + showDeck(d.id); + } +} + +// How opening an agent places it: beside the others (a new pane, or the +// focused one's place once the workspace is full), or in the focused one's +// place. +export type Placement = "auto" | "beside" | "replace"; + +// worktreeKey is the workspace key of the worktree a session runs in, if +// it runs in one. +function worktreeKey(box: string, session: string): string | undefined { + const s = useStore.getState().boxes[box]?.sessions?.find((x) => x.name === session); + if (!s?.dir) return undefined; + const key = wsKey(box, s.dir); + return refFor(key) ? key : undefined; +} + +// openInDeck brings a session into the workspace in front (or the last +// one, from another view). False when it is not a worktree's (a box's home +// terminal), so the usual way shows it. +export function openInDeck(box: string, session: string, how: Placement = "auto"): boolean { + const key = worktreeKey(box, session); + if (!key) return false; + let d = frontDeck() ?? activeDeck(); + if (!d) d = useDecks.getState().decks.find((x) => x.id === newDeck())!; + const t = deckTab(d); + if (!t || !d.key) { + claimFor(d.id, key, box, session); + showDeck(d.id); + return true; + } + showDeck(d.id); + const on = leaves(t.root).find((l) => l.content.kind === "terminal" && l.content.box === box && l.content.session === session); + if (on) { + focusPane(d.key, t.id, on.id); + return true; + } + const replace = how === "replace" || (how === "auto" && leaves(t.root).length >= MAX_PANES); + const target = t.focus; + bringSession(key, box, session, { tab: t.id, pane: target, side: "right" }); + if (replace) parkPane(d.key, t.id, target); + retile(d.id); + return true; +} + +// claimFor gives an empty workspace its first pane: the session's own pane, +// taken out of wherever it shows, or a new one. +function claimFor(id: string, key: string, box: string, session: string) { + const found = findSession(box, session); + const decks = useDecks.getState().decks; + if (found) { + const ws = useWorkspaces.getState().spaces[found.key]; + const tab = ws?.tabs.find((x) => x.id === found.tab); + if (tab && tab.root.kind === "leaf") { + // A tab of its own already: that tab becomes the workspace (another + // workspace it was gives it up). + for (const o of decks) if (o.key === found.key && o.tab === found.tab) patch(o.id, { key: undefined, tab: undefined }); + patch(id, { key: found.key, tab: found.tab }); + return; + } + paneToTab(found.key, found.tab, found.pane.id); + const now = useWorkspaces.getState().spaces[found.key]; + patch(id, { key: found.key, tab: now?.active }); + return; + } + ensure(key); + const l = leaf({ kind: "terminal", box, session }); + const tab: WsTab = { id: Math.random().toString(36).slice(2, 10), root: l, focus: l.id }; + useWorkspaces.setState((s) => ({ spaces: { ...s.spaces, [key]: { ...s.spaces[key], tabs: [...s.spaces[key].tabs, tab], known: [...new Set([...(s.spaces[key].known ?? []), session])] } } })); + patch(id, { key, tab: tab.id }); +} + +// dropSession lands an agent dragged from the strip: beside a pane (side), +// in its place (center, the pane it replaces going to the strip), or alone +// in an empty workspace. +export function dropSession(src: { key: string; box: string; session: string }, target: { tab: string; pane: string; side: Side | "center" }) { + const { current } = useWorkspaces.getState(); + if (!target.tab || !current) { + openInDeck(src.box, src.session); + return; + } + const t = useWorkspaces.getState().spaces[current]?.tabs.find((x) => x.id === target.tab); + const on = t && leaves(t.root).find((l) => l.content.kind === "terminal" && l.content.box === src.box && l.content.session === src.session); + if (on) { + if (on.id !== target.pane) paneBeside(current, target.tab, on.id, target.pane, target.side); + return; + } + bringSession(src.key, src.box, src.session, { tab: target.tab, pane: target.pane, side: target.side === "center" ? "right" : target.side }); + if (target.side === "center") parkPane(current, target.tab, target.pane); +} + +// openWorktreeInDeck brings a worktree in: its agent that needs you first, +// else false (the caller shows it the usual way). +export function openWorktreeInDeck(key: string, how: Placement = "auto"): boolean { + const agent = leadAgent(key); + return agent ? openInDeck(splitKey(key).box, agent, how) : false; +} + +// jumpToWaiting brings in, or focuses, the agent that has waited longest +// for you. False when none is waiting. +export function jumpToWaiting(): boolean { + const { boxes, status } = useStore.getState(); + const online = new Set(status?.boxes.filter((b) => b.state === "online").map((b) => b.name)); + const waiting = Object.entries(boxes) + .filter(([b]) => online.has(b)) + .flatMap(([box, data]) => (data.sessions ?? []).filter((x: Session) => agentOf(x) && sessionState(x, data.stats) === "waiting").map((x: Session) => ({ box, s: x }))) + .sort((a, b) => (a.s.state_since ?? "").localeCompare(b.s.state_since ?? "")); + const first = waiting[0]; + return first ? openInDeck(first.box, first.s.name) : false; +} + +// Keeping workspaces true to their tabs: one whose tab went (its last pane +// closed) is empty again, and one whose number of panes changed is tiled +// again when one leaves, so closing the middle of three columns leaves two +// even ones. (A pane dropped on a side stays where it was dropped.) +const counts = new Map(); +useWorkspaces.subscribe((s) => { + if (!deckOn()) return; + for (const d of useDecks.getState().decks) { + if (!d.key || !d.tab) continue; + const t = deckTab(d, s.spaces); + if (!t) { + patch(d.id, { key: undefined, tab: undefined }); + counts.delete(d.id); + continue; + } + const n = leaves(t.root).length; + const was = counts.get(d.id); + counts.set(d.id, n); + if (was !== undefined && n < was) queueMicrotask(() => retile(d.id)); + } +}); + +// The first time the layout is on: one workspace, holding what shows now. +export function seedDecks() { + if (useDecks.getState().decks.length) return; + const { current, spaces } = useWorkspaces.getState(); + const tab = current ? spaces[current]?.active : undefined; + const t = tab && current ? spaces[current].tabs.find((x) => x.id === tab) : undefined; + const d: Deck = { id: newDeckId(), name: "Main", arrange: "auto", ...(t && current ? { key: current, tab: t.id } : {}) }; + useDecks.setState({ decks: [d], active: d.id }); +} + +// Everything that shows an agent (a notification, ⌘K, a new task, the +// dashboard) brings it here while the layout is on. +setSessionOpener((box, session) => (deckOn() ? openInDeck(box, session) : false)); + +// onDeck says whether a pane of the workspace in front shows the session. +export function onDeck(t: WsTab | undefined, box: string, session: string): boolean { + return !!t && leaves(t.root).some((l) => l.content.kind === "terminal" && l.content.box === box && l.content.session === session); +} diff --git a/app/src/lib/layout.test.ts b/app/src/lib/layout.test.ts index 5fb4d7d8..993473e4 100644 --- a/app/src/lib/layout.test.ts +++ b/app/src/lib/layout.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { adopt, bounds, leaf, leaves, mixed, moveBetween, movePane, type PaneNode, paneWorktree, place, remove, sessionsShown, sideAt, split, swap, worktreesOf } from "./layout.ts"; +import { adopt, bounds, leaf, leaves, mixed, moveBetween, movePane, type PaneNode, paneWorktree, place, remove, sessionsShown, sideAt, split, swap, tile, worktreesOf, layout } from "./layout.ts"; const term = (s: string) => leaf({ kind: "terminal", box: "b", session: s }); const names = (n: PaneNode) => leaves(n).map((l) => (l.content.kind === "terminal" ? l.content.session : "?")); @@ -162,3 +162,28 @@ test("moveBetween takes one pane out and leaves the rest of its tab", () => { // Into its own tab, or onto itself, makes no sense. assert.equal(moveBetween(spaces, { key: A, tab: "t1", pane: b.id }, { key: A, tab: "t1", pane: a.id, side: "left" }), undefined); }); + +test("tile lays panes out in even columns, keeping their ids", () => { + const ls = [1, 2, 3, 4].map(() => leaf({ kind: "browser", url: "" })); + const t = tile(ls, "columns")!; + const rects = layout(t).leaves; + assert.deepEqual(rects.map((r) => r.leaf.id), ls.map((l) => l.id)); + for (const r of rects) { + assert.ok(Math.abs(r.rect.w - 0.25) < 1e-9); + assert.equal(r.rect.h, 1); + } +}); + +test("tile's grid puts three as one beside two, four as two over two", () => { + const ls = [1, 2, 3, 4].map(() => leaf({ kind: "browser", url: "" })); + const three = layout(tile(ls.slice(0, 3), "grid")!).leaves.map((l) => l.rect); + assert.deepEqual(three[0], { x: 0, y: 0, w: 0.5, h: 1 }); + assert.deepEqual(three[2], { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }); + const four = layout(tile(ls, "grid")!).leaves.map((l) => l.rect); + assert.deepEqual(four.map((r) => [r.x, r.y]), [[0, 0], [0.5, 0], [0, 0.5], [0.5, 0.5]]); + assert.equal(tile([], "grid"), undefined); + // auto: columns up to three, a grid at four. + for (const l of layout(tile(ls.slice(0, 3), "auto")!).leaves) assert.ok(Math.abs(l.rect.w - 1 / 3) < 1e-9 && l.rect.h === 1); + assert.deepEqual(layout(tile(ls, "auto")!).leaves.map((l) => [l.rect.x, l.rect.y]), [[0, 0], [0.5, 0], [0, 0.5], [0.5, 0.5]]); + assert.equal(tile(ls.slice(0, 1), "grid"), ls[0]); +}); diff --git a/app/src/lib/layout.ts b/app/src/lib/layout.ts index c5f36c81..c9ae0cad 100644 --- a/app/src/lib/layout.ts +++ b/app/src/lib/layout.ts @@ -266,3 +266,33 @@ export function moveBetween( out[dst.key] = { ...to, tabs: to.tabs.map((x) => (x === d ? { ...x, root: place(d.root, dst.pane, dst.side, moved), focus } : x)), active: d.id }; return out; } + +// How a workspace's panes are tiled (lib/deck.ts): side by side in equal +// columns, or as a grid (two beside each other; three as one beside two +// stacked; four as two over two). auto is columns up to three, then a grid: +// four columns are too narrow to read a chat in. +export type Arrangement = "auto" | "columns" | "grid"; + +// arranged is what auto comes to for n panes. +export const arranged = (how: Arrangement, n: number): "columns" | "grid" => (how === "auto" ? (n >= 4 ? "grid" : "columns") : how); + +const node = (dir: "row" | "col", ratio: number, a: PaneNode, b: PaneNode): PaneNode => ({ kind: "split", id: newId(), dir, ratio, a, b }); + +// evenly lays nodes out one after another in dir, each the same size. +function evenly(nodes: PaneNode[], dir: "row" | "col"): PaneNode { + if (nodes.length === 1) return nodes[0]; + return node(dir, 1 / nodes.length, nodes[0], evenly(nodes.slice(1), dir)); +} + +// tile arranges leaves, in order, as how says. Leaves keep their ids, so +// nothing they show is remounted; only the splits are new. +export function tile(ls: Leaf[], how: Arrangement): PaneNode | undefined { + if (!ls.length) return undefined; + if (ls.length === 1) return ls[0]; + if (arranged(how, ls.length) === "columns" || ls.length === 2) return evenly(ls, "row"); + if (ls.length === 3) return node("row", 0.5, ls[0], node("col", 0.5, ls[1], ls[2])); + const cols = Math.ceil(Math.sqrt(ls.length)); + const rows: PaneNode[] = []; + for (let i = 0; i < ls.length; i += cols) rows.push(evenly(ls.slice(i, i + cols), "row")); + return evenly(rows, "col"); +} diff --git a/app/src/lib/palette-shortcuts.ts b/app/src/lib/palette-shortcuts.ts index 6bd8afc6..2bf04da4 100644 --- a/app/src/lib/palette-shortcuts.ts +++ b/app/src/lib/palette-shortcuts.ts @@ -23,4 +23,7 @@ export const NOT_IN_PALETTE: Record = { "stop-agent": "stops the agent of the reply box the keyboard is in (its Stop button does too)", fold: "on the sidebar's project the keyboard is on", menu: "opens the menu of the row the keyboard is on", + "deck-switcher": "the workspace layout's finder; ⌘K finds the same agents and worktrees", + "deck-zoom": "zooms the pane the keyboard is in (its header has a button for it)", + "deck-swap": "moves the pane the keyboard is in", }; diff --git a/app/src/lib/prefs.ts b/app/src/lib/prefs.ts index ff81ef9d..0dbca6a8 100644 --- a/app/src/lib/prefs.ts +++ b/app/src/lib/prefs.ts @@ -46,6 +46,10 @@ export interface Prefs { // Labs: zen (⌘.): no sidebar or status bar, a switcher for a tab strip, // agents as conversations. zen: boolean; + // Labs: how the window is laid out. "sidebar" is the sidebar and tab + // strip; "workspace" is named workspaces of live agent panes you arrange, + // with everything else in a strip along the bottom (lib/deck.ts). + layout: "sidebar" | "workspace"; // Update a box's berthd as soon as Shipyard ships a newer one // (lib/outdated.ts). Off: the status bar offers it instead. autoUpdateBoxes: boolean; @@ -94,6 +98,7 @@ const DEFAULTS: Prefs = { labsChosen: false, agentView: "terminal", zen: false, + layout: "sidebar", autoUpdateBoxes: false, chatBackground: DEFAULT_CHAT_BACKGROUND, chatWidth: "default", @@ -149,12 +154,14 @@ usePrefs.subscribe((p) => save("berth.prefs", { ...p, version: PREFS_VERSION })) // The migration is kept at once, not only on the next change. save("berth.prefs", { ...usePrefs.getState(), version: PREFS_VERSION }); -// ?labs=1 turns Labs on, ?zen=1 zen, and ?view=conversation opens agents +// ?labs=1 turns Labs on, ?zen=1 zen, ?layout=workspace the workspace layout, and ?view=conversation opens agents // as conversations, for the demo. { const q = new URLSearchParams(location.search); if (q.has("labs")) usePrefs.setState({ labs: q.get("labs") !== "0" }); if (q.has("zen")) usePrefs.setState({ zen: q.get("zen") !== "0" }); + const l = q.get("layout"); + if (l === "sidebar" || l === "workspace") usePrefs.setState({ layout: l }); const v = q.get("view"); if (v === "terminal" || v === "conversation") usePrefs.setState({ agentView: v }); } diff --git a/app/src/lib/shortcuts.json b/app/src/lib/shortcuts.json index 6e1c009d..bad292f3 100644 --- a/app/src/lib/shortcuts.json +++ b/app/src/lib/shortcuts.json @@ -18,7 +18,7 @@ { "id": "file-tree", "group": "View", "label": "Files panel", "keys": "⌘⇧E", "accel": "CmdOrCtrl+Shift+E", "what": "Show or hide the worktree's files beside its tabs: everything, or what the agent changed this turn" }, { "id": "devtools", "group": "View", "label": "Console and network", "keys": "⌘⌥I", "accel": "CmdOrCtrl+Alt+I", "what": "In a Browser tab: show or hide the page's Console and Network drawer" }, { "id": "sidebar", "group": "View", "label": "Show or hide the sidebar", "keys": "⌘\\", "accel": "CmdOrCtrl+\\" }, - { "id": "dashboard", "group": "View", "label": "Agent Dashboard", "keys": "⌘J", "accel": "CmdOrCtrl+J" }, + { "id": "dashboard", "group": "View", "label": "Agent Dashboard", "keys": "⌘J", "accel": "CmdOrCtrl+J", "what": "The Agent Dashboard; in the workspace layout, bring in the agent that has waited longest for you" }, { "id": "notifications", "group": "View", "label": "Notifications", "keys": "⌘⇧N", "accel": "CmdOrCtrl+Shift+N" }, { "id": "zoom-in", "group": "View", "label": "Zoom in", "keys": "⌘+", "accel": "CmdOrCtrl+=", "sep": true }, { "id": "zoom-out", "group": "View", "label": "Zoom out", "keys": "⌘−", "accel": "CmdOrCtrl+-" }, @@ -26,8 +26,11 @@ { "id": "palette", "group": "Go", "label": "Search and commands…", "keys": "⌘K", "accel": "CmdOrCtrl+K" }, { "id": "files", "group": "Go", "label": "Go to file…", "keys": "⌘P", "accel": "CmdOrCtrl+P", "what": "Find a file in the worktree in front: what the agent changed this turn first. ↵ opens it in a File tab, ⌥↵ beside the pane, ⌘↵ in your editor" }, { "id": "save-file", "group": "Go", "label": "Save file", "keys": "⌘S", "what": "In a File tab: save it. If the agent wrote the file since, you choose what to keep instead" }, - { "id": "tab", "group": "Go", "label": "Tab {n}", "keys": "⌘1–9", "accel": "CmdOrCtrl+{n}", "count": 9, "sep": true, "what": "Go to tab 1–9" }, + { "id": "tab", "group": "Go", "label": "Tab {n}", "keys": "⌘1–9", "accel": "CmdOrCtrl+{n}", "count": 9, "sep": true, "what": "Go to tab 1–9; in the workspace layout, workspace 1–9" }, { "id": "focus", "group": "Go", "label": "Move between panes", "keys": "⌘⌥ ←↑→↓", "what": "Move between panes, out of a terminal too (a terminal keeps ⇥ for itself)" }, + { "id": "deck-switcher", "group": "Go", "label": "Open an agent or worktree", "keys": "⌘E", "labs": true, "what": "In the workspace layout: every agent, the ones that need you first, and every worktree. ↵ brings one in, ⌥↵ in the focused pane's place" }, + { "id": "deck-zoom", "group": "Go", "label": "Zoom the focused pane", "keys": "⌘⇧↵", "labs": true, "what": "In the workspace layout: the focused pane fills the workspace, or the others come back" }, + { "id": "deck-swap", "group": "Go", "label": "Swap panes", "keys": "⌘⌥⇧ ←↑→↓", "labs": true, "what": "In the workspace layout: trade the focused pane with its neighbour" }, { "id": "find", "group": "Go", "label": "Find in the chat", "keys": "⌘F", "what": "In a chat: find words in it; ↵ or ⌘G for the next, esc to close" }, { "id": "send", "group": "Go", "label": "Send the reply", "keys": "↵", "what": "In a reply box: send it; ⇧↵ for a new line" }, { "id": "stop-agent", "group": "Go", "label": "Stop the agent", "keys": "esc", "what": "In a chat's reply box while its agent works: stop it, as esc does at its terminal" }, diff --git a/app/src/lib/workspaces.ts b/app/src/lib/workspaces.ts index 14094697..052ec198 100644 --- a/app/src/lib/workspaces.ts +++ b/app/src/lib/workspaces.ts @@ -28,6 +28,9 @@ export interface WsTab { focus: string; // A Compare tab's two worktrees and lanes (lib/compare.ts). compare?: Compare; + // The workspace layout (Labs, lib/deck.ts): only the focused pane shows, + // filling the tab; the others stay mounted behind it. + zoomed?: boolean; } export interface Workspace { @@ -247,7 +250,7 @@ export function groupKeys(s: Pick = useWo export const isShown = (key: string) => groupKeys().includes(key); // ensure makes a workspace for a worktree that has none yet, mounted. -function ensure(key: string): boolean { +export function ensure(key: string): boolean { const ref = refFor(key); if (!ref) return false; useWorkspaces.setState((st) => ({ @@ -647,6 +650,8 @@ export function findSession(box: string, session: string): { key: string; tab: s // openSession shows a session: its worktree's workspace, then its pane, // adding a tab for it when it has none. export function openSession(box: string, session: Session) { + // The workspace layout (lib/deck.ts) brings it into the panes on screen. + if (opener?.(box, session.name)) return; const locations = useStore.getState().boxes[box]?.locations ?? []; const loc = locations.find((l) => l.worktrees?.some((w) => w.path === session.dir)); const wt = loc?.worktrees?.find((w) => w.path === session.dir); @@ -682,6 +687,40 @@ export function openSession(box: string, session: Session) { } else openTab({ kind: "terminal", box, session: session.name }, key); } +// opener, when set, gets first say in showing a session: true when it did. +let opener: ((box: string, session: string) => boolean) | undefined; +export const setSessionOpener = (fn: typeof opener) => { + opener = fn; +}; + +// setTabZoom zooms a tab's focused pane to fill it, or lets the others back. +export function setTabZoom(key: string, tabId: string, zoomed: boolean) { + updateTab(key, tabId, (t) => ({ ...t, zoomed: zoomed || undefined })); +} + +// setTabRoot replaces a tab's whole tree (a new arrangement of the same +// leaves: none of them remounts). +export function setTabRoot(key: string, tabId: string, root: PaneNode) { + updateTab(key, tabId, (t) => ({ ...t, root, focus: findLeaf(root, t.focus) ? t.focus : leaves(root)[0].id })); +} + +// parkPane takes a pane off a tab and gives it a tab of its own in its own +// worktree, behind whatever shows there: it keeps running and stays mounted, +// so bringing it back is instant. False when it is the tab's only pane. +export function parkPane(key: string, tabId: string, paneId: string): boolean { + const s = useWorkspaces.getState(); + const t = s.spaces[key]?.tabs.find((x) => x.id === tabId); + const l = t && findLeaf(t.root, paneId); + const rest = t && remove(t.root, paneId); + if (!l || !rest) return false; + const owner = paneWorktree(key, l); + if (owner !== key && !ensure(owner)) return false; + const { wt: _wt, ...alone } = l; + updateTab(key, tabId, (x) => ({ ...x, root: rest, focus: x.focus === paneId ? leaves(rest)[0].id : x.focus })); + update(owner, (ws) => ({ ...ws, tabs: [...ws.tabs, { id: newId(), root: alone, focus: alone.id }] })); + return true; +} + export function rememberUrl(url: string) { useWorkspaces.setState((s) => ({ recentUrls: [url, ...s.recentUrls.filter((u) => u !== url)].slice(0, 20) })); } diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx index adb84e1b..0984a6da 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..01dc49b3 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,14 @@ again. ## Unreleased +- **Labs: a workspace layout without the sidebar.** In Settings › Labs › + Layout, pick Workspaces: named workspaces of up to four agent panes side by + side, from any project or box, switched with ⌘1–9. Every other agent waits + in a strip along the bottom, the ones that need you first: point at one to + answer it there, click to bring it in, ⌥-click to swap it with the focused + pane, or drag it onto a pane. ⌘J brings in who has waited longest, ⌘E finds + any agent or worktree, ⌘⇧↵ zooms a pane, and ⌘⌥⇧ and an arrow swaps it + with its neighbour. 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. diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index f5a9bcb4..a4d212c7 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -4,7 +4,8 @@ description: Newer ideas in the app, on by default; Settings → Labs turns them --- Labs holds ideas that are still being tried: the harbour home, agents as -conversations, zen, and worktrees side by side. They are on by default since v0.3.1, and may change +conversations, zen, and worktrees side by side, on by default since v0.3.1, +and a workspace layout you can pick instead of the sidebar. They may change or go away. Settings → Labs turns them off, and that choice is kept. ## Harbour home @@ -246,6 +247,45 @@ shortcuts, Settings and Leave zen. ⌘K searches everything. ⌘. again brings it all back, and the app remembers which you left it in. In the Mac app, Zen is also under View in the menu bar. +## Workspace layout + +Settings › Labs › Layout › **Workspaces** trades the sidebar for live panes +you arrange, as in tmux or Zellij. The bar across the top holds Home, your +named workspaces (double-click one to rename it, right-click to close it; +nothing in it stops) and **+** for another, then how the panes are tiled +(side by side, a grid, or zoomed), New task, Search, Review, Automations and +Settings. + +A workspace holds up to four panes, each an agent from any project on any +box, with its worktree and box in its header. One that needs you has a warm +header that says so, and its workspace's tab counts it. + +The strip along the bottom holds every agent not on screen, the ones that +need you first. Point at one that needs you to see what it asks, and answer +a permission there (Deny, Allow once) without bringing it in. Click an agent +to bring it in beside the others (once there are four, it takes the focused +pane's place), ⌥-click to put it in the focused pane's place, or drag it onto +a pane: an edge puts it beside, the middle swaps. A pane's header puts it +back in the strip; it keeps running. Opening an agent any other way (a +notification, ⌘K, a new task) brings it into the workspace in front too. + +**Elsewhere** at the strip's start opens every box, project, worktree and +agent, the sidebar's tree on demand: each project is a line of counts that +opens to its worktrees, and a worktree without an agent can be opened from +there too. + +| Keys | What it does | +| --- | --- | +| ⌘1–9 | Go to workspace 1–9 | +| ⌘E | Find any agent or worktree; ↵ brings it in, ⌥↵ in the focused pane's place | +| ⌘J | Bring in the agent that has waited longest for you | +| ⌘⌥ and an arrow | Move between panes | +| ⌘⌥⇧ and an arrow | Swap the focused pane with its neighbour | +| ⌘⇧↵ | Zoom the focused pane, or show them all again | + +In a window narrower than 900 pixels a workspace shows one pane at a time, +and its others wait at the start of the strip. + ## Worktrees side by side Two worktrees can share the window: their tabs in one strip, and their