Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
62d187a
feat: add comments to task artifacts
puemos Jul 29, 2026
3203cc1
fix: preserve HTML styling and surface inline comments
puemos Jul 29, 2026
4fe0339
fix: open the HTML selection composer directly
puemos Jul 29, 2026
fa34352
fix: polish artifact comment navigation and layout
puemos Jul 29, 2026
1b9c153
fix: refine artifact comment UX
puemos Jul 29, 2026
9ee7280
feat: add Figma-style pins, filters, and mentions
puemos Jul 29, 2026
2e6f8fd
refactor: simplify artifact comments and hide resolved anchors
puemos Jul 29, 2026
00415bf
changes
puemos Jul 30, 2026
f50f912
refactor: move artifact comments into a Comments tab
puemos Jul 30, 2026
b79875c
test: cover the comment tab's cross-pane wiring
puemos Jul 30, 2026
e4d0b9d
test: stub jsdom scrolling instead of letting it fail the run
puemos Jul 30, 2026
6d71dc5
feat: broaden the Comments tab to PRs, the task, and a source filter
puemos Jul 30, 2026
fde9449
fix: pad the top of the comment list so the first card clears the header
puemos Jul 30, 2026
9fa1211
feat: show real PR titles in the comment list, truncated with hover
puemos Jul 30, 2026
7889ab9
fix: render GitHub-flavored comment bodies and link out where we can'…
puemos Jul 30, 2026
77ed979
fix: make the source filter menu readable — wider, wrapping, count pi…
puemos Jul 30, 2026
f1083eb
fix: lay out the source filter rows like the app's other pickers
puemos Jul 30, 2026
48b5518
feat: show a source-type icon in the filter menu rows
puemos Jul 30, 2026
ffdf9c8
fix: render SVG artifacts in the preview instead of a blank pane
puemos Jul 30, 2026
cdffb7c
fix: pin the task source to the top of the filter
puemos Jul 30, 2026
470f902
feat: give SVG the image surface, focus new composers, steady the pins
puemos Jul 30, 2026
f9cb556
fix: make the image comment pin a marker, not a button
puemos Jul 30, 2026
8a71a26
feat: put the commenter's face on the image pin
puemos Jul 30, 2026
84c70db
fix: real avatars in the comment list, opaque image pins
puemos Jul 30, 2026
259d1ab
fix: draw the image pin shell with tokens that resolve
puemos Jul 30, 2026
b890807
design
puemos Jul 30, 2026
a7463a2
feat: name the task a comment belongs to so mentions reach its feed
puemos Jul 31, 2026
1d49313
refactor: drop dead comment types and redundant exports
puemos Jul 31, 2026
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
79 changes: 79 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,44 @@ export interface TaskSessionStorageAccess {
content_sha256: string | null;
}

export interface ResourceCommentUser {
id: number;
uuid: string;
first_name?: string;
last_name?: string;
email: string;
}

/**
* The commentable resources this client knows how to address. `scope` is a
* free-form column on the backend `Comment` model, so adding a resource is a
* new member here plus a caller — no migration and no endpoint.
*/
export type CommentScope = "task_artifact" | "desktop_canvas" | "task";

/** A row from the generic comments API. Named `Resource*` so it never collides
* with the DOM's global `Comment` type. */
export interface ResourceComment {
id: string;
created_by: ResourceCommentUser | null;
content: string | null;
created_at: string;
item_id: string | null;
item_context: unknown;
scope: string;
source_comment: string | null;
completed_at?: string | null;
}

export interface CreateResourceCommentRequest {
scope: CommentScope;
itemId: string;
content: string;
context: unknown;
sourceCommentId?: string;
mentions?: number[];
}

/** Thrown when the backend rejects a cloud run with a 429 usage-limit error. */
export class CloudUsageLimitError extends Error {
limitType: UsageLimitType;
Expand Down Expand Up @@ -3293,6 +3331,47 @@ export class PostHogAPIClient {
return data.url;
}

async getResourceComments(
scope: CommentScope,
itemId: string,
): Promise<ResourceComment[]> {
const teamId = await this.getTeamId();
const comments: ResourceComment[] = [];
let cursor: string | undefined;
do {
const page = await this.api.get("/api/projects/{project_id}/comments/", {
path: { project_id: String(teamId) },
query: { scope, item_id: itemId, cursor },
});
comments.push(...(page.results as unknown as ResourceComment[]));
cursor = page.next
? (new URL(page.next).searchParams.get("cursor") ?? undefined)
: undefined;
} while (cursor);
return comments;
}

async createResourceComment(
request: CreateResourceCommentRequest,
): Promise<ResourceComment> {
const teamId = await this.getTeamId();
const payload = {
content: request.content,
scope: request.scope,
item_id: request.itemId,
item_context: request.context,
source_comment: request.sourceCommentId ?? null,
mentions: request.mentions ?? [],
// Resolution is represented by a thread-state reply so this stays on the
// same PAT-compatible write path as ordinary comments.
is_task: false,
};
return (await this.api.post("/api/projects/{project_id}/comments/", {
path: { project_id: String(teamId) },
body: payload as unknown as Schemas.Comment,
})) as unknown as ResourceComment;
}

async getTaskSessionStorageAccess(
taskId: string,
runId: string,
Expand Down
93 changes: 93 additions & 0 deletions packages/core/src/comments/anchors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
createTextCommentAnchor,
isThreadResolved,
resolveTextCommentAnchor,
} from "./anchors";

describe("artifact text anchors", () => {
it("creates and resolves a verified positional anchor", () => {
const text = "Before selected words after";
const anchor = createTextCommentAnchor(text, 7, 21);

if (!anchor) throw new Error("Expected an anchor");
expect(resolveTextCommentAnchor(text, anchor)).toEqual({
start: 7,
end: 21,
status: "exact",
});
});

it("reanchors a quote after surrounding content changes", () => {
const original = "Before selected words after";
const anchor = createTextCommentAnchor(original, 7, 21);
if (!anchor) throw new Error("Expected an anchor");
const changed = `New introduction. ${original}`;

expect(resolveTextCommentAnchor(changed, anchor)).toEqual({
start: 25,
end: 39,
status: "reanchored",
});
});

it("uses context to disambiguate repeated quotes", () => {
const original = "first repeated phrase then second repeated phrase end";
const start = original.lastIndexOf("repeated phrase");
const anchor = createTextCommentAnchor(
original,
start,
start + "repeated phrase".length,
);
if (!anchor) throw new Error("Expected an anchor");
const changed = `prefix ${original}`;

expect(resolveTextCommentAnchor(changed, anchor)?.start).toBe(
changed.lastIndexOf("repeated phrase"),
);
});

it("orphans deleted and ambiguous text instead of guessing", () => {
const deleted = createTextCommentAnchor("unique text", 0, 6);
if (!deleted) throw new Error("Expected an anchor");
expect(resolveTextCommentAnchor("replacement", deleted)).toBeNull();

const ambiguous = {
kind: "text" as const,
quote: "same",
prefix: "",
suffix: "",
start: 100,
end: 104,
};
expect(resolveTextCommentAnchor("same x same", ambiguous)).toBeNull();
});

it("rejects whitespace-only selections", () => {
expect(createTextCommentAnchor("a b", 1, 4)).toBeNull();
});

it("uses the latest thread-state event for resolution", () => {
const root = { completed_at: null };
const event = (state: "resolved" | "open", created_at: string) => ({
created_at,
item_context: {
anchor: { kind: "document" as const },
threadState: state,
},
});

expect(
isThreadResolved(root, [
event("resolved", "2026-01-01T00:00:00Z"),
event("open", "2026-01-01T00:01:00Z"),
]),
).toBe(false);
expect(
isThreadResolved(root, [
event("open", "2026-01-01T00:00:00Z"),
event("resolved", "2026-01-01T00:01:00Z"),
]),
).toBe(true);
});
});
182 changes: 182 additions & 0 deletions packages/core/src/comments/anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import type { CommentScope } from "@posthog/api-client/posthog-client";
import { z } from "zod";

const CONTEXT_LENGTH = 32;

/**
* Addresses one commentable thing. `itemId` must be the resource's STABLE id
* (an artifact id, a canvas row id) — never a name or a version, so comments
* survive renames and reverts.
*/
export type CommentTarget = {
scope: CommentScope;
itemId: string;
};

/** The target as one string, for map keys and cache-key membership tests. */
export function commentTargetKey(target: CommentTarget): string {
return `${target.scope}:${target.itemId}`;
}

export function isSameCommentTarget(
a: CommentTarget | null,
b: CommentTarget | null,
): boolean {
return a?.scope === b?.scope && a?.itemId === b?.itemId;
}

export const textCommentAnchorSchema = z.object({
kind: z.literal("text"),
quote: z.string().min(1),
prefix: z.string(),
suffix: z.string(),
start: z.number().int().nonnegative(),
end: z.number().int().positive(),
});

export const regionCommentAnchorSchema = z.object({
kind: z.literal("region"),
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
width: z.number().min(0).max(1),
height: z.number().min(0).max(1),
});

const documentCommentAnchorSchema = z.object({
kind: z.literal("document"),
});

export const commentAnchorSchema = z.discriminatedUnion("kind", [
textCommentAnchorSchema,
regionCommentAnchorSchema,
documentCommentAnchorSchema,
]);

export type TextCommentAnchor = z.infer<typeof textCommentAnchorSchema>;
export type RegionCommentAnchor = z.infer<typeof regionCommentAnchorSchema>;
export type CommentAnchor = z.infer<typeof commentAnchorSchema>;

export const commentContextSchema = z.object({
anchor: commentAnchorSchema,
threadState: z.enum(["resolved", "open"]).optional(),
// The task the commented resource belongs to. Artifact and canvas ids live in a run's
// JSON rather than a table, so the server can't get back to the task without being told.
taskId: z.string().optional(),
});

export type CommentContext = z.infer<typeof commentContextSchema>;

export function parseCommentContext(value: unknown): CommentContext | null {
const parsed = commentContextSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}

export type ThreadStateComment = {
created_at: string;
item_context: unknown;
};

export function isThreadResolved(
root: { completed_at?: string | null },
replies: ThreadStateComment[],
): boolean {
const latestState = replies
.map((comment) => ({
createdAt: comment.created_at,
state: parseCommentContext(comment.item_context)?.threadState,
}))
.filter(
(
entry,
): entry is {
createdAt: string;
state: "resolved" | "open";
} => !!entry.state,
)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
.at(-1)?.state;
return latestState ? latestState === "resolved" : !!root.completed_at;
}

export type ResolvedTextAnchor = {
start: number;
end: number;
status: "exact" | "reanchored";
};

export function createTextCommentAnchor(
text: string,
start: number,
end: number,
): TextCommentAnchor | null {
const safeStart = Math.max(0, Math.min(start, text.length));
const safeEnd = Math.max(safeStart, Math.min(end, text.length));
const quote = text.slice(safeStart, safeEnd);
if (!quote.trim()) return null;

return {
kind: "text",
quote,
prefix: text.slice(Math.max(0, safeStart - CONTEXT_LENGTH), safeStart),
suffix: text.slice(safeEnd, safeEnd + CONTEXT_LENGTH),
start: safeStart,
end: safeEnd,
};
}

function candidateScore(
text: string,
start: number,
anchor: TextCommentAnchor,
): number {
const prefix = text.slice(Math.max(0, start - anchor.prefix.length), start);
const end = start + anchor.quote.length;
const suffix = text.slice(end, end + anchor.suffix.length);
let score = 0;
if (anchor.prefix && prefix === anchor.prefix) score += 2;
if (anchor.suffix && suffix === anchor.suffix) score += 2;
return score;
}

/**
* Resolve a persisted text quote without ever guessing. The stored position is
* verified first. If content moved, prefix/suffix disambiguate quote matches;
* ties are deliberately treated as orphaned.
*/
export function resolveTextCommentAnchor(
text: string,
anchor: TextCommentAnchor,
): ResolvedTextAnchor | null {
if (text.slice(anchor.start, anchor.end) === anchor.quote) {
return { start: anchor.start, end: anchor.end, status: "exact" };
}

const candidates: number[] = [];
let from = 0;
while (from <= text.length - anchor.quote.length) {
const match = text.indexOf(anchor.quote, from);
if (match < 0) break;
candidates.push(match);
from = match + Math.max(anchor.quote.length, 1);
}
if (candidates.length === 0) return null;
if (candidates.length === 1) {
const start = candidates[0];
return {
start,
end: start + anchor.quote.length,
status: "reanchored",
};
}

const ranked = candidates
.map((start) => ({ start, score: candidateScore(text, start, anchor) }))
.sort((a, b) => b.score - a.score);
if (ranked[0].score === 0 || ranked[0].score === ranked[1].score) return null;

return {
start: ranked[0].start,
end: ranked[0].start + anchor.quote.length,
status: "reanchored",
};
}
Loading