From 161ef2c5631e6e74658ead16462489da3e5f68de Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:09:03 +0100 Subject: [PATCH 1/6] feat(app): labs workspace layout, named workspaces of agent panes with the rest in a strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Labs › Layout › Workspaces trades the sidebar for live panes you arrange, tmux or Zellij style: named workspaces as tabs across the top (⌘1–9), up to four agent panes from any project or box side by side, a grid or zoomed (⌘⇧↵), and every other agent in a strip along the bottom, the ones that need you first. Click, ⌥-click or drag one in; ⌘E finds any agent or worktree; ⌘⌥⇧ and an arrow swaps panes. Opening an agent any other way (a notification, ⌘K, a new task) brings it into the workspace in front. Each workspace is one tab of the existing pane machinery, so panes keep their ids and nothing remounts. The sidebar stays the default. Co-Authored-By: Claude Opus 5.5 --- app/e2e/workspace-layout.spec.ts | 107 ++++++ app/src/App.tsx | 40 +- app/src/components/deck/deck-bar.tsx | 284 ++++++++++++++ app/src/components/deck/deck-pane.tsx | 76 ++++ app/src/components/deck/deck-strip.tsx | 390 ++++++++++++++++++++ app/src/components/workspace/pane-layer.tsx | 50 ++- app/src/components/workspace/pane.tsx | 32 +- app/src/components/workspace/tab-drag.tsx | 39 +- app/src/hooks/use-shortcuts.ts | 22 +- app/src/lib/deck.ts | 364 ++++++++++++++++++ app/src/lib/layout.test.ts | 24 +- app/src/lib/layout.ts | 26 ++ app/src/lib/palette-shortcuts.ts | 3 + app/src/lib/prefs.ts | 9 +- app/src/lib/shortcuts.json | 5 +- app/src/lib/workspaces.ts | 41 +- app/src/views/settings/labs-section.tsx | 13 + docs/changelog.mdx | 7 + docs/guides/labs.mdx | 35 +- 19 files changed, 1535 insertions(+), 32 deletions(-) create mode 100644 app/e2e/workspace-layout.spec.ts create mode 100644 app/src/components/deck/deck-bar.tsx create mode 100644 app/src/components/deck/deck-pane.tsx create mode 100644 app/src/components/deck/deck-strip.tsx create mode 100644 app/src/lib/deck.ts diff --git a/app/e2e/workspace-layout.spec.ts b/app/e2e/workspace-layout.spec.ts new file mode 100644 index 00000000..841318ee --- /dev/null +++ b/app/e2e/workspace-layout.spec.ts @@ -0,0 +1,107 @@ +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']")).toContainText("1"); + + // 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(); +}); + +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/search-perf-claude").click(); + await expect(app.panes).toHaveCount(1); + + await page.locator("[data-deck-bar]").getByRole("button", { name: /New task/ }).click(); + const composer = page.getByRole("dialog"); + 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(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); +}); 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..3f581206 --- /dev/null +++ b/app/src/components/deck/deck-bar.tsx @@ -0,0 +1,284 @@ +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 { 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 = front?.arrange ?? "columns"; + 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..9997ad46 --- /dev/null +++ b/app/src/components/deck/deck-pane.tsx @@ -0,0 +1,76 @@ +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..40b8147d --- /dev/null +++ b/app/src/components/deck/deck-strip.tsx @@ -0,0 +1,390 @@ +import { GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayersIcon, SearchIcon } from "lucide-react"; +import { useEffect, 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 { 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 { openInDeck, openWorktreeInDeck, type Placement } from "@/lib/deck"; +import { agentOf, type SessionState, sessionName, 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 scroller = useRef(null); + const shape = lead.map((a) => a.e.session.name).join(" "); + // What it shows changed (one was brought in): back to its start. + useEffect(() => { + if (scroller.current) scroller.current.scrollLeft = 0; + }, [shape]); + const waiting = elsewhere.filter((a) => a.e.state === "waiting").length; + return ( +
+ + + Elsewhere + {elsewhere.length} + {waiting > 0 && {waiting} need you} + + {pinned.map((a) => ( + openInDeck(a.e.box, a.e.session.name, how)} /> + ))} +
+ {behind.length > 0 && ( + <> + {behind.map((a) => ( + focusBehind(showing, a)} /> + ))} + + + )} + {lead.map((a) => ( + openInDeck(a.e.box, a.e.session.name, how)} /> + ))} + {quiet.length > 0 && } + {!elsewhere.length && !behind.length && !pinned.length && Every agent is on screen.} +
+ + + +
+ ); +} + +// 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} + + + ))} + + + + ); +} + +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); +} + +function Chip({ a, here, onOpen }: { a: StripAgent; here?: boolean; onOpen(how: Placement): void }) { + const needs = a.e.state === "waiting"; + return ( + + + {a.title} · {a.place} on {a.e.box} + + {here ? "In this workspace, behind the one showing" : "Click to bring in · ⌥-click for the focused pane's place · or drag onto a pane"} + + } + side="top" + > + + + ); +} + +// ---- 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(); + for (const a of agents) { + 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. +export function DeckEmpty({ name }: { name: string }) { + const agents = useAgents().slice(0, 5); + return ( +
+ + + + + + {name} is empty + Bring agents in from the strip below, or start something new. Up to four sit side by side. + +
+ {agents.map((a) => ( + + ))} +
+
+ + +
+
+
+ ); +} diff --git a/app/src/components/workspace/pane-layer.tsx b/app/src/components/workspace/pane-layer.tsx index c0ed9140..0ff816e1 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,33 @@ 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 ( +
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..e5f3c4f5 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,17 +116,27 @@ 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 && } {/* 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. */} @@ -299,7 +314,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 +323,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"); 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..68024281 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, 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 @@ -167,6 +169,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 +198,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 +250,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/lib/deck.ts b/app/src/lib/deck.ts new file mode 100644 index 00000000..96e502fa --- /dev/null +++ b/app/src/lib/deck.ts @@ -0,0 +1,364 @@ +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: "columns" }; + 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: "columns" }; + 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: "columns", ...(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..7198c019 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,25 @@ 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); + 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..55ed9a1b 100644 --- a/app/src/lib/layout.ts +++ b/app/src/lib/layout.ts @@ -266,3 +266,29 @@ 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). +export type Arrangement = "columns" | "grid"; + +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 (how === "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..33a14f8c 100644 --- a/app/src/lib/shortcuts.json +++ b/app/src/lib/shortcuts.json @@ -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..47ce1f6f 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,13 @@ 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: click one to + bring it in, ⌥-click to swap it with the focused pane, or drag it onto a + pane. ⌘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..f3a27b37 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,38 @@ 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. Click one 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. + +| 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 | +| ⌘⌥ 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 From de63a612029d487d2a499e465d44cc556eb4fff2 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:28:22 +0100 Subject: [PATCH 2/6] feat(app): the workspace layout's strip opens every box and project, and four panes tile as a grid From the critique: the strip's label opens a tray of every box, project, worktree and agent (the sidebar's tree, on demand, offline boxes said so); needs-you chips stay pinned at its start and idle agents fold into one; four panes default to two over two; the focused pane's ring is clear; a workspace tab counts only who needs you and shows its number; Review and Automations are labelled; an empty workspace shows its gestures and keys. Co-Authored-By: Claude Opus 5.5 --- app/e2e/workspace-layout.spec.ts | 12 +- app/src/components/deck/deck-bar.tsx | 14 +- app/src/components/deck/deck-strip.tsx | 159 ++++++++++++++++---- app/src/components/workspace/pane-layer.tsx | 2 +- app/src/lib/deck.ts | 6 +- app/src/lib/layout.test.ts | 3 + app/src/lib/layout.ts | 10 +- 7 files changed, 165 insertions(+), 41 deletions(-) diff --git a/app/e2e/workspace-layout.spec.ts b/app/e2e/workspace-layout.spec.ts index 841318ee..2f092987 100644 --- a/app/e2e/workspace-layout.spec.ts +++ b/app/e2e/workspace-layout.spec.ts @@ -35,7 +35,7 @@ test("two agents side by side: focus moves between them, and one zooms to fill t 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']")).toContainText("1"); + 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", ""); @@ -77,6 +77,15 @@ test("a worktree opens from the strip or ⌘E, beside the others or in the focus 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"); + 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 }) => { @@ -95,6 +104,7 @@ test("a new task lands in a new pane, another workspace starts empty, and Settin // 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); diff --git a/app/src/components/deck/deck-bar.tsx b/app/src/components/deck/deck-bar.tsx index 3f581206..62daec27 100644 --- a/app/src/components/deck/deck-bar.tsx +++ b/app/src/components/deck/deck-bar.tsx @@ -13,7 +13,7 @@ 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 { leaves } from "@/lib/layout"; +import { arranged, leaves } from "@/lib/layout"; import { platformKeys } from "@/lib/platform"; import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; @@ -34,7 +34,7 @@ export function DeckBar() { 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 = front?.arrange ?? "columns"; + 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"; @@ -155,14 +155,16 @@ function Places() { )} {automations && ( - )} @@ -226,14 +228,14 @@ function DeckTab({ deck, index, on }: { deck: Deck; index: number; on: boolean } on ? "bg-accent font-medium text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} > + {index < 9 && {index + 1}} {deck.name} - {waiting > 0 ? ( + {/* Only what needs you is counted here; how many panes is in the tooltip. */} + {waiting > 0 && ( {waiting} - ) : ( - panes > 0 && {panes} )} diff --git a/app/src/components/deck/deck-strip.tsx b/app/src/components/deck/deck-strip.tsx index 40b8147d..da246f0d 100644 --- a/app/src/components/deck/deck-strip.tsx +++ b/app/src/components/deck/deck-strip.tsx @@ -1,4 +1,4 @@ -import { GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayersIcon, SearchIcon } from "lucide-react"; +import { ChevronUpIcon, FolderIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayersIcon, SearchIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { create } from "zustand"; @@ -8,13 +8,14 @@ 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 { openInDeck, openWorktreeInDeck, type Placement } from "@/lib/deck"; -import { agentOf, type SessionState, sessionName, worktreeOf } from "@/lib/derive"; +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"; @@ -113,12 +114,7 @@ export function DeckStrip() { const waiting = elsewhere.filter((a) => a.e.state === "waiting").length; return (
- - - Elsewhere - {elsewhere.length} - {waiting > 0 && {waiting} need you} - + {pinned.map((a) => ( openInDeck(a.e.box, a.e.session.name, how)} /> ))} @@ -180,6 +176,108 @@ function focusBehind(showing: ReturnType, a: StripAgent) { 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, setOpen] = useState(false); + const boxes = useStore((s) => s.boxes); + const status = useStore((s) => s.status); + const all = status?.boxes ?? []; + const bring = (fn: () => void) => () => { + setOpen(false); + fn(); + }; + return ( + + + + } + > + + Elsewhere + {count} + {waiting > 0 && {waiting} need you} + + + + +
+ {all.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) => ( +
+
+ + {loc.name} +
+ {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 ( + + ); + })} +
+ ); + })} +
+ ))} +
+ ); + })} +
+
+
+ ); +} + function Chip({ a, here, onOpen }: { a: StripAgent; here?: boolean; onOpen(how: Placement): void }) { const needs = a.e.state === "waiting"; return ( @@ -249,7 +347,9 @@ export function DeckSwitcher() { }; const groups = useMemo(() => { const byHead = new Map(); - for (const a of agents) { + // 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({ @@ -343,9 +443,20 @@ export function DeckSwitcher() { ); } -// DeckEmpty is a workspace with no panes yet. +// 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 agents = useAgents().slice(0, 5); + const waiting = useAgents().filter((a) => a.e.state === "waiting").length; return (
@@ -354,24 +465,18 @@ export function DeckEmpty({ name }: { name: string }) { {name} is empty - Bring agents in from the strip below, or start something new. Up to four sit side by side. + + 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. + -
- {agents.map((a) => ( - +
+ {HOW.map((h) => ( +
+
{platformKeys(h.keys)}
+
{h.what}
+
))} -
+
); }), diff --git a/app/src/lib/deck.ts b/app/src/lib/deck.ts index 96e502fa..cd9b3a3e 100644 --- a/app/src/lib/deck.ts +++ b/app/src/lib/deck.ts @@ -126,7 +126,7 @@ 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: "columns" }; + const d: Deck = { id: newDeckId(), name: name ?? `Workspace ${n}`, arrange: "auto" }; useDecks.setState((s) => ({ decks: [...s.decks, d] })); showDeck(d.id); return d.id; @@ -158,7 +158,7 @@ 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: "columns" }; + const d: Deck = { id: newDeckId(), name, key: current, tab, arrange: "auto" }; useDecks.setState((s) => ({ decks: [...s.decks, d], active: d.id, home: false })); } @@ -349,7 +349,7 @@ export function seedDecks() { 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: "columns", ...(t && current ? { key: current, tab: t.id } : {}) }; + const d: Deck = { id: newDeckId(), name: "Main", arrange: "auto", ...(t && current ? { key: current, tab: t.id } : {}) }; useDecks.setState({ decks: [d], active: d.id }); } diff --git a/app/src/lib/layout.test.ts b/app/src/lib/layout.test.ts index 7198c019..993473e4 100644 --- a/app/src/lib/layout.test.ts +++ b/app/src/lib/layout.test.ts @@ -182,5 +182,8 @@ test("tile's grid puts three as one beside two, four as two over two", () => { 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 55ed9a1b..c9ae0cad 100644 --- a/app/src/lib/layout.ts +++ b/app/src/lib/layout.ts @@ -269,8 +269,12 @@ export function moveBetween( // 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). -export type Arrangement = "columns" | "grid"; +// 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 }); @@ -285,7 +289,7 @@ function evenly(nodes: PaneNode[], dir: "row" | "col"): PaneNode { export function tile(ls: Leaf[], how: Arrangement): PaneNode | undefined { if (!ls.length) return undefined; if (ls.length === 1) return ls[0]; - if (how === "columns" || ls.length === 2) return evenly(ls, "row"); + 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[] = []; From 097610ff078432792de662c180b039ee45133c54 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:36:30 +0100 Subject: [PATCH 3/6] fix(app): the workspace layout's tray reads top down, and the strip keeps "ready" in sight The tray has a title and a count of who needs you and who works, boxes lead it (offline ones last), it fades where it scrolls, and its foot says how to bring an agent in. "N ready" sits outside the strip's scroll; a narrow window's other panes read "Here", dashed; the status bar leaves the agent counts to the strip and tabs. Co-Authored-By: Claude Opus 5.5 --- app/src/components/deck/deck-strip.tsx | 40 ++++++++++++++++++-------- app/src/components/status-bar.tsx | 5 +++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/src/components/deck/deck-strip.tsx b/app/src/components/deck/deck-strip.tsx index da246f0d..1b749eab 100644 --- a/app/src/components/deck/deck-strip.tsx +++ b/app/src/components/deck/deck-strip.tsx @@ -130,9 +130,9 @@ export function DeckStrip() { {lead.map((a) => ( openInDeck(a.e.box, a.e.session.name, how)} /> ))} - {quiet.length > 0 && } {!elsewhere.length && !behind.length && !pinned.length && Every agent is on screen.}
+ {quiet.length > 0 && } - Search everything ⌘K}> + + Search everything ⌘K + + } + > @@ -134,7 +146,15 @@ export function DeckBar() { - @@ -155,7 +175,7 @@ function Places() { @@ -164,7 +184,7 @@ function Places() { )} @@ -215,30 +235,41 @@ function DeckTab({ deck, index, on }: { deck: Deck; index: number; on: boolean } return ( }> - {panes ? `${panes} pane${panes === 1 ? "" : "s"}` : "Empty"}{index < 9 && {platformKeys(`⌘${index + 1}`)}}}> - - + } + > + + setEditing(true)}> diff --git a/app/src/components/deck/deck-pane.tsx b/app/src/components/deck/deck-pane.tsx index 9997ad46..502f506b 100644 --- a/app/src/components/deck/deck-pane.tsx +++ b/app/src/components/deck/deck-pane.tsx @@ -36,12 +36,7 @@ export function DeckPaneBits({ owner, needsYou, pane, focused, actions }: { owne }> - @@ -53,10 +48,7 @@ export function DeckPaneBits({ owner, needsYou, pane, focused, actions }: { owne return ( <> {place && ( - - - · - + {place} {box} diff --git a/app/src/components/deck/deck-strip.tsx b/app/src/components/deck/deck-strip.tsx index 1b749eab..6b65915f 100644 --- a/app/src/components/deck/deck-strip.tsx +++ b/app/src/components/deck/deck-strip.tsx @@ -1,5 +1,5 @@ -import { ChevronUpIcon, FolderIcon, GitBranchIcon, GitBranchPlusIcon, HouseIcon, LayersIcon, SearchIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +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"; @@ -14,7 +14,12 @@ 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 { openInDeck, openWorktreeInDeck, type Placement } from "@/lib/deck"; +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"; @@ -105,33 +110,66 @@ export function DeckStrip() { // 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 scroller = useRef(null); - const shape = lead.map((a) => a.e.session.name).join(" "); - // What it shows changed (one was brought in): back to its start. - useEffect(() => { - if (scroller.current) scroller.current.scrollLeft = 0; + 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) => ( - openInDeck(a.e.box, a.e.session.name, how)} /> + ))} -
- {behind.length > 0 && ( - <> - {behind.map((a) => ( - focusBehind(showing, a)} /> - ))} - - - )} - {lead.map((a) => ( - openInDeck(a.e.box, a.e.session.name, how)} /> +
+ {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 && } + + + ) + )} + +
+
+ + + ); +} + 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); @@ -181,7 +301,12 @@ function focusBehind(showing: ReturnType, a: StripAgent) { // 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, setOpen] = useState(false); + 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 ?? []; @@ -225,68 +350,126 @@ function Tray({ agents, visible, count, waiting }: { agents: StripAgent[]; visib
- {[...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) => ( -
-
- - {loc.name} -
- {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}`); + {[...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

@@ -294,8 +477,10 @@ function Tray({ agents, visible, count, waiting }: { agents: StripAgent[]; visib ); } -function Chip({ a, here, onOpen }: { a: StripAgent; here?: boolean; onOpen(how: Placement): void }) { +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 this workspace, behind the one showing" : "Click to bring in · ⌥-click for the focused pane's place · or drag onto a pane"} + {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 })); diff --git a/app/src/components/status-bar.tsx b/app/src/components/status-bar.tsx index 267072ef..17c09d79 100644 --- a/app/src/components/status-bar.tsx +++ b/app/src/components/status-bar.tsx @@ -20,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 @@ -156,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 c4ded541..40b6f2d8 100644 --- a/app/src/components/workspace/pane-layer.tsx +++ b/app/src/components/workspace/pane-layer.tsx @@ -99,12 +99,14 @@ export function PaneLayer({ showing }: { showing: boolean }) { // Where the keyboard goes home to when what had it closes (lib/focus-home.ts). data-pane-focused={on && tab.focus === leaf.id ? "" : undefined} data-zoomed={zoomed && on ? "" : undefined} + // The workspace layout quiets the panes without the keyboard: no chat controls. + data-deck-quiet={deck && !zoomed && leaves.length > 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 && } + {deck && !zoomed && leaves.length > 1 && tab.focus === leaf.id && }
); }), diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index e5f3c4f5..5112f5cb 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -138,6 +138,8 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare )}
{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" && ( @@ -339,7 +341,8 @@ function PaneTitle({ pane, bare }: { pane: Leaf; bare?: boolean }) { {label} {secondary && {secondary}} - {state && } + {/* Bare: "Needs you" beside it says waiting, once. */} + {state && !(bare && state === "waiting") && } {near && session?.usage && ( diff --git a/app/src/hooks/use-shortcuts.ts b/app/src/hooks/use-shortcuts.ts index 68024281..d9fa15bc 100644 --- a/app/src/hooks/use-shortcuts.ts +++ b/app/src/hooks/use-shortcuts.ts @@ -24,7 +24,7 @@ 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, showDeck, swapFocused, toggleZoom, useDecks } from "@/lib/deck"; +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 @@ -130,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": 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 index cd9b3a3e..8b49f21d 100644 --- a/app/src/lib/deck.ts +++ b/app/src/lib/deck.ts @@ -6,24 +6,7 @@ import { type Arrangement, leaf, leaves, neighbor, type Side, tile } from "@/lib 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"; +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 @@ -83,11 +66,7 @@ function patch(id: string, p: Partial) { // 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 { +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); @@ -361,4 +340,3 @@ setSessionOpener((box, session) => (deckOn() ? openInDeck(box, session) : false) 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/shortcuts.json b/app/src/lib/shortcuts.json index 33a14f8c..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+-" }, From bf9d8367814a436ca3f973d00fc95e00b1e3e6af Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:54:09 +0100 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20the=20workspace=20layout's=20strip?= =?UTF-8?q?=20answers=20agents,=20and=20=E2=8C=98J?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/changelog.mdx | 9 +++++---- docs/guides/labs.mdx | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 47ce1f6f..01dc49b3 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -14,10 +14,11 @@ again. - **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: click one to - bring it in, ⌥-click to swap it with the focused pane, or drag it onto a - pane. ⌘E finds any agent or worktree, ⌘⇧↵ zooms a pane, and ⌘⌥⇧ and an - arrow swaps it with its neighbour. The sidebar stays the default. + 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 f3a27b37..a4d212c7 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -261,17 +261,24 @@ 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. Click one 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. +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 | From 189716edd24ab9a46b1cdf6513ac6c0c2ce4bca0 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:00:40 +0100 Subject: [PATCH 6/6] fix(app): the workspace tray keeps orange for who needs you, and the ask card says how to bring it in Co-Authored-By: Claude Opus 5.5 --- app/src/components/deck/deck-strip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/deck/deck-strip.tsx b/app/src/components/deck/deck-strip.tsx index 6b65915f..03db88e2 100644 --- a/app/src/components/deck/deck-strip.tsx +++ b/app/src/components/deck/deck-strip.tsx @@ -285,6 +285,7 @@ function AskCard({ a }: { a: StripAgent }) { {question ? "Answer here" : "Bring in"}
+

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

@@ -456,7 +457,6 @@ function Tray({ agents, visible, count, waiting }: { agents: StripAgent[]; visib {a.title} {on && "on screen"} - );