diff --git a/app/e2e/switchboard.spec.ts b/app/e2e/switchboard.spec.ts new file mode 100644 index 00000000..f81b48bb --- /dev/null +++ b/app/e2e/switchboard.spec.ts @@ -0,0 +1,137 @@ +import type { Page } from "@playwright/test"; +import { expect, mockOnly, test } from "./fixtures"; + +// The Switchboard (⌘⇧K) is keyboard-first: type to filter, ↑↓ and ↵, ⌘S to +// star, ⌘E for a pickup note, Tab for the next area, ⇧F10 (or right-click, +// or the row's ⋯) for the rest. Its rows' buttons show on hover. + +const title = "Fix checkout webhook retries"; + +// Home's composer takes the keyboard once it is ready, over any dialog open +// by then (the palette's too): open the Switchboard after that. +async function homeReady(page: Page) { + await expect(page.getByRole("textbox", { name: "What should your agents work on?" })).toBeFocused(); +} + +test("Switchboard keeps focused work, pickup notes and areas across a reload, all from the keyboard", async ({ app }) => { + mockOnly(); + await app.open(); + const page = app.page; + await homeReady(page); + await page.getByRole("button", { name: /Open Switchboard/ }).click(); + const board = page.getByRole("dialog", { name: "Switchboard" }); + const search = board.getByRole("combobox", { name: "Search work" }); + await expect(search).toBeFocused(); + + // Typing filters; the first match is highlighted. + await search.fill("webhook"); + const row = board.getByRole("option", { name: new RegExp(`^${title}`) }); + await expect(row).toHaveAttribute("aria-selected", "true"); + + // ⌘S stars it into Focus. + await page.keyboard.press("Meta+s"); + const focus = board.getByRole("group", { name: "Focus" }); + await expect(focus.getByRole("option", { name: new RegExp(`^${title}`) })).toBeVisible(); + + // ⌘E leaves a pickup note; ↵ saves it and the keyboard is back in search. + await page.keyboard.press("Meta+e"); + const note = board.getByRole("textbox", { name: `Pickup note for ${title}` }); + await expect(note).toBeFocused(); + await note.fill("Decide whether to run the migration"); + await page.keyboard.press("Enter"); + await expect(note).toBeHidden(); + await expect(search).toBeFocused(); + await expect(focus).toContainText("Decide whether to run the migration"); + + // A new area, then the row moved into it from its menu (⇧F10). + await search.fill(""); + await board.getByRole("button", { name: "New area" }).click(); + await board.getByRole("textbox", { name: "Area name" }).fill("Launch"); + await page.keyboard.press("Enter"); + await expect(board.getByRole("tab", { name: "Launch" })).toBeVisible(); + await expect(search).toBeFocused(); + await search.fill("webhook"); + await expect(row).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Shift+F10"); + await page.getByRole("menuitem", { name: "Move to area" }).click(); + await page.getByRole("menuitemradio", { name: "Launch" }).click(); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(search).toBeFocused(); + + // Tab steps through the areas: All, Work, Personal, Launch. + await search.fill(""); + await page.keyboard.press("Tab"); + await expect(board.getByRole("tab", { name: "Work" })).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await expect(board.getByRole("tab", { name: "Launch" })).toHaveAttribute("aria-selected", "true"); + await expect(board.getByRole("option")).toHaveCount(1); + await expect(row).toBeVisible(); + + await page.reload(); + await homeReady(page); + await page.keyboard.press("Meta+Shift+K"); + const reopened = page.getByRole("dialog", { name: "Switchboard" }); + const again = reopened.getByRole("group", { name: "Focus" }).getByRole("option", { name: new RegExp(`^${title}`) }); + await expect(again).toContainText("Decide whether to run the migration"); + // Focus is first, so ↵ opens it. + await expect(again).toHaveAttribute("aria-selected", "true"); + await expect(reopened.getByRole("combobox", { name: "Search work" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(reopened).toBeHidden(); + await expect(page.getByTestId("status-worktree")).toContainText("checkout-fix"); +}); + +test("Switchboard moves its highlight with the arrow keys and shows a row's buttons on hover", async ({ app }) => { + mockOnly(); + await app.open(); + const page = app.page; + await homeReady(page); + await page.keyboard.press("Meta+Shift+K"); + const board = page.getByRole("dialog", { name: "Switchboard" }); + const options = board.getByRole("option"); + await expect(options.first()).toHaveAttribute("aria-selected", "true"); + + // ↓ moves down, ↑ from the top wraps to the bottom. + await page.keyboard.press("ArrowDown"); + await expect(options.nth(1)).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowUp"); + await expect(options.last()).toHaveAttribute("aria-selected", "true"); + + // Hovering a row shows its buttons: star it from there. + const row = board.locator("[data-row]").filter({ hasText: title }); + await row.hover(); + await row.getByRole("button", { name: `Add ${title} to Focus` }).click(); + await expect(board.getByRole("group", { name: "Focus" })).toContainText(title); + + // Its ⋯ menu: Esc closes just the menu. From it, a note; Esc leaves the + // note, then the Switchboard. + await row.hover(); + await row.getByRole("button", { name: `More for ${title}` }).click(); + await expect(page.getByRole("menu")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(board).toBeVisible(); + await row.hover(); + await row.getByRole("button", { name: `More for ${title}` }).click(); + await page.getByRole("menuitem", { name: "Add pickup note" }).click(); + const note = board.getByRole("textbox", { name: `Pickup note for ${title}` }); + await expect(note).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(note).toBeHidden(); + await expect(board).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(board).toBeHidden(); +}); + +test("Switchboard says so when nothing matches", async ({ app }) => { + mockOnly(); + await app.open(); + await homeReady(app.page); + await app.page.keyboard.press("Meta+Shift+K"); + const board = app.page.getByRole("dialog", { name: "Switchboard" }); + await board.getByRole("combobox", { name: "Search work" }).fill("zzqx"); + await expect(board).toContainText("Nothing matches “zzqx”"); + await expect(board.getByRole("option")).toHaveCount(0); +}); diff --git a/app/src/App.tsx b/app/src/App.tsx index a885d25b..911772bc 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,6 +5,7 @@ import { AddLocationDialog } from "@/components/add-location-dialog"; import { Connecting } from "@/components/agent-offline"; import { AppSidebar } from "@/components/app-sidebar"; import { CommandPalette } from "@/components/command-palette"; +import { Switchboard } from "@/components/switchboard"; import { FilePicker } from "@/components/files/file-picker"; import { TreeDockFrame } from "@/components/files/tree-dock"; import { WorktreePicker } from "@/components/workspace/worktree-picker"; @@ -204,6 +205,7 @@ export default function App() { + diff --git a/app/src/components/agent-glyph.tsx b/app/src/components/agent-glyph.tsx index 90161d04..6dcdd35a 100644 --- a/app/src/components/agent-glyph.tsx +++ b/app/src/components/agent-glyph.tsx @@ -8,23 +8,26 @@ import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; // AgentIcon marks which agent a session runs, in the agent's own colour. -export function AgentIcon({ agent, className }: { agent?: string; className?: string }) { +// Muted, it takes the colour of the text around it (currentColor), for +// lists where every row has one and state should carry the colour. +export function AgentIcon({ agent, className, muted }: { agent?: string; className?: string; muted?: boolean }) { const cls = cn("size-3.5 shrink-0", className); + const tone = (c: string) => (muted ? undefined : c); switch (agent) { case "claude": - return ; + return ; case "codex": - return ; + return ; case "gemini": - return ; + return ; case "pi": - return π; + return π; case "omp": - return π; + return π; case "opencode": - return ; + return ; default: - return ; + return ; } } diff --git a/app/src/components/command-palette.tsx b/app/src/components/command-palette.tsx index add3818c..194fd628 100644 --- a/app/src/components/command-palette.tsx +++ b/app/src/components/command-palette.tsx @@ -21,6 +21,7 @@ import { GitCompareArrowsIcon, GlobeIcon, LayoutDashboardIcon, + Layers3Icon, PanelTopIcon, PuzzleIcon, RefreshCwIcon, @@ -85,6 +86,7 @@ import { openAddBox } from "@/views/onboarding/add-box-dialog"; import { openAttempts, openComposer } from "@/lib/composer"; import { defaultScope } from "@/views/automations/flows/project-label"; import { placeLabel, worktreeLabel } from "@/lib/worktree-names"; +import { setSwitchboardOpen } from "@/lib/switchboard"; // Settings' sections ⌘K goes to, with words they answer to besides their name. const SETTINGS_SECTIONS: [SettingsSectionId, string, string][] = [ @@ -218,6 +220,7 @@ export function CommandPalette() { { value: "home harbour start", label: "Home", icon: slot(), run: go(goHome) }, { value: "review inbox approve changes", label: "Review", icon: slot(), run: go(() => st.setView({ kind: "review" })) }, { value: "dashboard", label: "Agent Dashboard", icon: slot(), shortcut: keysFor("dashboard"), run: go(() => st.setView({ kind: "dashboard" })) }, + { value: "switchboard focus areas recent work", label: "Switchboard…", icon: slot(), shortcut: keysFor("switchboard"), run: go(() => setSwitchboardOpen(true)) }, ...(hasWhatsNew() ? [{ value: "whats new release notes changes update", label: "What's new in Shipyard", icon: slot(), run: go(() => openWhatsNew("palette")) }] : []), { value: "keyboard shortcuts keys help", label: "Keyboard shortcuts", icon: slot(), shortcut: keysFor("shortcuts"), run: go(openShortcuts) }, { value: "notifications inbox bell", label: "Notifications", icon: slot(), shortcut: keysFor("notifications"), run: go(() => setNotificationsOpen(true)) }, diff --git a/app/src/components/switchboard.tsx b/app/src/components/switchboard.tsx new file mode 100644 index 00000000..470b87de --- /dev/null +++ b/app/src/components/switchboard.tsx @@ -0,0 +1,692 @@ +import { ArrowRightIcon, EllipsisIcon, GitBranchIcon, Layers3Icon, PencilIcon, PlusIcon, SearchIcon, StarIcon, XIcon } from "lucide-react"; +import { type KeyboardEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; + +import { AgentIcon, StateGlyph, stateText } from "@/components/agent-glyph"; +import { Tip } from "@/components/tip"; +import { CommandDialog, CommandDialogPopup, CommandFooter, CommandPanel } from "@/components/ui/command"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuPopup, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubPopup, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Kbd } from "@/components/ui/kbd"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useAllSessions } from "@/hooks/use-agent-counts"; +import { resumeConversation } from "@/lib/actions"; +import { boxApi, type Conversation } from "@/lib/api"; +import { agentOf, sessionName, type SessionState, worktreeOf } from "@/lib/derive"; +import { modKey } from "@/lib/platform"; +import { useProjects } from "@/lib/project-groups"; +import { useStore } from "@/lib/store"; +import { setSwitchboardOpen, switchboardActions, useSwitchboard, useSwitchboardOpen } from "@/lib/switchboard"; +import { boardLists, cycle, shortAgo, stepId } from "@/lib/switchboard-list"; +import { cn } from "@/lib/utils"; +import { openSession, refOf, selectWorktree, useWorkspaces, wsKey } from "@/lib/workspaces"; +import { worktreeLabel } from "@/lib/worktree-names"; + +interface BoardItem { + id: string; + title: string; + place: string; + area: string; + at: number; + agent?: string; + state?: SessionState; + kind: "conversation" | "worktree"; + open(): void; +} + +const date = (s: string | undefined) => (s ? Date.parse(s) || 0 : 0); + +// The Switchboard gathers work from every box. A live or ended session is +// shown once; a structured conversation without its old session still has a +// row that can resume it. Recently visited worktrees fill in work without +// an agent. Areas are the existing project sections, with personal overrides. +// +// It reads like the palette (⌘K): type to filter, ↑↓ and ↵, with Focus +// (starred work, with its pickup note) above Recent. A row's controls show +// only on the highlighted row; the rest are in its menu (right-click, ⋯, or +// ⇧F10), and every one has a key. +export function Switchboard() { + const open = useSwitchboardOpen((s) => s.open); + const choices = useSwitchboard(); + const allSessions = useAllSessions(); + const boxes = useStore((s) => s.boxes); + const status = useStore((s) => s.status); + const client = useStore((s) => s.client); + const spaces = useWorkspaces((s) => s.spaces); + const { projects, sections } = useProjects(); + const [conversations, setConversations] = useState>({}); + const [query, setQuery] = useState(""); + const [area, setArea] = useState("All"); + // The highlighted row, by id, so it stays on the same work as rows move. + const [active, setActive] = useState(); + const [editing, setEditing] = useState(); + // A new area being named: from the strip, or for a row (moved into it). + const [adding, setAdding] = useState<{ assign?: string }>(); + const search = useRef(null); + const list = useRef(null); + // A menu item that moves the keyboard elsewhere (a note, a new area) + // keeps the closing menu from sending it back to the search. + const keep = useRef(false); + const listId = useId(); + const conversationBoxes = (status?.boxes ?? []).filter((b) => b.state === "online" && boxes[b.name]?.info?.capabilities?.includes("conversations")).map((b) => b.name).join("|"); + + useEffect(() => { + if (!open || !client) return; + let live = true; + for (const box of conversationBoxes.split("|").filter(Boolean)) { + void boxApi.conversations(client, box, undefined, 200).then((list) => { + if (live) setConversations((old) => ({ ...old, [box]: list })); + }).catch(() => {}); + } + return () => { live = false; }; + }, [open, client, conversationBoxes]); + + useEffect(() => { + for (const [box, list] of Object.entries(conversations)) for (const conv of list) { + if (conv.session) switchboardActions.migrate(`session:${box}:${conv.session}`, `conversation:${box}:${conv.id}`); + } + }, [conversations]); + + const items = useMemo(() => { + const out: BoardItem[] = []; + const conversationBySession = new Map(); + for (const [box, list] of Object.entries(conversations)) for (const conv of list) if (conv.session) conversationBySession.set(`${box}:${conv.session}`, conv); + const projectByLocation = new Map(); + for (const p of projects) for (const m of p.members) projectByLocation.set(`${m.box.name}/${m.loc.name}`, { name: p.name, area: p.section }); + const areaOf = (id: string, project?: string) => choices.assigned[id] ?? project ?? "Unsorted"; + const sessionIds = new Set(); + for (const { box, session, state } of allSessions) { + const agent = agentOf(session); + if (!agent || session.service) continue; + const conversation = conversationBySession.get(`${box}:${session.name}`); + const id = conversation ? `conversation:${box}:${conversation.id}` : `session:${box}:${session.name}`; + sessionIds.add(`${box}:${session.name}`); + const found = worktreeOf(boxes[box]?.locations, session); + const project = found && projectByLocation.get(`${box}/${found.location.name}`); + out.push({ + id, + title: sessionName(session, { sessions: boxes[box]?.sessions }), + place: found ? `${project?.name ?? found.location.name} / ${worktreeLabel(found.worktree)}` : `${box} / Home`, + area: areaOf(id, project?.area), + at: date(session.state_since) || date(session.created), + agent, + state, + kind: "conversation", + open: () => openSession(box, session), + }); + } + for (const [box, list] of Object.entries(conversations)) for (const conv of list) { + if (conv.session && sessionIds.has(`${box}:${conv.session}`)) continue; + const found = boxes[box]?.locations?.flatMap((l) => (l.worktrees ?? []).map((w) => ({ location: l, worktree: w }))).find((x) => x.worktree.path === conv.dir); + if (!found) continue; + const id = `conversation:${box}:${conv.id}`; + const project = projectByLocation.get(`${box}/${found.location.name}`); + out.push({ + id, + title: conv.title || "Untitled conversation", + place: `${project?.name ?? found.location.name} / ${worktreeLabel(found.worktree)}`, + area: areaOf(id, project?.area), + at: date(conv.updated) || date(conv.created), + agent: conv.agent, + state: "exited", + kind: "conversation", + open: () => { + selectWorktree(refOf(box, found.location, found.worktree)); + void resumeConversation(box, conv, wsKey(box, conv.dir)); + }, + }); + } + for (const [key, ws] of Object.entries(spaces)) { + if (!ws.visitedAt || !ws.ref.location) continue; + const loc = boxes[ws.ref.box]?.locations?.find((l) => l.name === ws.ref.location); + const wt = loc?.worktrees?.find((w) => w.path === ws.ref.path); + if (!loc || !wt) continue; + const id = `worktree:${key}`; + if (!choices.focus.includes(id) && allSessions.some((entry) => entry.box === ws.ref.box && entry.session.dir === wt.path && !!agentOf(entry.session))) continue; + const project = projectByLocation.get(`${ws.ref.box}/${loc.name}`); + out.push({ + id, + title: worktreeLabel(wt), + place: `${project?.name ?? loc.name} / ${ws.ref.box}`, + area: areaOf(id, project?.area), + at: ws.visitedAt, + kind: "worktree", + open: () => selectWorktree(refOf(ws.ref.box, loc, wt)), + }); + } + return out.sort((a, b) => b.at - a.at); + }, [allSessions, boxes, conversations, spaces, projects, choices.assigned, choices.focus]); + + const areas = useMemo(() => ["All", ...new Set([...sections, ...choices.areas, ...items.map((i) => i.area)].filter((x) => !["all", "unsorted"].includes(x.toLowerCase()))), "Unsorted"], [sections, choices.areas, items]); + const { focused, recent } = useMemo(() => boardLists(items, choices.focus, area, query), [items, choices.focus, area, query]); + const rows = useMemo(() => [...focused, ...recent], [focused, recent]); + const current = rows.find((item) => item.id === active) ?? rows[0]; + const now = Date.now(); + + // The highlight stays in sight as the keys move it. + useEffect(() => { + if (!current) return; + list.current?.querySelector(`[data-row="${CSS.escape(current.id)}"]`)?.scrollIntoView({ block: "nearest" }); + }, [current?.id]); + + const enter = (item: BoardItem) => { + setSwitchboardOpen(false); + item.open(); + }; + const close = () => { + setSwitchboardOpen(false); + setQuery(""); + setArea("All"); + setActive(undefined); + setEditing(undefined); + setAdding(undefined); + }; + // Before the field that had the keyboard goes away, so the dialog never + // sends it to itself instead. + const backToSearch = () => { + search.current?.focus(); + requestAnimationFrame(() => search.current?.focus()); + }; + const editNote = (item: BoardItem) => { + setActive(item.id); + setEditing(item.id); + }; + const pickArea = (name: string) => { + setArea(name); + setActive(undefined); + }; + // The row's menu, from the keyboard: as a right-click on the row. + const openMenu = (item: BoardItem, at?: DOMRect) => { + const row = list.current?.querySelector(`[data-row="${CSS.escape(item.id)}"]`); + if (!row) return; + const r = at ?? row.getBoundingClientRect(); + row.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: at ? r.left : r.right - 120, clientY: at ? r.bottom : r.top + r.height / 2, button: 2 })); + }; + + const onSearchKey = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setActive(stepId(rows.map((r) => r.id), current?.id, e.key === "ArrowDown" ? 1 : -1)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (current) enter(current); + } else if (e.key === "Tab") { + e.preventDefault(); + pickArea(cycle(areas, area, e.shiftKey ? -1 : 1)); + } else if (modKey(e) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "s") { + e.preventDefault(); + if (current) switchboardActions.toggleFocus(current.id); + } else if (modKey(e) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "e") { + e.preventDefault(); + if (current) editNote(current); + } else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + if (current) openMenu(current); + } + }; + + const starred = current ? choices.focus.includes(current.id) : false; + const empty = !rows.length; + const row = (item: BoardItem, focus: boolean) => ( + setActive(item.id)} + onOpen={() => enter(item)} + onEditNote={() => editNote(item)} + onNewArea={() => setAdding({ assign: item.id })} + onMenu={(r) => openMenu(item, r)} + onDoneEditing={() => { + setEditing(undefined); + backToSearch(); + }} + keep={keep} + search={search} + /> + ); + + return ( + { + if (value) return setSwitchboardOpen(true); + // Esc leaves a note or a new area first, then the Switchboard. + if (details.reason === "escape-key" && (editing || adding)) { + details.cancel(); + setEditing(undefined); + setAdding(undefined); + backToSearch(); + return; + } + close(); + }} + > + + {/* Positioned, like the palette's input, so the shell's tint + (the popup's ::before) is under it, not over it. */} +
+ + { + setQuery(e.target.value); + setActive(undefined); + }} + onKeyDown={onSearchKey} + placeholder="Find a conversation or worktree…" + className="h-full min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground/72 sm:text-sm" + /> +
+ setAdding({})} + onAdded={(name) => { + switchboardActions.addArea(name); + const clean = name.trim(); + if (clean && adding?.assign) switchboardActions.assign(adding.assign, clean); + setAdding(undefined); + backToSearch(); + }} + onCancel={() => { + setAdding(undefined); + backToSearch(); + }} + /> + + +
+ {empty ? ( + + ) : ( + <> + {focused.length > 0 &&
{focused.map((item) => row(item, true))}
} + {recent.length > 0 &&
{recent.map((item) => row(item, false))}
} + + )} +
+
+
+ + {editing ? ( + + ↵ to save the note + + ) : ( + + {current && ( + <> + move + open + {starred ? "unstar" : "star"} + more + + )} + + )} + + esc {editing ? "to cancel" : "to close"} + + +
+
+ ); +} + +const rowId = (listId: string, id: string) => `${listId}-${id.replace(/[^\w-]/g, "_")}`; + +function Hint({ keys, children }: { keys: string[]; children: ReactNode }) { + return ( + + {keys.map((k) => ( + {k} + ))} + {children} + + ); +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + const id = useId(); + return ( +
+ + {children} +
+ ); +} + +function Empty({ query, area }: { query: string; area: string }) { + const [title, detail] = query.trim() + ? [`Nothing matches “${query.trim()}”`, area === "All" ? undefined : `in ${area}`] + : area === "All" + ? ["No recent work yet", "Conversations and worktrees you open show here."] + : [`Nothing in ${area} yet`, "Move work here from a row’s menu."]; + return ( +
+ +

{title}

+ {detail &&

{detail}

} +
+ ); +} + +function AreaStrip({ areas, area, adding, onPick, onAdd, onAdded, onCancel }: { areas: string[]; area: string; adding?: { assign?: string }; onPick(name: string): void; onAdd(): void; onAdded(name: string): void; onCancel(): void }) { + return ( +
+
+ {areas.map((a) => ( + + ))} +
+ {adding ? ( + + ) : ( + + + + )} + {/* The strip's own key, where it applies, rather than in the footer. */} + + ⇥ next area + +
+ ); +} + +// NewArea names a new area in place of the strip's +. ↵ or leaving the +// field adds it; Esc doesn't. +function NewArea({ onAdded, onCancel }: { onAdded(name: string): void; onCancel(): void }) { + const [name, setName] = useState(""); + const done = useRef(false); + const finish = (add: boolean) => { + if (done.current) return; + done.current = true; + if (add && name.trim()) onAdded(name); + else onCancel(); + }; + return ( +
{ + e.preventDefault(); + finish(true); + }} + > + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") done.current = true; + }} + onBlur={() => finish(true)} + placeholder="New area" + className="h-6 w-28 rounded-md border bg-background px-2 text-xs outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/24" + /> +
+ ); +} + +interface RowProps { + listId: string; + item: BoardItem; + focus: boolean; + note?: string; + showArea: boolean; + areas: string[]; + now: number; + highlighted: boolean; + editing: boolean; + onHighlight(): void; + onOpen(): void; + onEditNote(): void; + onNewArea(): void; + onMenu(at: DOMRect): void; + onDoneEditing(): void; + keep: React.RefObject; + search: React.RefObject; +} + +// Lead is a row's state, as the sidebar leads with it: a spinner while +// working, amber when it needs you, a check when done, a ring when idle, a +// dot once ended. A worktree without an agent leads with its branch. +function Lead({ item }: { item: BoardItem }) { + return ( + + {item.kind === "worktree" || !item.state ? : } + + ); +} + +function Row({ listId, item, focus, note, showArea, areas, now, highlighted, editing, onHighlight, onOpen, onEditNote, onNewArea, onMenu, onDoneEditing, keep, search }: RowProps) { + const starred = focus; + const waiting = item.state === "waiting"; + // Focus rows, and any row with a pickup note, have a second line. + const twoLine = focus || !!note; + const time = shortAgo(item.at, now); + const moveTo = [...new Set([...areas.filter((a) => a !== "All"), item.area])]; + return ( + { + if (o) return onHighlight(); + // Closed: the keyboard goes back to the search, unless what was + // chosen took it (a note, a new area). + if (keep.current) keep.current = false; + else window.setTimeout(() => search.current?.focus(), 0); + }} + > + } + data-row={item.id} + data-highlighted={highlighted || undefined} + onPointerMove={() => !highlighted && onHighlight()} + className="group/row scroll-my-2 rounded-lg data-highlighted:bg-accent data-popup-open:bg-accent dark:data-highlighted:bg-foreground/9 dark:data-popup-open:bg-foreground/9" + > +
+
+ +
+
+ {item.title} + {waiting && Needs you} +
+ {twoLine && ( +
+ {note ? ( + <> + {note} + · {item.place} + + ) : ( + item.place + )} +
+ )} +
+
+ {!twoLine && {item.place}} + {focus && showArea && item.area !== "Unsorted" && {item.area}} + {item.agent && } + {time} +
+
+ {/* The row's buttons fade in over its trailing end on hover, in + place of its details, so the row never moves; the keys (⌘S, + ⌘E, ⇧F10) reach the same from the keyboard. */} + {!editing && ( +
+ switchboardActions.toggleFocus(item.id)}> + + + + + + onMenu(e.currentTarget.getBoundingClientRect())}> + + +
+ )} +
+ {editing && } +
+ + + + Open + ↵ + + switchboardActions.toggleFocus(item.id)}> + + {starred ? "Remove from Focus" : "Add to Focus"} + ⌘S + + { + keep.current = true; + onEditNote(); + }} + > + + {note ? "Edit pickup note" : "Add pickup note"} + ⌘E + + {note && ( + switchboardActions.note(item.id, "")}> + + Clear pickup note + + )} + + + + + Move to area + + + switchboardActions.assign(item.id, v)}> + {moveTo.map((a) => ( + + {a} + + ))} + + + { + keep.current = true; + onNewArea(); + }} + > + + New area… + + + + +
+ ); +} + +function RowButton({ label, tip, pressed, onClick, children }: { label: string; tip: string; pressed?: boolean; onClick(e: React.MouseEvent): void; children: ReactNode }) { + return ( + + + + ); +} + +function NoteEditor({ item, note, onDone }: { item: BoardItem; note?: string; onDone(): void }) { + const [draft, setDraft] = useState(note ?? ""); + const done = useRef(false); + const save = () => { + if (done.current) return; + done.current = true; + switchboardActions.note(item.id, draft); + onDone(); + }; + return ( +
{ + e.preventDefault(); + save(); + }} + > + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") done.current = true; + }} + onBlur={save} + placeholder="Where did you leave off?" + className="h-7 min-w-0 flex-1 rounded-md border bg-background px-2 text-xs outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/24" + /> +
+ ); +} diff --git a/app/src/components/ui/command.tsx b/app/src/components/ui/command.tsx index 6d16fe88..b0883d3b 100644 --- a/app/src/components/ui/command.tsx +++ b/app/src/components/ui/command.tsx @@ -210,7 +210,9 @@ export function CommandItem({ }: React.ComponentProps): React.ReactElement { return ( diff --git a/app/src/hooks/use-shortcuts.ts b/app/src/hooks/use-shortcuts.ts index 130295ac..bd91b84e 100644 --- a/app/src/hooks/use-shortcuts.ts +++ b/app/src/hooks/use-shortcuts.ts @@ -24,6 +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 { setSwitchboardOpen, useSwitchboardOpen } from "@/lib/switchboard"; // 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 @@ -135,8 +136,13 @@ function run(id: string, from: "key" | "menu", arg?: number | Dir): boolean { return true; case "palette": if (useFiles.getState().pickerOpen) setPickerOpen(false); + if (useSwitchboardOpen.getState().open) setSwitchboardOpen(false); s.setPaletteOpen(!s.paletteOpen); return true; + case "switchboard": + if (s.paletteOpen) s.setPaletteOpen(false); + setSwitchboardOpen(!useSwitchboardOpen.getState().open); + return true; case "files": // ⌘P finds a file in the worktree you are acting in. if (s.paletteOpen) s.setPaletteOpen(false); @@ -160,7 +166,9 @@ function run(id: string, from: "key" | "menu", arg?: number | Dir): boolean { handOff("[data-testid=files-panel]", "[data-testid=files-panel]"); return true; case "save-file": { - // ⌘S in a File tab, wherever in it the focus is. + // ⌘S in a File tab, wherever in it the focus is. Over the + // Switchboard, ⌘S stars its highlighted row instead. + if (useSwitchboardOpen.getState().open) return false; const leaf = inWorkspace && tab && wsKey ? findLeaf(tab.root, tab.focus) : undefined; if (!leaf || leaf.content.kind !== "file" || !wsKey) return false; void save(docKey(paneWorktree(wsKey, leaf), leaf.content.path)); @@ -241,7 +249,7 @@ function fromKey(e: KeyboardEvent): [string, (number | Dir)?] | undefined { if (key === ".") return ["zen"]; if (key === "\\") return ["sidebar"]; if (key === "/" && !shift) return ["shortcuts"]; - if (key === "k") return ["palette"]; + if (key === "k") return [shift ? "switchboard" : "palette"]; if (key === "p" && !shift) return ["files"]; if (key === "s" && !shift) return ["save-file"]; if (key === "e" && shift) return ["file-tree"]; diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 005a2b2e..52729899 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -271,8 +271,12 @@ export const boxApi = { stopSession: (c: Client, box: string, name: string) => c.box(box, "DELETE", `sessions/${encodeURIComponent(name)}`), // A structured agent's conversations, those that worked in path // ("conversations" capability). - conversations: async (c: Client, box: string, path?: string) => - (await c.box(box, "GET", `conversations${path ? `?path=${encodeURIComponent(path)}` : ""}`)) ?? [], + conversations: async (c: Client, box: string, path?: string, limit?: number) => { + const params = new URLSearchParams(); + if (path) params.set("path", path); + if (limit) params.set("limit", String(limit)); + return (await c.box(box, "GET", `conversations${params.size ? `?${params}` : ""}`)) ?? []; + }, // Carries a conversation on: in its session, started again if it ended, // or in a new one when that is gone. resumeConversation: (c: Client, box: string, id: string) => c.box(box, "POST", `conversations/${encodeURIComponent(id)}/resume`), diff --git a/app/src/lib/palette-shortcuts.ts b/app/src/lib/palette-shortcuts.ts index 6bd8afc6..0f7281a6 100644 --- a/app/src/lib/palette-shortcuts.ts +++ b/app/src/lib/palette-shortcuts.ts @@ -4,7 +4,7 @@ // palette-shortcuts.test.ts checks every shortcut is in one list here. // The palette has its own item for these, with words of its own. -export const OWN_ITEMS = ["new-worktree", "new-terminal", "new-browser", "open-editor", "split-worktree", "zen", "dashboard", "notifications", "shortcuts", "rename"] as const; +export const OWN_ITEMS = ["new-worktree", "new-terminal", "new-browser", "open-editor", "split-worktree", "zen", "dashboard", "switchboard", "notifications", "shortcuts", "rename"] as const; // These it lists as the shortcut table names them; labs ones with Labs on. export const FROM_TABLE = ["files", "split-right", "split-down", "compare", "close-pane", "close-group", "file-tree", "devtools", "sidebar", "zoom-in", "zoom-out", "zoom-reset", "compare-swap", "prev-group", "next-group"] as const; diff --git a/app/src/lib/shortcuts.json b/app/src/lib/shortcuts.json index 6e1c009d..dc0fc7e6 100644 --- a/app/src/lib/shortcuts.json +++ b/app/src/lib/shortcuts.json @@ -24,6 +24,7 @@ { "id": "zoom-out", "group": "View", "label": "Zoom out", "keys": "⌘−", "accel": "CmdOrCtrl+-" }, { "id": "zoom-reset", "group": "View", "label": "Actual size", "keys": "⌘0", "accel": "CmdOrCtrl+0" }, { "id": "palette", "group": "Go", "label": "Search and commands…", "keys": "⌘K", "accel": "CmdOrCtrl+K" }, + { "id": "switchboard", "group": "Go", "label": "Switchboard…", "keys": "⌘⇧K", "accel": "CmdOrCtrl+Shift+K", "what": "Pick up focused and recent work across areas" }, { "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" }, diff --git a/app/src/lib/switchboard-list.test.ts b/app/src/lib/switchboard-list.test.ts new file mode 100644 index 00000000..f3aacde9 --- /dev/null +++ b/app/src/lib/switchboard-list.test.ts @@ -0,0 +1,66 @@ +// node --experimental-strip-types --test src/lib/switchboard-list.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { boardLists, cycle, matches, shortAgo, stepId } from "./switchboard-list.ts"; + +const item = (id: string, title: string, area = "Work", place = "shop / main") => ({ id, title, place, area }); +const items = [item("a", "Fix checkout webhook retries"), item("b", "Tune the judge prompt", "Personal", "evals / judge-v2"), item("c", "Plan the checkout release"), item("d", "Speed up product search")]; + +test("a query matches every word, in any order, across title, place and area", () => { + assert.equal(matches(items[0], "All", "retries checkout"), true); + assert.equal(matches(items[1], "All", "evals tune"), true); + assert.equal(matches(items[1], "All", "personal"), true); + assert.equal(matches(items[0], "All", "checkout refund"), false); + assert.equal(matches(items[0], "All", " "), true); +}); + +test("an area shows only its own work; All shows everything", () => { + assert.equal(matches(items[1], "Work", ""), false); + assert.equal(matches(items[1], "Personal", ""), true); + assert.equal(matches(items[1], "All", ""), true); +}); + +test("Focus keeps the order work was starred in and leaves Recent", () => { + const { focused, recent } = boardLists(items, ["c", "a"], "All", ""); + assert.deepEqual(focused.map((i) => i.id), ["c", "a"]); + assert.deepEqual(recent.map((i) => i.id), ["b", "d"]); +}); + +test("Focus and Recent both follow the area and the query", () => { + const { focused, recent } = boardLists(items, ["a", "b"], "Work", "checkout"); + assert.deepEqual(focused.map((i) => i.id), ["a"]); + assert.deepEqual(recent.map((i) => i.id), ["c"]); +}); + +test("Recent is capped", () => { + assert.equal(boardLists(items, [], "All", "", 2).recent.length, 2); +}); + +test("the highlight steps and wraps, and starts over when its row is gone", () => { + const ids = ["a", "b", "c"]; + assert.equal(stepId(ids, "a", 1), "b"); + assert.equal(stepId(ids, "c", 1), "a"); + assert.equal(stepId(ids, "a", -1), "c"); + assert.equal(stepId(ids, "gone", 1), "a"); + assert.equal(stepId(ids, undefined, -1), "c"); + assert.equal(stepId([], "a", 1), undefined); +}); + +test("Tab cycles through areas both ways", () => { + const areas = ["All", "Work", "Personal", "Unsorted"]; + assert.equal(cycle(areas, "All", 1), "Work"); + assert.equal(cycle(areas, "Unsorted", 1), "All"); + assert.equal(cycle(areas, "All", -1), "Unsorted"); + assert.equal(cycle(areas, "Gone", 1), "All"); +}); + +test("times fit a narrow column", () => { + const now = 10_000_000_000; + assert.equal(shortAgo(now - 5_000, now), "now"); + assert.equal(shortAgo(now - 4 * 60_000, now), "4m"); + assert.equal(shortAgo(now - 3 * 3_600_000, now), "3h"); + assert.equal(shortAgo(now - 2 * 86_400_000, now), "2d"); + assert.equal(shortAgo(now - 15 * 86_400_000, now), "2w"); + assert.equal(shortAgo(0, now), ""); +}); diff --git a/app/src/lib/switchboard-list.ts b/app/src/lib/switchboard-list.ts new file mode 100644 index 00000000..ae01cb3f --- /dev/null +++ b/app/src/lib/switchboard-list.ts @@ -0,0 +1,55 @@ +// What the Switchboard lists, and how its keys move through it: pure, so +// switchboard-list.test.ts can check it without a page. + +export interface ListItem { + id: string; + title: string; + place: string; + area: string; +} + +// matches is whether an item shows in an area for a query: every word of the +// query is somewhere in its title, place or area, in any order. +export function matches(item: ListItem, area: string, query: string): boolean { + if (area !== "All" && item.area !== area) return false; + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const text = `${item.title} ${item.place} ${item.area}`.toLowerCase(); + return words.every((w) => text.includes(w)); +} + +// boardLists splits what matches into Focus, in the order it was starred, +// and the most recent of the rest. +export function boardLists(items: T[], focus: string[], area: string, query: string, limit = 24): { focused: T[]; recent: T[] } { + const shown = items.filter((item) => matches(item, area, query)); + const byId = new Map(shown.map((item) => [item.id, item])); + const focused = focus.map((id) => byId.get(id)).filter((item): item is T => !!item); + const starred = new Set(focus); + return { focused, recent: shown.filter((item) => !starred.has(item.id)).slice(0, limit) }; +} + +// stepId is the id ↑ or ↓ moves the highlight to, wrapping at the ends. A +// highlight that is gone (filtered away) starts again from the top. +export function stepId(ids: string[], current: string | undefined, delta: number): string | undefined { + if (!ids.length) return undefined; + const at = current === undefined ? -1 : ids.indexOf(current); + if (at < 0) return delta < 0 ? ids[ids.length - 1] : ids[0]; + return ids[(((at + delta) % ids.length) + ids.length) % ids.length]; +} + +// cycle is the next (or previous) of a list, wrapping: Tab through areas. +export function cycle(list: T[], current: T, delta: number): T { + const at = list.indexOf(current); + if (at < 0) return list[0]; + return list[(((at + delta) % list.length) + list.length) % list.length]; +} + +// shortAgo is how long ago, in a column's few characters: now, 4m, 2h, 3d. +export function shortAgo(at: number, now: number): string { + if (!at) return ""; + const s = Math.max(0, (now - at) / 1000); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`; + return `${Math.floor(s / (86400 * 7))}w`; +} diff --git a/app/src/lib/switchboard.ts b/app/src/lib/switchboard.ts new file mode 100644 index 00000000..02e2adeb --- /dev/null +++ b/app/src/lib/switchboard.ts @@ -0,0 +1,73 @@ +import { create } from "zustand"; + +import { load, save } from "@/lib/storage"; + +// A person's lightweight choices for the Switchboard. The underlying work +// stays on its box; these labels can be rebuilt or changed without moving it. +interface SwitchboardDoc { + focus: string[]; + areas: string[]; + assigned: Record; + notes: Record; +} + +const KEY = "berth.switchboard"; +const empty: SwitchboardDoc = { focus: [], areas: [], assigned: {}, notes: {} }; +const saved = load>(KEY, empty); +const initial: SwitchboardDoc = { + focus: Array.isArray(saved.focus) ? saved.focus.filter((x): x is string => typeof x === "string") : [], + areas: Array.isArray(saved.areas) ? saved.areas.filter((x): x is string => typeof x === "string") : [], + assigned: saved.assigned && typeof saved.assigned === "object" ? saved.assigned : {}, + notes: saved.notes && typeof saved.notes === "object" ? saved.notes : {}, +}; + +export const useSwitchboard = create()(() => initial); +export const useSwitchboardOpen = create<{ open: boolean }>()(() => ({ open: false })); +export const setSwitchboardOpen = (open: boolean) => useSwitchboardOpen.setState({ open }); + +function update(fn: (doc: SwitchboardDoc) => SwitchboardDoc) { + useSwitchboard.setState((doc) => { + const next = fn(doc); + if (next !== doc) save(KEY, next); + return next; + }); +} + +export const switchboardActions = { + toggleFocus(id: string) { + update((d) => ({ ...d, focus: d.focus.includes(id) ? d.focus.filter((x) => x !== id) : [...d.focus, id] })); + }, + addArea(name: string) { + const area = name.trim(); + if (area && !["all", "unsorted"].includes(area.toLowerCase())) update((d) => ({ ...d, areas: d.areas.some((a) => a.toLowerCase() === area.toLowerCase()) ? d.areas : [...d.areas, area] })); + }, + assign(id: string, area?: string) { + update((d) => { + const assigned = { ...d.assigned }; + if (area) assigned[id] = area; + else delete assigned[id]; + return { ...d, assigned }; + }); + }, + note(id: string, value: string) { + update((d) => { + const notes = { ...d.notes }; + const note = value.trim(); + if (note) notes[id] = note; + else delete notes[id]; + return { ...d, notes }; + }); + }, + migrate(from: string, to: string) { + if (from === to) return; + update((d) => { + if (!d.focus.includes(from) && !(from in d.assigned) && !(from in d.notes)) return d; + const focus = [...new Set(d.focus.map((id) => id === from ? to : id))]; + const assigned = { ...d.assigned }; + const notes = { ...d.notes }; + if (from in assigned) { assigned[to] ??= assigned[from]; delete assigned[from]; } + if (from in notes) { notes[to] ??= notes[from]; delete notes[from]; } + return { ...d, focus, assigned, notes }; + }); + }, +}; diff --git a/app/src/views/home/home-view.tsx b/app/src/views/home/home-view.tsx index a8a2674a..849d7b68 100644 --- a/app/src/views/home/home-view.tsx +++ b/app/src/views/home/home-view.tsx @@ -5,6 +5,9 @@ import { cn } from "@/lib/utils"; import { usePrefs } from "@/lib/prefs"; import { HomeGrid } from "@/views/home/widgets/grid"; import { TeamSuggestCards } from "@/views/home/team-suggest-card"; +import { setSwitchboardOpen } from "@/lib/switchboard"; +import { Layers3Icon } from "lucide-react"; +import { platformKeys } from "@/lib/platform"; // HomeView is the workspace before any worktree is open: one composer to // start work (lib/composer), then the person's own grid of widgets @@ -30,6 +33,9 @@ export function HomeView() { )}

What should your agents work on?

+
diff --git a/design/switchboard/area-work-light.png b/design/switchboard/area-work-light.png new file mode 100644 index 00000000..cd15c5d0 Binary files /dev/null and b/design/switchboard/area-work-light.png differ diff --git a/design/switchboard/focus-dark.png b/design/switchboard/focus-dark.png new file mode 100644 index 00000000..72251247 Binary files /dev/null and b/design/switchboard/focus-dark.png differ diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 359fdd01..b0d9b0c0 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,7 @@ again. ## Unreleased +- **Switchboard:** Pick up focused and recent conversations and worktrees with ⌘⇧K or from Home. Type to find one, ↑ ↓ and ↵ to open it, ⌘S to star it into Focus, ⌘E to leave a pickup note, and Tab to step through your areas. Right-click a row (or ⇧F10) to move it to another area. Your focus, areas and notes stay on this computer. - **Choose the picture at the top of a new chat.** Settings › Appearance › Header picture picks the painting across the top of a new chat, an empty worktree and Home: the harbour (still the default), one of six new