diff --git a/apps/web/src/bench/bench-drawer.tsx b/apps/web/src/bench/bench-drawer.tsx
index 930559765..30b0c9d9e 100644
--- a/apps/web/src/bench/bench-drawer.tsx
+++ b/apps/web/src/bench/bench-drawer.tsx
@@ -21,7 +21,7 @@ export const DrawerTabContext = createContext<(tab: DrawerTab) => void>(() => un
/** The floating card in the grid column that pushes the thread left. Each
* tab's body arrives as a prop keyed by tab name; a tab without one shows a
- * short empty state until its own ticket lands. */
+ * short empty state. */
export function BenchDrawer({
open,
title,
@@ -128,9 +128,7 @@ export function BenchDrawer({
- {body ?? (
- {tab} is coming to this drawer soon.
- )}
+ {body ?? Nothing to show for {tab} yet.
}
diff --git a/apps/web/src/bench/tools-tab.tsx b/apps/web/src/bench/tools-tab.tsx
index 0697529c3..dcb5c47b6 100644
--- a/apps/web/src/bench/tools-tab.tsx
+++ b/apps/web/src/bench/tools-tab.tsx
@@ -1,8 +1,8 @@
import { Button } from "@corbits/react-ui";
import { QueryView } from "@/lib/api-query";
-import { Plugs } from "@/lib/icons";
import { Link } from "../navigation";
+import { toolCountLabel } from "../tools/tool-count";
import { useMcpServers } from "../tools/mcp-servers-query";
import "../tools/tools-page.css";
import "./drawer.css";
@@ -20,14 +20,12 @@ export function ToolsTab({ workbenchTenantId }: { readonly workbenchTenantId: st
Tools
-
- No tools are connected in this workbench yet.
+
+ Nothing connected yet — connect a server from the Tools page and every tool it offers
+ shows up here.
-
-
-
- Connect a tool
-
+
+ Connect a tool
) : (
@@ -35,8 +33,13 @@ export function ToolsTab({ workbenchTenantId }: { readonly workbenchTenantId: st
{servers.map((server) => (
-
{server.name}
- Add tools
+
+ {server.name}{" "}
+ {toolCountLabel(server.tools.length)}
+
+
+ Add tools
+
{server.tools.map((tool) => (
diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx
index 3a2d48908..270119dd5 100644
--- a/apps/web/src/pages/tools-page.tsx
+++ b/apps/web/src/pages/tools-page.tsx
@@ -1,5 +1,6 @@
-// Two idioms, per DESIGN.md: a data table for what the workspace catalog
-// already carries, and a card catalog for the servers it could add.
+// Two idioms: a dense icon grid for the workspace MCP catalog — connected
+// servers first, then the popular official ones — and a data table for the
+// tool packages the workbench's agents already carry.
import { useState } from "react";
@@ -9,6 +10,7 @@ import {
Card,
CardDescription,
CardTitle,
+ EmptyState,
Input,
RichEmptyState,
Section,
@@ -21,10 +23,11 @@ import {
} from "@corbits/react-ui";
import { toast } from "@corbits/react-ui/ui/toast";
import { QueryView } from "@/lib/api-query";
-import { Plugs } from "@/lib/icons";
+import { CircleNotch, MagnifyingGlass, Plugs, type Icon } from "@/lib/icons";
import { describeApiError } from "@/lib/api-query";
import { MCP_SERVER_CATALOG, type McpCatalogEntry } from "../mcp-servers";
+import { toolCountLabel } from "../tools/tool-count";
import {
describeRedeployResult,
useAddMcpServer,
@@ -33,12 +36,14 @@ import {
useSignInMcpServer,
type McpServerRow,
} from "../tools/mcp-servers-query";
+import { mcpServerIcon } from "../tools/mcp-server-icons";
import { useDeployedToolPackages } from "../tools/deployed-tool-packages";
import { useBench } from "../bench-context";
import { useFromBench } from "../shell/page-crumbs";
import { PageLayout } from "../shell/page-layout";
import { StageTopBar } from "../shell/stage-top-bar";
import { ConfirmButton } from "../components/confirm-button";
+import { ListCard, ListFilter } from "./library-list";
/** The handle a pasted URL's server is stored under; the host's first label
* reads better than a random id and is what a person would have typed. */
@@ -53,13 +58,13 @@ function toolHost(url: string): string {
}
function ToolTile({
- mark,
+ icon: TileIcon,
name,
desc,
official,
children,
}: {
- readonly mark: string;
+ readonly icon: Icon;
readonly name: string;
readonly desc: string;
readonly official: boolean;
@@ -68,7 +73,7 @@ function ToolTile({
return (
- {mark}
+
{name}
@@ -80,7 +85,7 @@ function ToolTile({
);
}
-function ConnectedTile({
+function ConnectedRow({
server,
onRemove,
removing,
@@ -90,31 +95,40 @@ function ConnectedTile({
readonly removing: boolean;
}) {
const official = MCP_SERVER_CATALOG.some((entry) => entry.handle === server.handle);
+ const RowIcon = mcpServerIcon(server.handle);
const agents = server.agentNames.length === 0 ? "" : ` · ${server.agentNames.join(", ")}`;
return (
-
-
-
{`${String(server.tools.length)} tools live`}
-
+
+
+
+
+
+
+
+ {server.name}
+ {official ? "Official" : "Custom"}
+
+ {`${toolHost(server.url)}${agents}`}
+
+
+
+ {`${toolCountLabel(server.tools.length)} live`}
+
+
Disconnect
-
-
+
+
);
}
-function AvailableTile({
+function PopularTile({
entry,
onConnect,
busy,
@@ -129,18 +143,27 @@ function AvailableTile({
return (
-
- {keyless ? "Connect" : "Sign in"}
+
+ {waiting ? (
+ <>
+
+ Waiting for sign-in…
+ >
+ ) : keyless ? (
+ "Connect"
+ ) : (
+ "Sign in"
+ )}
-
- {waiting ? "Waiting for sign-in…" : keyless ? "No key needed" : "OAuth"}
-
+ {waiting ? null : (
+ {keyless ? "No key needed" : "OAuth"}
+ )}
);
@@ -213,15 +236,28 @@ function AddByUrl({
}}
/>
-
- Add
+
+ {waiting && token !== "" ? (
+ <>
+
+ Waiting for sign-in…
+ >
+ ) : (
+ "Add"
+ )}
{token === "" ? (
-
- Sign in
+
+ {waiting ? (
+ <>
+
+ Waiting for sign-in…
+ >
+ ) : (
+ "Sign in"
+ )}
) : null}
- {waiting ? Waiting for sign-in… : null}
);
@@ -280,15 +316,7 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
return stage(
-
{
- setFilter(event.target.value);
- }}
- />
+
{(servers) => {
@@ -296,18 +324,71 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
const available = MCP_SERVER_CATALOG.filter(
(entry) => !servers.some((server) => server.handle === entry.handle) && matches(entry),
);
+ const filtered = needle !== "";
+ if (connected.length === 0 && available.length === 0) {
+ return filtered ? (
+ }
+ title={`No tools match "${filter.trim()}"`}
+ description="Try a different search."
+ action={
+ {
+ setFilter("");
+ }}
+ >
+ Clear filter
+
+ }
+ />
+ ) : (
+ }
+ title="Nothing connected yet"
+ description="Connect a popular server below and its tools reach every workbench's worker."
+ actions={[
+ {
+ label: "Browse popular servers",
+ href: "#popular-servers",
+ variant: "primary",
+ },
+ ]}
+ />
+ );
+ }
return (
<>
{connected.length === 0 ? (
- Nothing connected yet.
+ filtered ? (
+ No connected servers match this filter.
+ ) : (
+ }
+ title="Nothing connected yet"
+ description="Connect a popular server below and its tools reach every workbench's worker."
+ actions={[
+ {
+ label: "Browse popular servers",
+ href: "#popular-servers",
+ variant: "primary",
+ },
+ ]}
+ />
+ )
) : (
-
+
{connected.map((server) => (
-
))}
-
+
)}
-
-
- {available.map((entry) => (
-
{
- const server = { url: entry.url, name: entry.name, handle: entry.handle };
- if (entry.auth === "oauth") signIn.start.mutate(server);
- else addServer(server);
+
+
+ {available.length === 0 ? (
+
+ {filtered
+ ? "No popular servers match this filter."
+ : "Every official server is already connected."}
+
+ ) : null}
+
+ {available.map((entry) => (
+
{
+ const server = { url: entry.url, name: entry.name, handle: entry.handle };
+ if (entry.auth === "oauth") signIn.start.mutate(server);
+ else addServer(server);
+ }}
+ />
+ ))}
+ {
+ signIn.start.mutate(server);
}}
+ adding={add.isPending || signIn.busy}
+ waiting={signIn.waitingHandle !== null}
/>
- ))}
- {
- signIn.start.mutate(server);
- }}
- adding={add.isPending || signIn.busy}
- waiting={signIn.waitingHandle !== null}
- />
-
-
+
+
+
>
);
}}
diff --git a/apps/web/src/tools/mcp-server-icons.test.ts b/apps/web/src/tools/mcp-server-icons.test.ts
new file mode 100644
index 000000000..d774bb624
--- /dev/null
+++ b/apps/web/src/tools/mcp-server-icons.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test";
+
+import { MCP_SERVER_CATALOG } from "@corbits/worker/workflow-ids";
+import { Captions, GitPullRequest, MagnifyingGlass, Plugs } from "@/lib/icons";
+
+import { mcpServerIcon } from "./mcp-server-icons";
+
+describe("mcpServerIcon", () => {
+ test("popular servers wear their own mark", () => {
+ expect(mcpServerIcon("exa")).toBe(MagnifyingGlass);
+ expect(mcpServerIcon("linear")).toBe(GitPullRequest);
+ expect(mcpServerIcon("granola")).toBe(Captions);
+ });
+
+ test("unknown handles fall back to the plugs mark", () => {
+ expect(mcpServerIcon("some-custom-server")).toBe(Plugs);
+ expect(mcpServerIcon("")).toBe(Plugs);
+ });
+
+ test("every platform catalog entry has its own mark", () => {
+ for (const entry of MCP_SERVER_CATALOG) {
+ expect(mcpServerIcon(entry.handle)).not.toBe(Plugs);
+ }
+ });
+});
diff --git a/apps/web/src/tools/mcp-server-icons.ts b/apps/web/src/tools/mcp-server-icons.ts
new file mode 100644
index 000000000..89775a670
--- /dev/null
+++ b/apps/web/src/tools/mcp-server-icons.ts
@@ -0,0 +1,16 @@
+// Which Lucide mark each MCP server's tile wears. The catalog itself stays
+// platform-owned (`MCP_SERVER_CATALOG`); this only maps a handle to its icon,
+// so an unknown or custom handle falls back to the plugs mark.
+
+import { Captions, GitPullRequest, MagnifyingGlass, Plugs, type Icon } from "@/lib/icons";
+
+const HANDLE_ICONS: Readonly> = {
+ exa: MagnifyingGlass,
+ granola: Captions,
+ linear: GitPullRequest,
+};
+
+/** The tile mark for an MCP server handle; custom servers get the plugs mark. */
+export function mcpServerIcon(handle: string): Icon {
+ return HANDLE_ICONS[handle] ?? Plugs;
+}
diff --git a/apps/web/src/tools/tool-count.test.ts b/apps/web/src/tools/tool-count.test.ts
new file mode 100644
index 000000000..629efaaef
--- /dev/null
+++ b/apps/web/src/tools/tool-count.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "bun:test";
+
+import { toolCountLabel } from "./tool-count";
+
+describe("toolCountLabel", () => {
+ test("singular for one tool", () => {
+ expect(toolCountLabel(1)).toBe("1 tool");
+ });
+
+ test("plural otherwise", () => {
+ expect(toolCountLabel(0)).toBe("0 tools");
+ expect(toolCountLabel(3)).toBe("3 tools");
+ });
+});
diff --git a/apps/web/src/tools/tool-count.ts b/apps/web/src/tools/tool-count.ts
new file mode 100644
index 000000000..f9ca850e7
--- /dev/null
+++ b/apps/web/src/tools/tool-count.ts
@@ -0,0 +1,5 @@
+/** One voice for server tool counts: the Tools page pills and the drawer
+ * headers read from the same helper so their numbers can never drift apart. */
+export function toolCountLabel(count: number): string {
+ return `${String(count)} tool${count === 1 ? "" : "s"}`;
+}
diff --git a/apps/web/src/tools/tools-page.css b/apps/web/src/tools/tools-page.css
index b7bbe73f9..d97744411 100644
--- a/apps/web/src/tools/tools-page.css
+++ b/apps/web/src/tools/tools-page.css
@@ -1,4 +1,5 @@
-/* Quiet, token-only pills: no filled green, no vendor colour. */
+/* One quiet pill for every status tag: a sunk wash with an inset ring, never
+ * a filled green or a vendor colour. */
.tool-live,
.tool-kind {
display: inline-flex;
@@ -7,34 +8,24 @@
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
- color: var(--ink);
-}
-
-.tool-live {
- background: var(--success-wash);
-}
-
-.tool-kind {
- background: var(--surface-sunk);
color: var(--ink-2);
+ background: var(--surface-sunk);
box-shadow: inset 0 0 0 1px var(--line);
}
+/* The popular catalog: a dense tile grid. Connected servers read as rows in
+ * the shared list card — tiles are for browsing, not status. */
.tools-grid {
display: grid;
- gap: 16px;
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
-}
-
-.tools-filter {
- max-width: 320px;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.tool-tile {
display: flex;
flex-direction: column;
- gap: 4px;
- padding: 20px;
+ gap: 2px;
+ padding: 16px;
border-radius: var(--r-lg);
background: var(--surface);
box-shadow: var(--raised);
@@ -45,18 +36,35 @@
box-shadow: var(--raised-hover);
}
-/* A neutral monogram: a vendor's brand color never tints a tile. */
-.tool-tile-mark {
+/* Keyboard users land on the tile's buttons, so the tile itself names the
+ * focused group with the theme ring. */
+.tool-tile:focus-within {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+/* A neutral icon tile: a vendor's brand color never tints a tile. */
+.tool-tile-mark,
+.tools-row-mark {
display: grid;
+ flex: none;
place-items: center;
- width: 36px;
- height: 36px;
- margin-bottom: 14px;
- border-radius: 10px;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--r-md);
background: var(--surface-sunk);
box-shadow: inset 0 0 0 1px var(--line);
- color: var(--ink);
- font-weight: 800;
+ color: var(--ink-2);
+}
+
+.tool-tile-mark {
+ margin-bottom: 10px;
+}
+
+.tool-tile-mark svg,
+.tools-row-mark svg {
+ width: 16px;
+ height: 16px;
}
.tool-tile-title {
@@ -64,22 +72,48 @@
align-items: center;
gap: 8px;
margin: 0;
- font-size: 15px;
- font-weight: 800;
+ font-size: 14px;
+ font-weight: 700;
}
.tool-tile-desc {
flex: 1;
- margin: 0 0 16px;
+ margin: 0 0 12px;
overflow-wrap: anywhere;
- font-size: 13.5px;
+ font-size: 12.5px;
color: var(--ink-2);
}
.tool-tile-foot {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
+}
+
+/* A sign-in in flight spins inside its own disabled button — the wait is
+ * named where the action was, not beside it. */
+.tool-spin {
+ animation: tool-spin 0.8s linear infinite;
+}
+
+@keyframes tool-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .tool-tile {
+ transition: none;
+ }
+
+ .tool-spin {
+ animation: none;
+ }
}
.tool-tile-hint {
@@ -109,7 +143,7 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
- padding: 1rem;
+ padding: 16px;
}
.tools-sections {
@@ -118,7 +152,82 @@
gap: 2rem;
}
+/* Connected servers read as rows in the shared list card: a mark, the name
+ * and host, the live-tool pill, then Disconnect. The mark's 32px plus the
+ * row padding keeps every row over the 40px target. */
+.tools-row-main {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.tools-row-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.tools-row-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ font-size: 14px;
+ font-weight: 700;
+}
+
+.tools-row-name-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tools-row-name .tool-kind {
+ flex: none;
+}
+
+.tools-row-desc {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12.5px;
+ color: var(--ink-2);
+}
+
+/* The drawer's per-server headers: a quiet count beside the name, and an Add
+ * tools link with visible hover, active, and focus states. */
+.tools-tab-count {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ink-3);
+ white-space: nowrap;
+}
+
+.tools-tab-add {
+ font-size: 12.5px;
+ color: var(--ink-3);
+ text-underline-offset: 3px;
+}
+
+.tools-tab-add:hover {
+ color: var(--ink);
+ text-decoration: underline;
+}
+
+.tools-tab-add:active {
+ color: var(--ink-2);
+}
+
+.tools-tab-add:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
.tools-cell-name {
+ font-family: var(--font-mono);
+ font-size: 12.5px;
font-weight: 500;
}
diff --git a/e2e/mcp/oauth-connect.test.ts b/e2e/mcp/oauth-connect.test.ts
index 633b92ee1..019083751 100644
--- a/e2e/mcp/oauth-connect.test.ts
+++ b/e2e/mcp/oauth-connect.test.ts
@@ -146,7 +146,7 @@ describeBrowser("oauth mcp connect", () => {
await popup.close();
// Model returning to Tools: its sign-in query does not poll while backgrounded.
await page.bringToFront();
- await page.waitForFunction(`document.body.innerText.includes("1 tools live")`, {
+ await page.waitForFunction(`document.body.innerText.includes("1 tool live")`, {
timeout: 120_000,
polling: 500,
});
diff --git a/e2e/mcp/tools-popular.test.ts b/e2e/mcp/tools-popular.test.ts
new file mode 100644
index 000000000..acd179ef6
--- /dev/null
+++ b/e2e/mcp/tools-popular.test.ts
@@ -0,0 +1,87 @@
+// The Tools page's Popular section lists the official MCP catalog as icon
+// tiles in a dense multi-column grid: every tile carries an icon mark and the
+// grid fills a standard viewport with more than one column.
+import { expect, test } from "bun:test";
+import { type } from "arktype";
+import { bootBrowserApp, browserGate } from "../lib/browser";
+import { runFirstRunFlow, waitForText } from "../lib/first-run";
+
+const describeBrowser = browserGate(import.meta.path);
+
+describeBrowser("tools popular grid", () => {
+ const app = bootBrowserApp();
+
+ test("popular servers render with icons in a dense grid", async () => {
+ const { page, errors } = await app().newPage();
+ try {
+ await page.setViewport({ width: 1280, height: 800 });
+ await runFirstRunFlow(page, app().origin);
+ await page.goto(`${app().origin}/tools`, { waitUntil: "domcontentloaded" });
+ await waitForText(page, "Popular");
+
+ const grid = type({
+ columns: "number",
+ gap: "string",
+ tilePad: "string",
+ tiles: type({ title: "string", icon: "boolean" }).array(),
+ firstRow: "number",
+ fullPack: "boolean",
+ }).assert(
+ await page.evaluate(`(() => {
+ const gridEl = document.querySelector(".tools-grid");
+ if (!gridEl) throw new Error("Popular grid did not render");
+ const style = getComputedStyle(gridEl);
+ const items = Array.from(gridEl.children).map((child) => ({
+ top: Math.round(child.getBoundingClientRect().top),
+ tile: child.classList.contains("tool-tile")
+ ? {
+ title: child.querySelector(".tool-tile-title")?.textContent ?? "",
+ icon: child.querySelector(".tool-tile-mark svg") !== null,
+ pad: getComputedStyle(child).paddingTop,
+ }
+ : null,
+ }));
+ const columns = style.gridTemplateColumns.split(" ").filter((t) => t !== "").length;
+ const perRow = new Map();
+ for (const item of items) perRow.set(item.top, (perRow.get(item.top) ?? 0) + 1);
+ const counts = [...perRow.entries()].sort((a, b) => a[0] - b[0]).map(([, n]) => n);
+ const firstTop = Math.min(...items.map((item) => item.top));
+ const tiles = items.map((item) => item.tile).filter((tile) => tile !== null);
+ return {
+ columns,
+ gap: style.gap,
+ tilePad: tiles[0]?.pad ?? "",
+ tiles: tiles.map(({ title, icon }) => ({ title, icon })),
+ firstRow: items.filter((item) => item.top === firstTop).length,
+ fullPack: counts.slice(0, -1).every((n) => n === columns),
+ };
+ })()`),
+ );
+
+ // Setup auto-connects keyless Exa, so a fresh workspace offers the rest
+ // of the official catalog here; every tile wears its icon mark.
+ expect(grid.tiles.length).toBeGreaterThanOrEqual(1);
+ for (const tile of grid.tiles) expect(tile.icon).toBe(true);
+ expect(
+ grid.tiles.some((tile) =>
+ ["Exa", "Linear", "Granola"].some((name) => tile.title.includes(name)),
+ ),
+ ).toBe(true);
+ // Dense, not a single-column stack: several 200px tracks on a standard
+ // viewport (the 12px gap and 16px tile padding fingerprint the new
+ // CSS), a packed first row, and no holes before the final partial row.
+ expect(grid.columns).toBeGreaterThanOrEqual(2);
+ expect(grid.gap).toBe("12px");
+ expect(grid.tilePad).toBe("16px");
+ expect(grid.firstRow).toBeGreaterThanOrEqual(2);
+ expect(grid.fullPack).toBe(true);
+ } catch (cause) {
+ process.stderr.write(
+ `tools-popular failed at ${page.url()}\nconsole: ${errors.join(" | ")}\n`,
+ );
+ throw cause;
+ } finally {
+ await page.close();
+ }
+ }, 420_000);
+});