diff --git a/.changeset/clean-agents-connect.md b/.changeset/clean-agents-connect.md
new file mode 100644
index 0000000..5a31614
--- /dev/null
+++ b/.changeset/clean-agents-connect.md
@@ -0,0 +1,5 @@
+---
+"sideshow": patch
+---
+
+Port the cleaner connect-an-agent onboarding screen from sideshow-cloud to the self-hosted viewer.
diff --git a/README.md b/README.md
index 1497314..6bbd666 100644
--- a/README.md
+++ b/README.md
@@ -64,8 +64,8 @@ surfaces and read your comments. Ask it to "sketch this on sideshow" and watch
the card appear.
The running viewer has the same handoff built in: its sidebar footer carries an
-**agent setup** link (the block above) and a **Connect Claude Code** button, so
-you can grab it without leaving the browser.
+**agent setup** link (the block above) and a polished **connect agent** screen, so
+you can grab the right MCP command without leaving the browser.
No agent handy? `npx sideshow demo` seeds two example sessions to look around.
diff --git a/docs/connecting-agents.md b/docs/connecting-agents.md
index 214bbe9..77fb0ae 100644
--- a/docs/connecting-agents.md
+++ b/docs/connecting-agents.md
@@ -86,8 +86,8 @@ watcher:
On install it asks for your **Sideshow URL** (default `http://localhost:8228`, or
your deployed instance) and an optional token. The monitor runs `sideshow watch`
against your workspace; comments are delivered to the agent exactly once. Requires
-Claude Code ≥ 2.1.105. The viewer's "connect Claude Code" link (sidebar footer)
-shows the same steps. The plugin lives in [`../plugin/`](../plugin/).
+Claude Code ≥ 2.1.105. The viewer's "connect agent" link (sidebar footer) shows
+generic MCP client setup; the Claude Code plugin lives in [`../plugin/`](../plugin/).
## The design contract
diff --git a/e2e/public-read.spec.ts b/e2e/public-read.spec.ts
index 985b023..5ccfcc1 100644
--- a/e2e/public-read.spec.ts
+++ b/e2e/public-read.spec.ts
@@ -155,7 +155,7 @@ test("readonly full-mode chrome hides sidebar write controls", async ({
await expect(page.locator("#sessionList .sess .x")).toHaveCount(0);
await expect(page.locator(".theme-picker")).toHaveCount(0);
- await expect(page.getByRole("link", { name: "connect Claude Code" })).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "connect agent" })).toHaveCount(0);
await expect(page.locator("#sessTitle")).toHaveAttribute("contenteditable", "false");
});
@@ -165,7 +165,7 @@ test("readonly empty board shows a simple empty state", async ({ page, publicRea
await expect(page.locator("#onboard")).toBeVisible();
await expect(page.locator("#onboard h1")).toHaveText("Nothing here yet");
await expect(page.locator("#onboard .snip")).toHaveCount(0);
- await expect(page.locator("#onboard .connect-btn")).toHaveCount(0);
+ await expect(page.locator("#onboard .connect-block")).toHaveCount(0);
await expect(page.getByRole("link", { name: "design guide" })).toBeVisible();
await expect(page.getByRole("link", { name: "agent setup" })).toBeVisible();
});
diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts
index a908de6..bcd4e9d 100644
--- a/e2e/viewer.spec.ts
+++ b/e2e/viewer.spec.ts
@@ -464,22 +464,36 @@ test("timeline traces wrap cleanly at iPhone 14 Pro width", async ({ page, serve
await expectNoHorizontalOverflow(page, ".timeline");
});
-test("the Connect Claude Code modal shows the plugin install commands", async ({
+test("the Connect an agent page shows the add-mcp logo picker", async ({ page, server }) => {
+ await page.goto(server.url);
+
+ await page.getByRole("link", { name: "connect agent" }).click();
+ await expect(page).toHaveURL(`${server.url}/connect`);
+ await expect(page.getByRole("heading", { name: "Connect an agent" })).toBeVisible();
+ await expect(page.getByRole("dialog", { name: "Connect an agent" })).toHaveCount(0);
+ await expect(page.getByRole("radiogroup", { name: "Choose how to connect" })).toBeVisible();
+ await expect(page.getByRole("radio", { name: "Most agents" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await expect(page.locator(".connect-page")).toContainText(`npx add-mcp ${server.url}/mcp`);
+
+ await page.getByRole("radio", { name: "Other" }).click();
+ await expect(page.locator(".connect-page")).toContainText('"mcpServers"');
+ await expect(page.locator(".connect-page")).toContainText(`${server.url}/mcp`);
+});
+
+test("the Connect an agent page is reachable directly when sessions already exist", async ({
page,
server,
}) => {
- await page.goto(server.url);
+ await publish(server.url, { html: "
connected
", title: "Existing", agent: "e2e" });
- await page.getByRole("link", { name: "connect Claude Code" }).click();
- const modal = page.getByRole("dialog", { name: "Connect Claude Code" });
- await expect(modal).toBeVisible();
- await expect(modal).toContainText("/plugin marketplace add modem-dev/sideshow");
- await expect(modal).toContainText("/plugin install sideshow@sideshow");
- await expect(modal).toContainText("sideshow watch");
+ await page.goto(`${server.url}/connect`);
- // Escape dismisses it
- await page.keyboard.press("Escape");
- await expect(modal).toBeHidden();
+ await expect(page).toHaveURL(`${server.url}/connect`);
+ await expect(page.getByRole("heading", { name: "Connect an agent" })).toBeVisible();
+ await expect(page.locator(".connect-page")).toContainText(`npx add-mcp ${server.url}/mcp`);
});
test("version select appears live after an update", async ({ page, server }) => {
diff --git a/server/app.ts b/server/app.ts
index a6351d0..0dae938 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -950,6 +950,7 @@ export function createApp({
return opts.post ? injectHead(html, postPreviewHead(opts.post, c.req.raw)) : html;
};
app.get("/", (c) => c.html(configuredViewerHtml(c)));
+ app.get("/connect", (c) => c.html(configuredViewerHtml(c, { title: "Connect an agent" })));
app.get("/session/:id", async (c) => {
const session = await store.getSession(c.req.param("id"));
if (isUnauthenticatedSessionRead(c) && !session) {
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index d8c8227..688c847 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show }
import { AgentMark } from "./agentMarks.tsx";
import {
api,
+ appPath,
initialPageTitle,
isReadonly,
layoutMode,
@@ -12,6 +13,7 @@ import {
} from "./api.ts";
import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts";
import { applyFrameHeight, Card, cardEls, frameForSource } from "./Card.tsx";
+import { ConnectInstructions } from "./Connect.tsx";
import { renderNotes } from "./notes.ts";
import { SessionTimeline } from "./SessionTimeline.tsx";
import { MoonIcon, PlugIcon, SunIcon, SystemIcon } from "./icons.tsx";
@@ -58,9 +60,15 @@ import {
viewMode,
} from "./state.ts";
-// The "Connect Claude Code" integrations modal — module-level so the sidebar
-// footer, the onboarding screen, and the overlay can all reach it.
-const [connectOpen, setConnectOpen] = createSignal(false);
+function isConnectPath() {
+ const basePath = window.__SIDESHOW_BASE_PATH__ ?? "";
+ const rest = location.pathname.startsWith(basePath)
+ ? location.pathname.slice(basePath.length)
+ : location.pathname;
+ return rest === "/connect";
+}
+const [connectPath, setConnectPath] = createSignal(isConnectPath());
+
// Stream-only layout: no sidebar, session list, or session chrome — just the
// current session's stream. Driven by the host's `layout` (cloud embed) or the
// self-hosted public-read "session" link (see api.ts `layoutMode`).
@@ -74,7 +82,15 @@ const streamMode = () => layoutMode() === "stream";
// showing a full-page view over an empty workspace.
function Brand() {
return (
-