Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/e2e/a11y-scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
143 changes: 143 additions & 0 deletions app/e2e/inbox.spec.ts
Original file line number Diff line number Diff line change
@@ -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();

Check failure on line 75 in app/e2e/inbox.spec.ts

View workflow job for this annotation

GitHub Actions / app

e2e/inbox.spec.ts:15:1 › the inbox lists agents by what they need

2) e2e/inbox.spec.ts:15:1 › the inbox lists agents by what they need, and the keyboard triages them Error: expect(locator).toHaveCount(expected) failed Locator: locator('[data-inbox-row]:focus') Expected: 1 Received: 0 Timeout: 1000ms Call log: - Expect "toHaveCount" locator('[data-inbox-row]:focus') with timeout 1000ms - waiting for locator('[data-inbox-row]:focus') 11 × locator resolved to 0 elements - unexpected value "0" Call Log: - Test timeout of 30000ms exceeded 73 | await page.keyboard.press("ControlOrMeta+j"); 74 | await expect(page.locator("[data-inbox-row]:focus")).toHaveCount(1, { timeout: 1000 }); > 75 | }).toPass(); | ^ 76 | await page.keyboard.press("b"); 77 | await expect(page.locator("[data-testid=inbox-row][data-id='devl/checkout-fix-claude']")).toHaveAttribute("data-open", "true"); 78 | await expect(page.locator(`[data-testid=inbox-row][data-id='${otherId}']`)).not.toHaveAttribute("data-open", "true"); at /home/runner/work/shipyard/shipyard/app/e2e/inbox.spec.ts:75:6
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();
});
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -161,7 +166,7 @@ export default function App() {
<div className="flex min-h-0 flex-1">
{!zen && (
<Disconnectable>
<AppSidebar />
{inbox ? <InboxPane /> : <AppSidebar />}
</Disconnectable>
)}
<div className="relative flex min-w-0 flex-1 flex-col">
Expand Down Expand Up @@ -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 <HomeView />;
const inbox = useInboxOn();
return inbox ? <InboxHome /> : <HomeView />;
}

// Disconnectable dims what cannot work until the agent answers.
Expand Down
62 changes: 62 additions & 0 deletions app/src/components/inbox/inbox-home.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
useEffect(() => {
if (!composeAsk) return;
const id = requestAnimationFrame(() => ref.current?.querySelector<HTMLElement>("textarea, [contenteditable=true]")?.focus());
return () => cancelAnimationFrame(id);
}, [composeAsk]);
return (
<div data-testid="inbox-home" className="absolute inset-0 overflow-y-auto bg-background">
<div className="relative min-h-full">
<div aria-hidden className="absolute inset-x-0 top-0" style={{ height: BAND }}>
<DitherBand src={HARBOUR[light]} position={0.42} fade={0.45} mute={HARBOUR_MUTE[light]} className="size-full" />
</div>
<div ref={ref} className="relative mx-auto flex w-full max-w-[640px] flex-col items-center px-6 pb-10" style={{ paddingTop: `calc(${BAND} - 84px)` }}>
<h1 className="mb-4 text-balance text-center font-heading font-semibold text-2xl tracking-tight [text-shadow:0_0_6px_var(--background),0_0_16px_var(--background)]">What should your agents work on?</h1>
{/* Not focused on its own: the keyboard is the list's (j, k, ↵)
until c or New task bring it here. */}
<TaskComposer />
<TeamSuggestCards />
<ul aria-label="Inbox keys" className="mt-8 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-[12px] text-muted-foreground">
{KEYS.map(([k, what]) => (
<li key={k} className="flex items-center gap-1.5">
<Kbd className="h-5 min-w-5 px-1 text-[11px]">{k}</Kbd>
{what}
</li>
))}
</ul>
</div>
</div>
</div>
);
}
Loading
Loading