From 42cd63f94bee584e58af75a2b56b8e9ea45438fc Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 21:09:06 +0100 Subject: [PATCH 1/5] feat(app): labs inbox layout, every agent in one list with what needs you on top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Labs › Layout › Inbox swaps the sidebar for a triage list: Needs you, Working, Recent. Allow or deny on the row, j/k to move, Enter to open the worktree beside the list, e for done, z to undo, c for a new task, ⌘J back to the list. The projects tree is a toggle away; narrow windows fold the list to a strip while a worktree is open. Co-Authored-By: Claude Opus 5.5 --- app/e2e/a11y-scenes.ts | 10 + app/e2e/inbox.spec.ts | 127 +++++ app/package.json | 2 +- app/src/App.tsx | 10 +- app/src/components/inbox/inbox-home.tsx | 61 +++ app/src/components/inbox/inbox-pane.tsx | 697 ++++++++++++++++++++++++ app/src/components/inbox/inbox-row.tsx | 189 +++++++ app/src/components/inbox/inbox-state.ts | 112 ++++ app/src/hooks/use-shortcuts.ts | 7 + app/src/lib/inbox.test.ts | 91 ++++ app/src/lib/inbox.ts | 142 +++++ app/src/lib/prefs.ts | 11 +- app/src/views/home/widgets/agents.tsx | 4 +- app/src/views/settings/labs-section.tsx | 13 + docs/changelog.mdx | 6 + 15 files changed, 1475 insertions(+), 7 deletions(-) create mode 100644 app/e2e/inbox.spec.ts create mode 100644 app/src/components/inbox/inbox-home.tsx create mode 100644 app/src/components/inbox/inbox-pane.tsx create mode 100644 app/src/components/inbox/inbox-row.tsx create mode 100644 app/src/components/inbox/inbox-state.ts create mode 100644 app/src/lib/inbox.test.ts create mode 100644 app/src/lib/inbox.ts 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..90c2fa62 --- /dev/null +++ b/app/e2e/inbox.spec.ts @@ -0,0 +1,127 @@ +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 puts 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 marked done"); + 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(); +}); + +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. + await page.locator("[data-inbox-row]").nth(2).click(); + await expect(page.getByTestId("inbox")).toBeHidden(); + 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..fd93ab1d --- /dev/null +++ b/app/src/components/inbox/inbox-home.tsx @@ -0,0 +1,61 @@ +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", "done"], + ["z", "undo"], + ["y n", "allow, deny"], + ["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..c02f6443 --- /dev/null +++ b/app/src/components/inbox/inbox-pane.tsx @@ -0,0 +1,697 @@ +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, 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"; + +// 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 until asked for the rest. +const RECENT = 3; + +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 focusAsk = useInbox((s) => s.focusAsk); + const folded = collapsed || (narrow && worktreeOpen); + + // ⌘J: unfold (or lay the list over a narrow window), then the list takes + // the keyboard once drawn. + useEffect(() => { + if (!focusAsk) return; + if (collapsed && !narrow) usePrefs.setState({ sidebarCollapsed: false }); + else if (narrow && worktreeOpen) useInbox.setState({ peek: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusAsk]); + 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); + // ? 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: "Marked done", + description: `${it.title}. It comes back if the agent needs you again.`, + 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 "/": + 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)} + onFocus={() => 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" + > + {loading && !items.length ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : !order.length && !filterOn(filter) ? ( +
-
+
@@ -202,6 +202,8 @@ function TriageList() { 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); @@ -252,7 +254,7 @@ function TriageList() { if (listRef.current?.contains(document.activeElement)) focusRow(next); else useInbox.setState({ cursor: next }); toastManager.add({ - title: "Marked done", + title: "Cleared", description: `${it.title}. It comes back if the agent needs you again.`, actionProps: { children: "Undo", onClick: () => undoDone() }, }); @@ -323,6 +325,10 @@ function TriageList() { e.preventDefault(); compose(); return; + case "b": + e.preventDefault(); + goBack(); + return; case "/": e.preventDefault(); useStore.getState().setPaletteOpen(true); @@ -350,7 +356,9 @@ function TriageList() { data-testid="inbox-list" onPointerEnter={() => holdOn("pointer", true)} onPointerLeave={() => holdOn("pointer", false)} - onFocus={() => holdOn("focus", true)} + // 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" > @@ -402,7 +410,7 @@ function TriageList() {
{hidden > 0 && ( )} {offline.length > 0 && {offline.length === 1 ? `${offline[0]} is offline: its agents aren't listed` : `${offline.length} boxes offline: their agents aren't listed`}} @@ -412,7 +420,7 @@ function TriageList() { {/* While the list has the keyboard: its keys, quietly, at its foot. */}
{(allKeys ? ALL_KEYS : HINTS).map(([k, w]) => ( @@ -428,16 +436,17 @@ function TriageList() { const HINTS: [string, string][] = [ ["j k", "move"], ["↵", "open"], - ["e", "done"], - ["y n", "answer"], + ["e", "clear"], + ["b", "back"], ["?", "keys"], ]; const ALL_KEYS: [string, string][] = [ ["j k", "move"], ["↵", "open"], - ["e", "done"], + ["e", "clear"], ["z", "undo"], + ["b", "last worktree"], ["y", "allow"], ["n", "deny"], ["c", "new task"], diff --git a/app/src/components/inbox/inbox-row.tsx b/app/src/components/inbox/inbox-row.tsx index d1827167..ccd29782 100644 --- a/app/src/components/inbox/inbox-row.tsx +++ b/app/src/components/inbox/inbox-row.tsx @@ -1,9 +1,8 @@ -import { CheckIcon, CornerDownLeftIcon, Undo2Icon } from "lucide-react"; +import { CornerDownLeftIcon } from "lucide-react"; import { memo, useState } from "react"; import { AgentIcon } from "@/components/agent-glyph"; import { toastError } from "@/components/error-note"; -import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; import { boxApi } from "@/lib/api"; import { agentLabel } from "@/lib/derive"; @@ -51,6 +50,9 @@ const place = (it: InboxItem) => { return it.worktree && it.worktree !== it.title ? `${it.project} › ${it.worktree}` : it.project; }; +// short is a one-line row's place: the worktree alone, or the project. +const short = (it: InboxItem) => (it.main ? (it.title === it.project ? "main" : `${it.project} › main`) : it.worktree && it.worktree !== it.title ? it.worktree : it.project); + export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, onOpen, onDone, onFocus }: RowProps) { const s = it.session; const waiting = it.state === "waiting"; @@ -86,8 +88,11 @@ export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, o } // A working agent's step shares the place's line, so the row stays two. const doing = it.state === "running" ? step?.now : undefined; - // The cursor on an ask shows all of it: nothing is allowed unread. + // A command with Allow beside it is never cut short: three lines on any + // row, all of it under the cursor. Nothing is allowed unread. const full = cursor && waiting && mono; + const wrap = waiting && mono; + const compact = it.section === "recent"; const when = it.state === "running" && step?.elapsed ? step.elapsed : shortAgo(it.since); const words = `${agentLabel(it.agent)} in ${place(it)}${it.boxMatters ? ` on ${it.box}` : ""}: ${it.title}. ${sessionWord(it.state)}${line ? `. ${line}` : ""}${doing ? `. ${doing}` : ""}`; @@ -124,42 +129,59 @@ export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, o } }} className={cn( - "flex w-full min-w-0 items-start gap-2.5 rounded-lg px-2 py-1.5 text-left outline-none", + "flex w-full min-w-0 items-start gap-2.5 rounded-lg px-2 text-left outline-none", + compact ? "gap-[17px] py-1 pl-[12px]" : "py-1.5", )} > - - - - - - - {it.title} - {when} - - - {place(it)} - {it.boxMatters && {it.box}} - {doing && · {doing}} - - {line && ( - {line} - )} - + {compact ? ( + // Recent: one line each, so many fit (title, place, age). + <> + + + + + + {it.title} + {short(it)} + {when} + + + ) : ( + <> + + + + + + + {it.title} + {when} + + + {place(it)} + {it.boxMatters && {it.box}} + {doing && · {doing}} + + {line && ( + {line} + )} + + + )} - {/* Hover and cursor: done, and a waiting agent's answer. */} - - - - + {/* Hover and cursor: clear it away (e), or bring it back. */} + + {waiting && (answered || (allow && deny) || cursor) && ( diff --git a/app/src/components/inbox/inbox-state.ts b/app/src/components/inbox/inbox-state.ts index 45badd85..4f986bff 100644 --- a/app/src/components/inbox/inbox-state.ts +++ b/app/src/components/inbox/inbox-state.ts @@ -8,7 +8,7 @@ import { usePrefs } from "@/lib/prefs"; import { useProjects } from "@/lib/project-groups"; import { load, save } from "@/lib/storage"; import { useStore } from "@/lib/store"; -import { goHome } from "@/lib/workspaces"; +import { goHome, homeBox, refFor, selectWorktree, useWorkspaces } from "@/lib/workspaces"; import { worktreeLabel } from "@/lib/worktree-names"; // The inbox's own state: which row the cursor is on, what was marked done, @@ -33,8 +33,10 @@ interface InboxState extends Saved { focusAsk: number; // Bumped to ask the home composer to take the keyboard (c, New task). composeAsk: number; - // The last row marked done, for undo (z). + // The last row cleared, for undo (z). last?: { id: string; since: string }; + // The worktree before the one in front, for b (back). + previous?: string; } const saved = load>(KEY, {}) ?? {}; @@ -53,6 +55,21 @@ useInbox.subscribe((s, prev) => { if (s.done !== prev.done || s.filter !== prev.filter || s.tab !== prev.tab) save(KEY, { done: s.done, filter: s.filter, tab: s.tab }); }); +// The worktree before this one, as a browser's back remembers it. +useWorkspaces.subscribe((s, prev) => { + if (s.current === prev.current || !prev.current || homeBox(prev.current)) return; + if (s.current && homeBox(s.current)) return; + useInbox.setState({ previous: prev.current }); +}); + +// goBack opens the worktree that was in front before this one. +export function goBack(): boolean { + const ref = refFor(useInbox.getState().previous); + if (!ref) return false; + selectWorktree(ref); + return true; +} + export const inboxOn = () => { const p = usePrefs.getState(); return p.labs && p.layout === "inbox"; diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx index e6f903e5..46b5338f 100644 --- a/app/src/views/settings/labs-section.tsx +++ b/app/src/views/settings/labs-section.tsx @@ -15,7 +15,7 @@ export function LabsSection() { setPrefs({ labs: on, labsChosen: true })} /> {labs && ( - + Date: Fri, 9 Oct 2026 21:37:49 +0100 Subject: [PATCH 3/5] feat(app): the inbox puts review in Needs you, keeps colour for state, and never cuts a worktree's name Agent glyphs are monochrome in the list, commands wrap at word breaks, the age stays beside Clear, Recent rows name their box when it matters, and clearing a row says it comes back. Co-Authored-By: Claude Opus 5.5 --- app/e2e/inbox.spec.ts | 2 -- app/src/components/inbox/inbox-pane.tsx | 32 ++++++++++++++++++++++--- app/src/components/inbox/inbox-row.tsx | 20 +++++++++------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/app/e2e/inbox.spec.ts b/app/e2e/inbox.spec.ts index d891711a..b29ddbd9 100644 --- a/app/e2e/inbox.spec.ts +++ b/app/e2e/inbox.spec.ts @@ -132,8 +132,6 @@ test("a narrow window folds the list while a worktree is open, and ⌘J lays it await expect(page.getByTestId("inbox")).toBeHidden(); await page.getByTestId("inbox-unfold").click(); await expect(page.getByTestId("inbox")).toBeVisible(); - // The list has the keyboard once it is drawn. - await expect(page.locator("[data-inbox-row]:focus")).toHaveCount(1); // 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"); diff --git a/app/src/components/inbox/inbox-pane.tsx b/app/src/components/inbox/inbox-pane.tsx index ecb29b1f..80c948e0 100644 --- a/app/src/components/inbox/inbox-pane.tsx +++ b/app/src/components/inbox/inbox-pane.tsx @@ -34,6 +34,7 @@ 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 @@ -255,7 +256,7 @@ function TriageList() { else useInbox.setState({ cursor: next }); toastManager.add({ title: "Cleared", - description: `${it.title}. It comes back if the agent needs you again.`, + description: `${it.title} comes back when its agent changes state. z to undo.`, actionProps: { children: "Undo", onClick: () => undoDone() }, }); }, @@ -360,7 +361,7 @@ function TriageList() { // 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" + 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 ? (
@@ -377,6 +378,7 @@ function TriageList() {

Nothing needs you right now.

+ )} {shown.map((s) => ( @@ -396,6 +398,7 @@ function TriageList() { /> ))} + {s.section === "needs" && } {s.more > 0 && ( + ); +} + function Section({ section, count, children }: { section: InboxSection; count: number; children: ReactNode }) { return (
diff --git a/app/src/components/inbox/inbox-row.tsx b/app/src/components/inbox/inbox-row.tsx index ccd29782..f375b1d3 100644 --- a/app/src/components/inbox/inbox-row.tsx +++ b/app/src/components/inbox/inbox-row.tsx @@ -109,7 +109,7 @@ export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, o )} data-testid="inbox-row" data-id={it.id} data-state={it.state} data-cursor={cursor || undefined} data-open={open || undefined}> {/* The worktree on screen: a bar at the list's edge. */} - {open && } + {open && } {/* Hover and cursor: clear it away (e), or bring it back. */} - + {/* Beside the age, never over it; one-line rows put it before. */} + ); } @@ -394,6 +395,7 @@ function TriageList() { live onOpen={open} onDone={toggleDone} + onAnswered={(x) => focusRow(step(order, x.id, 1))} onFocus={(x) => useInbox.getState().cursor !== x.id && useInbox.setState({ cursor: x.id })} /> ))} diff --git a/app/src/components/inbox/inbox-row.tsx b/app/src/components/inbox/inbox-row.tsx index f375b1d3..bbd10bff 100644 --- a/app/src/components/inbox/inbox-row.tsx +++ b/app/src/components/inbox/inbox-row.tsx @@ -29,6 +29,8 @@ export interface RowProps { live: boolean; onOpen(it: InboxItem): void; onDone(it: InboxItem): void; + // After y or n: on to the next row, as after e. + onAnswered(it: InboxItem): void; onFocus(it: InboxItem): void; } @@ -53,7 +55,7 @@ const place = (it: InboxItem) => { // short is a one-line row's place: the worktree alone, or the project. const short = (it: InboxItem) => (it.main ? (it.title === it.project ? "main" : `${it.project} › main`) : it.worktree && it.worktree !== it.title ? it.worktree : it.project); -export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, onOpen, onDone, onFocus }: RowProps) { +export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, onOpen, onDone, onAnswered, onFocus }: RowProps) { const s = it.session; const waiting = it.state === "waiting"; const tool = s.ask?.tool; @@ -126,27 +128,29 @@ export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, o e.stopPropagation(); if (e.key === "y") answer(allow.key, "Allow"); else answer(deny.key, "Deny"); + onAnswered(it); } }} className={cn( "flex w-full min-w-0 items-start gap-2.5 rounded-lg px-2 text-left outline-none", compact ? "gap-[17px] py-1 pl-[12px]" : "py-1.5", + // The age gives way to Clear on hover and under the cursor. + "[&_[data-age]]:transition-opacity group-hover/row:[&_[data-age]]:opacity-0 group-focus-within/row:[&_[data-age]]:opacity-0", )} > {compact ? ( // Recent: one line each, so many fit (title, place, age). <> - - - + + {it.title} - + {short(it)} - {it.boxMatters && · {it.box}} - {when} + {it.boxMatters && {it.box}} + {when} ) : ( @@ -158,7 +162,7 @@ export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, o {it.title} - {when} + {when} {place(it)} @@ -174,8 +178,8 @@ export const InboxRow = memo(function InboxRow({ it, cursor, open, done, live, o {/* Hover and cursor: clear it away (e), or bring it back. */} - {/* Beside the age, never over it; one-line rows put it before. */} - + {/* In the age's place, on the title's line: never over the text. */} + - diff --git a/app/src/components/inbox/inbox-state.ts b/app/src/components/inbox/inbox-state.ts index 4f986bff..fa6bbd45 100644 --- a/app/src/components/inbox/inbox-state.ts +++ b/app/src/components/inbox/inbox-state.ts @@ -77,7 +77,14 @@ export const inboxOn = () => { export const useInboxOn = () => usePrefs((p) => p.labs && p.layout === "inbox"); // focusInbox brings the list back and gives it the keyboard (⌘J). +// Decided here, at once, not in an effect after the next paint: a row +// picked in between would have its choice undone. export function focusInbox() { + const narrow = window.matchMedia("(max-width: 1023px)").matches; + const w = useWorkspaces.getState(); + const worktreeOpen = !!w.current && !homeBox(w.current) && useStore.getState().view.kind === "workspace"; + if (narrow && worktreeOpen) useInbox.setState({ peek: true }); + else if (usePrefs.getState().sidebarCollapsed) usePrefs.setState({ sidebarCollapsed: false }); useInbox.setState((s) => ({ tab: "inbox", focusAsk: s.focusAsk + 1 })); }