Skip to content

Commit 63bd97b

Browse files
committed
feat(viewer): add anchored surface comments
1 parent ab5f91a commit 63bd97b

14 files changed

Lines changed: 785 additions & 45 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sideshow": patch
3+
---
4+
5+
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.

server/app.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type AssetKind,
2020
type CodeSurface,
2121
type Comment,
22+
type CommentAnchor,
2223
type DiffSurface,
2324
htmlSurface,
2425
type MarkdownSurface,
@@ -265,6 +266,7 @@ export interface Feedback {
265266
surfaceTitle: string | null;
266267
text: string;
267268
at: string;
269+
anchor?: CommentAnchor;
268270
}
269271

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

278281
export function createApp({
@@ -654,10 +657,65 @@ export function createApp({
654657
return reviseSurface(id, { parts: indexed });
655658
}
656659

660+
function numberInRange(value: unknown, min: number, max: number): number | null {
661+
const n = Number(value);
662+
return Number.isFinite(n) && n >= min && n <= max ? n : null;
663+
}
664+
665+
function sanitizeCommentAnchor(raw: unknown, post: Post): CommentAnchor | undefined {
666+
if (!raw || typeof raw !== "object") return undefined;
667+
const input = raw as Record<string, unknown>;
668+
const kind = input.kind === "rect" || input.kind === "lineRange" ? input.kind : "point";
669+
let surfaceIndex = Number(input.surfaceIndex);
670+
if (
671+
!Number.isInteger(surfaceIndex) ||
672+
surfaceIndex < 0 ||
673+
surfaceIndex >= post.surfaces.length
674+
) {
675+
const surfaceId = typeof input.surfaceId === "string" ? input.surfaceId : undefined;
676+
surfaceIndex = post.surfaces.findIndex((s) => s.id === surfaceId);
677+
}
678+
if (surfaceIndex < 0 || surfaceIndex >= post.surfaces.length) return undefined;
679+
const surface = post.surfaces[surfaceIndex];
680+
const base = {
681+
surfaceIndex,
682+
...(surface.id && { surfaceId: surface.id }),
683+
surfaceKind: surface.kind,
684+
// The server pins anchors to the current stored version instead of trusting
685+
// the client-supplied value.
686+
postVersion: post.version,
687+
};
688+
if (kind === "lineRange") {
689+
const startLine = Number(input.startLine);
690+
const endLine = Number(input.endLine);
691+
if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1) {
692+
return undefined;
693+
}
694+
return {
695+
kind,
696+
...base,
697+
startLine,
698+
endLine: Math.max(startLine, endLine),
699+
...(typeof input.file === "string" && { file: input.file.slice(0, MAX_TITLE) }),
700+
};
701+
}
702+
const x = numberInRange(input.x, 0, 1);
703+
const y = numberInRange(input.y, 0, 1);
704+
if (x == null || y == null) return undefined;
705+
if (kind === "rect") {
706+
const w = numberInRange(input.w, 0, 1);
707+
const h = numberInRange(input.h, 0, 1);
708+
if (w == null || h == null) return undefined;
709+
return { kind, ...base, x, y, w, h };
710+
}
711+
return { kind: "point", ...base, x, y };
712+
}
713+
657714
async function createComment(input: {
658715
text: string;
659716
surface?: string;
660717
author: string;
718+
anchor?: unknown;
661719
}): Promise<
662720
{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 }
663721
> {
@@ -671,6 +729,7 @@ export function createApp({
671729
postId: surface.id,
672730
author: input.author,
673731
text: input.text.trim().slice(0, MAX_COMMENT_TEXT),
732+
anchor: sanitizeCommentAnchor(input.anchor, surface),
674733
});
675734
if (!comment) return { error: "session not found", status: 404 };
676735
bus.broadcast({
@@ -1274,6 +1333,7 @@ export function createApp({
12741333
text: body.text,
12751334
surface: typeof surface === "string" ? surface : undefined,
12761335
author: typeof body.author === "string" ? body.author : "user",
1336+
anchor: body.anchor,
12771337
});
12781338
if ("error" in result) return c.json({ error: result.error }, result.status);
12791339
return c.json(
@@ -1282,6 +1342,13 @@ export function createApp({
12821342
);
12831343
});
12841344

1345+
app.delete("/api/comments/:id", async (c) => {
1346+
const comment = await store.removeComment(c.req.param("id"));
1347+
if (!comment) return c.json({ error: "comment not found" }, 404);
1348+
bus.broadcast({ type: "comment-deleted", id: comment.id, sessionId: comment.sessionId });
1349+
return c.json({ ok: true });
1350+
});
1351+
12851352
// The viewer's update notice: running version vs latest published release.
12861353
app.get("/api/version", async (c) => {
12871354
if (!version) return c.json({ current: null, latest: null, updateAvailable: false });

server/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type FeedEvent =
99
surfaceId: string | null;
1010
seq: number;
1111
}
12+
| { type: "comment-deleted"; id: string; sessionId: string }
1213
// Workspace theme changed; `id` is the new theme id. Other open tabs re-theme.
1314
| { type: "theme-changed"; id: string }
1415
// Session-scoped agent trace gained steps (synced in a batch). Carries only

server/mcpHttp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export function registerMcp(app: Hono, deps: McpDeps) {
151151
surfaceTitle: c.postTitle,
152152
text: c.text,
153153
at: c.createdAt,
154+
...(c.anchor && { anchor: c.anchor }),
154155
})),
155156
lastSeq: result.lastSeq,
156157
},

server/sqlStore.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export class SqlStore implements Store {
8181
if (!sessionCols.some((c) => c.name === "agentSeq")) {
8282
this.sql.exec("ALTER TABLE sessions ADD COLUMN agentSeq INTEGER NOT NULL DEFAULT 0");
8383
}
84+
const commentCols = this.sql.exec("SELECT name FROM pragma_table_info('comments')").toArray();
85+
if (!commentCols.some((c) => c.name === "anchor")) {
86+
this.sql.exec("ALTER TABLE comments ADD COLUMN anchor TEXT");
87+
}
8488
this.migrateToSurfaces();
8589
this.migrateToPosts();
8690
this.migrateSurfaceIds();
@@ -245,6 +249,14 @@ export class SqlStore implements Store {
245249
}
246250

247251
private rowToComment(r: Record<string, SqlStorageValue>): Comment {
252+
let anchor: Comment["anchor"] | undefined;
253+
if (typeof r.anchor === "string" && r.anchor) {
254+
try {
255+
anchor = JSON.parse(r.anchor) as Comment["anchor"];
256+
} catch {
257+
anchor = undefined;
258+
}
259+
}
248260
return {
249261
id: r.id as string,
250262
seq: r.seq as number,
@@ -254,6 +266,7 @@ export class SqlStore implements Store {
254266
author: r.author as string,
255267
text: r.text as string,
256268
createdAt: r.createdAt as string,
269+
...(anchor && { anchor }),
257270
};
258271
}
259272

@@ -484,14 +497,15 @@ export class SqlStore implements Store {
484497
const author = stripNul(input.author).trim() || "user";
485498
const text = stripNul(input.text);
486499
this.sql.exec(
487-
"INSERT INTO comments (id, sessionId, postId, postTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)",
500+
"INSERT INTO comments (id, sessionId, postId, postTitle, author, text, createdAt, anchor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
488501
id,
489502
input.sessionId,
490503
surface?.id ?? null,
491504
surface?.title ?? null,
492505
author,
493506
text,
494507
createdAt,
508+
input.anchor ? JSON.stringify(input.anchor) : null,
495509
);
496510
const seq = this.sql.exec("SELECT last_insert_rowid() AS seq").one().seq as number;
497511
this.touch(input.sessionId);
@@ -504,9 +518,19 @@ export class SqlStore implements Store {
504518
author,
505519
text,
506520
createdAt,
521+
...(input.anchor && { anchor: input.anchor }),
507522
};
508523
}
509524

525+
async removeComment(id: string) {
526+
const rows = this.sql.exec("SELECT * FROM comments WHERE id = ?", id).toArray();
527+
if (rows.length === 0) return null;
528+
const comment = this.rowToComment(rows[0]);
529+
this.sql.exec("DELETE FROM comments WHERE id = ?", id);
530+
this.touch(comment.sessionId);
531+
return comment;
532+
}
533+
510534
// --- trace ---
511535

512536
private rowToTraceStep(r: Record<string, SqlStorageValue>): TraceStep {
@@ -700,7 +724,7 @@ export class SqlStore implements Store {
700724
}
701725
for (const c of snapshot.comments) {
702726
this.sql.exec(
703-
"INSERT INTO comments (seq, id, sessionId, postId, postTitle, author, text, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
727+
"INSERT INTO comments (seq, id, sessionId, postId, postTitle, author, text, createdAt, anchor) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
704728
c.seq,
705729
c.id,
706730
c.sessionId,
@@ -709,6 +733,7 @@ export class SqlStore implements Store {
709733
c.author,
710734
c.text,
711735
c.createdAt,
736+
c.anchor ? JSON.stringify(c.anchor) : null,
712737
);
713738
}
714739
for (const t of snapshot.traces) {

server/storage.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ function liftComment(c: LegacyComment): Comment {
107107
author: c.author,
108108
text: c.text,
109109
createdAt: c.createdAt,
110+
...(c.anchor && { anchor: c.anchor }),
110111
};
111112
}
112113

@@ -433,13 +434,24 @@ export class JsonFileStore implements Store {
433434
author: stripNul(input.author).trim() || "user",
434435
text: stripNul(input.text),
435436
createdAt: new Date().toISOString(),
437+
...(input.anchor && { anchor: input.anchor }),
436438
};
437439
this.comments.push(comment);
438440
this.touch(input.sessionId);
439441
await this.persist();
440442
return clone(comment);
441443
}
442444

445+
async removeComment(id: string) {
446+
await this.load();
447+
const idx = this.comments.findIndex((c) => c.id === id);
448+
if (idx < 0) return null;
449+
const [comment] = this.comments.splice(idx, 1);
450+
this.touch(comment.sessionId);
451+
await this.persist();
452+
return clone(comment);
453+
}
454+
443455
// --- trace ---
444456

445457
async listTrace(sessionId: string) {

server/types.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,38 @@ export interface Post {
192192
history: PostVersion[];
193193
}
194194

195+
export type CommentAnchor =
196+
| {
197+
kind: "point";
198+
surfaceIndex: number;
199+
surfaceId?: string;
200+
surfaceKind?: SurfaceKind;
201+
postVersion: number;
202+
x: number;
203+
y: number;
204+
}
205+
| {
206+
kind: "rect";
207+
surfaceIndex: number;
208+
surfaceId?: string;
209+
surfaceKind?: SurfaceKind;
210+
postVersion: number;
211+
x: number;
212+
y: number;
213+
w: number;
214+
h: number;
215+
}
216+
| {
217+
kind: "lineRange";
218+
surfaceIndex: number;
219+
surfaceId?: string;
220+
surfaceKind?: SurfaceKind;
221+
postVersion: number;
222+
startLine: number;
223+
endLine: number;
224+
file?: string;
225+
};
226+
195227
export interface Comment {
196228
id: string;
197229
seq: number;
@@ -201,6 +233,10 @@ export interface Comment {
201233
author: string;
202234
text: string;
203235
createdAt: string;
236+
// Optional host-authored anchor for comments on a specific rendered surface
237+
// area/line. It is data only: render with text/positioned elements in the
238+
// trusted viewer, never as HTML.
239+
anchor?: CommentAnchor;
204240
}
205241

206242
// An uploaded blob (image, trace file, arbitrary file) the agent pushes once and
@@ -252,6 +288,7 @@ export interface CreateCommentInput {
252288
postId?: string;
253289
author: string;
254290
text: string;
291+
anchor?: CommentAnchor;
255292
}
256293

257294
export interface CommentQuery {
@@ -284,6 +321,7 @@ export interface Store {
284321

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

288326
// Session-scoped agent trace: the steps that produced a session's surfaces,
289327
// synced from the transcript. setTrace replaces the whole list (windowed

test/api.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,63 @@ test("comments attach to snippets and filter by author/after", async () => {
688688
assert.equal(later.comments.length, 0);
689689
});
690690

691+
test("comments can carry sanitized surface anchors", async () => {
692+
const app = makeApp();
693+
const created = (await (
694+
await app.request(
695+
"/api/posts",
696+
json({
697+
title: "Anchors",
698+
surfaces: [
699+
{ kind: "html", html: "<p>x</p>" },
700+
{ kind: "markdown", markdown: "# y" },
701+
],
702+
}),
703+
)
704+
).json()) as any;
705+
const post = (await (await app.request(`/api/posts/${created.id}`)).json()) as any;
706+
707+
await app.request(
708+
"/api/comments",
709+
json({
710+
surface: post.id,
711+
text: "look here",
712+
author: "user",
713+
anchor: { kind: "point", surfaceIndex: 1, x: 0.25, y: 0.75, postVersion: 999 },
714+
}),
715+
);
716+
717+
const all = (await (await app.request(`/api/comments?session=${post.sessionId}`)).json()) as any;
718+
assert.deepEqual(all.comments[0].anchor, {
719+
kind: "point",
720+
surfaceIndex: 1,
721+
surfaceId: post.surfaces[1].id,
722+
surfaceKind: "markdown",
723+
postVersion: 1,
724+
x: 0.25,
725+
y: 0.75,
726+
});
727+
});
728+
729+
test("comments can be deleted", async () => {
730+
const app = makeApp();
731+
const s = (await (await app.request("/api/snippets", json({ html: "<p>x</p>" }))).json()) as any;
732+
const created = (await (
733+
await app.request("/api/comments", json({ snippet: s.id, text: "remove me", author: "user" }))
734+
).json()) as any;
735+
736+
assert.equal(
737+
(await app.request(`/api/comments/${created.id}`, { method: "DELETE" })).status,
738+
200,
739+
);
740+
const all = (await (await app.request(`/api/comments?session=${s.sessionId}`)).json()) as any;
741+
assert.equal(all.comments.length, 0);
742+
assert.equal(
743+
(await app.request(`/api/comments/${created.id}`, { method: "DELETE" })).status,
744+
404,
745+
);
746+
});
747+
691748
test("a comment must target a surface", async () => {
692749
const app = makeApp();
693750
const s = (await (await app.request("/api/snippets", json({ html: "<p>x</p>" }))).json()) as any;

0 commit comments

Comments
 (0)