diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a96db0..7a1efe3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts
index c9bb6fe..31c5a56 100644
--- a/e2e/viewer.spec.ts
+++ b/e2e/viewer.spec.ts
@@ -7,7 +7,7 @@ test("snippet published over HTTP appears live via SSE, no reload", async ({ pag
await publish(server.url, { html: "
It works
", 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");
});
@@ -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: "v1
", 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}`);
@@ -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: "x
", 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: "y
", 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: "v1
", title: "Doc", agent: "e2e" });
diff --git a/guide/AGENT_SETUP.md b/guide/AGENT_SETUP.md
index b2973ec..a0b5fa1 100644
--- a/guide/AGENT_SETUP.md
+++ b/guide/AGENT_SETUP.md
@@ -21,11 +21,20 @@ snippet instead of posting a new one:
curl -s -X PUT http://localhost:4242/api/snippets/ \
-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=&author=user&after=&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=&author=user&after=&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`
diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md
index 4bb9fa4..5089dd8 100644
--- a/guide/DESIGN_GUIDE.md
+++ b/guide/DESIGN_GUIDE.md
@@ -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
diff --git a/mcp/server.ts b/mcp/server.ts
index 1f2d661..134c2ba 100644
--- a/mcp/server.ts
+++ b/mcp/server.ts
@@ -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.",
},
);
@@ -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"),
@@ -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"),
diff --git a/server/app.ts b/server/app.ts
index 87f287a..d4fecad 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -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;
+
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 {
+ 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 };
}
@@ -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 };
}
@@ -89,7 +116,7 @@ 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: {
@@ -97,7 +124,9 @@ export function createApp({ store, viewerHtml, guideMarkdown, setupText, authTok
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);
@@ -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.
@@ -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 };
}
@@ -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) => {
@@ -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) => {
@@ -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
diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts
index 34dafc6..7701319 100644
--- a/server/mcpHttp.ts
+++ b/server/mcpHttp.ts
@@ -1,5 +1,5 @@
import type { Hono } from "hono";
-import type { CommentWait } from "./app.ts";
+import type { CommentWait, Feedback } from "./app.ts";
import type { Comment, Snippet, Store } from "./types.ts";
// Stateless MCP over streamable HTTP: every request is self-contained, which
@@ -13,16 +13,16 @@ export interface McpDeps {
title?: string;
session?: string;
agent?: string;
- }): Promise<{ snippet: Snippet } | { error: string; status: number }>;
+ }): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>;
reviseSnippet(
id: string,
patch: { html?: string; title?: string },
- ): Promise<{ snippet: Snippet } | { error: string; status: number }>;
+ ): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>;
createComment(input: {
text: string;
snippet?: string;
author: string;
- }): Promise<{ comment: Comment } | { error: string; status: number }>;
+ }): Promise<{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: number }>;
waitForComments(q: CommentWait): Promise<{ comments: Comment[]; lastSeq: number }>;
guide: string;
}
@@ -33,7 +33,9 @@ const INSTRUCTIONS =
"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 " +
"snippets in their browser; call wait_for_feedback (passing the lastSeq cursor from the previous result) " +
- "after publishing something you want a reaction to.";
+ "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.";
const TOOLS = [
{
@@ -41,7 +43,8 @@ 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. Call get_design_guide first if you have not this 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.",
inputSchema: {
type: "object",
properties: {
@@ -66,7 +69,8 @@ const TOOLS = [
{
name: "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: {
type: "object",
properties: {
@@ -146,7 +150,13 @@ export function registerMcp(app: Hono, deps: McpDeps) {
if ("error" in result) throw new Error(result.error);
const s = result.snippet;
return JSON.stringify(
- { id: s.id, sessionId: s.sessionId, version: s.version, url: `${origin}/s/${s.id}` },
+ {
+ id: s.id,
+ sessionId: s.sessionId,
+ version: s.version,
+ url: `${origin}/s/${s.id}`,
+ ...(result.userFeedback && { userFeedback: result.userFeedback }),
+ },
null,
2,
);
@@ -159,7 +169,13 @@ export function registerMcp(app: Hono, deps: McpDeps) {
if ("error" in result) throw new Error(result.error);
const s = result.snippet;
return JSON.stringify(
- { id: s.id, sessionId: s.sessionId, version: s.version, url: `${origin}/s/${s.id}` },
+ {
+ id: s.id,
+ sessionId: s.sessionId,
+ version: s.version,
+ url: `${origin}/s/${s.id}`,
+ ...(result.userFeedback && { userFeedback: result.userFeedback }),
+ },
null,
2,
);
@@ -199,7 +215,11 @@ export function registerMcp(app: Hono, deps: McpDeps) {
author: typeof args.author === "string" ? args.author : "agent",
});
if ("error" in result) throw new Error(result.error);
- return JSON.stringify(result.comment, null, 2);
+ return JSON.stringify(
+ { ...result.comment, ...(result.userFeedback && { userFeedback: result.userFeedback }) },
+ null,
+ 2,
+ );
}
case "list_snippets": {
const snippets = await deps.store.listSnippets(
diff --git a/server/storage.ts b/server/storage.ts
index 12ef896..08b6700 100644
--- a/server/storage.ts
+++ b/server/storage.ts
@@ -42,7 +42,10 @@ export class JsonFileStore implements Store {
try {
const raw = await readFile(this.filePath, "utf8");
const data = JSON.parse(raw) as FileShape;
- for (const s of data.sessions ?? []) this.sessions.set(s.id, s);
+ // agentSeq arrived after 0.2.0 — default it for data files written before
+ for (const s of data.sessions ?? []) {
+ this.sessions.set(s.id, { ...s, agentSeq: s.agentSeq ?? 0 });
+ }
for (const s of data.snippets ?? []) this.snippets.set(s.id, s);
this.comments = data.comments ?? [];
this.lastSeq = data.lastSeq ?? 0;
@@ -93,6 +96,7 @@ export class JsonFileStore implements Store {
cwd: input.cwd ?? null,
createdAt: now,
lastActiveAt: now,
+ agentSeq: 0,
};
this.sessions.set(session.id, session);
await this.persist();
@@ -124,6 +128,14 @@ export class JsonFileStore implements Store {
if (session) session.lastActiveAt = new Date().toISOString();
}
+ async markAgentSeen(sessionId: string, seq: number) {
+ await this.load();
+ const session = this.sessions.get(sessionId);
+ if (!session || seq <= session.agentSeq) return;
+ session.agentSeq = seq;
+ await this.persist();
+ }
+
// --- snippets ---
async listSnippets(sessionId?: string) {
diff --git a/server/types.ts b/server/types.ts
index 4045bff..28f5836 100644
--- a/server/types.ts
+++ b/server/types.ts
@@ -7,6 +7,9 @@ export interface Session {
cwd: string | null;
createdAt: string;
lastActiveAt: string;
+ // Highest comment seq already delivered to the agent — lets responses to
+ // agent writes piggyback comments the agent has not seen yet.
+ agentSeq: number;
}
export interface SnippetVersion {
@@ -76,6 +79,8 @@ export interface Store {
createSession(input: CreateSessionInput): Promise;
renameSession(id: string, title: string): Promise;
removeSession(id: string): Promise;
+ // Advance the delivered-to-agent comment cursor (never moves backwards).
+ markAgentSeen(sessionId: string, seq: number): Promise;
listSnippets(sessionId?: string): Promise;
getSnippet(id: string): Promise;
diff --git a/skills/sideshow/SKILL.md b/skills/sideshow/SKILL.md
index 83a5585..6f2f167 100644
--- a/skills/sideshow/SKILL.md
+++ b/skills/sideshow/SKILL.md
@@ -43,15 +43,28 @@ Rules of thumb:
## The feedback loop
-After publishing something that needs a reaction:
+Feedback reaches you three ways — prefer them in this order:
-```sh
-sideshow wait --timeout 120 # blocks until the user comments, prints JSON
-```
+1. **Piggyback (no action needed).** Publish/update/reply responses may
+ include a `userFeedback` array: comments the user left since your last
+ call, delivered once. Read them whenever they appear and treat them as
+ user instructions.
+2. **Background watch (don't block, don't poll).** After your first publish,
+ arm a listener as a background process and keep working:
+
+ ```sh
+ sideshow wait --timeout 600 # run in the background (e.g. run_in_background)
+ ```
+
+ It exits the moment the user comments, which surfaces the output to you.
+ Handle the comments, then re-arm it. Always arm it on the session you just
+ published to — never a guessed one.
+
+3. **Blocking wait.** Only when you explicitly need a reaction before
+ continuing: `sideshow wait --timeout 120` in the foreground.
-Treat returned comments as user instructions. Acknowledge briefly with
-`sideshow comment "..." --snippet ` when useful; do substantial changes
-as snippet updates.
+Acknowledge briefly with `sideshow comment "..." --snippet ` when useful;
+do substantial changes as snippet updates.
## Remote surfaces
diff --git a/test/api.test.ts b/test/api.test.ts
index 77241bd..047f651 100644
--- a/test/api.test.ts
+++ b/test/api.test.ts
@@ -257,6 +257,110 @@ test("mcp endpoint requires bearer when token configured", async () => {
assert.equal(ok.status, 200);
});
+test("agent writes piggyback unseen user comments, delivered once", async () => {
+ const app = makeApp();
+ const s = (await (
+ await app.request("/api/snippets", json({ html: "v1
", title: "Doc" }))
+ ).json()) as any;
+ assert.equal(s.userFeedback, undefined);
+
+ // the user comments while the agent works on something else
+ await app.request("/api/comments", json({ snippet: s.id, text: "wrong color", author: "user" }));
+ await app.request("/api/comments", json({ session: s.sessionId, text: "also add a key" }));
+
+ // the agent's next write carries the feedback
+ const updated = (await (
+ await app.request(`/api/snippets/${s.id}`, { ...json({ html: "v2
" }), method: "PUT" })
+ ).json()) as any;
+ assert.deepEqual(
+ updated.userFeedback.map((f: any) => f.text),
+ ["wrong color", "also add a key"],
+ );
+ assert.equal(updated.userFeedback[0].snippetTitle, "Doc");
+
+ // delivered once — the next write is clean
+ const again = (await (
+ await app.request(`/api/snippets/${s.id}`, { ...json({ html: "v3
" }), method: "PUT" })
+ ).json()) as any;
+ assert.equal(again.userFeedback, undefined);
+
+ // agent replies piggyback too; the user's own comments never do
+ await app.request("/api/comments", json({ snippet: s.id, text: "more", author: "user" }));
+ const userPost = (await (
+ await app.request("/api/comments", json({ snippet: s.id, text: "and more", author: "user" }))
+ ).json()) as any;
+ assert.equal(userPost.userFeedback, undefined);
+ const reply = (await (
+ await app.request("/api/comments", json({ snippet: s.id, text: "on it", author: "claude" }))
+ ).json()) as any;
+ assert.deepEqual(
+ reply.userFeedback.map((f: any) => f.text),
+ ["more", "and more"],
+ );
+});
+
+test("a consumed wait is not re-delivered as piggyback", async () => {
+ const app = makeApp();
+ const s = (await (await app.request("/api/snippets", json({ html: "x
" }))).json()) as any;
+ await app.request(
+ "/api/comments",
+ json({ snippet: s.id, text: "seen via wait", author: "user" }),
+ );
+
+ // the agent receives it through the long-poll...
+ const waited = (await (
+ await app.request(`/api/comments?session=${s.sessionId}&author=user`)
+ ).json()) as any;
+ assert.equal(waited.comments.length, 1);
+
+ // ...so the next write carries nothing
+ const updated = (await (
+ await app.request(`/api/snippets/${s.id}`, { ...json({ html: "v2
" }), method: "PUT" })
+ ).json()) as any;
+ assert.equal(updated.userFeedback, undefined);
+
+ // the viewer's unfiltered reads do NOT consume the cursor
+ await app.request("/api/comments", json({ snippet: s.id, text: "fresh", author: "user" }));
+ await app.request(`/api/comments?session=${s.sessionId}`); // viewer-style read
+ const next = (await (
+ await app.request(`/api/snippets/${s.id}`, { ...json({ html: "v3
" }), method: "PUT" })
+ ).json()) as any;
+ assert.deepEqual(
+ next.userFeedback.map((f: any) => f.text),
+ ["fresh"],
+ );
+});
+
+test("mcp publish result carries userFeedback", async () => {
+ const app = makeApp();
+ const published = (await (
+ await app.request(
+ "/mcp",
+ mcpCall(1, "tools/call", {
+ name: "publish_snippet",
+ arguments: { title: "One", html: "1
", agent: "mcp-agent" },
+ }),
+ )
+ ).json()) as any;
+ const first = JSON.parse(published.result.content[0].text);
+ await app.request("/api/comments", json({ snippet: first.id, text: "neat", author: "user" }));
+
+ const second = (await (
+ await app.request(
+ "/mcp",
+ mcpCall(2, "tools/call", {
+ name: "publish_snippet",
+ arguments: { title: "Two", html: "2
", session: first.sessionId },
+ }),
+ )
+ ).json()) as any;
+ const payload = JSON.parse(second.result.content[0].text);
+ assert.deepEqual(
+ payload.userFeedback.map((f: any) => f.text),
+ ["neat"],
+ );
+});
+
test("rejects empty and oversized html", async () => {
const app = makeApp();
assert.equal((await app.request("/api/snippets", json({ html: "" }))).status, 400);
diff --git a/test/jsonFileStore.test.ts b/test/jsonFileStore.test.ts
index ccc74df..5c24e62 100644
--- a/test/jsonFileStore.test.ts
+++ b/test/jsonFileStore.test.ts
@@ -23,8 +23,11 @@ test("JsonFileStore: data survives a reload from disk", async () => {
text: "hi",
});
+ await store.markAgentSeen(session.id, 1);
+
const reloaded = new JsonFileStore(path);
assert.equal((await reloaded.getSession(session.id))?.title, "Persisted");
+ assert.equal((await reloaded.getSession(session.id))?.agentSeq, 1);
const got = await reloaded.getSnippet(snippet?.id ?? "");
assert.equal(got?.version, 2);
assert.equal(got?.history.length, 1);
diff --git a/test/storeContract.ts b/test/storeContract.ts
index 16550ab..a22cd4c 100644
--- a/test/storeContract.ts
+++ b/test/storeContract.ts
@@ -67,6 +67,23 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise<
);
});
+ contract("tracks the delivered-to-agent comment cursor", async (store) => {
+ const session = await store.createSession({ agent: "pi" });
+ assert.equal(session.agentSeq, 0);
+
+ await store.markAgentSeen(session.id, 5);
+ assert.equal((await store.getSession(session.id))?.agentSeq, 5);
+
+ // never moves backwards
+ await store.markAgentSeen(session.id, 3);
+ assert.equal((await store.getSession(session.id))?.agentSeq, 5);
+ await store.markAgentSeen(session.id, 9);
+ assert.equal((await store.getSession(session.id))?.agentSeq, 9);
+
+ // unknown session is a no-op, not an error
+ await store.markAgentSeen("missing", 1);
+ });
+
contract("removeSession returns false for unknown ids", async (store) => {
assert.equal(await store.removeSession("missing"), false);
const session = await store.createSession({ agent: "pi" });
diff --git a/viewer/index.html b/viewer/index.html
index 6831895..f67ee3b 100644
--- a/viewer/index.html
+++ b/viewer/index.html
@@ -596,8 +596,46 @@ or try it yourself
stream.appendChild(e);
}
for (const meta of snippets) await upsertCard(meta.id, { scroll: false });
+ ensureSessionThread();
const { comments } = await api(`/api/comments?session=${id}`);
- for (const c of comments) if (c.snippetId) renderComment(c);
+ for (const c of comments) renderComment(c);
+ }
+
+ // Comments without a snippet (e.g. `sideshow comment` with no --snippet)
+ // live in a session-level thread at the bottom of the stream, which also
+ // lets the user message the agent without picking a snippet.
+ function ensureSessionThread() {
+ if ($("sessionThread")) return;
+ const card = document.createElement("div");
+ card.className = "card";
+ card.id = "sessionThread";
+ card.innerHTML = `
+
+ Session thread
+ not tied to a snippet
+
+ `;
+ const input = card.querySelector(".composer input");
+ const send = async () => {
+ const text = input.value.trim();
+ if (!text) return;
+ input.value = "";
+ await api("/api/comments", {
+ method: "POST",
+ body: JSON.stringify({ session: state.selected, text, author: "user" }),
+ });
+ };
+ input.onkeydown = (e) => {
+ if (e.key === "Enter") send();
+ };
+ card.querySelector(".composer button").onclick = send;
+ $("stream").appendChild(card);
}
// --- snippet cards ---
@@ -651,7 +689,9 @@ or try it yourself
}
};
state.cards.set(s.id, card);
- $("stream").appendChild(card);
+ const thread = $("sessionThread");
+ if (thread) $("stream").insertBefore(card, thread);
+ else $("stream").appendChild(card);
}
card.querySelector(".card-title").textContent = s.title;
@@ -688,7 +728,7 @@ or try it yourself
}
function renderComment(c) {
- const card = state.cards.get(c.snippetId);
+ const card = c.snippetId ? state.cards.get(c.snippetId) : $("sessionThread");
if (!card) return;
const cmts = card.querySelector(".cmts");
if (cmts.querySelector(`[data-cid="${c.id}"]`)) return;
@@ -753,10 +793,11 @@ or try it yourself
state.cards.delete(e.id);
await refreshSessionsQuiet();
} else if (e.type === "comment-created") {
- if (e.sessionId === state.selected && e.snippetId) {
- const { comments } = await api(`/api/comments?snippet=${e.snippetId}`);
+ if (e.sessionId === state.selected) {
+ const query = e.snippetId ? `snippet=${e.snippetId}` : `session=${e.sessionId}`;
+ const { comments } = await api(`/api/comments?${query}`);
for (const c of comments) renderComment(c);
- } else if (e.sessionId !== state.selected) {
+ } else {
state.unread.add(e.sessionId);
renderSidebar();
}
diff --git a/workers/sqlStore.ts b/workers/sqlStore.ts
index 79d439e..2aa699f 100644
--- a/workers/sqlStore.ts
+++ b/workers/sqlStore.ts
@@ -22,7 +22,8 @@ export class SqlStore implements Store {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, agent TEXT NOT NULL, title TEXT, cwd TEXT,
- createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL
+ createdAt TEXT NOT NULL, lastActiveAt TEXT NOT NULL,
+ agentSeq INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS snippets (
id TEXT PRIMARY KEY, sessionId TEXT NOT NULL, title TEXT NOT NULL,
@@ -35,6 +36,12 @@ export class SqlStore implements Store {
author TEXT NOT NULL, text TEXT NOT NULL, createdAt TEXT NOT NULL
);
`);
+ // Boards created before agentSeq existed need the column added; SQLite
+ // has no ADD COLUMN IF NOT EXISTS, so probe and patch.
+ const cols = this.sql.exec("SELECT name FROM pragma_table_info('sessions')").toArray();
+ if (!cols.some((c) => c.name === "agentSeq")) {
+ this.sql.exec("ALTER TABLE sessions ADD COLUMN agentSeq INTEGER NOT NULL DEFAULT 0");
+ }
}
private rowToSession(r: Record): Session {
@@ -45,6 +52,7 @@ export class SqlStore implements Store {
cwd: (r.cwd as string) ?? null,
createdAt: r.createdAt as string,
lastActiveAt: r.lastActiveAt as string,
+ agentSeq: (r.agentSeq as number) ?? 0,
};
}
@@ -97,9 +105,10 @@ export class SqlStore implements Store {
cwd: input.cwd ?? null,
createdAt: now,
lastActiveAt: now,
+ agentSeq: 0,
};
this.sql.exec(
- "INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt) VALUES (?, ?, ?, ?, ?, ?)",
+ "INSERT INTO sessions (id, agent, title, cwd, createdAt, lastActiveAt, agentSeq) VALUES (?, ?, ?, ?, ?, ?, 0)",
session.id,
session.agent,
session.title,
@@ -134,6 +143,15 @@ export class SqlStore implements Store {
);
}
+ async markAgentSeen(sessionId: string, seq: number) {
+ this.sql.exec(
+ "UPDATE sessions SET agentSeq = ? WHERE id = ? AND agentSeq < ?",
+ seq,
+ sessionId,
+ seq,
+ );
+ }
+
// --- snippets ---
async listSnippets(sessionId?: string) {