Skip to content
Merged
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
137 changes: 137 additions & 0 deletions app/e2e/switchboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Page } from "@playwright/test";
import { expect, mockOnly, test } from "./fixtures";

// The Switchboard (⌘⇧K) is keyboard-first: type to filter, ↑↓ and ↵, ⌘S to
// star, ⌘E for a pickup note, Tab for the next area, ⇧F10 (or right-click,
// or the row's ⋯) for the rest. Its rows' buttons show on hover.

const title = "Fix checkout webhook retries";

// Home's composer takes the keyboard once it is ready, over any dialog open
// by then (the palette's too): open the Switchboard after that.
async function homeReady(page: Page) {
await expect(page.getByRole("textbox", { name: "What should your agents work on?" })).toBeFocused();
}

test("Switchboard keeps focused work, pickup notes and areas across a reload, all from the keyboard", async ({ app }) => {
mockOnly();
await app.open();
const page = app.page;
await homeReady(page);
await page.getByRole("button", { name: /Open Switchboard/ }).click();
const board = page.getByRole("dialog", { name: "Switchboard" });
const search = board.getByRole("combobox", { name: "Search work" });
await expect(search).toBeFocused();

// Typing filters; the first match is highlighted.
await search.fill("webhook");
const row = board.getByRole("option", { name: new RegExp(`^${title}`) });
await expect(row).toHaveAttribute("aria-selected", "true");

// ⌘S stars it into Focus.
await page.keyboard.press("Meta+s");
const focus = board.getByRole("group", { name: "Focus" });
await expect(focus.getByRole("option", { name: new RegExp(`^${title}`) })).toBeVisible();

// ⌘E leaves a pickup note; ↵ saves it and the keyboard is back in search.
await page.keyboard.press("Meta+e");
const note = board.getByRole("textbox", { name: `Pickup note for ${title}` });
await expect(note).toBeFocused();
await note.fill("Decide whether to run the migration");
await page.keyboard.press("Enter");
await expect(note).toBeHidden();
await expect(search).toBeFocused();
await expect(focus).toContainText("Decide whether to run the migration");

// A new area, then the row moved into it from its menu (⇧F10).
await search.fill("");
await board.getByRole("button", { name: "New area" }).click();
await board.getByRole("textbox", { name: "Area name" }).fill("Launch");
await page.keyboard.press("Enter");
await expect(board.getByRole("tab", { name: "Launch" })).toBeVisible();
await expect(search).toBeFocused();
await search.fill("webhook");
await expect(row).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Shift+F10");
await page.getByRole("menuitem", { name: "Move to area" }).click();
await page.getByRole("menuitemradio", { name: "Launch" }).click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(search).toBeFocused();

// Tab steps through the areas: All, Work, Personal, Launch.
await search.fill("");
await page.keyboard.press("Tab");
await expect(board.getByRole("tab", { name: "Work" })).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await expect(board.getByRole("tab", { name: "Launch" })).toHaveAttribute("aria-selected", "true");
await expect(board.getByRole("option")).toHaveCount(1);
await expect(row).toBeVisible();

await page.reload();
await homeReady(page);
await page.keyboard.press("Meta+Shift+K");
const reopened = page.getByRole("dialog", { name: "Switchboard" });
const again = reopened.getByRole("group", { name: "Focus" }).getByRole("option", { name: new RegExp(`^${title}`) });
await expect(again).toContainText("Decide whether to run the migration");
// Focus is first, so ↵ opens it.
await expect(again).toHaveAttribute("aria-selected", "true");
await expect(reopened.getByRole("combobox", { name: "Search work" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(reopened).toBeHidden();
await expect(page.getByTestId("status-worktree")).toContainText("checkout-fix");
});

test("Switchboard moves its highlight with the arrow keys and shows a row's buttons on hover", async ({ app }) => {
mockOnly();
await app.open();
const page = app.page;
await homeReady(page);
await page.keyboard.press("Meta+Shift+K");
const board = page.getByRole("dialog", { name: "Switchboard" });
const options = board.getByRole("option");
await expect(options.first()).toHaveAttribute("aria-selected", "true");

// ↓ moves down, ↑ from the top wraps to the bottom.
await page.keyboard.press("ArrowDown");
await expect(options.nth(1)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("ArrowUp");
await page.keyboard.press("ArrowUp");
await expect(options.last()).toHaveAttribute("aria-selected", "true");

// Hovering a row shows its buttons: star it from there.
const row = board.locator("[data-row]").filter({ hasText: title });
await row.hover();
await row.getByRole("button", { name: `Add ${title} to Focus` }).click();
await expect(board.getByRole("group", { name: "Focus" })).toContainText(title);

// Its ⋯ menu: Esc closes just the menu. From it, a note; Esc leaves the
// note, then the Switchboard.
await row.hover();
await row.getByRole("button", { name: `More for ${title}` }).click();
await expect(page.getByRole("menu")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(board).toBeVisible();
await row.hover();
await row.getByRole("button", { name: `More for ${title}` }).click();
await page.getByRole("menuitem", { name: "Add pickup note" }).click();
const note = board.getByRole("textbox", { name: `Pickup note for ${title}` });
await expect(note).toBeFocused();
await page.keyboard.press("Escape");
await expect(note).toBeHidden();
await expect(board).toBeVisible();
await page.keyboard.press("Escape");
await expect(board).toBeHidden();
});

test("Switchboard says so when nothing matches", async ({ app }) => {
mockOnly();
await app.open();
await homeReady(app.page);
await app.page.keyboard.press("Meta+Shift+K");
const board = app.page.getByRole("dialog", { name: "Switchboard" });
await board.getByRole("combobox", { name: "Search work" }).fill("zzqx");
await expect(board).toContainText("Nothing matches “zzqx”");
await expect(board.getByRole("option")).toHaveCount(0);
});
2 changes: 2 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AddLocationDialog } from "@/components/add-location-dialog";
import { Connecting } from "@/components/agent-offline";
import { AppSidebar } from "@/components/app-sidebar";
import { CommandPalette } from "@/components/command-palette";
import { Switchboard } from "@/components/switchboard";
import { FilePicker } from "@/components/files/file-picker";
import { TreeDockFrame } from "@/components/files/tree-dock";
import { WorktreePicker } from "@/components/workspace/worktree-picker";
Expand Down Expand Up @@ -204,6 +205,7 @@ export default function App() {
</div>
<ErrorBoundary scope="a dialog">
<CommandPalette />
<Switchboard />
<FilePicker />
<WorktreePicker />
<BoxPicker />
Expand Down
19 changes: 11 additions & 8 deletions app/src/components/agent-glyph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,26 @@ import { useStore } from "@/lib/store";
import { cn } from "@/lib/utils";

// AgentIcon marks which agent a session runs, in the agent's own colour.
export function AgentIcon({ agent, className }: { agent?: string; className?: string }) {
// Muted, it takes the colour of the text around it (currentColor), for
// lists where every row has one and state should carry the colour.
export function AgentIcon({ agent, className, muted }: { agent?: string; className?: string; muted?: boolean }) {
const cls = cn("size-3.5 shrink-0", className);
const tone = (c: string) => (muted ? undefined : c);
switch (agent) {
case "claude":
return <AsteriskIcon className={cn(cls, "text-[#d97757]")} strokeWidth={2.75} />;
return <AsteriskIcon className={cn(cls, tone("text-[#d97757]"))} strokeWidth={2.75} />;
case "codex":
return <HexagonIcon className={cn(cls, "text-foreground/80")} strokeWidth={2.25} />;
return <HexagonIcon className={cn(cls, tone("text-foreground/80"))} strokeWidth={2.25} />;
case "gemini":
return <SparkleIcon className={cn(cls, "text-[#6f9bff]")} strokeWidth={2.25} />;
return <SparkleIcon className={cn(cls, tone("text-[#6f9bff]"))} strokeWidth={2.25} />;
case "pi":
return <span className={cn("shrink-0 font-semibold text-[#a78bfa] text-xs leading-none", className)}>π</span>;
return <span className={cn("shrink-0 font-semibold text-xs leading-none", tone("text-[#a78bfa]"), className)}>π</span>;
case "omp":
return <span className={cn("shrink-0 font-semibold text-[#f472b6] text-xs leading-none", className)}>π</span>;
return <span className={cn("shrink-0 font-semibold text-xs leading-none", tone("text-[#f472b6]"), className)}>π</span>;
case "opencode":
return <SquareTerminalIcon className={cn(cls, "text-foreground/80")} />;
return <SquareTerminalIcon className={cn(cls, tone("text-foreground/80"))} />;
default:
return <TerminalIcon className={cn(cls, "text-muted-foreground")} />;
return <TerminalIcon className={cn(cls, tone("text-muted-foreground"))} />;
}
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
GitCompareArrowsIcon,
GlobeIcon,
LayoutDashboardIcon,
Layers3Icon,
PanelTopIcon,
PuzzleIcon,
RefreshCwIcon,
Expand Down Expand Up @@ -85,6 +86,7 @@ import { openAddBox } from "@/views/onboarding/add-box-dialog";
import { openAttempts, openComposer } from "@/lib/composer";
import { defaultScope } from "@/views/automations/flows/project-label";
import { placeLabel, worktreeLabel } from "@/lib/worktree-names";
import { setSwitchboardOpen } from "@/lib/switchboard";

// Settings' sections ⌘K goes to, with words they answer to besides their name.
const SETTINGS_SECTIONS: [SettingsSectionId, string, string][] = [
Expand Down Expand Up @@ -218,6 +220,7 @@ export function CommandPalette() {
{ value: "home harbour start", label: "Home", icon: slot(<HouseIcon />), run: go(goHome) },
{ value: "review inbox approve changes", label: "Review", icon: slot(<InboxIcon />), run: go(() => st.setView({ kind: "review" })) },
{ value: "dashboard", label: "Agent Dashboard", icon: slot(<LayoutDashboardIcon />), shortcut: keysFor("dashboard"), run: go(() => st.setView({ kind: "dashboard" })) },
{ value: "switchboard focus areas recent work", label: "Switchboard…", icon: slot(<Layers3Icon />), shortcut: keysFor("switchboard"), run: go(() => setSwitchboardOpen(true)) },
...(hasWhatsNew() ? [{ value: "whats new release notes changes update", label: "What's new in Shipyard", icon: slot(<SparkleIcon />), run: go(() => openWhatsNew("palette")) }] : []),
{ value: "keyboard shortcuts keys help", label: "Keyboard shortcuts", icon: slot(<KeyboardIcon />), shortcut: keysFor("shortcuts"), run: go(openShortcuts) },
{ value: "notifications inbox bell", label: "Notifications", icon: slot(<BellIcon />), shortcut: keysFor("notifications"), run: go(() => setNotificationsOpen(true)) },
Expand Down
Loading
Loading