diff --git a/app/e2e/a11y-scenes.ts b/app/e2e/a11y-scenes.ts index 7aa96432..0e5e3566 100644 --- a/app/e2e/a11y-scenes.ts +++ b/app/e2e/a11y-scenes.ts @@ -351,6 +351,16 @@ export const scenes: Scene[] = [ await expect(app.page.getByRole("navigation", { name: "Agents" })).toBeVisible(); }, }, + { + // Labs › Layout › Inbox (components/inbox): the agents' list, the cursor + // on its first row, Allow and Deny on it. + id: "inbox", + async run(app, theme) { + await app.context.addInitScript((t) => localStorage.setItem("berth.ui", JSON.stringify({ themeId: t })), theme); + await app.page.goto("/?mock=1&layout=inbox"); + await expect(app.page.locator("[data-testid=inbox-row][data-cursor]")).toBeVisible(); + }, + }, { id: "artifact-card", async run(app, theme) { diff --git a/app/e2e/inbox.spec.ts b/app/e2e/inbox.spec.ts new file mode 100644 index 00000000..b29ddbd9 --- /dev/null +++ b/app/e2e/inbox.spec.ts @@ -0,0 +1,143 @@ +import { type App, expect, mockOnly, test } from "./fixtures"; + +// Labs › Layout › Inbox (components/inbox): every agent in one list by what +// it needs from you, the worktree you open beside it, and keys to triage. + +async function openInbox(app: App) { + await app.page.goto("/?mock=1&layout=inbox"); + await expect(app.page.getByTestId("inbox")).toBeVisible(); + // Connected: the agents are listed. + await expect(app.page.getByTestId("inbox-row").first()).toBeVisible(); +} + +const cursorRow = (app: App) => app.page.locator("[data-testid=inbox-row][data-cursor]"); + +test("the inbox lists agents by what they need, and the keyboard triages them", async ({ app }) => { + mockOnly(); + await openInbox(app); + const page = app.page; + const sections = page.getByTestId("inbox-section"); + await expect(sections).toHaveCount(3); + expect(await sections.evaluateAll((els) => els.map((e) => e.getAttribute("data-section")))).toEqual(["needs", "working", "recent"]); + // The agent that has waited longest is on top, and says what it asks. + const top = page.locator("[data-testid=inbox-row]").first(); + await expect(top).toHaveAttribute("data-id", "devl/checkout-fix-claude"); + await expect(top).toContainText("Fix checkout webhook retries"); + await expect(top).toContainText("pnpm prisma migrate dev"); + + // The list has the keyboard: j and k move the cursor. + await expect(cursorRow(app)).toHaveAttribute("data-id", "devl/checkout-fix-claude"); + await expect(page.locator("[data-inbox-row='devl/checkout-fix-claude']")).toBeFocused(); + await page.keyboard.press("j"); + const second = await page.locator("[data-testid=inbox-row]").nth(1).getAttribute("data-id"); + await expect(cursorRow(app)).toHaveAttribute("data-id", second!); + await page.keyboard.press("k"); + await expect(cursorRow(app)).toHaveAttribute("data-id", "devl/checkout-fix-claude"); + + // e clears it away, the cursor goes to the next row; z brings it back. + await page.keyboard.press("e"); + await expect(page.locator("[data-testid=inbox-row][data-id='devl/checkout-fix-claude']")).toHaveCount(0); + await expect(cursorRow(app)).toHaveAttribute("data-id", second!); + await expect(page.getByTestId("inbox-show-done")).toContainText("1 cleared"); + await page.keyboard.press("z"); + await expect(page.locator("[data-testid=inbox-row][data-id='devl/checkout-fix-claude']")).toBeVisible(); + await expect(page.getByTestId("inbox-show-done")).toHaveCount(0); + + // y allows the ask under the cursor, as its button says. + const ask = page.locator("[data-testid=inbox-row][data-id='devl/checkout-fix-claude']"); + await expect(ask.getByRole("button", { name: /^Allow once/ })).toBeVisible(); + await page.locator("[data-inbox-row='devl/checkout-fix-claude']").focus(); + await page.keyboard.press("y"); + await expect(ask).toContainText("Allowed · resuming"); + + // ↵ opens the agent's worktree beside the list, its tab in front. + await expect(async () => { + await page.locator("[data-inbox-row='devl/checkout-fix-claude']").focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("[data-testid=inbox-row][data-id='devl/checkout-fix-claude']")).toHaveAttribute("data-open", "true", { timeout: 2000 }); + }).toPass(); + await expect(page.locator("[role=tab][aria-selected=true]")).toHaveAccessibleName(/Fix checkout webhook retries/); + await expect(app.panes.first()).toBeVisible(); + + // ⌘J brings the keyboard back to the list from the worktree. + await expect(async () => { + await page.keyboard.press("ControlOrMeta+j"); + await expect(page.locator("[data-inbox-row]:focus")).toHaveCount(1, { timeout: 1000 }); + }).toPass(); + // Another worktree, then b goes back to the last one. + const other = page.locator("[data-testid=inbox-row]:not([data-id='devl/checkout-fix-claude'])").first(); + const otherId = await other.getAttribute("data-id"); + await other.locator("[data-inbox-row]").click(); + await expect(other).toHaveAttribute("data-open", "true"); + await expect(async () => { + await page.keyboard.press("ControlOrMeta+j"); + await expect(page.locator("[data-inbox-row]:focus")).toHaveCount(1, { timeout: 1000 }); + }).toPass(); + await page.keyboard.press("b"); + await expect(page.locator("[data-testid=inbox-row][data-id='devl/checkout-fix-claude']")).toHaveAttribute("data-open", "true"); + await expect(page.locator(`[data-testid=inbox-row][data-id='${otherId}']`)).not.toHaveAttribute("data-open", "true"); +}); + +test("New task brings the composer forward and starts work", async ({ app }) => { + mockOnly("starts an agent"); + await openInbox(app); + const page = app.page; + await page.getByTestId("inbox-compose").click(); + await expect(page.getByTestId("inbox-home")).toBeVisible(); + const box = page.getByTestId("task-composer").getByRole("textbox").first(); + await expect(box).toBeFocused(); + await page.keyboard.type("Add a health check endpoint"); + await page.getByTestId("task-composer").getByRole("button", { name: "Start" }).click(); + // The new task's worktree opens beside the list. + await expect(app.panes.first()).toBeVisible(); + await expect(page.getByTestId("inbox-home")).toBeHidden(); +}); + +test("c starts a task from the list too", async ({ app }) => { + mockOnly(); + await openInbox(app); + const page = app.page; + await expect(page.locator("[data-inbox-row]:focus")).toHaveCount(1); + await page.keyboard.press("c"); + await expect(page.getByTestId("task-composer").getByRole("textbox").first()).toBeFocused(); +}); + +test("the projects tree, Review and Settings are a click away, and Labs switches back", async ({ app }) => { + mockOnly(); + await openInbox(app); + const page = app.page; + + // The tree, a tab away, opens a worktree too. + await page.getByTestId("inbox-tab-projects").click(); + await app.openWorktree("devl/search-perf"); + + // Review, at the foot. + await page.getByTestId("inbox-nav-review").click(); + await expect(page.getByTestId("inbox-nav-review")).toHaveAttribute("aria-current", "page"); + + // Settings › Labs › Layout: back to the sidebar. + const labs = await app.openSettings("labs"); + await labs.getByRole("button", { name: "Sidebar", exact: true }).click(); + await expect(page.getByTestId("inbox")).toBeHidden(); + await expect(page.getByTestId("nav-home")).toBeVisible(); +}); + +test("a narrow window folds the list while a worktree is open, and ⌘J lays it over", async ({ app }) => { + mockOnly(); + await app.page.setViewportSize({ width: 900, height: 700 }); + await openInbox(app); + const page = app.page; + await page.keyboard.press("Enter"); + await expect(page.getByTestId("inbox-strip")).toBeVisible(); + await expect(page.getByTestId("inbox")).toBeHidden(); + await page.getByTestId("inbox-unfold").click(); + await expect(page.getByTestId("inbox")).toBeVisible(); + // Picking a row puts it away again. + const pick = page.locator("[data-testid=inbox-row][data-state=running]").first(); + const id = await pick.getAttribute("data-id"); + await pick.locator("[data-inbox-row]").click(); + await expect(page.getByTestId("inbox")).toBeHidden(); + await expect(page.locator(`[role=tab][aria-selected=true][data-ws]`)).toBeVisible(); + expect(id).toBeTruthy(); + await expect(page.getByTestId("inbox-strip")).toBeVisible(); +}); diff --git a/app/package.json b/app/package.json index 3c57f969..32bd1314 100644 --- a/app/package.json +++ b/app/package.json @@ -21,7 +21,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts ../site/review/review.test.mjs" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/inbox.test.ts ../site/review/review.test.mjs" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index 4428588e..95285fca 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,6 +4,9 @@ import { lazy, Suspense, useEffect } from "react"; import { AddLocationDialog } from "@/components/add-location-dialog"; import { Connecting } from "@/components/agent-offline"; import { AppSidebar } from "@/components/app-sidebar"; +import { InboxHome } from "@/components/inbox/inbox-home"; +import { InboxPane } from "@/components/inbox/inbox-pane"; +import { useInboxOn } from "@/components/inbox/inbox-state"; import { CommandPalette } from "@/components/command-palette"; import { FilePicker } from "@/components/files/file-picker"; import { TreeDockFrame } from "@/components/files/tree-dock"; @@ -99,6 +102,8 @@ export default function App() { const workspace = view.kind === "workspace"; // Labs: zen (⌘.) puts away the sidebar, the tab strip and the status bar. const zen = usePrefs((p) => p.labs && p.zen); + // Labs › Layout › Inbox: the agents' list in the sidebar's place. + const inbox = useInboxOn(); // 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]); @@ -161,7 +166,7 @@ export default function App() {
{!zen && ( - + {inbox ? : } )}
@@ -267,7 +272,8 @@ function MainView() { // to start work, and the agents and worktrees to go back to. Labs adds the // harbour across the top. function NoWorktree() { - return ; + const inbox = useInboxOn(); + return inbox ? : ; } // Disconnectable dims what cannot work until the agent answers. diff --git a/app/src/components/inbox/inbox-home.tsx b/app/src/components/inbox/inbox-home.tsx new file mode 100644 index 00000000..21bda56a --- /dev/null +++ b/app/src/components/inbox/inbox-home.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; + +import { DitherBand } from "@/components/art/dither-band"; +import { HARBOUR, HARBOUR_MUTE, useHarbourLight } from "@/components/art/harbour-art"; +import { TaskComposer } from "@/components/conversation/task-composer"; +import { useInbox } from "@/components/inbox/inbox-state"; +import { Kbd } from "@/components/ui/kbd"; +import { TeamSuggestCards } from "@/views/home/team-suggest-card"; + +// InboxHome is what is beside the inbox with no worktree open: the +// composer, to start work (c or New task bring it here with the keyboard), +// and the inbox's keys, so they can be learned at a glance. The agents +// themselves are in the list, so Home's widgets would only say it twice. + +const BAND = "clamp(180px, 40vh, 400px)"; + +const KEYS: [string, string][] = [ + ["j k", "move"], + ["↵", "open"], + ["e", "clear"], + ["z", "undo"], + ["y n", "allow, deny"], + ["b", "last worktree"], + ["c", "new task"], + ["⌘J", "back to the list"], + ["?", "all keys"], +]; + +export function InboxHome() { + const light = useHarbourLight(); + const composeAsk = useInbox((s) => s.composeAsk); + const ref = useRef(null); + useEffect(() => { + if (!composeAsk) return; + const id = requestAnimationFrame(() => ref.current?.querySelector("textarea, [contenteditable=true]")?.focus()); + return () => cancelAnimationFrame(id); + }, [composeAsk]); + return ( +
+
+
+ +
+
+

What should your agents work on?

+ {/* Not focused on its own: the keyboard is the list's (j, k, ↵) + until c or New task bring it here. */} + + +
    + {KEYS.map(([k, what]) => ( +
  • + {k} + {what} +
  • + ))} +
+
+
+
+ ); +} diff --git a/app/src/components/inbox/inbox-pane.tsx b/app/src/components/inbox/inbox-pane.tsx new file mode 100644 index 00000000..672f541b --- /dev/null +++ b/app/src/components/inbox/inbox-pane.tsx @@ -0,0 +1,721 @@ +import { + EllipsisIcon, + FolderPlusIcon, + InboxIcon, + ListFilterIcon, + PanelLeftCloseIcon, + PanelLeftOpenIcon, + SearchIcon, + ServerIcon, + SettingsIcon, + SquarePenIcon, + XIcon, +} from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Scene } from "@/components/art/scenes"; +import { compose, focusInbox, goBack, useInbox, useInboxItems } from "@/components/inbox/inbox-state"; +import { InboxRow } from "@/components/inbox/inbox-row"; +import { NotificationBell } from "@/components/notifications/notification-center"; +import { MoreItems, useArrangedNav } from "@/components/sidebar/nav"; +import { Projects, useSidebarPrefs } from "@/components/sidebar/projects"; +import { RailAgents } from "@/components/sidebar/rail"; +import { RowLayer } from "@/components/sidebar/row-layer"; +import { Tip } from "@/components/tip"; +import { Kbd } from "@/components/ui/kbd"; +import { Menu, MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu"; +import { SidebarContext, type SidebarContextProps } from "@/components/ui/sidebar"; +import { toastManager } from "@/components/ui/toast"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { afterDone, arrange, filterOn, type Hold, holdOf, type InboxItem, type InboxSection, markDone, prune, SECTION_WORDS, step, undone } from "@/lib/inbox"; +import { usePrefs } from "@/lib/prefs"; +import { useProjects } from "@/lib/project-groups"; +import { NONE, useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { homeBox, openSession, useWorkspaces, wsKey } from "@/lib/workspaces"; +import { openAddBox } from "@/views/onboarding/add-box-dialog"; +import { useReviewCount } from "@/views/review/review-store"; + +// The inbox layout (Labs › Layout › Inbox): instead of a sidebar of places +// and projects, one list of every agent by what it needs from you, beside +// the worktree you open from it. j and k move, ↵ opens, e marks done, z +// undoes, c starts new work, ⌘J comes back to the list from anywhere. +// The projects tree is a tab away; Review, Automations and the rest are +// at the foot, Settings beside them. +// +// ⌘\ folds the list into a strip of the agents by state (the sidebar's +// rail). A narrow window folds it on its own while a worktree is open, and +// the strip's inbox button lays the list over the worktree until a row is +// picked. + +const alwaysOpen: SidebarContextProps = { + state: "expanded", + open: true, + setOpen: () => {}, + openMobile: false, + setOpenMobile: () => {}, + isMobile: false, + toggleSidebar: () => {}, +}; + +// Recent shows this many (one line each) until asked for the rest. +const RECENT = 12; + +export function InboxPane() { + const collapsed = usePrefs((p) => p.sidebarCollapsed); + const narrow = useMediaQuery("max-lg"); + const hasWorktree = useWorkspaces((s) => !!s.current && !homeBox(s.current)); + const inWorkspace = useStore((s) => s.view.kind === "workspace"); + const worktreeOpen = hasWorktree && inWorkspace; + const peek = useInbox((s) => s.peek); + const folded = collapsed || (narrow && worktreeOpen); + + useEffect(() => { + if (!folded) useInbox.setState({ peek: false }); + }, [folded]); + + if (!folded) return ; + return ( + <> + + {peek && ( + <> +
useInbox.setState({ peek: false })} /> +
e.key === "Escape" && useInbox.setState({ peek: false })}> + +
+ + )} + + ); +} + +// ---- The list --------------------------------------------------------------- + +function InboxList({ overlay }: { overlay?: boolean }) { + const tab = useInbox((s) => s.tab); + const view = useStore((s) => s.view); + const setView = useStore((s) => s.setView); + const context = useMemo(() => alwaysOpen, []); + const noBoxes = useStore((s) => !!s.status && s.status.boxes.length === 0); + + return ( + + + + ); +} + +function TabButton({ id, label }: { id: "inbox" | "projects"; label: string }) { + const tab = useInbox((s) => s.tab); + const items = useInboxItems(); + const needs = items.filter((i) => i.section === "needs").length; + const on = tab === id; + return ( + + ); +} + +// ---- Triage ----------------------------------------------------------------- + +function TriageList() { + const items = useInboxItems(); + const done = useInbox((s) => s.done); + const filter = useInbox((s) => s.filter); + const showDone = useInbox((s) => s.showDone); + const cursor = useInbox((s) => s.cursor); + const focusAsk = useInbox((s) => s.focusAsk); + const current = useWorkspaces((s) => s.current); + const inWorkspace = useStore((s) => s.view.kind === "workspace"); + const loading = useStore((s) => !s.status || s.status.boxes.some((b) => b.state === "online" && !s.boxes[b.name]?.sessions)); + const statusBoxes = useStore((s) => s.status?.boxes ?? NONE); + const offline = useMemo(() => statusBoxes.filter((b) => b.state !== "online").map((b) => b.name), [statusBoxes]); + const [allRecent, setAllRecent] = useState(false); + // Home has its own legend; the strip at the foot is for elsewhere. + const onHome = useWorkspaces((s) => !s.current) && inWorkspace; + // ? shows every key at the list's foot, until pressed again. + const [allKeys, setAllKeys] = useState(false); + const listRef = useRef(null); + + // Rows hold their places while the pointer or the keyboard is in the list. + const [hold, setHold] = useState(); + const inside = useRef({ pointer: false, focus: false }); + const { sections, hidden } = useMemo(() => arrange(items, { done, filter, showDone, hold }), [items, done, filter, showDone, hold]); + const sectionsRef = useRef(sections); + sectionsRef.current = sections; + const holdOn = (what: "pointer" | "focus", on: boolean) => { + const was = inside.current.pointer || inside.current.focus; + inside.current[what] = on; + const now = inside.current.pointer || inside.current.focus; + if (now && !was) setHold(holdOf(sectionsRef.current.flatMap((x) => x.items))); + else if (!now && was) setHold(undefined); + }; + const shown = useMemo(() => sections.map((s) => (s.section === "recent" && !allRecent ? { ...s, more: Math.max(0, s.items.length - RECENT), items: s.items.slice(0, RECENT) } : { ...s, more: 0 })), [sections, allRecent]); + const order = useMemo(() => shown.flatMap((s) => s.items), [shown]); + + // Marks for rows that moved on are let go of. + useEffect(() => { + if (loading) return; + const next = prune(done, items); + if (Object.keys(next).length !== Object.keys(done).length) useInbox.setState({ done: next }); + }, [items, done, loading]); + + const focusRow = useCallback((id: string | undefined) => { + useInbox.setState({ cursor: id }); + if (!id) return; + requestAnimationFrame(() => listRef.current?.querySelector(`[data-inbox-row="${CSS.escape(id)}"]`)?.focus({ preventScroll: false })); + }, []); + + const open = useCallback((it: InboxItem) => { + useInbox.setState({ cursor: it.id, peek: false }); + openSession(it.box, it.session); + }, []); + + const toggleDone = useCallback( + (it: InboxItem) => { + const s = useInbox.getState(); + if (s.done[it.id] === it.since) { + useInbox.setState({ done: undone(s.done, it.id) }); + return; + } + const next = afterDone(order, it.id); + useInbox.setState({ done: markDone(s.done, it), last: { id: it.id, since: it.since } }); + if (listRef.current?.contains(document.activeElement)) focusRow(next); + else useInbox.setState({ cursor: next }); + toastManager.add({ + title: "Cleared", + description: `${it.title} comes back when its agent changes state. z to undo.`, + actionProps: { children: "Undo", onClick: () => undoDone() }, + }); + }, + [order, focusRow], + ); + + // On arrival, with the keyboard nowhere else, the list takes it. + const arrived = useRef(false); + useEffect(() => { + if (arrived.current || !order.length) return; + arrived.current = true; + if (document.activeElement === document.body || !document.activeElement) focusRow(order[0].id); + }, [order, focusRow]); + + // ⌘J and the strip's button: the list takes the keyboard. + useEffect(() => { + if (!focusAsk) return; + const id = order.some((o) => o.id === cursor) ? cursor : order[0]?.id; + focusRow(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusAsk]); + + // The keys. On the window, but only while the keyboard is in the list or + // nowhere at all (a click on empty page), never in a terminal, a text + // field or a dialog. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return; + const t = e.target instanceof HTMLElement ? e.target : null; + const inList = !!t?.closest("[data-inbox]"); + if (t && t !== document.body && !inList) return; + if (t?.closest("input, textarea, select, [contenteditable=true], [role=menu], [role=dialog]")) return; + if (document.querySelector('[data-slot="dialog-popup"], [data-slot="alert-dialog-popup"], [data-slot="sheet-popup"]')) return; + const cur = order.find((o) => o.id === useInbox.getState().cursor); + switch (e.key) { + case "j": + case "ArrowDown": + e.preventDefault(); + focusRow(step(order, cur?.id, 1)); + return; + case "k": + case "ArrowUp": + e.preventDefault(); + focusRow(step(order, cur?.id, -1)); + return; + case "Enter": + if (t?.closest("button") || !cur) return; + e.preventDefault(); + open(cur); + return; + case "o": + if (!cur) return; + e.preventDefault(); + open(cur); + return; + case "e": + case "Backspace": + if (!cur) return; + e.preventDefault(); + toggleDone(cur); + return; + case "z": + e.preventDefault(); + undoDone(); + return; + case "c": + e.preventDefault(); + compose(); + return; + case "b": + e.preventDefault(); + goBack(); + return; + case "/": + e.preventDefault(); + useStore.getState().setPaletteOpen(true); + return; + case "?": + e.preventDefault(); + setAllKeys((v) => !v); + return; + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [order, focusRow, open, toggleDone]); + + const cursorId = order.some((o) => o.id === cursor) ? cursor : order[0]?.id; + const openKey = inWorkspace ? current : undefined; + const needsEmpty = !sections.some((s) => s.section === "needs"); + + return ( + <> + holdOn("pointer", true)} + onPointerLeave={() => holdOn("pointer", false)} + // The keyboard holds the rows once it is used in the list, not when + // the list is merely given it on arrival. + onKeyDown={() => holdOn("focus", true)} + onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node | null) && holdOn("focus", false)} + className="peer/list min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pt-1 pb-3 [mask-image:linear-gradient(to_bottom,black_calc(100%-14px),transparent)]" + > + {loading && !items.length ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : !order.length && !filterOn(filter) ? ( +