Skip to content
Draft
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
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 @@ -199,6 +199,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 @@ -3329,6 +3367,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
Loading