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 ( - ); @@ -94,16 +110,6 @@ function pageTitle( } export default function App() { - // Escape closes the integrations modal while it is open. - createEffect(() => { - if (!connectOpen()) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setConnectOpen(false); - }; - document.addEventListener("keydown", onKey); - onCleanup(() => document.removeEventListener("keydown", onKey)); - }); - onMount(() => { // Await the initial route resolution (the standalone post fetch, or the // first session fetch), then mark the workspace decided and tell the host @@ -156,7 +162,16 @@ export default function App() { window.addEventListener("keydown", onKeydown); onCleanup(() => window.removeEventListener("keydown", onKeydown)); // Routing: the host tells us when the route changes (back/forward). - onCleanup(host().router.subscribe(applyRoute)); + onCleanup( + host().router.subscribe((route) => { + setConnectPath(isConnectPath()); + applyRoute(route); + }), + ); + }); + + createEffect(() => { + if (selected()) setConnectPath(false); }); // unseen activity badges the tab title — self-hosted only; an embedding host @@ -264,16 +279,7 @@ export default function App() { agent setup {" "} -  · {" "} - { - e.preventDefault(); - setConnectOpen(true); - }} - > - connect Claude Code - +  ·  connect agent @@ -288,19 +294,25 @@ export default function App() { workspace; an embedder projects a `slot="ss:main"` child to take over the pane (e.g. a cloud Settings page) while the sidebar stays. */} - - + + + + + + + } + > + -
setNavOpen(false)}>
- - setConnectOpen(false)} /> -
{toastText()}
@@ -497,10 +509,14 @@ function SessionItem(props: { session: SessionRow }) { role="button" tabIndex={0} aria-current={props.session.id === selected() ? "true" : undefined} - onClick={() => select(props.session.id)} + onClick={() => { + setConnectPath(false); + select(props.session.id); + }} onKeyDown={(e) => { if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); + setConnectPath(false); select(props.session.id); } }} @@ -675,13 +691,6 @@ function SessionTitle(props: { current: SessionRow | undefined }) { ); } -// withOrigin on the server rewrites these localhost URLs to the deployed -// origin when serving the built document — keep them as plain literals. -const SETUP_SNIP = "curl -s http://localhost:8228/setup >> AGENTS.md"; -const TRY_SNIP = - "curl -s -X POST http://localhost:8228/api/snippets -H 'content-type: application/json' " + - `-d '{"agent": "me", "title": "Hello", "html": "

It works

"}'`; - function Onboard() { return ( ); } -// Install instructions for the Claude Code plugin: a background monitor that -// streams the user's comments to the agent as notifications, plus the sideshow -// MCP server. There is no browser→terminal handoff, so "connect" is two -// copy-paste commands, stated honestly. -const MARKETPLACE_CMD = "/plugin marketplace add modem-dev/sideshow"; -const INSTALL_CMD = "/plugin install sideshow@sideshow"; - -function ConnectModal(props: { onClose: () => void }) { +function ConnectPage() { return ( - ); } - -function Snip(props: { text: string }) { - const [label, setLabel] = createSignal("copy"); - return ( -
- {props.text} - -
- ); -} diff --git a/viewer/src/Connect.tsx b/viewer/src/Connect.tsx new file mode 100644 index 0000000..fb8bde7 --- /dev/null +++ b/viewer/src/Connect.tsx @@ -0,0 +1,263 @@ +// Shared self-hosted connect flow. Backported from sideshow-cloud's polished +// connect-an-agent screen, but kept generic for local/self-hosted deployments: +// the URL is the artifact, and the default path is the universal add-mcp installer. +import { createSignal, For, type JSX } from "solid-js"; +import { appPath } from "./api.ts"; + +function defaultMcpUrl(): string { + return `${location.origin}${appPath("/mcp")}`; +} + +function serverUrlFromMcpUrl(url: string): string { + const u = new URL(url, location.href); + if (u.pathname.endsWith("/mcp")) u.pathname = u.pathname.slice(0, -"/mcp".length) || "/"; + u.search = ""; + u.hash = ""; + return u.toString().replace(/\/$/, ""); +} + +export function CopyField(props: { value: string }) { + const [label, setLabel] = createSignal("copy"); + return ( +
+
{props.value}
+ +
+ ); +} + +// Brand marks for the lane picker. They are monochrome SVGs in currentColor so the +// selected/hover states and all themes stay legible. +type Logo = () => JSX.Element; + +const LogoClaude: Logo = () => ( + +); + +const LogoCursor: Logo = () => ( + +); + +const LogoVSCode: Logo = () => ( + +); + +const LogoOpenAI: Logo = () => ( + +); + +const LogoOpenCode: Logo = () => ( + +); + +const LogoPi: Logo = () => ( + +); + +const LogoPlug: Logo = () => ( + +); + +const LogoMostAgents: Logo = () => ( + + + + + + + +); + +type Client = { + name: string; + logo: Logo; + setup: (url: string) => JSX.Element; + approve?: () => JSX.Element; +}; + +const CLIENTS: Client[] = [ + { + name: "Most agents", + logo: LogoMostAgents, + setup: (url) => , + approve: () => <>Reload your agent if it asks, then ask it to publish a sideshow test post., + }, + { + name: "Pi", + logo: LogoPi, + setup: (url) => { + const serverUrl = serverUrlFromMcpUrl(url); + return ( + <> +

Install the sideshow extension in Pi:

+ +

Then point it at this server:

+ +

+ If this server uses SIDESHOW_TOKEN, also export it as{" "} + SIDESHOW_TOKEN. +

+ + ); + }, + approve: () => ( + <> + /sideshow in Pi confirms the extension can reach this server. + + ), + }, + { + name: "Other", + logo: LogoPlug, + setup: (url) => ( + <> +

Most MCP clients accept a generic HTTP server entry:

+ + + ), + }, +]; + +function Step(props: { n: number; label: string; live?: boolean; children: JSX.Element }) { + return ( +
  • + + {props.n} + +
    +
    {props.label}
    + {props.children} +
    +
  • + ); +} + +function ClientSteps(props: { url: string; awaiting?: boolean }) { + const [idx, setIdx] = createSignal(0); + const tiles: HTMLButtonElement[] = []; + const client = () => CLIENTS[idx()]; + const move = (to: number) => { + setIdx(to); + tiles[to]?.focus(); + }; + const onKeyDown = (e: KeyboardEvent) => { + const n = CLIENTS.length; + if (e.key === "ArrowRight" || e.key === "ArrowDown") move((idx() + 1) % n); + else if (e.key === "ArrowLeft" || e.key === "ArrowUp") move((idx() - 1 + n) % n); + else if (e.key === "Home") move(0); + else if (e.key === "End") move(n - 1); + else return; + e.preventDefault(); + }; + + return ( + <> +

    How do you want to connect?

    +
    + + {(c, i) => ( + + )} + +
    +
      + + {client().setup(props.url)} + + {client().approve && ( + + {client().approve?.()} + + )} + {props.awaiting && ( + +
      + + This page updates the moment your agent publishes. +
      +
      + )} +
    + + ); +} + +export function ConnectInstructions(props: { + url?: string; + variant?: "card" | "plain"; + title?: string; + subtitle?: string; + awaiting?: boolean; +}) { + const url = () => props.url ?? defaultMcpUrl(); + const card = () => props.variant === "card"; + return ( +
    + {card() && ( + + )} + {props.title &&

    {props.title}

    } + {props.subtitle &&

    {props.subtitle}

    } + +

    + Protected server? Add Authorization: Bearer <SIDESHOW_TOKEN> in your MCP + client, or set SIDESHOW_TOKEN for CLI/Pi integrations. +

    +
    + ); +} diff --git a/viewer/src/state.ts b/viewer/src/state.ts index fdb0baf..e2cf195 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -187,6 +187,10 @@ export async function enterStandalone(id: string) { if (post) setStandaloneInternal(post); } +function isConnectRoute(): boolean { + return location.pathname === appPath("/connect"); +} + export async function refreshSessions(targetPostId?: string | null) { if (isReadonly() && publicReadMode() === "session") { const route = host().router.get(); @@ -231,9 +235,10 @@ export async function refreshSessions(targetPostId?: string | null) { // host's home shows with nothing selected (no auto-open, no highlight). const route = host().router.get(); const lastId = localStorage.getItem(LAST_SESSION_KEY); - const fallback = host().homeView - ? null - : (lastId && sessions.some((s) => s.id === lastId) && lastId) || sessions[0].id; + const fallback = + host().homeView || isConnectRoute() + ? null + : (lastId && sessions.some((s) => s.id === lastId) && lastId) || sessions[0].id; const target = (route.sessionId && sessions.some((s) => s.id === route.sessionId) && route.sessionId) || fallback; diff --git a/viewer/src/styles.css b/viewer/src/styles.css index aeadcf5..d6dfe4f 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -1417,21 +1417,301 @@ iframe { color: var(--text); } -.connect-btn { +.settings-page { + width: 100%; +} +.settings-col { + max-width: 46rem; + margin: 0 auto; + padding: 28px 24px 64px; +} +.settings-top { + padding-bottom: 18px; + border-bottom: 0.5px solid var(--border); +} +.settings-top h1 { + font-size: 20px; + font-weight: 600; + margin: 0; +} +.settings-top p { + color: var(--muted); + font-size: 13px; + line-height: 1.55; + margin: 8px 0 0; + max-width: 620px; +} +.settings-sec { + padding: 22px 0; + border-bottom: 0.5px solid var(--border); +} +.settings-sec:last-child { + border-bottom: none; +} +.settings-sec > h2 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); + margin: 0 0 14px; +} +.connect-block { + max-width: 46rem; +} +.connect-block.connect-card { + margin: 4px 0 0; + padding: 24px; + background: var(--surface); + border: 0.5px solid var(--border); + border-radius: 12px; + text-align: left; +} +.connect-block .muted { + color: var(--muted); + font-size: 13px; + line-height: 1.55; + margin: 0 0 8px; +} +.connect-block code { font: - 13px -apple-system, - sans-serif; + 12px ui-monospace, + SFMono-Regular, + Menlo, + monospace; + background: var(--surface); + border: 0.5px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} +.connect-badge { + width: 44px; + height: 44px; + border-radius: 12px; + display: grid; + place-items: center; + margin-bottom: 16px; + background: var(--accent-bg); + color: var(--accent); +} +.connect-badge svg { + width: 22px; + height: 22px; +} +.connect-hero-title { + font-size: 19px; + font-weight: 600; + margin: 0 0 6px; color: var(--text); +} +.connect-hero-sub { + font-size: 14px; + color: var(--muted); + line-height: 1.55; + margin: 0 0 20px; +} +.copy-field { + display: flex; + gap: 8px; + align-items: stretch; +} +.copy-field pre { + flex: 1; + min-width: 0; + margin: 0; background: var(--surface); border: 0.5px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + overflow: auto; + font: + 12.5px/1.6 ui-monospace, + SFMono-Regular, + Menlo, + monospace; + color: var(--text); + white-space: pre-wrap; + word-break: break-all; +} +.copy-field .copy { + flex: none; + align-self: stretch; + display: inline-flex; + align-items: center; + justify-content: center; + font: + 12px -apple-system, + sans-serif; + color: var(--text); + background: var(--bg); + border: 0.5px solid var(--border); border-radius: 8px; - padding: 8px 14px; + padding: 6px 10px; cursor: pointer; + text-decoration: none; } -.connect-btn:hover { +.copy-field .copy:hover { border-color: var(--muted); } -/* the "Connect Claude Code" integrations modal */ +.connect-pick-label { + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); + margin: 0 0 10px; +} +.connect-logos { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 8px; + margin: 0 0 24px; +} +@media (max-width: 480px) { + .connect-logos { + grid-template-columns: 1fr; + } +} +.connect-logo { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-width: 0; + height: 76px; + padding: 0 10px; + border-radius: 12px; + cursor: pointer; + border: 0.5px solid var(--border); + background: var(--bg); + color: var(--muted); + font: inherit; +} +.connect-logo:hover { + color: var(--text); + border-color: var(--border-2); +} +.connect-logo svg { + width: 22px; + height: 22px; +} +.connect-logo-name { + font-size: 11.5px; + color: var(--muted); + line-height: 1; +} +.connect-logo.on { + border-color: var(--accent); + background: var(--accent-bg); + color: var(--accent); +} +.connect-logo.on .connect-logo-name { + color: var(--accent); + font-weight: 500; +} +.connect-logo-row { + display: flex; + align-items: center; + gap: 13px; +} +.connect-logo-row svg { + width: 20px; + height: 20px; +} +.connect-flow { + list-style: none; + margin: 0; + padding: 0; +} +.connect-step { + display: grid; + grid-template-columns: 26px 1fr; + gap: 14px; + padding: 2px 0 18px; + position: relative; +} +.connect-step:not(:last-child)::before { + content: ""; + position: absolute; + left: 12.5px; + top: 30px; + bottom: 0; + width: 1.5px; + background: var(--border); +} +.connect-num { + width: 26px; + height: 26px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12.5px; + font-weight: 500; + z-index: 1; + background: var(--panel); + border: 0.5px solid var(--border); + color: var(--muted); +} +.connect-num.live { + background: var(--accent-bg); + border-color: transparent; + color: var(--accent); +} +.connect-step-body { + min-width: 0; +} +.connect-slabel { + font-size: 14px; + font-weight: 500; + color: var(--text); + margin: 3px 0 8px; +} +.connect-wait { + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; + color: var(--muted); +} +.connect-pulse { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; + background: var(--accent); + position: relative; +} +.connect-pulse::after { + content: ""; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid var(--accent); + opacity: 0.5; + animation: connect-pulse 1.6s ease-out infinite; +} +@keyframes connect-pulse { + 0% { + transform: scale(0.6); + opacity: 0.6; + } + 100% { + transform: scale(1.5); + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .connect-pulse::after { + animation: none; + } +} +.connect-token-note { + margin: 4px 0 0; + padding-top: 16px; + border-top: 0.5px solid var(--border); + font-size: 13px; + color: var(--muted); +} +/* the "Connect an agent" integrations modal */ .modal-backdrop { position: fixed; inset: 0;