From aabaa654d6b4c27497fae06986e55e3411c5355d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 2 Oct 2026 23:33:47 -0700 Subject: [PATCH 1/5] Add tests for MCP server tile icons --- apps/web/src/tools/mcp-server-icons.test.ts | 25 +++++++++++++++++++++ apps/web/src/tools/mcp-server-icons.ts | 16 +++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 apps/web/src/tools/mcp-server-icons.test.ts create mode 100644 apps/web/src/tools/mcp-server-icons.ts 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; +} From 3aed56c4f21460b6594a9f1c8979d033c21f494d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 2 Oct 2026 23:33:54 -0700 Subject: [PATCH 2/5] Tools page shows popular MCPs with icons in a denser layout --- apps/web/src/pages/tools-page.tsx | 81 ++++++++++++++++++++++++++----- apps/web/src/tools/tools-page.css | 57 ++++++++++++++++------ 2 files changed, 109 insertions(+), 29 deletions(-) diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx index 3a2d48908..fc435644f 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,9 +10,11 @@ import { Card, CardDescription, CardTitle, + EmptyState, Input, RichEmptyState, Section, + Skeleton, Table, TableBody, TableCell, @@ -21,7 +24,7 @@ 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 { MagnifyingGlass, Plugs, type Icon } from "@/lib/icons"; import { describeApiError } from "@/lib/api-query"; import { MCP_SERVER_CATALOG, type McpCatalogEntry } from "../mcp-servers"; @@ -33,6 +36,7 @@ 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"; @@ -53,13 +57,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 +72,7 @@ function ToolTile({ return (

{name} @@ -93,7 +97,7 @@ function ConnectedTile({ const agents = server.agentNames.length === 0 ? "" : ` · ${server.agentNames.join(", ")}`; return ( - +

+ } + > {(servers) => { const connected = servers.filter(matches); 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={ + + } + /> + ) : ( + } + title="No servers connected" + description="Connect a popular server below and its tools reach every workbench's worker." + /> + ); + } return ( <>
{connected.length === 0 ? ( -

Nothing connected yet.

+

+ {filtered + ? "No connected servers match this filter." + : "Nothing connected yet — pick a popular server below."} +

) : (
{connected.map((server) => ( @@ -329,10 +374,20 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { )}
-
+
+ {available.length === 0 ? ( +

+ {filtered + ? "No popular servers match this filter." + : "Every official server is already connected."} +

+ ) : null}
{available.map((entry) => ( - Date: Fri, 2 Oct 2026 23:52:42 -0700 Subject: [PATCH 3/5] Tools page reads connected servers as rows with designer-audit polish Designer audit follow-up: Connected servers move from tiles to the shared ListCard rows idiom (Popular stays a catalog grid); sections carry counts; empties speak with one voice ('Nothing connected yet' + CTA); waiting sign-ins spin inside their disabled buttons; all page targets are lg/40px; pills share one wash+inset-ring style; rhythm is 16px cards / 32px sections; the drawer's coming-soon fallback is an honest empty. --- apps/web/src/bench/bench-drawer.tsx | 6 +- apps/web/src/bench/tools-tab.tsx | 23 +-- apps/web/src/pages/tools-page.tsx | 215 +++++++++++++++----------- apps/web/src/tools/tool-count.test.ts | 14 ++ apps/web/src/tools/tool-count.ts | 5 + apps/web/src/tools/tools-page.css | 134 +++++++++++++--- 6 files changed, 270 insertions(+), 127 deletions(-) create mode 100644 apps/web/src/tools/tool-count.test.ts create mode 100644 apps/web/src/tools/tool-count.ts 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.

-
) : ( @@ -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 fc435644f..270119dd5 100644 --- a/apps/web/src/pages/tools-page.tsx +++ b/apps/web/src/pages/tools-page.tsx @@ -14,7 +14,6 @@ import { Input, RichEmptyState, Section, - Skeleton, Table, TableBody, TableCell, @@ -24,10 +23,11 @@ import { } from "@corbits/react-ui"; import { toast } from "@corbits/react-ui/ui/toast"; import { QueryView } from "@/lib/api-query"; -import { MagnifyingGlass, Plugs, type Icon } 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, @@ -43,6 +43,7 @@ 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. */ @@ -84,7 +85,7 @@ function ToolTile({ ); } -function ConnectedTile({ +function ConnectedRow({ server, onRemove, removing, @@ -94,27 +95,36 @@ 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 -
  • -
    + + ); } @@ -139,12 +149,21 @@ function PopularTile({ official >
    - - - {waiting ? "Waiting for sign-in…" : keyless ? "No key needed" : "OAuth"} - + {waiting ? null : ( + {keyless ? "No key needed" : "OAuth"} + )}
    ); @@ -217,15 +236,28 @@ function AddByUrl({ }} />
    - {token === "" ? ( - ) : null} - {waiting ? Waiting for sign-in… : null}
    ); @@ -284,28 +316,9 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { return stage(
    - { - setFilter(event.target.value); - }} - /> + -
    - } - > + {(servers) => { const connected = servers.filter(matches); const available = MCP_SERVER_CATALOG.filter( @@ -332,8 +345,15 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { ) : ( } - title="No servers connected" + 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", + }, + ]} /> ); } @@ -342,17 +362,33 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
    {connected.length === 0 ? ( -

    - {filtered - ? "No connected servers match this filter." - : "Nothing connected yet — pick a popular server below."} -

    + 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.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); + +
    + ); }} 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 9bd34277e..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: 12px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } -.tools-filter { - max-width: 320px; -} - .tool-tile { display: flex; flex-direction: column; gap: 2px; - padding: 14px 16px; + padding: 16px; border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--raised); @@ -53,19 +44,25 @@ } /* A neutral icon tile: a vendor's brand color never tints a tile. */ -.tool-tile-mark { +.tool-tile-mark, +.tools-row-mark { display: grid; + flex: none; place-items: center; width: 32px; height: 32px; - margin-bottom: 10px; - border-radius: 10px; + border-radius: var(--r-md); background: var(--surface-sunk); box-shadow: inset 0 0 0 1px var(--line); color: var(--ink-2); } -.tool-tile-mark svg { +.tool-tile-mark { + margin-bottom: 10px; +} + +.tool-tile-mark svg, +.tools-row-mark svg { width: 16px; height: 16px; } @@ -93,16 +90,30 @@ gap: 8px; } -/* Loading tiles hold the dense grid's shape until the catalog lands. */ -.tool-tile-skeleton { - min-height: 128px; - border-radius: var(--r-lg); +/* 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 { @@ -132,13 +143,86 @@ display: flex; flex-direction: column; gap: 0.75rem; - padding: 1rem; + padding: 16px; } .tools-sections { display: flex; flex-direction: column; - gap: 1.5rem; + 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 { From a441bdbdeada73f7928aa10481b8046b1a0a875c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 3 Oct 2026 09:31:36 -0700 Subject: [PATCH 4/5] Add E2E proof for popular MCP grid on the Tools page Popular tiles render with icon marks in a dense multi-column grid: the 12px gap and 16px tile padding fingerprint the new CSS, and every non-final row packs full. --- e2e/mcp/tools-popular.test.ts | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 e2e/mcp/tools-popular.test.ts 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); +}); From 13d0b3233daaeaf461080f45a3c7464f87e1c66a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 3 Oct 2026 09:31:36 -0700 Subject: [PATCH 5/5] Fix stale connected-server copy in oauth-connect E2E The Tools page now renders the singular "1 tool live" via toolCountLabel; the wait expected the old plural and timed out. --- e2e/mcp/oauth-connect.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, });