Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
23a4194
feat(comments): desktop comment engine, artifact and activity UI
puemos Aug 7, 2026
2464931
fix(comments): make desktop core independently valid
puemos Aug 7, 2026
225a499
fix(desktop): restrict artifact preview scripts
puemos Aug 7, 2026
3e5609d
fix(desktop): bound pull request comment queries
puemos Aug 7, 2026
accf683
fix(desktop): restrict GitHub comment images
puemos Aug 7, 2026
8f1bb8f
fix(desktop): block artifact preview redirects
puemos Aug 7, 2026
3c8594d
fix(desktop): gate comment surfaces
puemos Aug 7, 2026
810a6e3
fix(desktop): preserve add-to-chat selection action
puemos Aug 7, 2026
6808d84
fix(desktop): gate comment activity navigation
puemos Aug 7, 2026
cbba8ea
fix(desktop): restrict PR review comment images
puemos Aug 7, 2026
8ed97e8
fix(desktop): handle deleted PR comment authors
puemos Aug 7, 2026
4fba900
fix(desktop): label deleted comment authors neutrally
puemos Aug 7, 2026
554067d
fix(desktop): preserve artifact file sizes
puemos Aug 7, 2026
0a17c24
fix(desktop): keep artifacts visible on comment errors
puemos Aug 7, 2026
50dbb7e
fix(desktop): gate artifact member queries
puemos Aug 7, 2026
2a4e98f
fix(desktop): mark exact hover activity read
puemos Aug 7, 2026
ede3fef
fix(desktop): gate hover comment activity
puemos Aug 7, 2026
4939560
fix(desktop): preserve task mention activity
puemos Aug 7, 2026
3aecbeb
fix(desktop): label partial activity reads accurately
puemos Aug 7, 2026
80ef21c
fix(desktop): bound artifact comment polling
puemos Aug 7, 2026
c831208
fix(desktop): limit concurrent PR comment loads
puemos Aug 7, 2026
5a2aff1
fix(desktop): keep state events out of thread ordering
puemos Aug 7, 2026
1b52238
fix(desktop): resume artifact follow from all sources
puemos Aug 7, 2026
20989ee
fix(desktop): keep artifact links outside comment frames
puemos Aug 7, 2026
7a58c0a
fix(desktop): preserve cross-task comment focus
puemos Aug 7, 2026
23b90bc
fix(desktop): preserve pending source filters
puemos Aug 7, 2026
4b838ca
fix(desktop): derive visible comments in one pass
puemos Aug 7, 2026
4796e20
fix(desktop): skip unnecessary artifact HTML parsing
puemos Aug 7, 2026
462c0fb
fix(desktop): isolate resource comment failures
puemos Aug 7, 2026
037932f
fix(desktop): preserve partial PR comment results
puemos Aug 7, 2026
cf9f2ac
fix(desktop): handle async thread resolution
puemos Aug 7, 2026
4de9ef2
fix(desktop): make full comment threads selectable
puemos Aug 7, 2026
c9fbcbd
fix(desktop): distinguish text comment highlights
puemos Aug 7, 2026
e843fcb
fix(desktop): reuse text index across highlights
puemos Aug 7, 2026
6675563
fix(desktop): reuse text index during selection
puemos Aug 7, 2026
f441934
fix(desktop): surface artifact comment errors
puemos Aug 7, 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
7 changes: 4 additions & 3 deletions products/desktop/packages/api-client/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4366,7 +4366,7 @@ export namespace Schemas {
export type ColorMode = "light" | "dark";
export type Comment = {
id: string;
created_by: UserBasic & unknown;
created_by: (UserBasic & unknown) | null;
deleted?: (boolean | null) | undefined;
mentions?: Array<number> | undefined;
slug?: string | undefined;
Expand All @@ -4375,9 +4375,10 @@ export namespace Schemas {
version: number;
created_at: string;
item_id?: (string | null) | undefined;
item_context?: null | undefined;
item_context?: unknown;
scope: string;
source_comment?: (string | null) | undefined;
completed_at?: (string | null) | undefined;
};
export type CompareItem = { label: string; value: string };
export type ConclusionEnum = "won" | "lost" | "inconclusive" | "stopped_early" | "invalid";
Expand Down Expand Up @@ -18454,7 +18455,7 @@ export namespace Endpoints {
path: "/api/projects/{project_id}/comments/";
requestFormat: "json";
parameters: {
query: Partial<{ cursor: string; item_id: string; scope: string; search: string; source_comment: string }>;
query: Partial<{ cursor: string; item_id: string; task_id: string; scope: string; search: string; source_comment: string }>;
path: { project_id: string };
};
responses: { 200: Schemas.PaginatedCommentList };
Expand Down
77 changes: 73 additions & 4 deletions products/desktop/packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,29 @@ export interface TaskSessionStorageAccess {
content_sha256: string | null;
}

/**
* 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";

/** Named `Resource*` so it never collides with the DOM's global `Comment`.
* Optimistic rows do not have a server version yet, while item_context is a
* real JSON value despite the generated serializer's historically narrow type. */
export type ResourceComment = Omit<Schemas.Comment, "version"> & {
version?: number;
};

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 @@ -2708,8 +2731,7 @@ export class PostHogAPIClient {
return (await response.json()) as TaskMention[];
}

// Tasks the current user is involved in (created, mentioned, or messaged),
// one row per task, newest activity first.
// Task lifecycle and individual comment activity, newest first.
async getTaskActivity(options?: {
before?: string;
beforeId?: string;
Expand All @@ -2732,8 +2754,7 @@ export class PostHogAPIClient {
return (await response.json()) as TaskActivityPage;
}

// Read state is per task, so callers name the tasks the user has seen rather than
// clearing the whole feed.
// Task lifecycle activity clears by task timestamp; comment activity clears by row id.
async markTaskActivityRead(
activities: TaskActivityReadMarker[],
): Promise<TaskActivityMarkReadResult> {
Expand Down Expand Up @@ -3227,6 +3248,54 @@ export class PostHogAPIClient {
return data.url;
}

async getResourceComments(
scope: CommentScope,
itemId: string,
taskId: string,
): Promise<ResourceComment[]> {
const MAX_COMMENT_PAGES = 50;
const teamId = await this.getTeamId();
const comments: ResourceComment[] = [];
let cursor: string | undefined;
for (let pageIndex = 0; pageIndex < MAX_COMMENT_PAGES; pageIndex++) {
const page = await this.api.get("/api/projects/{project_id}/comments/", {
path: { project_id: String(teamId) },
query: { scope, item_id: itemId, task_id: taskId, cursor },
});
comments.push(...page.results);
cursor = page.next
? (new URL(page.next).searchParams.get("cursor") ?? undefined)
: undefined;
if (!cursor) return comments;
}
log.warn(
`getResourceComments hit MAX_PAGES (${MAX_COMMENT_PAGES}); returning partial results`,
{ scope, itemId, returned: comments.length },
);
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,
});
}

async getTaskSessionStorageAccess(
taskId: string,
runId: string,
Expand Down
19 changes: 19 additions & 0 deletions products/desktop/packages/core/src/canvas/taskActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,30 @@ describe("toTaskActivityItems", () => {
snippet: "ping @[Me](me@posthog.com)",
author: ann,
messageId: "m1",
commentId: null,
commentTarget: null,
isUnread: true,
},
]);
});

it("maps a comment activity target for exact navigation", () => {
const [item] = toTaskActivityItems([
activity({
latest_message_id: null,
latest_comment_id: "comment-1",
latest_comment_scope: "task_artifact",
latest_comment_item_id: "artifact-1",
}),
]);

expect(item.commentId).toBe("comment-1");
expect(item.commentTarget).toEqual({
scope: "task_artifact",
itemId: "artifact-1",
});
});

it("labels untitled tasks and tolerates missing optional values", () => {
const [item] = toTaskActivityItems([
activity({
Expand Down
15 changes: 13 additions & 2 deletions products/desktop/packages/core/src/canvas/taskActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type {
TaskActivityKind,
UserBasic,
} from "@posthog/shared/domain-types";
import type { CommentTarget } from "../comments/anchors";

/**
* The Activity feed — tasks the current user is involved in (created, mentioned
* in, or messaged in) — as served by the backend task-activity index
* (`getTaskActivity`). One row per task, newest activity first; the client only
* maps DTOs to items.
* (`getTaskActivity`). Task state collapses per task, while comment notifications
* are individual entries; the client only maps DTOs to items.
*/

export interface TaskActivityItem {
Expand All @@ -25,6 +26,8 @@ export interface TaskActivityItem {
snippet: string;
author: UserBasic | null;
messageId: string | null;
commentId?: string | null;
commentTarget?: CommentTarget | null;
isUnread: boolean;
}

Expand All @@ -43,6 +46,14 @@ export function toTaskActivityItems(
snippet: row.snippet,
author: row.latest_author ?? null,
messageId: row.latest_message_id ?? null,
commentId: row.latest_comment_id ?? null,
commentTarget:
row.latest_comment_scope && row.latest_comment_item_id
? {
scope: row.latest_comment_scope as CommentTarget["scope"],
itemId: row.latest_comment_item_id,
}
: null,
isUnread: row.is_unread,
}));
}
123 changes: 123 additions & 0 deletions products/desktop/packages/core/src/comments/anchors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
createTextCommentAnchor,
isThreadResolved,
parseCommentContext,
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("rejects selections larger than the persisted anchor contract", () => {
const text = "x".repeat(10_001);
expect(createTextCommentAnchor(text, 0, text.length)).toBeNull();
});

it("validates versioned comment context and anchor bounds", () => {
expect(
parseCommentContext({
anchor: { kind: "document" },
canvasVersionId: "version-2",
}),
).toEqual({
anchor: { kind: "document" },
canvasVersionId: "version-2",
});
expect(
parseCommentContext({
anchor: {
kind: "text",
quote: "x".repeat(10_001),
prefix: "",
suffix: "",
start: 0,
end: 10_001,
},
}),
).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);
});
});
Loading
Loading