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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@ All notable user-visible changes to this project are documented in this file.

### Added

- A session thread at the bottom of each session in the viewer: a composer
for messaging the agent without picking a snippet.
- Feedback now reaches agents without polling: publish/update/reply responses
carry a `userFeedback` array with any comments the user left since the
agent's last call (delivered once; a consumed `wait` also counts as seen).
- 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.

### Changed

### Fixed

- Comments not attached to a snippet (e.g. `sideshow comment` without
`--snippet`) were stored and delivered to agents but never shown in the
viewer; they now render in the session thread.

## [0.2.0] - 2026-06-11

### Added
Expand Down
48 changes: 44 additions & 4 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test("snippet published over HTTP appears live via SSE, no reload", async ({ pag
await publish(server.url, { html: "<h2>It works</h2>", title: "Live test", agent: "e2e" });

// the card streams in over SSE — the page is never reloaded
await expect(page.locator(".card-title")).toHaveText("Live test");
await expect(page.locator(".card:not(#sessionThread) .card-title")).toHaveText("Live test");
await expect(page.locator("#onboard")).toBeHidden();
await expect(page.locator(".sess-title")).toContainText("e2e session");
});
Expand All @@ -30,13 +30,14 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv
const snippet = await publish(server.url, { html: "<p>v1</p>", title: "Doc", agent: "e2e" });

await page.goto(server.url);
const input = page.locator(".composer input");
const card = page.locator(".card:not(#sessionThread)");
const input = card.locator(".composer input");
await input.fill("ship it");
await input.press("Enter");

// renders in the thread (via SSE) and is persisted server-side
await expect(page.locator(".cmt .txt")).toHaveText("ship it");
await expect(page.locator(".cmt .who")).toHaveText("you");
await expect(card.locator(".cmt .txt")).toHaveText("ship it");
await expect(card.locator(".cmt .who")).toHaveText("you");
await expect
.poll(async () => {
const res = await fetch(`${server.url}/api/comments?snippet=${snippet.id}`);
Expand All @@ -46,6 +47,45 @@ test("comment typed in the composer round-trips to the API", async ({ page, serv
.toContain("ship it");
});

test("session thread shows snippet-less comments and messages the agent", async ({
page,
server,
}) => {
const snippet = await publish(server.url, { html: "<p>x</p>", title: "Doc", agent: "e2e" });

await page.goto(server.url);
const thread = page.locator("#sessionThread");
await expect(thread).toBeVisible();

// an agent comment with no snippet attached (sideshow comment without --snippet)
await fetch(`${server.url}/api/comments`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session: snippet.sessionId, text: "agent note", author: "e2e" }),
});
await expect(thread.locator(".cmt .txt")).toHaveText("agent note");

// the user can reply without picking a snippet; it lands as a user comment
const input = thread.locator(".composer input");
await input.fill("user note");
await input.press("Enter");
await expect(thread.locator(".cmt .txt")).toHaveText(["agent note", "user note"]);
await expect
.poll(async () => {
const res = await fetch(
`${server.url}/api/comments?session=${snippet.sessionId}&author=user`,
);
const data = (await res.json()) as { comments: { snippetId: string | null; text: string }[] };
return data.comments.filter((c) => !c.snippetId).map((c) => c.text);
})
.toContain("user note");

// snippets published later still appear above the session thread
await publish(server.url, { html: "<p>y</p>", title: "Later", session: snippet.sessionId });
await expect(page.locator("#stream > .card").last()).toHaveId("sessionThread");
await expect(page.locator("#stream > .card")).toHaveCount(3);
});

test("version select appears live after an update", async ({ page, server }) => {
const snippet = await publish(server.url, { html: "<p>v1</p>", title: "Doc", agent: "e2e" });

Expand Down
17 changes: 13 additions & 4 deletions guide/AGENT_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ snippet instead of posting a new one:
curl -s -X PUT http://localhost:4242/api/snippets/<id> \
-H 'content-type: application/json' -d '{"html": "..."}'

The user can comment on your snippets in their browser. Check for feedback
(blocks up to 60s, returns JSON; use `after` from the previous response's
`lastSeq` to avoid re-reading):
The user can comment on your snippets in their browser. Feedback reaches you
two ways:

curl -s 'http://localhost:4242/api/comments?session=<sessionId>&author=user&after=<lastSeq>&wait=60'
1. Publish/update responses may include a `userFeedback` array — comments the
user left since your last call. Treat them as messages from the user; they
are delivered once.
2. To explicitly wait for a reaction (blocks up to 60s, returns JSON; use
`after` from the previous response's `lastSeq` to avoid re-reading):

curl -s 'http://localhost:4242/api/comments?session=<sessionId>&author=user&after=<lastSeq>&wait=60'

If you can run background processes, run this in the background after your
first publish and keep working — it exits the moment the user comments;
handle the output and re-arm it.

If the `sideshow` CLI is installed, these are equivalent and easier:
`sideshow publish file.html --title "..."`, `sideshow wait`, `sideshow guide`
Expand Down
22 changes: 17 additions & 5 deletions guide/DESIGN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,23 @@ are kept and the user can flip between them.

## The feedback loop

The user can type comments under any snippet. Poll or block on
`wait_for_feedback` (MCP), `sideshow wait` (CLI), or the long-poll endpoint
after publishing something that needs a reaction. You can answer in the thread
with `reply_to_user` / `sideshow comment` — keep replies short; do substantial
revisions as snippet updates instead.
The user can type comments under any snippet, or in the session thread at the
bottom of the stream. Feedback reaches you three ways:

- **Piggyback (automatic).** Every publish/update/reply response may include a
`userFeedback` array — comments the user left since your last call. Treat
them as messages from the user; they are delivered once. You never need to
poll while you are actively publishing.
- **Blocking wait.** `wait_for_feedback` (MCP), `sideshow wait` (CLI), or the
long-poll endpoint — use at a checkpoint when you explicitly want a reaction
before continuing.
- **Background watch.** If your harness supports background processes, arm
`sideshow wait --timeout 600` in the background after your first publish and
keep working; when it exits with comments, handle them and re-arm. Always arm
it on the session you actually published to.

You can answer in the thread with `reply_to_user` / `sideshow comment` — keep
replies short; do substantial revisions as snippet updates instead.

## HTML contract

Expand Down
11 changes: 7 additions & 4 deletions mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ const server = new McpServer(
"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. " +
"The user can comment on snippets in their browser; check with wait_for_feedback after publishing something " +
"you want a reaction to.",
"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 @@ -67,8 +68,9 @@ 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. Call get_design_guide first if you have " +
"not this session.",
"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.",
inputSchema: {
title: z.string().describe("Short human-readable title shown above the snippet"),
html: z.string().describe("HTML body fragment to render"),
Expand All @@ -90,7 +92,8 @@ server.registerTool(
"update_snippet",
{
description:
"Revise an existing snippet in place (same card, new version). Prefer this over publishing a near-duplicate.",
"Revise an existing snippet in place (same card, new version). Prefer this over publishing a " +
"near-duplicate. If the result includes userFeedback, those are new comments from the user — read them.",
inputSchema: {
id: z.string().describe("Snippet id returned by publish_snippet"),
html: z.string().optional().describe("Replacement HTML body fragment"),
Expand Down
68 changes: 59 additions & 9 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,44 @@ export interface CommentWait {
waitSeconds: number;
}

// Lean comment shape attached to agent-facing responses.
export const feedbackView = (c: Comment) => ({
snippetId: c.snippetId,
snippetTitle: c.snippetTitle,
text: c.text,
at: c.createdAt,
});

export type Feedback = ReturnType<typeof feedbackView>;

export function createApp({ store, viewerHtml, guideMarkdown, setupText, authToken }: AppOptions) {
const app = new Hono();
const bus = new EventBus();

// --- shared flows (used by both the REST API and the MCP endpoint) ---

// User comments the agent has not seen yet ride along on its next write, so
// agents hear feedback without blocking on the long-poll. The cursor also
// advances past the agent's own comments to keep reads cheap.
async function collectFeedback(sessionId: string): Promise<Feedback[] | undefined> {
const session = await store.getSession(sessionId);
if (!session) return undefined;
const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
if (fresh.length === 0) return undefined;
await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
const feedback = fresh.filter((cm) => cm.author === "user");
return feedback.length > 0 ? feedback.map(feedbackView) : undefined;
}

async function publishSnippet(input: {
html: string;
title?: string;
session?: string;
agent?: string;
cwd?: string;
}): Promise<{ snippet: Snippet } | { error: string; status: 404 | 413 }> {
}): Promise<
{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: 404 | 413 }
> {
if (input.html.length > MAX_HTML_BYTES) {
return { error: `html exceeds ${MAX_HTML_BYTES} bytes`, status: 413 };
}
Expand All @@ -71,13 +96,15 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
});
if (!snippet) return { error: "session not found", status: 404 };
bus.broadcast({ type: "snippet-created", id: snippet.id, sessionId, version: 1 });
return { snippet };
return { snippet, userFeedback: await collectFeedback(sessionId) };
}

async function reviseSnippet(
id: string,
patch: { html?: string; title?: string },
): Promise<{ snippet: Snippet } | { error: string; status: 404 | 413 }> {
): Promise<
{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: 404 | 413 }
> {
if (typeof patch.html === "string" && patch.html.length > MAX_HTML_BYTES) {
return { error: `html exceeds ${MAX_HTML_BYTES} bytes`, status: 413 };
}
Expand All @@ -89,15 +116,17 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
sessionId: snippet.sessionId,
version: snippet.version,
});
return { snippet };
return { snippet, userFeedback: await collectFeedback(snippet.sessionId) };
}

async function createComment(input: {
text: string;
snippet?: string;
session?: string;
author: string;
}): Promise<{ comment: Comment } | { error: string; status: 400 | 404 }> {
}): Promise<
{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 }
> {
let sessionId = input.session;
if (input.snippet) {
const snippet = await store.getSnippet(input.snippet);
Expand All @@ -119,7 +148,11 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
snippetId: comment.snippetId,
seq: comment.seq,
});
return { comment };
// agent replies are writes too — piggyback pending feedback on them, but
// never on the user's own comments
const userFeedback =
input.author === "user" ? undefined : await collectFeedback(comment.sessionId);
return { comment, userFeedback };
}

// Long-poll: resolves as soon as a matching comment lands, or at timeout.
Expand Down Expand Up @@ -150,6 +183,11 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
comments = matches(await store.listComments(query));
}
const lastSeq = comments.length > 0 ? comments[comments.length - 1].seq : (q.afterSeq ?? 0);
// An author=user query is the agent listening (the viewer never filters by
// author) — what it receives here should not be re-delivered as piggyback.
if (q.author === "user" && q.sessionId && comments.length > 0) {
await store.markAgentSeen(q.sessionId, lastSeq);
}
return { comments, lastSeq };
}

Expand Down Expand Up @@ -257,7 +295,13 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
cwd: typeof body.cwd === "string" ? body.cwd : undefined,
});
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json(snippetMeta(result.snippet), 201);
return c.json(
{
...snippetMeta(result.snippet),
...(result.userFeedback && { userFeedback: result.userFeedback }),
},
201,
);
});

app.put("/api/snippets/:id", async (c) => {
Expand All @@ -268,7 +312,10 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
title: typeof body.title === "string" ? body.title : undefined,
});
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json(snippetMeta(result.snippet));
return c.json({
...snippetMeta(result.snippet),
...(result.userFeedback && { userFeedback: result.userFeedback }),
});
});

app.delete("/api/snippets/:id", async (c) => {
Expand All @@ -293,7 +340,10 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
author: typeof body.author === "string" ? body.author : "user",
});
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json(result.comment, 201);
return c.json(
{ ...result.comment, ...(result.userFeedback && { userFeedback: result.userFeedback }) },
201,
);
});

// Long-poll friendly: ?wait=N holds the request open up to N seconds until
Expand Down
Loading
Loading