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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ All notable user-visible changes to this project are documented in this file.
- The design guide, setup block, and Claude Code skill teach the background
watch pattern: arm `sideshow wait` as a background process after publishing
and react when it exits, instead of blocking or polling.
- Agents can name their session at creation: `sessionTitle` on the publish
body and both MCP `publish_snippet` tools, `--session-title` on
`sideshow publish`. Applied only when the publish creates the session —
it never overwrites a title, including renames made in the viewer.

### Changed

Expand Down
16 changes: 14 additions & 2 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ usage:
sideshow publish <file|-> [options] publish an HTML fragment as a snippet
--title <t> snippet title
--session <id> target session (default: auto per agent session)
--session-title <t> name for a newly created session — name the task,
e.g. "Auth refactor" (ignored if the session exists)
--agent <name> agent name for new sessions (default: $SIDESHOW_AGENT or "agent")
--new-session force a fresh session
sideshow update <id> <file|-> revise a snippet (new version, same card)
Expand Down Expand Up @@ -140,7 +142,11 @@ async function resolveSession(flags, { create = false } = {}) {
if (!create) return null;
const session = await api("/api/sessions", {
method: "POST",
body: JSON.stringify({ agent: agentName(flags), cwd: process.cwd() }),
body: JSON.stringify({
agent: agentName(flags),
title: flags["session-title"],
cwd: process.cwd(),
}),
});
writeState({ session: session.id, agent: agentName(flags), lastSeq: 0 });
return session.id;
Expand Down Expand Up @@ -208,6 +214,7 @@ const commands = {
options: {
title: { type: "string" },
session: { type: "string" },
"session-title": { type: "string" },
agent: { type: "string" },
"new-session": { type: "boolean" },
},
Expand All @@ -216,7 +223,12 @@ const commands = {
const session = await resolveSession(flags, { create: true });
const snippet = await api("/api/snippets", {
method: "POST",
body: JSON.stringify({ html, title: flags.title, session }),
body: JSON.stringify({
html,
title: flags.title,
session,
sessionTitle: flags["session-title"],
}),
});
out({ ...snippet, url: `${BASE}/s/${snippet.id}` });
},
Expand Down
8 changes: 5 additions & 3 deletions guide/AGENT_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ Publish a snippet (HTML body fragment only — no doctype/html/head/body):

curl -s -X POST http://localhost:4242/api/snippets \
-H 'content-type: application/json' \
-d '{"agent": "YOUR_NAME", "title": "Short title", "html": "<p>...</p>"}'
-d '{"agent": "YOUR_NAME", "sessionTitle": "Task name", "title": "Short title", "html": "<p>...</p>"}'

The response includes `id` and `sessionId`. Pass `"session": "<sessionId>"`
on later publishes so your snippets group into one session. To revise a
snippet instead of posting a new one:
on later publishes so your snippets group into one session. `sessionTitle`
labels that session in the sidebar — name the task at hand ("Auth refactor"),
not your tool; it is honored only on the publish that creates the session.
To revise a snippet instead of posting a new one:

curl -s -X PUT http://localhost:4242/api/snippets/<id> \
-H 'content-type: application/json' -d '{"html": "..."}'
Expand Down
5 changes: 4 additions & 1 deletion guide/DESIGN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ GET /api/comments?session=<id>&author=user&after=<lastSeq>&wait=60 # user fee
```

Omit `session` on your first publish and the response's `sessionId` is yours —
reuse it so your snippets stay grouped. When refining an illustration you
reuse it so your snippets stay grouped. On that first publish, also set a
session title naming the task ("Auth refactor"), not your tool — `sessionTitle`
(MCP and HTTP) or `--session-title` (CLI). It applies only when the session is
created; never retitle it later. When refining an illustration you
already published, UPDATE it rather than publishing a near-duplicate; versions
are kept and the user can flip between them.

Expand Down
27 changes: 20 additions & 7 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ const text = (value: unknown) => ({
let sessionId: string | null = process.env.SIDESHOW_SESSION ?? null;
let lastSeq = 0;

async function ensureSession(): Promise<string> {
// `title` is used only when this call creates the session — once one exists
// (here or in the viewer, where the user can rename it) it is never retitled.
async function ensureSession(title?: string): Promise<string> {
if (sessionId) return sessionId;
const session = JSON.parse(
await api("/api/sessions", {
method: "POST",
body: JSON.stringify({ agent: AGENT, cwd: process.cwd() }),
body: JSON.stringify({ agent: AGENT, cwd: process.cwd(), title }),
}),
);
sessionId = session.id as string;
Expand All @@ -57,6 +59,8 @@ const server = new McpServer(
"sideshow is a live visual surface the user watches in a browser. Publish HTML snippets to illustrate " +
"concepts, sketch UI ideas, or visualize data while you work. Call get_design_guide once before your first " +
"publish — it defines the HTML contract. Your snippets are grouped into one session for this conversation. " +
'On your first publish, pass sessionTitle to name the session after the task at hand (e.g. "Auth refactor") ' +
"so the user can tell sessions apart in the sidebar. " +
"The user can comment on snippets in their browser; check with wait_for_feedback after publishing something " +
"you want a reaction to. Any publish/update/reply result may also carry a userFeedback array — comments " +
"the user left since your last call. Treat them as messages from the user; they are delivered once.",
Expand All @@ -68,16 +72,25 @@ server.registerTool(
{
description:
"Publish an HTML snippet to the user's sideshow surface. Send a body fragment only (no " +
"doctype/html/head/body). Returns the snippet id and view URL. If the result includes userFeedback, " +
"those are new comments from the user — read them. Call get_design_guide first if you have not this " +
"session.",
"doctype/html/head/body). Returns the snippet id and view URL. On your first publish, pass " +
"sessionTitle naming the task to label this conversation's session in the viewer sidebar (honored " +
"only when the session is created). If the result includes userFeedback, those are new comments " +
"from the user — read them. Call get_design_guide first if you have not this session.",
inputSchema: {
title: z.string().describe("Short human-readable title shown above the snippet"),
html: z.string().describe("HTML body fragment to render"),
sessionTitle: z
.string()
.optional()
.describe(
'Session name shown in the viewer sidebar — name the task, e.g. "Auth refactor", not your ' +
"tool. Used only on the first publish, when the session is created; it never retitles an " +
"existing session.",
),
},
},
async ({ title, html }) => {
const session = await ensureSession();
async ({ title, html, sessionTitle }) => {
const session = await ensureSession(sessionTitle);
const created = JSON.parse(
await api("/api/snippets", {
method: "POST",
Expand Down
10 changes: 9 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
html: string;
title?: string;
session?: string;
sessionTitle?: string;
agent?: string;
cwd?: string;
}): Promise<
Expand All @@ -85,7 +86,13 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
return { error: `session "${sessionId}" not found`, status: 404 };
}
if (!sessionId) {
const session = await store.createSession({ agent: input.agent ?? "agent", cwd: input.cwd });
// sessionTitle applies only here — an existing session keeps its title,
// which the user may have set by renaming it in the viewer.
const session = await store.createSession({
agent: input.agent ?? "agent",
title: input.sessionTitle,
cwd: input.cwd,
});
bus.broadcast({ type: "session-created", id: session.id });
sessionId = session.id;
}
Expand Down Expand Up @@ -291,6 +298,7 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
html: body.html,
title: typeof body.title === "string" ? body.title : undefined,
session: typeof body.session === "string" ? body.session : undefined,
sessionTitle: typeof body.sessionTitle === "string" ? body.sessionTitle : undefined,
agent: typeof body.agent === "string" ? body.agent : undefined,
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
});
Expand Down
19 changes: 16 additions & 3 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface McpDeps {
html: string;
title?: string;
session?: string;
sessionTitle?: string;
agent?: string;
}): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>;
reviseSnippet(
Expand All @@ -31,7 +32,9 @@ const INSTRUCTIONS =
"sideshow is a live visual surface the user watches in a browser. Publish HTML snippets to illustrate " +
"concepts, sketch UI ideas, or visualize data while you work. Call get_design_guide once before your first " +
"publish — it defines the HTML contract. Your first publish_snippet creates a session and returns its " +
"sessionId: pass it as `session` on every later call so your snippets stay grouped. The user can comment on " +
"sessionId: pass it as `session` on every later call so your snippets stay grouped. On that first publish, " +
'also pass sessionTitle to name the session after the task at hand (e.g. "Auth refactor") so the user can ' +
"tell sessions apart in the sidebar. The user can comment on " +
"snippets in their browser; call wait_for_feedback (passing the lastSeq cursor from the previous result) " +
"after publishing something you want a reaction to. Any publish/update/reply result may also carry a " +
"userFeedback array — comments the user left since your last call. Treat them as messages from the user; " +
Expand All @@ -43,8 +46,10 @@ const TOOLS = [
description:
"Publish an HTML snippet to the user's sideshow surface. Send a body fragment only (no " +
"doctype/html/head/body). Returns the snippet id, view URL, and sessionId — pass sessionId as `session` " +
"on later calls. If the result includes userFeedback, those are new comments from the user — read them. " +
"Call get_design_guide first if you have not this session.",
"on later calls. On your first publish, pass sessionTitle naming the task to label the session in the " +
"viewer sidebar (honored only when the publish creates the session). If the result includes " +
"userFeedback, those are new comments from the user — read them. Call get_design_guide first if you " +
"have not this session.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -57,6 +62,13 @@ const TOOLS = [
type: "string",
description: "Session id from a previous publish (omit on first publish)",
},
sessionTitle: {
type: "string",
description:
'Session name shown in the viewer sidebar — name the task, e.g. "Auth refactor", not your ' +
"tool. Honored only when this publish creates the session (first publish, no `session`); it " +
"never retitles an existing session.",
},
agent: {
type: "string",
description:
Expand Down Expand Up @@ -145,6 +157,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
html: String(args.html ?? ""),
title: typeof args.title === "string" ? args.title : undefined,
session: typeof args.session === "string" ? args.session : undefined,
sessionTitle: typeof args.sessionTitle === "string" ? args.sessionTitle : undefined,
agent: typeof args.agent === "string" ? args.agent : undefined,
});
if ("error" in result) throw new Error(result.error);
Expand Down
6 changes: 5 additions & 1 deletion skills/sideshow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ Prefer MCP tools if the sideshow MCP server is connected
Otherwise use the CLI — session grouping is automatic:

```sh
sideshow publish sketch.html --title "Cache layout" --agent your-name
sideshow publish sketch.html --title "Cache layout" --agent your-name --session-title "Cache redesign"
echo '<p>...</p>' | sideshow publish - --title "Quick note"
```

Rules of thumb:

- On your first publish, set a session title that names the task ("Auth
refactor"), not the tool — `--session-title` on the CLI, `sessionTitle` on
the MCP tool. It applies only when the session is created; never try to
retitle later (the user may have renamed it in the viewer).
- One concept per snippet, with a clear title. A series of small snippets
beats one giant page.
- **Iterate with `sideshow update <id>`** (same card, new version) instead of
Expand Down
68 changes: 68 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,41 @@ test("publish into an existing session groups snippets", async () => {
assert.equal(list.length, 2);
});

test("publish with sessionTitle names the auto-created session", async () => {
const app = makeApp();
const res = await app.request(
"/api/snippets",
json({ html: "<p>x</p>", agent: "pi", sessionTitle: "Auth refactor" }),
);
assert.equal(res.status, 201);
const snippet = (await res.json()) as any;
const sessions = (await (await app.request("/api/sessions")).json()) as any;
assert.equal(sessions.length, 1);
assert.equal(sessions[0].id, snippet.sessionId);
assert.equal(sessions[0].title, "Auth refactor");
});

test("sessionTitle never retitles an existing session", async () => {
const app = makeApp();
const first = (await (
await app.request("/api/snippets", json({ html: "<p>1</p>", sessionTitle: "Original" }))
).json()) as any;
// the user renames the session in the viewer...
await app.request(`/api/sessions/${first.sessionId}`, {
...json({ title: "User's pick" }),
method: "PATCH",
});
// ...and a later publish carrying a sessionTitle must not clobber it
const res = await app.request(
"/api/snippets",
json({ html: "<p>2</p>", session: first.sessionId, sessionTitle: "Clobber attempt" }),
);
assert.equal(res.status, 201);
const sessions = (await (await app.request("/api/sessions")).json()) as any;
assert.equal(sessions.length, 1);
assert.equal(sessions[0].title, "User's pick");
});

test("publish into unknown session 404s instead of silently creating", async () => {
const app = makeApp();
const res = await app.request("/api/snippets", json({ html: "<p>x</p>", session: "nope" }));
Expand Down Expand Up @@ -237,6 +272,39 @@ test("mcp endpoint: initialize, tools/list, publish round trip", async () => {
assert.ok(fb.lastSeq > 0);
});

test("mcp publish_snippet honors sessionTitle on first publish only", async () => {
const app = makeApp();
const published = (await (
await app.request(
"/mcp",
mcpCall(1, "tools/call", {
name: "publish_snippet",
arguments: { title: "One", html: "<p>1</p>", sessionTitle: "Cache design" },
}),
)
).json()) as any;
const payload = JSON.parse(published.result.content[0].text);
const sessions = (await (await app.request("/api/sessions")).json()) as any;
assert.equal(sessions.length, 1);
assert.equal(sessions[0].title, "Cache design");

// publishing into the existing session with another sessionTitle is a no-op
await app.request(
"/mcp",
mcpCall(2, "tools/call", {
name: "publish_snippet",
arguments: {
title: "Two",
html: "<p>2</p>",
session: payload.sessionId,
sessionTitle: "Other",
},
}),
);
const after = (await (await app.request("/api/sessions")).json()) as any;
assert.equal(after[0].title, "Cache design");
});

test("mcp endpoint: unknown method and unknown tool", async () => {
const app = makeApp();
const bad = (await (await app.request("/mcp", mcpCall(1, "resources/list"))).json()) as any;
Expand Down
Loading