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 && (
+ <>
+
An agent that needs you, is working or just finished shows here.
+
+
+ );
+}
+
+// ---- Filters ----------------------------------------------------------------
+
+function FilterMenu() {
+ const filter = useInbox((s) => s.filter);
+ const boxes = useStore((s) => s.status?.boxes ?? NONE);
+ const { projects } = useProjects();
+ const names = useMemo(() => [...new Set(projects.map((p) => p.name))].sort((a, b) => a.localeCompare(b)), [projects]);
+ const on = filterOn(filter);
+ const set = (f: Partial) => useInbox.setState((s) => ({ filter: { ...s.filter, ...f } }));
+ const words = [filter.project, filter.hiddenBoxes.length ? `${boxes.length - filter.hiddenBoxes.length} of ${boxes.length} boxes` : ""].filter(Boolean).join(" · ");
+ return (
+
+ {on && (
+
+ )}
+
+
+ );
+}
+
+// ---- Projects, the tree a tab away -------------------------------------------
+
+function ProjectsTree() {
+ const [prefs, update] = useSidebarPrefs();
+ return (
+
+
+
+ );
+}
+
+function ProjectsMenu() {
+ const [prefs, update] = useSidebarPrefs();
+ return (
+
+ );
+}
+
+// ---- The foot: the other places, and Settings ---------------------------------
+
+function Foot({ view, onSettings }: { view: string; onSettings(dev: boolean): void }) {
+ const { pinned, more } = useArrangedNav();
+ // Home is the list itself and the composer; the projects tree is a tab.
+ const places = pinned.filter((n) => n.id !== "home" && n.id !== "worktrees");
+ const rest = [...pinned.filter((n) => n.id === "worktrees"), ...more];
+ return (
+
+ {places.map((n) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+// ---- Folded: the strip ---------------------------------------------------------
+
+function InboxStrip() {
+ const view = useStore((s) => s.view);
+ const narrow = useMediaQuery("max-lg");
+ const collapsed = usePrefs((p) => p.sidebarCollapsed);
+ const items = useInboxItems();
+ const needs = items.filter((i) => i.section === "needs").length;
+ const unfold = focusInbox;
+ const item = (label: string, icon: ReactNode, onClick: () => void, opts: { active?: boolean; badge?: number; testid?: string } = {}) => (
+
+
+
+ );
+ return (
+
+ );
+}
diff --git a/app/src/components/inbox/inbox-row.tsx b/app/src/components/inbox/inbox-row.tsx
new file mode 100644
index 00000000..4a01bb40
--- /dev/null
+++ b/app/src/components/inbox/inbox-row.tsx
@@ -0,0 +1,225 @@
+import { CornerDownLeftIcon } from "lucide-react";
+import { memo, useState } from "react";
+
+import { AgentIcon } from "@/components/agent-glyph";
+import { toastError } from "@/components/error-note";
+import { Button } from "@/components/ui/button";
+import { boxApi } from "@/lib/api";
+import { agentLabel } from "@/lib/derive";
+import type { InboxItem } from "@/lib/inbox";
+import { permissionChoices } from "@/lib/screen";
+import { sessionWord } from "@/lib/state-model";
+import { useStore } from "@/lib/store";
+import { useAsk } from "@/lib/transcript-feed";
+import { cn } from "@/lib/utils";
+import { QUESTION_TOOLS, useLiveStep } from "@/views/home/widgets/agents";
+import { shortAgo } from "@/views/home/widgets/parts";
+
+// One row of the inbox: the agent (its icon in a ring of its state, as the
+// rail draws it), what the work is called and how long it has been so,
+// where it is (the box only when its project is on more than one), and one
+// calm line of what it asks or is doing. A waiting agent's Allow and Deny
+// are on the row; the rest opens the worktree.
+
+export interface RowProps {
+ it: InboxItem;
+ cursor: boolean;
+ open: boolean;
+ done: boolean;
+ 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;
+}
+
+function Ring({ state }: { state: InboxItem["state"] }) {
+ const base = "pointer-events-none absolute -inset-[3px] rounded-full";
+ if (state === "running") return ;
+ if (state === "waiting") return ;
+ if (state === "finished") return ;
+ return ;
+}
+
+// Where it is: the project, and the worktree unless the title is already
+// it; a main checkout says so rather than repeat the project's name.
+// A key a button answers to, small and quiet on it.
+const Key = ({ children }: { children: string }) => {children};
+
+const place = (it: InboxItem) => {
+ if (it.main) return it.title === it.project ? "main checkout" : `${it.project} › main`;
+ 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, onAnswered, onFocus }: RowProps) {
+ const s = it.session;
+ const waiting = it.state === "waiting";
+ const tool = s.ask?.tool;
+ const question = !tool || QUESTION_TOOLS.test(tool);
+ // A waiting agent's options are on its screen; a working one's step too.
+ const ask = useAsk(it.box, s.name, live && waiting && !question, s.state_since);
+ const step = useLiveStep(it.box, s.name, it.agent, live && it.state === "running");
+ const client = useStore((st) => st.client);
+ const choices = ask && !ask.form ? permissionChoices(ask.choices) : undefined;
+ const allow = choices?.find((c) => c.label === "Allow");
+ const deny = choices?.find((c) => c.label === "Deny");
+ const [sent, setSent] = useState<{ at?: string; label: string }>();
+ const answered = sent && sent.at === s.state_since ? sent.label : undefined;
+
+ const answer = (key: string, label: string) => {
+ if (!client) return;
+ setSent({ at: s.state_since, label });
+ boxApi.send(client, it.box, s.name, key, false, { when: "now", force: true }).catch((err) => {
+ setSent(undefined);
+ toastError(err, { title: "Couldn't answer", box: it.box });
+ });
+ };
+
+ // The one calm line: what it asks, what it is doing, or nothing.
+ let line: string | undefined;
+ let mono = false;
+ if (waiting) {
+ if (tool && !question) {
+ line = [tool === "Bash" ? "" : tool, s.ask?.input].filter(Boolean).join(" ") || "Wants permission";
+ mono = true;
+ } else line = s.ask?.message || ask?.detail || (question ? "Asks you a question" : "Waiting for you");
+ }
+ // A working agent's step shares the place's line, so the row stays two.
+ const doing = it.state === "running" ? step?.now : undefined;
+ // 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}` : ""}`;
+
+ return (
+
+ {/* The worktree on screen: a bar at the list's edge. */}
+ {open && }
+
+
+ {/* Hover and cursor: clear it away (e), or bring it back. */}
+ {/* In the age's place, on the title's line: never over the text. */}
+
+
+
+ {waiting && (answered || (allow && deny) || cursor) && (
+
+ {answered ? (
+ {answered === "Deny" ? "Denied" : "Allowed"} · resuming
+ ) : allow && deny ? (
+ <>
+
+ {/* Solid only under the cursor: the brightest thing on screen is never a button you aren't on. */}
+
+ >
+ ) : (
+
+ )}
+
+ )}
+
+ );
+});
diff --git a/app/src/components/inbox/inbox-state.ts b/app/src/components/inbox/inbox-state.ts
new file mode 100644
index 00000000..fa6bbd45
--- /dev/null
+++ b/app/src/components/inbox/inbox-state.ts
@@ -0,0 +1,136 @@
+import { useMemo } from "react";
+import { create } from "zustand";
+
+import { useAllSessions } from "@/hooks/use-agent-counts";
+import { agentOf, worktreeOf } from "@/lib/derive";
+import { type Done, type InboxFilter, type InboxItem, NO_FILTER, sectionOf } from "@/lib/inbox";
+import { usePrefs } from "@/lib/prefs";
+import { useProjects } from "@/lib/project-groups";
+import { load, save } from "@/lib/storage";
+import { useStore } from "@/lib/store";
+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,
+// the filter, and whether the list shows the inbox or the projects tree.
+// Done, the filter and the tab are kept on this computer.
+
+const KEY = "berth.inbox";
+
+interface Saved {
+ done: Done;
+ filter: InboxFilter;
+ tab: "inbox" | "projects";
+}
+
+interface InboxState extends Saved {
+ cursor?: string;
+ // Show rows marked done, greyed, until turned off.
+ showDone: boolean;
+ // Narrow windows: the list over the worktree, until a row is picked.
+ peek: boolean;
+ // Bumped to ask the list to take the keyboard (⌘J, the strip's button).
+ focusAsk: number;
+ // Bumped to ask the home composer to take the keyboard (c, New task).
+ composeAsk: number;
+ // 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, {}) ?? {};
+
+export const useInbox = create()(() => ({
+ done: saved.done ?? {},
+ filter: { ...NO_FILTER, ...saved.filter },
+ tab: saved.tab === "projects" ? "projects" : "inbox",
+ showDone: false,
+ peek: false,
+ focusAsk: 0,
+ composeAsk: 0,
+}));
+
+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";
+};
+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 }));
+}
+
+// compose starts new work: Home's composer, in front, with the keyboard.
+export function compose() {
+ goHome();
+ useInbox.setState((s) => ({ peek: false, composeAsk: s.composeAsk + 1 }));
+}
+
+// useInboxItems is every agent on every online box, as inbox rows.
+export function useInboxItems(): InboxItem[] {
+ const all = useAllSessions();
+ const data = useStore((s) => s.boxes);
+ const { projects } = useProjects();
+ return useMemo(() => {
+ const projectOf = new Map();
+ for (const p of projects) {
+ const boxes = new Set(p.members.map((m) => m.box.name)).size;
+ for (const m of p.members) projectOf.set(`${m.box.name}/${m.loc.name}`, { name: p.name, boxes });
+ }
+ const out: InboxItem[] = [];
+ for (const e of all) {
+ const agent = agentOf(e.session);
+ const section = sectionOf(e.state);
+ if (!agent || !section || e.session.service) continue;
+ const at = worktreeOf(data[e.box]?.locations, e.session);
+ if (!at) continue;
+ const p = projectOf.get(`${e.box}/${at.location.name}`);
+ const wt = at.worktree.main ? "" : worktreeLabel(at.worktree);
+ out.push({
+ id: `${e.box}/${e.session.name}`,
+ box: e.box,
+ session: e.session,
+ state: e.state,
+ section,
+ agent,
+ title: e.session.title?.trim() || wt || p?.name || at.location.name,
+ project: p?.name ?? at.location.name,
+ worktree: wt,
+ location: at.location.name,
+ path: at.worktree.path,
+ main: !!at.worktree.main,
+ since: e.session.state_since ?? e.session.created,
+ boxMatters: (p?.boxes ?? 1) > 1,
+ });
+ }
+ return out;
+ }, [all, data, projects]);
+}
diff --git a/app/src/hooks/use-shortcuts.ts b/app/src/hooks/use-shortcuts.ts
index 130295ac..2bc92b4a 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 { focusInbox, inboxOn } from "@/components/inbox/inbox-state";
// The app's shortcuts (lib/shortcuts.json) come two ways: as keys, caught on
// the window before a terminal sees them, and, in the Mac app, from the menu
@@ -128,6 +129,12 @@ function run(id: string, from: "key" | "menu", arg?: number | Dir): boolean {
usePrefs.setState((p) => ({ sidebarCollapsed: !p.sidebarCollapsed }));
return true;
case "dashboard":
+ // The inbox layout has the agents in its list: ⌘J goes back to it.
+ if (inboxOn()) {
+ if (s.view.kind !== "workspace") s.setView({ kind: "workspace" });
+ focusInbox();
+ return true;
+ }
s.setView({ kind: "dashboard" });
return true;
case "notifications":
diff --git a/app/src/lib/inbox.test.ts b/app/src/lib/inbox.test.ts
new file mode 100644
index 00000000..e4e71e0e
--- /dev/null
+++ b/app/src/lib/inbox.test.ts
@@ -0,0 +1,91 @@
+// node --experimental-strip-types --test src/lib/inbox.test.ts (pnpm test)
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { afterDone, arrange, holdOf, type InboxItem, markDone, NO_FILTER, prune, sectionOf, step, undone } from "./inbox.ts";
+
+const item = (id: string, section: InboxItem["section"], since: string, more: Partial = {}): InboxItem => ({
+ id,
+ box: "devl",
+ session: { name: id, dir: `/w/${id}`, created: since, attached: 0, exited: false },
+ state: section === "needs" ? "waiting" : section === "working" ? "running" : "finished",
+ section,
+ agent: "claude",
+ title: id,
+ project: "shop",
+ worktree: id,
+ location: "shop",
+ path: `/w/${id}`,
+ main: false,
+ since,
+ boxMatters: false,
+ ...more,
+});
+
+const ids = (xs: { id: string }[]) => xs.map((x) => x.id);
+
+test("states go to their sections; shells and ended agents stay out", () => {
+ assert.equal(sectionOf("waiting"), "needs");
+ assert.equal(sectionOf("running"), "working");
+ assert.equal(sectionOf("finished"), "recent");
+ assert.equal(sectionOf("ready"), "recent");
+ assert.equal(sectionOf("idle"), undefined);
+ assert.equal(sectionOf("exited"), undefined);
+});
+
+test("needs you comes first, longest waiting on top; the rest newest first", () => {
+ const rows = [item("r1", "recent", "2026-01-01T10:00:00Z"), item("w1", "working", "2026-01-01T09:00:00Z"), item("n-new", "needs", "2026-01-01T11:00:00Z"), item("n-old", "needs", "2026-01-01T08:00:00Z"), item("w2", "working", "2026-01-01T12:00:00Z")];
+ const { sections, order } = arrange(rows, { done: {}, filter: NO_FILTER });
+ assert.deepEqual(
+ sections.map((s) => s.section),
+ ["needs", "working", "recent"],
+ );
+ assert.deepEqual(ids(order), ["n-old", "n-new", "w2", "w1", "r1"]);
+});
+
+test("a row marked done stays away until its agent's state changes", () => {
+ const before = item("a", "needs", "2026-01-01T08:00:00Z");
+ let done = markDone({}, before);
+ assert.deepEqual(ids(arrange([before], { done, filter: NO_FILTER }).order), []);
+ assert.equal(arrange([before], { done, filter: NO_FILTER }).hidden, 1);
+ // Shown again when asked for.
+ assert.deepEqual(ids(arrange([before], { done, filter: NO_FILTER, showDone: true }).order), ["a"]);
+ // It asks again: back in the inbox.
+ const again = { ...before, since: "2026-01-01T09:00:00Z" };
+ assert.deepEqual(ids(arrange([again], { done, filter: NO_FILTER }).order), ["a"]);
+ // Undo, and pruning marks for rows that moved on.
+ assert.deepEqual(undone(done, "a"), {});
+ done = markDone(done, item("gone", "recent", "x"));
+ assert.deepEqual(prune(done, [before]), { a: before.since });
+});
+
+test("filters by box and by project", () => {
+ const rows = [item("a", "working", "1", { box: "devl" }), item("b", "working", "2", { box: "gpu" }), item("c", "working", "3", { project: "evals" })];
+ assert.deepEqual(ids(arrange(rows, { done: {}, filter: { hiddenBoxes: ["gpu"] } }).order), ["c", "a"]);
+ assert.deepEqual(ids(arrange(rows, { done: {}, filter: { hiddenBoxes: [], project: "evals" } }).order), ["c"]);
+});
+
+test("the cursor moves within the list and lands on the next row after done", () => {
+ const order = [{ id: "a" }, { id: "b" }, { id: "c" }];
+ assert.equal(step(order, undefined, 1), "a");
+ assert.equal(step(order, "a", 1), "b");
+ assert.equal(step(order, "c", 1), "c");
+ assert.equal(step(order, "a", -1), "a");
+ assert.equal(step(order, "gone", 1), "a");
+ assert.equal(step([], "a", 1), undefined);
+ assert.equal(afterDone(order, "b"), "c");
+ assert.equal(afterDone(order, "c"), "b");
+ assert.equal(afterDone([{ id: "a" }], "a"), undefined);
+});
+
+test("rows hold their places while the list is held, and new ones still show", () => {
+ const a = item("a", "needs", "1");
+ const b = item("b", "working", "2");
+ const hold = holdOf(arrange([a, b], { done: {}, filter: NO_FILTER }).order);
+ // a is answered and works now; c starts.
+ const moved = [{ ...a, section: "working" as const, state: "running" as const, since: "3" }, b, item("c", "working", "4")];
+ const held = arrange(moved, { done: {}, filter: NO_FILTER, hold });
+ assert.deepEqual(held.sections.map((s) => [s.section, ids(s.items)]), [["needs", ["a"]], ["working", ["b", "c"]]]);
+ // Let go: where each belongs.
+ assert.deepEqual(ids(arrange(moved, { done: {}, filter: NO_FILTER }).order), ["c", "a", "b"]);
+});
diff --git a/app/src/lib/inbox.ts b/app/src/lib/inbox.ts
new file mode 100644
index 00000000..54c9a982
--- /dev/null
+++ b/app/src/lib/inbox.ts
@@ -0,0 +1,142 @@
+import type { Session } from "@/lib/api";
+import type { SessionState } from "@/lib/derive";
+
+// The inbox (Labs › Layout › Inbox): every agent on every box as one list,
+// by what it needs from you. Needs you on top, then Working, then Recent
+// (done and idle agents, newest first). Marking a row done ("e") puts it
+// away until its agent's state changes, so an agent that asks again comes
+// back on its own, as a reply brings a thread back in a mail inbox.
+//
+// This file is the list's model with no React in it (lib/inbox.test.ts);
+// components/inbox draws it.
+
+export type InboxSection = "needs" | "working" | "recent";
+export const SECTIONS: InboxSection[] = ["needs", "working", "recent"];
+export const SECTION_WORDS: Record = { needs: "Needs you", working: "Working", recent: "Recent" };
+
+// sectionOf is where a session's state puts it; nothing for a plain shell
+// or an agent whose program ended.
+export function sectionOf(state: SessionState): InboxSection | undefined {
+ if (state === "waiting") return "needs";
+ if (state === "running") return "working";
+ if (state === "finished" || state === "ready") return "recent";
+ return undefined;
+}
+
+export interface InboxItem {
+ // box/session
+ id: string;
+ box: string;
+ session: Session;
+ state: SessionState;
+ section: InboxSection;
+ agent: string;
+ // What the work is called, else its worktree.
+ title: string;
+ project: string;
+ // The worktree's display name; empty for a project's main checkout.
+ worktree: string;
+ location: string;
+ path: string;
+ main: boolean;
+ // When its state last changed.
+ since: string;
+ // Its project lives on more than one box, so which box is worth saying.
+ boxMatters: boolean;
+}
+
+// Done is what was marked done: a row's id and the state_since it had then.
+export type Done = Record;
+
+export const doneMark = (it: Pick) => it.since;
+export const isDone = (done: Done, it: Pick) => done[it.id] !== undefined && done[it.id] === doneMark(it);
+
+// markDone and undone return a new Done; prune drops marks for rows that
+// moved on, so the record never grows.
+export function markDone(done: Done, it: Pick): Done {
+ return { ...done, [it.id]: doneMark(it) };
+}
+export function undone(done: Done, id: string): Done {
+ const { [id]: _gone, ...rest } = done;
+ return rest;
+}
+export function prune(done: Done, items: Pick[]): Done {
+ const live = new Map(items.map((i) => [i.id, i.since]));
+ const out: Done = {};
+ for (const [id, at] of Object.entries(done)) if (live.get(id) === at) out[id] = at;
+ return out;
+}
+
+export interface InboxFilter {
+ // Boxes left out; a new box shows up on its own.
+ hiddenBoxes: string[];
+ // One project, or all of them.
+ project?: string;
+}
+
+export const NO_FILTER: InboxFilter = { hiddenBoxes: [] };
+
+export function filterOn(f: InboxFilter): boolean {
+ return f.hiddenBoxes.length > 0 || !!f.project;
+}
+
+// Hold is where each row was while the person points at or types in the
+// list: rows keep those places until they leave it, so nothing jumps from
+// under the cursor. A row new since then goes at the end of its section.
+export type Hold = Map;
+
+export function holdOf(order: Pick[]): Hold {
+ return new Map(order.map((it, index) => [it.id, { section: it.section, index }]));
+}
+
+// arrange sorts, filters and splits the rows into sections. Needs you is
+// oldest first (whoever has waited longest is on top), Working and Recent
+// newest first. Inside a tie, by place, so a row only moves when its agent
+// changes state.
+export function arrange(items: InboxItem[], opts: { done: Done; filter: InboxFilter; showDone?: boolean; hold?: Hold }): { sections: { section: InboxSection; items: InboxItem[] }[]; hidden: number; order: InboxItem[] } {
+ const { done, filter } = opts;
+ let hidden = 0;
+ const kept = items.filter((it) => {
+ if (filter.hiddenBoxes.includes(it.box)) return false;
+ if (filter.project && it.project !== filter.project) return false;
+ if (isDone(done, it)) {
+ hidden++;
+ return !!opts.showDone;
+ }
+ return true;
+ });
+ const place = (a: InboxItem, b: InboxItem) => a.project.localeCompare(b.project) || a.worktree.localeCompare(b.worktree) || a.box.localeCompare(b.box) || a.session.name.localeCompare(b.session.name);
+ const hold = opts.hold;
+ const sectionOfIt = (it: InboxItem) => hold?.get(it.id)?.section ?? it.section;
+ const sections = SECTIONS.map((section) => {
+ const list = kept.filter((it) => sectionOfIt(it) === section);
+ const natural = (a: InboxItem, b: InboxItem) => (section === "needs" ? a.since.localeCompare(b.since) : b.since.localeCompare(a.since)) || place(a, b);
+ list.sort((a, b) => {
+ const ha = hold?.get(a.id);
+ const hb = hold?.get(b.id);
+ if (ha && hb) return ha.index - hb.index;
+ // A new row goes after the held ones, so none of them moves.
+ if (ha || hb) return ha ? -1 : 1;
+ return natural(a, b);
+ });
+ return { section, items: list };
+ }).filter((s) => s.items.length);
+ return { sections, hidden, order: sections.flatMap((s) => s.items) };
+}
+
+// step moves the cursor by delta through order, staying at the ends; with
+// no cursor (or one whose row went away) it starts at the top.
+export function step(order: Pick[], cursor: string | undefined, delta: number): string | undefined {
+ if (!order.length) return undefined;
+ const i = order.findIndex((it) => it.id === cursor);
+ if (i < 0) return order[0].id;
+ return order[Math.max(0, Math.min(order.length - 1, i + delta))].id;
+}
+
+// afterDone is where the cursor goes once the row under it is put away: the
+// next row, else the one before, as a mail client does.
+export function afterDone(order: Pick[], id: string): string | undefined {
+ const i = order.findIndex((it) => it.id === id);
+ if (i < 0) return order[0]?.id;
+ return order[i + 1]?.id ?? order[i - 1]?.id;
+}
diff --git a/app/src/lib/prefs.ts b/app/src/lib/prefs.ts
index ff81ef9d..4f528629 100644
--- a/app/src/lib/prefs.ts
+++ b/app/src/lib/prefs.ts
@@ -46,6 +46,10 @@ export interface Prefs {
// Labs: zen (⌘.): no sidebar or status bar, a switcher for a tab strip,
// agents as conversations.
zen: boolean;
+ // Labs: how the window is laid out. "sidebar" is the places and projects
+ // down the left; "inbox" is a list of every agent by what it needs from
+ // you, with the worktree you open beside it (components/inbox).
+ layout: "sidebar" | "inbox";
// Update a box's berthd as soon as Shipyard ships a newer one
// (lib/outdated.ts). Off: the status bar offers it instead.
autoUpdateBoxes: boolean;
@@ -94,6 +98,7 @@ const DEFAULTS: Prefs = {
labsChosen: false,
agentView: "terminal",
zen: false,
+ layout: "sidebar",
autoUpdateBoxes: false,
chatBackground: DEFAULT_CHAT_BACKGROUND,
chatWidth: "default",
@@ -149,12 +154,14 @@ usePrefs.subscribe((p) => save("berth.prefs", { ...p, version: PREFS_VERSION }))
// The migration is kept at once, not only on the next change.
save("berth.prefs", { ...usePrefs.getState(), version: PREFS_VERSION });
-// ?labs=1 turns Labs on, ?zen=1 zen, and ?view=conversation opens agents
-// as conversations, for the demo.
+// ?labs=1 turns Labs on, ?zen=1 zen, ?layout=inbox the inbox, and
+// ?view=conversation opens agents as conversations, for the demo.
{
const q = new URLSearchParams(location.search);
if (q.has("labs")) usePrefs.setState({ labs: q.get("labs") !== "0" });
if (q.has("zen")) usePrefs.setState({ zen: q.get("zen") !== "0" });
+ const l = q.get("layout");
+ if (l === "sidebar" || l === "inbox") usePrefs.setState({ layout: l });
const v = q.get("view");
if (v === "terminal" || v === "conversation") usePrefs.setState({ agentView: v });
}
diff --git a/app/src/views/home/widgets/agents.tsx b/app/src/views/home/widgets/agents.tsx
index 64919636..8f35abe9 100644
--- a/app/src/views/home/widgets/agents.tsx
+++ b/app/src/views/home/widgets/agents.tsx
@@ -95,7 +95,7 @@ function OfflineNote({ boxes }: { boxes: string[] }) {
// A question's tools: these are answered in the agent's own form, not with
// Allow or Deny.
-const QUESTION_TOOLS = /^(AskUserQuestion|request_user_input|ExitPlanMode)$/;
+export const QUESTION_TOOLS = /^(AskUserQuestion|request_user_input|ExitPlanMode)$/;
export function NeedsYouWidget() {
const { lines, height } = useHomeWidget();
@@ -208,7 +208,7 @@ export function WorkingWidget() {
// useLiveStep is what a working agent's screen says it is doing: its step
// ("Running pnpm test…"), what that step printed last, and its status
// line's clock. Read every 12s while the row is on screen.
-function useLiveStep(box: string, session: string, agent: string, enabled: boolean) {
+export function useLiveStep(box: string, session: string, agent: string, enabled: boolean) {
const client = useStore((s) => s.client);
const [step, setStep] = useState<{ now?: string; sub?: string; elapsed?: string }>();
useEffect(() => {
diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx
index adb84e1b..46b5338f 100644
--- a/app/src/views/settings/labs-section.tsx
+++ b/app/src/views/settings/labs-section.tsx
@@ -7,12 +7,25 @@ import { SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"
export function LabsSection() {
const labs = usePrefs((p) => p.labs);
const agentView = usePrefs((p) => p.agentView);
+ const layout = usePrefs((p) => p.layout);
return (
setPrefs({ labs: on, labsChosen: true })} />
+ {labs && (
+
+ setPrefs({ layout: v })}
+ />
+
+ )}
{labs && (