From 7cbf4aa5b60082e6fe42dc805c40292db0e6fcaa Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:43:31 -0400 Subject: [PATCH] feat(server): let agents name a session when their first publish creates it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions created by agents over MCP or bare CLI publish had no title, so the viewer sidebar became a wall of identical "claude-code session" rows. An optional sessionTitle now threads through all three tiers — REST publish body, both MCP publish_snippet tools, and `sideshow publish --session-title` — and applies only when the publish creates the session, so it can never clobber a rename the user made in the viewer. The skill, setup block, and design guide tell agents to name the task ("Auth refactor"), not the tool. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +++ bin/sideshow.js | 16 ++++++++-- guide/AGENT_SETUP.md | 8 +++-- guide/DESIGN_GUIDE.md | 5 ++- mcp/server.ts | 27 +++++++++++----- server/app.ts | 10 +++++- server/mcpHttp.ts | 19 +++++++++-- skills/sideshow/SKILL.md | 6 +++- test/api.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 145 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1efe3..e7b6c41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bin/sideshow.js b/bin/sideshow.js index cb59036..319ebc5 100644 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -18,6 +18,8 @@ usage: sideshow publish [options] publish an HTML fragment as a snippet --title snippet title --session target session (default: auto per agent session) + --session-title name for a newly created session — name the task, + e.g. "Auth refactor" (ignored if the session exists) --agent agent name for new sessions (default: $SIDESHOW_AGENT or "agent") --new-session force a fresh session sideshow update revise a snippet (new version, same card) @@ -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; @@ -208,6 +214,7 @@ const commands = { options: { title: { type: "string" }, session: { type: "string" }, + "session-title": { type: "string" }, agent: { type: "string" }, "new-session": { type: "boolean" }, }, @@ -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}` }); }, diff --git a/guide/AGENT_SETUP.md b/guide/AGENT_SETUP.md index a0b5fa1..db0372c 100644 --- a/guide/AGENT_SETUP.md +++ b/guide/AGENT_SETUP.md @@ -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": "

...

"}' + -d '{"agent": "YOUR_NAME", "sessionTitle": "Task name", "title": "Short title", "html": "

...

"}' The response includes `id` and `sessionId`. Pass `"session": ""` -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/ \ -H 'content-type: application/json' -d '{"html": "..."}' diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md index 5089dd8..2e0c3e0 100644 --- a/guide/DESIGN_GUIDE.md +++ b/guide/DESIGN_GUIDE.md @@ -17,7 +17,10 @@ GET /api/comments?session=&author=user&after=&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. diff --git a/mcp/server.ts b/mcp/server.ts index 134c2ba..1b0bba3 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -38,12 +38,14 @@ const text = (value: unknown) => ({ let sessionId: string | null = process.env.SIDESHOW_SESSION ?? null; let lastSeq = 0; -async function ensureSession(): Promise { +// `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 { 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; @@ -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.", @@ -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", diff --git a/server/app.ts b/server/app.ts index d4fecad..05c3bf4 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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< @@ -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; } @@ -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, }); diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index 7701319..1ff6a94 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -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( @@ -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; " + @@ -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: { @@ -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: @@ -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); diff --git a/skills/sideshow/SKILL.md b/skills/sideshow/SKILL.md index 6f2f167..2106ad8 100644 --- a/skills/sideshow/SKILL.md +++ b/skills/sideshow/SKILL.md @@ -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 '

...

' | 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 `** (same card, new version) instead of diff --git a/test/api.test.ts b/test/api.test.ts index 047f651..92aebd8 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -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: "

x

", 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: "

1

", 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: "

2

", 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: "

x

", session: "nope" })); @@ -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: "

1

", 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: "

2

", + 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;