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
5 changes: 5 additions & 0 deletions .changeset/clean-agents-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Port the cleaner connect-an-agent onboarding screen from sideshow-cloud to the self-hosted viewer.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/connecting-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions e2e/public-read.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand All @@ -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();
});
Expand Down
36 changes: 25 additions & 11 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>connected</p>", 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 }) => {
Expand Down
1 change: 1 addition & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
194 changes: 76 additions & 118 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show }
import { AgentMark } from "./agentMarks.tsx";
import {
api,
appPath,
initialPageTitle,
isReadonly,
layoutMode,
Expand All @@ -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";
Expand Down Expand Up @@ -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`).
Expand All @@ -74,7 +82,15 @@ const streamMode = () => layoutMode() === "stream";
// showing a full-page view over an empty workspace.
function Brand() {
return (
<button class="brand" type="button" aria-label="sideshow — home" onClick={() => goHome()}>
<button
class="brand"
type="button"
aria-label="sideshow — home"
onClick={() => {
setConnectPath(false);
goHome();
}}
>
<span class="livedot" classList={{ on: live() }}></span>sideshow
</button>
);
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -264,16 +279,7 @@ export default function App() {
agent setup
</a>{" "}
<Show when={!isReadonly()}>
&nbsp;·&nbsp;{" "}
<a
href="#"
onClick={(e) => {
e.preventDefault();
setConnectOpen(true);
}}
>
connect Claude Code
</a>
&nbsp;·&nbsp; <a href={appPath("/connect")}>connect agent</a>
</Show>
</slot>
</div>
Expand All @@ -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. */}
<slot name={SLOTS.main}>
<Show when={!streamMode()}>
<Onboard />
<Show
when={connectPath()}
fallback={
<>
<Show when={!streamMode()}>
<Onboard />
</Show>
<SessionView />
</>
}
>
<ConnectPage />
</Show>
<SessionView />
</slot>
</main>
</div>
<Show when={!streamMode()}>
<div id="scrim" onClick={() => setNavOpen(false)}></div>
</Show>
<Show when={connectOpen()}>
<ConnectModal onClose={() => setConnectOpen(false)} />
</Show>
<div id="toast" role="status" aria-live="polite" classList={{ show: toastShow() }}>
{toastText()}
</div>
Expand Down Expand Up @@ -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);
}
}}
Expand Down Expand Up @@ -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": "<h2>It works</h2>"}'`;

function Onboard() {
return (
<div id="onboard" hidden={!initialLoaded() || sessions.length > 0}>
Expand All @@ -700,74 +709,42 @@ function Onboard() {
</>
}
>
<h1>The show hasn&rsquo;t started yet</h1>
<p class="sub">
sideshow is a live stage where coding agents post HTML — diagrams, sketches, explainers
— while they work in your terminal.
</p>
<h2>teach your agent about it</h2>
<Snip text={SETUP_SNIP} />
<h2>or try it yourself</h2>
<Snip text={TRY_SNIP} />
<h2>using claude code?</h2>
<button class="connect-btn" onClick={() => setConnectOpen(true)}>
Connect Claude Code →
</button>
<ConnectInstructions
variant="card"
title="Connect your first agent"
subtitle="Sideshow is a live stage where coding agents post HTML — diagrams, sketches, explainers — while they work in your terminal."
awaiting
/>
</Show>
</slot>
</div>
);
}

// 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 (
<div class="modal-backdrop" onClick={props.onClose}>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label="Connect Claude Code"
onClick={(e) => e.stopPropagation()}
>
<div class="modal-head">
<h2>Connect Claude Code</h2>
<button class="x" aria-label="Close" onClick={props.onClose}>
</button>
</div>
<p class="sub">
Install the sideshow plugin so your comments reach the agent on their own. A background
monitor streams each comment to Claude Code as a notification — no copy-pasting, no
re-arming a watcher.
</p>
<h3>1 · add the marketplace</h3>
<Snip text={MARKETPLACE_CMD} />
<h3>2 · install the plugin</h3>
<Snip text={INSTALL_CMD} />
<p class="note">
Run both inside Claude Code. On install it asks for your <strong>Sideshow URL</strong>{" "}
(default <code>http://localhost:8228</code>, or your deployed instance) and an optional
token.
</p>
<h3>what it runs</h3>
<p class="note">
The plugin connects the sideshow MCP server and runs <code>sideshow watch</code> against
your workspace as a background process — unsandboxed, the same trust level as hooks, with
no per-comment prompt. Comments are delivered to the agent exactly once.
</p>
<p class="caveat">
Requires Claude Code ≥ 2.1.105. It&rsquo;s two commands, not a true one-click — Claude
Code has no browser-to-terminal handoff yet.
</p>
<section class="settings-page connect-page" aria-label="Connect an agent">
<div class="settings-col">
<header class="settings-top">
<h1>Connect an agent</h1>
<Show
when={!isReadonly()}
fallback={<p>This workspace is read-only, so new agents cannot connect from here.</p>}
>
<p>
One command wires sideshow into Claude Code, Cursor, Codex, VS Code, opencode, and
other MCP-capable agents. New posts show up here automatically.
</p>
</Show>
</header>
<Show when={!isReadonly()}>
<section class="settings-sec">
<h2>MCP setup</h2>
<ConnectInstructions />
</section>
</Show>
</div>
</div>
</section>
);
}

Expand Down Expand Up @@ -825,22 +802,3 @@ function ThemePicker() {
</div>
);
}

function Snip(props: { text: string }) {
const [label, setLabel] = createSignal("copy");
return (
<div class="snip">
{props.text}
<button
class="copy"
onClick={() => {
navigator.clipboard.writeText(props.text);
setLabel("copied");
setTimeout(() => setLabel("copy"), 1500);
}}
>
{label()}
</button>
</div>
);
}
Loading
Loading