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/surface-comment-anchors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Add a proof-of-concept for anchored comments on surfaces. Comments can now carry sanitized surface anchor metadata, the viewer can place host-owned pins over rendered surfaces, and agent feedback includes anchor context.
67 changes: 67 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type AssetKind,
type CodeSurface,
type Comment,
type CommentAnchor,
type DiffSurface,
htmlSurface,
type MarkdownSurface,
Expand Down Expand Up @@ -265,6 +266,7 @@ export interface Feedback {
surfaceTitle: string | null;
text: string;
at: string;
anchor?: CommentAnchor;
}

// Lean comment shape attached to agent-facing responses.
Expand All @@ -273,6 +275,7 @@ const feedbackView = (c: Comment): Feedback => ({
surfaceTitle: c.postTitle,
text: c.text,
at: c.createdAt,
...(c.anchor && { anchor: c.anchor }),
});

export function createApp({
Expand Down Expand Up @@ -654,10 +657,65 @@ export function createApp({
return reviseSurface(id, { parts: indexed });
}

function numberInRange(value: unknown, min: number, max: number): number | null {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}

function sanitizeCommentAnchor(raw: unknown, post: Post): CommentAnchor | undefined {
if (!raw || typeof raw !== "object") return undefined;
const input = raw as Record<string, unknown>;
const kind = input.kind === "rect" || input.kind === "lineRange" ? input.kind : "point";
let surfaceIndex = Number(input.surfaceIndex);
if (
!Number.isInteger(surfaceIndex) ||
surfaceIndex < 0 ||
surfaceIndex >= post.surfaces.length
) {
const surfaceId = typeof input.surfaceId === "string" ? input.surfaceId : undefined;
surfaceIndex = post.surfaces.findIndex((s) => s.id === surfaceId);
}
if (surfaceIndex < 0 || surfaceIndex >= post.surfaces.length) return undefined;
const surface = post.surfaces[surfaceIndex];
const base = {
surfaceIndex,
...(surface.id && { surfaceId: surface.id }),
surfaceKind: surface.kind,
// The server pins anchors to the current stored version instead of trusting
// the client-supplied value.
postVersion: post.version,
};
if (kind === "lineRange") {
const startLine = Number(input.startLine);
const endLine = Number(input.endLine);
if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1) {
return undefined;
}
return {
kind,
...base,
startLine,
endLine: Math.max(startLine, endLine),
...(typeof input.file === "string" && { file: input.file.slice(0, MAX_TITLE) }),
};
}
const x = numberInRange(input.x, 0, 1);
const y = numberInRange(input.y, 0, 1);
if (x == null || y == null) return undefined;
if (kind === "rect") {
const w = numberInRange(input.w, 0, 1);
const h = numberInRange(input.h, 0, 1);
if (w == null || h == null) return undefined;
return { kind, ...base, x, y, w, h };
}
return { kind: "point", ...base, x, y };
}

async function createComment(input: {
text: string;
surface?: string;
author: string;
anchor?: unknown;
}): Promise<
{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 }
> {
Expand All @@ -671,6 +729,7 @@ export function createApp({
postId: surface.id,
author: input.author,
text: input.text.trim().slice(0, MAX_COMMENT_TEXT),
anchor: sanitizeCommentAnchor(input.anchor, surface),
});
if (!comment) return { error: "session not found", status: 404 };
bus.broadcast({
Expand Down Expand Up @@ -1274,6 +1333,7 @@ export function createApp({
text: body.text,
surface: typeof surface === "string" ? surface : undefined,
author: typeof body.author === "string" ? body.author : "user",
anchor: body.anchor,
});
if ("error" in result) return c.json({ error: result.error }, result.status);
return c.json(
Expand All @@ -1282,6 +1342,13 @@ export function createApp({
);
});

app.delete("/api/comments/:id", async (c) => {
const comment = await store.removeComment(c.req.param("id"));
if (!comment) return c.json({ error: "comment not found" }, 404);
bus.broadcast({ type: "comment-deleted", id: comment.id, sessionId: comment.sessionId });
return c.json({ ok: true });
});

// The viewer's update notice: running version vs latest published release.
app.get("/api/version", async (c) => {
if (!version) return c.json({ current: null, latest: null, updateAvailable: false });
Expand Down
1 change: 1 addition & 0 deletions server/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type FeedEvent =
surfaceId: string | null;
seq: number;
}
| { type: "comment-deleted"; id: string; sessionId: string }
// Workspace theme changed; `id` is the new theme id. Other open tabs re-theme.
| { type: "theme-changed"; id: string }
// Session-scoped agent trace gained steps (synced in a batch). Carries only
Expand Down
1 change: 1 addition & 0 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
surfaceTitle: c.postTitle,
text: c.text,
at: c.createdAt,
...(c.anchor && { anchor: c.anchor }),
})),
lastSeq: result.lastSeq,
},
Expand Down
29 changes: 27 additions & 2 deletions server/sqlStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export class SqlStore implements Store {
if (!sessionCols.some((c) => c.name === "agentSeq")) {
this.sql.exec("ALTER TABLE sessions ADD COLUMN agentSeq INTEGER NOT NULL DEFAULT 0");
}
const commentCols = this.sql.exec("SELECT name FROM pragma_table_info('comments')").toArray();
if (!commentCols.some((c) => c.name === "anchor")) {
this.sql.exec("ALTER TABLE comments ADD COLUMN anchor TEXT");
}
this.migrateToSurfaces();
this.migrateToPosts();
this.migrateSurfaceIds();
Expand Down Expand Up @@ -245,6 +249,14 @@ export class SqlStore implements Store {
}

private rowToComment(r: Record<string, SqlStorageValue>): Comment {
let anchor: Comment["anchor"] | undefined;
if (typeof r.anchor === "string" && r.anchor) {
try {
anchor = JSON.parse(r.anchor) as Comment["anchor"];
} catch {
anchor = undefined;
}
}
return {
id: r.id as string,
seq: r.seq as number,
Expand All @@ -254,6 +266,7 @@ export class SqlStore implements Store {
author: r.author as string,
text: r.text as string,
createdAt: r.createdAt as string,
...(anchor && { anchor }),
};
}

Expand Down Expand Up @@ -484,14 +497,15 @@ export class SqlStore implements Store {
const author = stripNul(input.author).trim() || "user";
const text = stripNul(input.text);
this.sql.exec(
"INSERT INTO comments (id, sessionId, postId, postTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO comments (id, sessionId, postId, postTitle, author, text, createdAt, anchor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
id,
input.sessionId,
surface?.id ?? null,
surface?.title ?? null,
author,
text,
createdAt,
input.anchor ? JSON.stringify(input.anchor) : null,
);
const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq as number;
this.touch(input.sessionId);
Expand All @@ -504,9 +518,19 @@ export class SqlStore implements Store {
author,
text,
createdAt,
...(input.anchor && { anchor: input.anchor }),
};
}

async removeComment(id: string) {
const rows = this.sql.exec("SELECT * FROM comments WHERE id = ?", id).toArray();
if (rows.length === 0) return null;
const comment = this.rowToComment(rows[0]);
this.sql.exec("DELETE FROM comments WHERE id = ?", id);
this.touch(comment.sessionId);
return comment;
}

// --- trace ---

private rowToTraceStep(r: Record<string, SqlStorageValue>): TraceStep {
Expand Down Expand Up @@ -700,7 +724,7 @@ export class SqlStore implements Store {
}
for (const c of snapshot.comments) {
this.sql.exec(
"INSERT INTO comments (seq, id, sessionId, postId, postTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO comments (seq, id, sessionId, postId, postTitle, author, text, createdAt, anchor) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
c.seq,
c.id,
c.sessionId,
Expand All @@ -709,6 +733,7 @@ export class SqlStore implements Store {
c.author,
c.text,
c.createdAt,
c.anchor ? JSON.stringify(c.anchor) : null,
);
}
for (const t of snapshot.traces) {
Expand Down
12 changes: 12 additions & 0 deletions server/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ function liftComment(c: LegacyComment): Comment {
author: c.author,
text: c.text,
createdAt: c.createdAt,
...(c.anchor && { anchor: c.anchor }),
};
}

Expand Down Expand Up @@ -433,13 +434,24 @@ export class JsonFileStore implements Store {
author: stripNul(input.author).trim() || "user",
text: stripNul(input.text),
createdAt: new Date().toISOString(),
...(input.anchor && { anchor: input.anchor }),
};
this.comments.push(comment);
this.touch(input.sessionId);
await this.persist();
return clone(comment);
}

async removeComment(id: string) {
await this.load();
const idx = this.comments.findIndex((c) => c.id === id);
if (idx < 0) return null;
const [comment] = this.comments.splice(idx, 1);
this.touch(comment.sessionId);
await this.persist();
return clone(comment);
}

// --- trace ---

async listTrace(sessionId: string) {
Expand Down
38 changes: 38 additions & 0 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,38 @@ export interface Post {
history: PostVersion[];
}

export type CommentAnchor =
| {
kind: "point";
surfaceIndex: number;
surfaceId?: string;
surfaceKind?: SurfaceKind;
postVersion: number;
x: number;
y: number;
}
| {
kind: "rect";
surfaceIndex: number;
surfaceId?: string;
surfaceKind?: SurfaceKind;
postVersion: number;
x: number;
y: number;
w: number;
h: number;
}
| {
kind: "lineRange";
surfaceIndex: number;
surfaceId?: string;
surfaceKind?: SurfaceKind;
postVersion: number;
startLine: number;
endLine: number;
file?: string;
};

export interface Comment {
id: string;
seq: number;
Expand All @@ -201,6 +233,10 @@ export interface Comment {
author: string;
text: string;
createdAt: string;
// Optional host-authored anchor for comments on a specific rendered surface
// area/line. It is data only: render with text/positioned elements in the
// trusted viewer, never as HTML.
anchor?: CommentAnchor;
}

// An uploaded blob (image, trace file, arbitrary file) the agent pushes once and
Expand Down Expand Up @@ -252,6 +288,7 @@ export interface CreateCommentInput {
postId?: string;
author: string;
text: string;
anchor?: CommentAnchor;
}

export interface CommentQuery {
Expand Down Expand Up @@ -284,6 +321,7 @@ export interface Store {

listComments(query: CommentQuery): Promise<Comment[]>;
createComment(input: CreateCommentInput): Promise<Comment | null>;
removeComment(id: string): Promise<Comment | null>;

// Session-scoped agent trace: the steps that produced a session's surfaces,
// synced from the transcript. setTrace replaces the whole list (windowed
Expand Down
57 changes: 57 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,63 @@ test("comments attach to snippets and filter by author/after", async () => {
assert.equal(later.comments.length, 0);
});

test("comments can carry sanitized surface anchors", async () => {
const app = makeApp();
const created = (await (
await app.request(
"/api/posts",
json({
title: "Anchors",
surfaces: [
{ kind: "html", html: "<p>x</p>" },
{ kind: "markdown", markdown: "# y" },
],
}),
)
).json()) as any;
const post = (await (await app.request(`/api/posts/${created.id}`)).json()) as any;

await app.request(
"/api/comments",
json({
surface: post.id,
text: "look here",
author: "user",
anchor: { kind: "point", surfaceIndex: 1, x: 0.25, y: 0.75, postVersion: 999 },
}),
);

const all = (await (await app.request(`/api/comments?session=${post.sessionId}`)).json()) as any;
assert.deepEqual(all.comments[0].anchor, {
kind: "point",
surfaceIndex: 1,
surfaceId: post.surfaces[1].id,
surfaceKind: "markdown",
postVersion: 1,
x: 0.25,
y: 0.75,
});
});

test("comments can be deleted", async () => {
const app = makeApp();
const s = (await (await app.request("/api/snippets", json({ html: "<p>x</p>" }))).json()) as any;
const created = (await (
await app.request("/api/comments", json({ snippet: s.id, text: "remove me", author: "user" }))
).json()) as any;

assert.equal(
(await app.request(`/api/comments/${created.id}`, { method: "DELETE" })).status,
200,
);
const all = (await (await app.request(`/api/comments?session=${s.sessionId}`)).json()) as any;
assert.equal(all.comments.length, 0);
assert.equal(
(await app.request(`/api/comments/${created.id}`, { method: "DELETE" })).status,
404,
);
});

test("a comment must target a surface", async () => {
const app = makeApp();
const s = (await (await app.request("/api/snippets", json({ html: "<p>x</p>" }))).json()) as any;
Expand Down
Loading
Loading