diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 5007ffc495..d60e422a70 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -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; @@ -3329,6 +3367,47 @@ export class PostHogAPIClient { return data.url; } + async getResourceComments( + scope: CommentScope, + itemId: string, + ): Promise { + 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 { + 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, diff --git a/packages/core/src/comments/anchors.test.ts b/packages/core/src/comments/anchors.test.ts new file mode 100644 index 0000000000..fb72a8ee45 --- /dev/null +++ b/packages/core/src/comments/anchors.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/comments/anchors.ts b/packages/core/src/comments/anchors.ts new file mode 100644 index 0000000000..6dfc25b8c6 --- /dev/null +++ b/packages/core/src/comments/anchors.ts @@ -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; +export type RegionCommentAnchor = z.infer; +export type CommentAnchor = z.infer; + +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; + +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", + }; +} diff --git a/packages/core/src/panels/panelStoreHelpers.ts b/packages/core/src/panels/panelStoreHelpers.ts index 2b41453240..aaff4ad4b2 100644 --- a/packages/core/src/panels/panelStoreHelpers.ts +++ b/packages/core/src/panels/panelStoreHelpers.ts @@ -62,6 +62,35 @@ export function getLeafPanel( return panel?.type === "leaf" ? panel : null; } +/** + * The artifact the user is looking at, if any: the focused panel's active tab + * when that is an artifact, else any other panel's. Lets a pane elsewhere (the + * task's comment list) narrow itself to whatever is on screen. + */ +export function activeArtifactId(layout: TaskLayout): string | null { + const activeArtifact = (node: PanelNode): string | null => { + if (node.type !== "leaf") { + for (const child of node.children) { + const found = activeArtifact(child); + if (found) return found; + } + return null; + } + const active = node.content.tabs.find( + (tab) => tab.id === node.content.activeTabId, + ); + return active?.data.type === "artifact" ? active.data.artifactId : null; + }; + + const focused = layout.focusedPanelId + ? getLeafPanel(layout.panelTree, layout.focusedPanelId) + : null; + return ( + (focused ? activeArtifact(focused) : null) ?? + activeArtifact(layout.panelTree) + ); +} + export function getGroupPanel( tree: PanelNode, panelId: string, diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index cf2da4368c..bec00dc566 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -9,6 +9,10 @@ import type { SessionConfigSelectOption, SessionUpdate, } from "@agentclientprotocol/sdk"; +import type { + CreateResourceCommentRequest, + ResourceComment, +} from "@posthog/api-client/posthog-client"; import { type AcpMessage, type Adapter, @@ -49,6 +53,7 @@ import { isTerminalStatus, type Task, } from "@posthog/shared/domain-types"; +import type { CommentTarget } from "../comments/anchors"; import type { SpeechKind, SpeechSource } from "../speech/identifiers"; import { CONTEXT_WINDOW_OPTION_CATEGORY, @@ -7489,6 +7494,49 @@ export class SessionService { } } + async getResourceComments(target: CommentTarget): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") return []; + return authStatus.auth.client.getResourceComments( + target.scope, + target.itemId, + ); + } + + /** + * Comments for several resources at once, for surfaces that centralize threads + * across a task's artifacts and canvases. Returns one flat list — every row + * already carries `scope` and `item_id`, so callers group without bookkeeping. + * Fanning out here (rather than in a hook) keeps the multi-source read in a + * service and lets the caller hold a single query. + */ + async getResourceCommentsForTargets( + targets: CommentTarget[], + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready" || targets.length === 0) return []; + const client = authStatus.auth.client; + const pages = await Promise.all( + targets.map((target) => + client + .getResourceComments(target.scope, target.itemId) + // One unreadable resource must not blank the whole pane. + .catch(() => [] as ResourceComment[]), + ), + ); + return pages.flat(); + } + + async createResourceComment( + request: CreateResourceCommentRequest, + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") { + throw new Error("Sign in to comment"); + } + return authStatus.auth.client.createResourceComment(request); + } + async getCloudRunArtifacts( taskId: string, runId: string, diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.test.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.test.tsx new file mode 100644 index 0000000000..202ba3bca5 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityPanel.test.tsx @@ -0,0 +1,170 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; + +vi.mock("@posthog/ui/features/canvas/hooks/useThreadConversation", () => ({ + useThreadConversation: () => ({ + timeline: [], + agentStatus: null, + events: [], + isPromptPending: false, + isReady: true, + members: [], + currentUser: null, + isTaskAuthor: true, + canForward: true, + draft: "", + setDraft: vi.fn(), + isSubmitDisabled: false, + submit: vi.fn(), + sendMessageToAgent: vi.fn(), + deleteMessage: vi.fn(), + onMentionInsert: vi.fn(), + }), +})); +vi.mock("@posthog/ui/features/canvas/components/ActivityTimeline", () => ({ + ActivityTimeline: () =>
timeline body
, +})); +vi.mock("@posthog/ui/features/canvas/components/TaskArtifactsList", () => ({ + TaskArtifactsList: () =>
artifacts body
, +})); +vi.mock("@posthog/ui/features/canvas/components/TaskCommentsList", () => ({ + TaskCommentsList: () =>
comments body
, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelFeedView", () => ({ + TaskCard: () =>
task card
, +})); +vi.mock("@posthog/ui/features/canvas/components/ThreadPanel", () => ({ + AgentStatusLine: () =>
agent status
, + ThreadLoadingState: () =>
loading
, + ThreadReplyComposer: () =>
composer
, +})); +vi.mock("@posthog/ui/features/tasks/queries", () => ({ + taskDetailQuery: () => ({ queryKey: ["task"], queryFn: vi.fn() }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); + +import { useCommentNavigationStore } from "@posthog/ui/features/sessions/commentNavigationStore"; +import { ActivityPanel } from "./ActivityPanel"; + +const task = { id: "task-1", title: "Ship it" } as unknown as Task; + +function renderPanel(taskId = "task-1") { + return render( + , + ); +} + +describe("ActivityPanel", () => { + let scrollTo: MockInstance; + + beforeEach(() => { + scrollTo = vi.spyOn(Element.prototype, "scrollTo"); + useCommentNavigationStore.setState({ + focusByTask: {}, + resolutionsByTarget: {}, + }); + }); + + afterEach(() => { + scrollTo.mockRestore(); + }); + + it("offers comments as a third tab beside the timeline and artifacts", () => { + renderPanel(); + + expect(screen.getByRole("tab", { name: "Timeline" })).toBeTruthy(); + expect(screen.getByRole("tab", { name: "Artifacts" })).toBeTruthy(); + fireEvent.click(screen.getByRole("tab", { name: "Comments" })); + + expect(screen.getByText("comments body")).toBeTruthy(); + // The composer belongs to the conversation, not to a list of threads. + expect(screen.queryByText("composer")).toBeNull(); + }); + + // A thread picked on the artifact itself lands in this tab, so the pick has + // to bring the tab with it. + it("switches to comments when a thread is picked elsewhere", () => { + renderPanel(); + expect(screen.getByText("timeline body")).toBeTruthy(); + + act(() => + useCommentNavigationStore + .getState() + .requestCommentFocus( + "task-1", + { scope: "task_artifact", itemId: "artifact-1" }, + "comment-1", + ), + ); + + expect(screen.getByText("comments body")).toBeTruthy(); + }); + + it("leaves a focus request for another task alone", () => { + renderPanel(); + + act(() => + useCommentNavigationStore + .getState() + .requestCommentFocus( + "task-2", + { scope: "task_artifact", itemId: "artifact-1" }, + "comment-1", + ), + ); + + expect(screen.getByText("timeline body")).toBeTruthy(); + }); + + // A focus left over from an earlier visit must not hijack the panel, and the + // panel is reused across tasks without remounting. + it("does not open comments for a focus that predates the task", () => { + useCommentNavigationStore + .getState() + .requestCommentFocus( + "task-2", + { scope: "task_artifact", itemId: "artifact-1" }, + "comment-1", + ); + const { rerender } = renderPanel("task-1"); + + rerender( + , + ); + + expect(screen.getByText("timeline body")).toBeTruthy(); + }); + + // Only the timeline reads bottom-up; the thread lists put what matters on top. + it("keeps the comments list where it was scrolled to", () => { + renderPanel(); + expect(scrollTo).toHaveBeenCalled(); + const timelineScrolls = scrollTo.mock.calls.length; + + fireEvent.click(screen.getByRole("tab", { name: "Comments" })); + + expect(scrollTo.mock.calls.length).toBe(timelineScrolls); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx index c59a26979b..cda7bc4dfa 100644 --- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -9,23 +9,26 @@ import type { Task } from "@posthog/shared/domain-types"; import { ActivityTimeline } from "@posthog/ui/features/canvas/components/ActivityTimeline"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList"; +import { TaskCommentsList } from "@posthog/ui/features/canvas/components/TaskCommentsList"; import { AgentStatusLine, ThreadLoadingState, ThreadReplyComposer, } from "@posthog/ui/features/canvas/components/ThreadPanel"; import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; +import { useCommentNavigationStore } from "@posthog/ui/features/sessions/commentNavigationStore"; import { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { track } from "@posthog/ui/shell/analytics"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -type ActivityTab = "timeline" | "artifacts"; +type ActivityTab = "timeline" | "artifacts" | "comments"; const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [ { key: "timeline", label: "Timeline" }, { key: "artifacts", label: "Artifacts" }, + { key: "comments", label: "Comments" }, ] as const; /** The 32px row this panel leads with: the tabs are the header, so the strip @@ -161,15 +164,41 @@ function ActivityConversation({ [tab, events, isPromptPending], ); + // A thread picked on the artifact itself lives in the Comments tab, so the + // pick has to bring the tab with it. Only a fresh request switches tabs: a + // focus left over from an earlier visit must not hijack the panel on mount. + const commentFocus = useCommentNavigationStore( + (state) => state.focusByTask[taskId], + ); + // Tracks the task too: this panel is reused across tasks without remounting, + // so a nonce seen for the previous task says nothing about this one. + const seenFocus = useRef({ taskId, nonce: commentFocus?.nonce }); + // Adjust during render rather than in an effect, so the panel never commits a + // stale tab before switching. A new task resets the baseline (an old focus + // must not hijack it); a fresh focus nonce for this task brings the Comments + // tab with the pick. + if (seenFocus.current.taskId !== taskId) { + seenFocus.current = { taskId, nonce: commentFocus?.nonce }; + } else if (commentFocus && commentFocus.nonce !== seenFocus.current.nonce) { + seenFocus.current = { taskId, nonce: commentFocus.nonce }; + // Not handleTabChange: a programmatic switch isn't a user tab change. + setTab("comments"); + } + const scrollRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { + // Only the timeline reads bottom-up; the other tabs put what matters on top. + if (tab !== "timeline") return; scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [timeline, events.length, agentStatus?.phase, tab]); const showComposer = tab === "timeline"; const body = () => { + if (tab === "comments") { + return ; + } if (tab === "artifacts") { return ( ({ @@ -33,6 +33,36 @@ vi.mock("@posthog/ui/features/pr-review/usePrComments", () => ({ vi.mock("@posthog/ui/features/pr-review/usePrReviewThreads", () => ({ usePrReviewThreads: () => ({ data: undefined }), })); +vi.mock("@posthog/ui/features/sessions/components/useComments", () => ({ + useCommentsForTargetsQuery: () => ({ + data: [ + { + id: "comment-1", + source_comment: null, + item_id: "a", + content: "Tighten this summary", + created_at: "2024-01-01T00:00:00Z", + item_context: { anchor: { kind: "document" } }, + }, + { + id: "reply-1", + source_comment: "comment-1", + item_id: "a", + content: "Agreed", + created_at: "2024-01-01T00:01:00Z", + item_context: { anchor: { kind: "document" } }, + }, + { + id: "comment-2", + source_comment: null, + item_id: "a", + content: "Second thread", + created_at: "2024-01-01T00:02:00Z", + item_context: { anchor: { kind: "document" } }, + }, + ], + }), +})); import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { TaskArtifactsList } from "./TaskArtifactsList"; @@ -123,15 +153,29 @@ describe("TaskArtifactsList", () => { expect(screen.getByText("Pull request #2")).toBeTruthy(); }); - it("lists the files the agent uploaded, with their size", () => { + it("lists uploaded files with their comment count", () => { + mocks.runs = [ + run("run-1", { artifacts: [outputFile({ id: "a", size: 16861 })] }), + ]; + + render(); + + const row = screen.getByText("report.md").closest("button"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText("2")).toBeTruthy(); + expect(within(row as HTMLElement).queryByText(/File|KB/)).toBeNull(); + }); + + // The threads themselves live in the Comments tab now, so the pane must not + // grow a second list of them. + it("leaves the thread list to the Comments tab", () => { mocks.runs = [ run("run-1", { artifacts: [outputFile({ id: "a", size: 16861 })] }), ]; render(); - expect(screen.getByText("report.md")).toBeTruthy(); - expect(screen.getByText("File · 17 KB")).toBeTruthy(); + expect(screen.queryByText("Tighten this summary")).toBeNull(); }); // The row should read like the chat's file list: markdown looks like @@ -196,7 +240,7 @@ describe("TaskArtifactsList", () => { render(); expect(screen.getAllByText("report.md")).toHaveLength(1); - expect(screen.getByText("File · 2 KB")).toBeTruthy(); + expect(screen.queryByText(/File ·|KB/)).toBeNull(); }); it.each([ diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index e24ded715d..6a75fd0728 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -1,128 +1,39 @@ import { ArrowSquareOutIcon, + ChatCircleIcon, PackageIcon, SlackLogoIcon, } from "@phosphor-icons/react"; -import { - OUTPUT_ARTIFACT_TYPES, - parseRunArtifacts, - type RunArtifact, -} from "@posthog/core/canvas/runArtifactSchemas"; +import type { ResourceComment } from "@posthog/api-client/posthog-client"; import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; import { + Badge, Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@posthog/quill"; -import { readPrUrls } from "@posthog/shared"; -import type { - Task, - TaskRun, - TaskThreadMessage, -} from "@posthog/shared/domain-types"; +import type { Task, TaskThreadMessage } from "@posthog/shared/domain-types"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { + buildRows, + commentTargets, + openCanvasFromUrl, +} from "@posthog/ui/features/canvas/components/taskArtifactRows"; import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; import { openPrInReview } from "@posthog/ui/features/code-review/openPrInReview"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; +import { buildCommentThreads } from "@posthog/ui/features/sessions/components/commentViewTypes"; +import { useCommentsForTargetsQuery } from "@posthog/ui/features/sessions/components/useComments"; import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; -import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; -import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; -import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { type ReactNode, useMemo, useState } from "react"; -type ArtifactRow = - | { kind: "pr"; key: string; url: string } - | { kind: "canvas"; key: string; name: string; url: string | null } - | { - kind: "file"; - key: string; - artifactId: string | null; - name: string; - runId: string | null; - size: number | undefined; - } - | { kind: "slack"; key: string; url: string }; - -function readRunOutputs(run: TaskRun): RunArtifact[] { - return parseRunArtifacts( - (run as { artifacts?: unknown }).artifacts, - OUTPUT_ARTIFACT_TYPES, - ); -} - -function buildRows( - task: Task, - timeline: ThreadTimelineRow[], - runs: TaskRun[], -): ArtifactRow[] { - const rows: ArtifactRow[] = []; - const seenPrUrls = new Set(); - - const addPr = (url: string, key: string) => { - if (seenPrUrls.has(url)) return; - seenPrUrls.add(url); - rows.push({ kind: "pr", key, url }); - }; - - for (const row of timeline) { - if (row.kind !== "artifact") continue; - if (row.artifact.kind === "pr") { - addPr(row.artifact.url, row.message.id); - } else { - rows.push({ - kind: "canvas", - key: row.message.id, - name: row.artifact.name, - url: row.artifact.url, - }); - } - } - - const allRuns = - runs.length > 0 ? runs : task.latest_run ? [task.latest_run] : []; - - // Re-uploading a file replaces it rather than adding a second one: agents - // revise a deliverable and upload it again under the same name, so keeping - // every copy would bury the current one under its own drafts. - const newestByName = new Map(); - for (const run of allRuns) { - for (const outputPr of readPrUrls(run.output)) { - addPr(outputPr, `output-pr:${outputPr}`); - } - for (const file of readRunOutputs(run)) { - if (!file.name) continue; - const previous = newestByName.get(file.name); - const isNewer = - !previous || - (file.uploaded_at ?? "") >= (previous.file.uploaded_at ?? ""); - if (isNewer) newestByName.set(file.name, { file, runId: run.id }); - } - } - for (const [name, { file, runId }] of newestByName) { - rows.push({ - kind: "file", - key: `file:${file.id ?? file.storage_path ?? name}`, - artifactId: file.id ?? null, - name, - runId, - size: file.size, - }); - } - - const slackUrl = task.latest_run?.state?.slack_thread_url; - if (typeof slackUrl === "string" && slackUrl) { - rows.push({ kind: "slack", key: "slack-thread", url: slackUrl }); - } - - return rows; -} +const EMPTY_COMMENTS: ResourceComment[] = []; function ArtifactListRow({ icon, @@ -135,7 +46,7 @@ function ArtifactListRow({ }: { icon: ReactNode; title: string; - detail?: string | null; + detail?: ReactNode; external?: boolean; onOpen?: () => void; /** Renders a trailing button that leaves the app instead of opening the @@ -229,29 +140,30 @@ function PrRow({ ); } -function CanvasRow({ name, url }: { name: string; url: string | null }) { - const parsed = url ? parseHttpsUrl(url) : null; - const target = parsed ? parseShareLink(parsed.href) : null; - const open = - parsed && target - ? () => { - const currentPostHogUrl = getPostHogUrl("/"); - const currentPostHogOrigin = currentPostHogUrl - ? parseHttpsUrl(currentPostHogUrl)?.origin - : null; - if (parsed.origin === currentPostHogOrigin) { - navigateToShareTarget(target); - } else { - openExternalUrl(parsed.href); - } - } - : undefined; +function CanvasRow({ + name, + url, + commentCount, +}: { + name: string; + url: string | null; + commentCount: number; +}) { return ( 0 ? ( + + + {commentCount} + + ) : ( + "Canvas" + ) + } + onOpen={openCanvasFromUrl(url)} /> ); } @@ -261,13 +173,14 @@ function FileRow({ runId, artifactId, name, - size, + commentCount, }: { taskId: string; runId: string | null; artifactId: string | null; name: string; - size: number | undefined; + /** Supplied by the pane's single comments query so each row doesn't fetch. */ + commentCount: number; }) { const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); const canOpen = !!runId && !!artifactId; @@ -284,7 +197,12 @@ function FileRow({ } title={name} - detail={["File", formatFileSize(size)].filter(Boolean).join(" · ")} + detail={ + + + {commentCount} + + } onOpen={onOpen} /> ); @@ -306,6 +224,22 @@ export function TaskArtifactsList({ () => buildRows(task, timeline, runs), [task, timeline, runs], ); + // One query for every row's badge, so N resources cost one request rather + // than one per row. The threads themselves live in the Comments tab. + const targets = useMemo(() => commentTargets(rows), [rows]); + const commentsQuery = useCommentsForTargetsQuery(targets); + const comments = commentsQuery.data ?? EMPTY_COMMENTS; + // Open threads only, so a row's badge agrees with what the Comments tab + // shows on the same resource. + const openCountByItem = useMemo(() => { + const counts = new Map(); + for (const thread of buildCommentThreads(comments)) { + const itemId = thread.root.item_id; + if (thread.resolved || !itemId) continue; + counts.set(itemId, (counts.get(itemId) ?? 0) + 1); + } + return counts; + }, [comments]); if (rows.length === 0) { return ( @@ -334,7 +268,14 @@ export function TaskArtifactsList({ openInPlaceTaskId={canOpenInPlace ? task.id : undefined} /> ) : row.kind === "canvas" ? ( - + ) : row.kind === "file" ? ( ) : ( ({ + runs: [] as TaskRun[], + comments: [] as unknown[], + activeArtifactId: null as string | null, + prConversation: [] as unknown[], + prReviewThreads: [] as unknown[], + openArtifactTab: vi.fn(), + openPrInReview: vi.fn(), + openExternalUrl: vi.fn(), + requestScrollToFile: vi.fn(), + prReply: vi.fn(async () => true), + prResolve: vi.fn(async () => true), + createComment: vi.fn(), + setResolved: vi.fn(), + createdFor: [] as unknown[], + resolvedFor: [] as unknown[], +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useTaskRuns", () => ({ + useTaskRuns: () => ({ runs: mocks.runs, isLoading: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useOrgMembers", () => ({ + useOrgMembers: () => ({ members: [] }), +})); +vi.mock("@posthog/ui/features/panels/panelLayoutStore", () => ({ + usePanelLayoutStore: () => mocks.openArtifactTab, + useActiveArtifactId: () => mocks.activeArtifactId, +})); +vi.mock("@posthog/ui/features/pr-review/usePrCommentsForUrls", () => ({ + usePrCommentsForUrls: (urls: string[]) => ({ + byUrl: new Map(urls.map((url) => [url, mocks.prConversation])), + isLoading: false, + }), +})); +vi.mock("@posthog/ui/features/pr-review/usePrReviewThreadsForUrls", () => ({ + usePrReviewThreadsForUrls: (urls: string[]) => ({ + byUrl: new Map(urls.map((url) => [url, mocks.prReviewThreads])), + isLoading: false, + }), +})); +vi.mock("@posthog/ui/features/git-interaction/usePrDetails", () => ({ + usePrTitles: () => ({}), +})); +vi.mock("@posthog/ui/shell/openExternal", () => ({ + openExternalUrl: (url: string) => mocks.openExternalUrl(url), +})); +// GitHub bodies render through MarkdownRenderer; the wiring under test is the +// list, not the markdown pipeline, so keep it to plain text here. +vi.mock("@posthog/ui/features/editor/components/MarkdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( + {content} + ), +})); +vi.mock("@posthog/ui/features/code-review/openPrInReview", () => ({ + openPrInReview: (taskId: string, url: string) => + mocks.openPrInReview(taskId, url), +})); +vi.mock("@posthog/ui/features/code-review/reviewNavigationStore", () => ({ + useReviewNavigationStore: { + getState: () => ({ requestScrollToFile: mocks.requestScrollToFile }), + }, +})); +// Tiptap's editor renders no placeholder attribute and drags a lot of DOM into +// jsdom; the wiring under test is which target a composed comment posts to. +vi.mock("@posthog/ui/features/sessions/components/CommentComposer", () => ({ + CommentComposer: ({ + placeholder, + onSubmit, + }: { + placeholder: string; + onSubmit: (content: string, mentions: number[]) => void; + }) => ( + + ), +})); +vi.mock("@posthog/ui/features/code-review/hooks/usePrCommentActions", () => ({ + usePrCommentActions: () => ({ + reply: mocks.prReply, + resolve: mocks.prResolve, + }), +})); +vi.mock("@posthog/ui/features/sessions/components/useComments", () => ({ + useCommentsForTargetsQuery: () => ({ + data: mocks.comments, + isLoading: false, + }), + useCreateComment: (target: unknown) => { + mocks.createdFor.push(target); + return { mutate: mocks.createComment, isPending: false }; + }, + useSetCommentResolved: (target: unknown) => { + mocks.resolvedFor.push(target); + return { mutate: mocks.setResolved, isPending: false }; + }, +})); + +import { useCommentNavigationStore } from "@posthog/ui/features/sessions/commentNavigationStore"; +import { TaskCommentsList } from "./TaskCommentsList"; + +const task = { id: "task-1", latest_run: null } as unknown as Task; + +function run(artifacts: Partial[], id = "run-1"): TaskRun { + return { id, output: null, artifacts } as unknown as TaskRun; +} + +function outputFile( + overrides: Partial, +): Partial { + return { + type: "output", + name: "report.md", + storage_path: "runs/1/report.md", + ...overrides, + }; +} + +function prRun(url: string): TaskRun { + return { + id: "run-pr", + output: { pr_url: url }, + artifacts: [], + } as unknown as TaskRun; +} + +function reviewThread(overrides: Record = {}) { + return { + nodeId: "node-1", + isResolved: false, + rootId: 501, + filePath: "packages/ui/src/App.tsx", + comments: [ + { + id: 501, + body: "This needs a guard", + path: "packages/ui/src/App.tsx", + line: 12, + user: { login: "octocat", avatar_url: "" }, + created_at: "2024-01-02T00:00:00Z", + }, + ], + ...overrides, + }; +} + +function comment(overrides: Partial): ResourceComment { + return { + id: "comment-1", + created_by: null, + content: "Tighten this summary", + created_at: "2024-01-01T00:00:00Z", + item_id: "a", + item_context: { anchor: { kind: "document" } }, + scope: "task_artifact", + source_comment: null, + ...overrides, + } as ResourceComment; +} + +describe("TaskCommentsList", () => { + beforeEach(() => { + mocks.runs = [ + run([ + outputFile({ id: "a", name: "report.md" }), + outputFile({ + id: "b", + name: "summary.md", + storage_path: "runs/1/summary.md", + }), + ]), + ]; + mocks.comments = [ + comment({}), + comment({ + id: "reply-1", + source_comment: "comment-1", + content: "Agreed", + created_at: "2024-01-01T00:01:00Z", + }), + comment({ + id: "comment-2", + item_id: "b", + content: "Second thread", + created_at: "2024-01-01T00:02:00Z", + }), + ]; + mocks.activeArtifactId = null; + mocks.prConversation = []; + mocks.prReviewThreads = []; + mocks.openArtifactTab.mockReset(); + mocks.openPrInReview.mockReset(); + mocks.openExternalUrl.mockReset(); + mocks.requestScrollToFile.mockReset(); + mocks.prReply.mockClear(); + mocks.prResolve.mockClear(); + mocks.createComment.mockReset(); + mocks.setResolved.mockReset(); + mocks.createdFor = []; + mocks.resolvedFor = []; + useCommentNavigationStore.setState({ + focusByTask: {}, + resolutionsByTarget: {}, + }); + }); + + // The tab is the one place to see every thread the task produced, so each row + // has to say which artifact it came from. + it("lists open threads from every artifact, newest first", () => { + render(); + + const newest = screen.getByText("Second thread"); + const oldest = screen.getByText("Tighten this summary"); + expect( + newest.compareDocumentPosition(oldest) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText("summary.md")).toBeTruthy(); + expect(screen.getByText("report.md")).toBeTruthy(); + expect(screen.getByText(/1 reply/)).toBeTruthy(); + // The resolve/reopen reply is thread state, not a comment of its own. + expect(screen.queryByText("Agreed")).toBeTruthy(); + }); + + it("opens the artifact a thread belongs to and focuses that thread", () => { + render(); + + fireEvent.click(screen.getByText("Tighten this summary")); + + expect(mocks.openArtifactTab).toHaveBeenCalledWith("task-1", { + runId: "run-1", + artifactId: "a", + name: "report.md", + }); + expect(useCommentNavigationStore.getState().focusByTask["task-1"]).toEqual({ + target: { scope: "task_artifact", itemId: "a" }, + threadId: "comment-1", + nonce: expect.any(Number), + }); + }); + + // Clicking the same thread twice has to scroll twice, so every request is a + // new nonce rather than a no-op set. + it("re-requests focus for a thread already focused", () => { + render(); + + fireEvent.click(screen.getByText("Tighten this summary")); + const first = useCommentNavigationStore.getState().focusByTask["task-1"]; + fireEvent.click(screen.getByText("Tighten this summary")); + const second = useCommentNavigationStore.getState().focusByTask["task-1"]; + + expect(second?.nonce).toBeGreaterThan(first?.nonce ?? 0); + }); + + it("filters between open and resolved threads", () => { + mocks.comments = [ + comment({}), + comment({ + id: "state-1", + source_comment: "comment-1", + content: "Resolved this thread", + created_at: "2024-01-01T00:03:00Z", + item_context: { + anchor: { kind: "document" }, + threadState: "resolved", + }, + }), + comment({ + id: "comment-2", + item_id: "b", + content: "Second thread", + created_at: "2024-01-01T00:02:00Z", + }), + ]; + + render(); + + expect(screen.getByText("Second thread")).toBeTruthy(); + expect(screen.queryByText("Tighten this summary")).toBeNull(); + + fireEvent.click(screen.getByLabelText("Filter comments")); + fireEvent.click(screen.getByText("Resolved (1)")); + + expect(screen.getByText("Tighten this summary")).toBeTruthy(); + expect(screen.queryByText("Second thread")).toBeNull(); + }); + + it("warns when the anchored text the thread points at has changed", () => { + useCommentNavigationStore.setState({ + resolutionsByTarget: { + "task_artifact:a": new Map([["comment-1", "orphaned" as const]]), + }, + }); + + render(); + + expect(screen.getByText("The highlighted text changed")).toBeTruthy(); + }); + + it("replies and resolves against the thread's own resource", () => { + render(); + + // Each row builds its mutations from its own target, since the list spans + // several resources. + expect(mocks.createdFor).toContainEqual({ + scope: "task_artifact", + itemId: "a", + }); + expect(mocks.resolvedFor).toContainEqual({ + scope: "task_artifact", + itemId: "b", + }); + + const thread = screen + .getByText("Tighten this summary") + .closest("[data-comment-thread-id]") as HTMLElement; + fireEvent.click(within(thread).getByText("Resolve")); + + expect(mocks.setResolved).toHaveBeenCalledWith({ + root: expect.objectContaining({ id: "comment-1" }), + resolved: true, + }); + }); + + // The pane follows what's on screen, but a reader who picks a source owns the + // filter from then on. + it("narrows to the artifact open in the main pane", () => { + mocks.activeArtifactId = "b"; + + render(); + + expect(screen.getByText("Second thread")).toBeTruthy(); + expect(screen.queryByText("Tighten this summary")).toBeNull(); + }); + + it("stops following the main pane once a source is picked by hand", () => { + mocks.activeArtifactId = "b"; + const { rerender } = render(); + + fireEvent.click(screen.getByLabelText("Filter by source")); + fireEvent.click(screen.getByText(/^All sources/)); + mocks.activeArtifactId = "a"; + rerender(); + + expect(screen.getByText("Second thread")).toBeTruthy(); + expect(screen.getByText("Tighten this summary")).toBeTruthy(); + }); + + it("lists a PR's review threads and conversation comments", () => { + mocks.runs = [prRun("https://github.com/acme/repo/pull/7")]; + mocks.comments = []; + mocks.prReviewThreads = [reviewThread()]; + mocks.prConversation = [ + { + id: 900, + author: "octocat", + avatarUrl: null, + body: "Shipping this", + createdAt: "2024-01-03T00:00:00Z", + url: "https://github.com/acme/repo/pull/7#issuecomment-900", + }, + ]; + + render(); + + expect(screen.getByText("This needs a guard")).toBeTruthy(); + expect(screen.getByText("Shipping this")).toBeTruthy(); + expect(screen.getAllByText("PR #7").length).toBe(2); + // Only the file-anchored thread can be resolved on GitHub. + expect(screen.getAllByText("Resolve")).toHaveLength(1); + // The conversation comment can't be handled here, so it links out instead. + expect(screen.getByText("View on GitHub")).toBeTruthy(); + }); + + it("links a conversation comment out to GitHub", () => { + mocks.runs = [prRun("https://github.com/acme/repo/pull/7")]; + mocks.comments = []; + mocks.prConversation = [ + { + id: 900, + author: "octocat", + avatarUrl: null, + body: "Shipping this", + createdAt: "2024-01-03T00:00:00Z", + url: "https://github.com/acme/repo/pull/7#issuecomment-900", + }, + ]; + + render(); + fireEvent.click(screen.getByText("View on GitHub")); + + expect(mocks.openExternalUrl).toHaveBeenCalledWith( + "https://github.com/acme/repo/pull/7#issuecomment-900", + ); + }); + + it("opens a PR thread in the review pane at its file", () => { + mocks.runs = [prRun("https://github.com/acme/repo/pull/7")]; + mocks.comments = []; + mocks.prReviewThreads = [reviewThread()]; + + render(); + fireEvent.click(screen.getByText("This needs a guard")); + + expect(mocks.openPrInReview).toHaveBeenCalledWith( + "task-1", + "https://github.com/acme/repo/pull/7", + ); + expect(mocks.requestScrollToFile).toHaveBeenCalledWith( + "task-1", + "packages/ui/src/App.tsx", + ); + }); + + it("replies and resolves a PR thread on GitHub", () => { + mocks.runs = [prRun("https://github.com/acme/repo/pull/7")]; + mocks.comments = []; + mocks.prReviewThreads = [reviewThread()]; + + render(); + const thread = screen + .getByText("This needs a guard") + .closest("[data-comment-thread-id]") as HTMLElement; + fireEvent.click(within(thread).getByText("Resolve")); + + expect(mocks.prResolve).toHaveBeenCalledWith("node-1", true); + expect(mocks.setResolved).not.toHaveBeenCalled(); + }); + + // Not every comment belongs to a deliverable; some are about the work. + it("posts a comment on the task itself", () => { + render(); + + fireEvent.click(screen.getByText(/Comment on this task/)); + + expect(mocks.createdFor).toContainEqual({ + scope: "task", + itemId: "task-1", + }); + expect(mocks.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + content: "Composed comment", + context: { anchor: { kind: "document" } }, + }), + ); + }); + + it("shows an empty state pointing at the artifact surfaces", () => { + mocks.comments = []; + + render(); + + expect(screen.getByText("No open comments")).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/TaskCommentsList.tsx b/packages/ui/src/features/canvas/components/TaskCommentsList.tsx new file mode 100644 index 0000000000..dad875e90c --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskCommentsList.tsx @@ -0,0 +1,583 @@ +import { + CaretDownIcon, + ChatCircleIcon, + FunnelSimpleIcon, + GitPullRequestIcon, +} from "@phosphor-icons/react"; +import type { ResourceComment } from "@posthog/api-client/posthog-client"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { commentTargetKey } from "@posthog/core/comments/anchors"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Spinner, +} from "@posthog/quill"; +import type { + Task, + TaskThreadMessage, + UserBasic, +} from "@posthog/shared/domain-types"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { + buildRows, + type CommentSource, + commentSources, + openCanvasFromUrl, + taskCommentTarget, +} from "@posthog/ui/features/canvas/components/taskArtifactRows"; +import { + byNewestActivity, + prCommentThreads, + resourceCommentThreads, + type SourceKind, + type TaskCommentThread, + threadSourceOptions, +} from "@posthog/ui/features/canvas/components/taskCommentThreads"; +import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; +import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; +import { usePrCommentActions } from "@posthog/ui/features/code-review/hooks/usePrCommentActions"; +import { openPrInReview } from "@posthog/ui/features/code-review/openPrInReview"; +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { usePrTitles } from "@posthog/ui/features/git-interaction/usePrDetails"; +import { + useActiveArtifactId, + usePanelLayoutStore, +} from "@posthog/ui/features/panels/panelLayoutStore"; +import { usePrCommentsForUrls } from "@posthog/ui/features/pr-review/usePrCommentsForUrls"; +import { usePrReviewThreadsForUrls } from "@posthog/ui/features/pr-review/usePrReviewThreadsForUrls"; +import { useCommentNavigationStore } from "@posthog/ui/features/sessions/commentNavigationStore"; +import { CommentComposer } from "@posthog/ui/features/sessions/components/CommentComposer"; +import { CommentThreadCard } from "@posthog/ui/features/sessions/components/CommentThreadCard"; +import type { HighlightResolution } from "@posthog/ui/features/sessions/components/commentViewTypes"; +import { readCommentContext } from "@posthog/ui/features/sessions/components/commentViewTypes"; +import { + useCommentsForTargetsQuery, + useCreateComment, + useSetCommentResolved, +} from "@posthog/ui/features/sessions/components/useComments"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const EMPTY_COMMENTS: ResourceComment[] = []; +/** The whole task's threads in one request; slower than a single artifact's own + * poll because this one fans out across every resource. */ +const POLL_INTERVAL_MS = 15_000; +const PULSE_MS = 1_200; +const ALL_SOURCES = "all"; + +type StateFilter = "open" | "resolved"; + +/** The icon a source shows wherever it's named — the card label and the + * filter menu — so the two always agree. */ +function sourceIcon(kind: SourceKind, label: string, size = 12) { + switch (kind) { + case "pr": + return ( + + ); + case "canvas": + return iconForTemplate("", { size, className: "text-violet-9" }); + case "task": + return ; + default: + return ; + } +} + +function SourceLabel({ thread }: { thread: TaskCommentThread }) { + const replies = thread.entries.length - 1; + return ( + + {sourceIcon(thread.sourceKind, thread.sourceLabel)} + + {thread.sourceLabel} + + {thread.origin.kind === "pr-review" && ( + + · {thread.origin.filePath.split("/").at(-1)} + + )} + {replies > 0 && ( + + · {replies} {replies === 1 ? "reply" : "replies"} + + )} + + ); +} + +/** + * A PostHog comment thread. Its own component so it can hold the mutations for + * its thread's resource — the list spans several, each with its own target. + */ +function ResourceThreadRow({ + thread, + source, + root, + taskId, + members, + selected, + pulsing, + resolution, + onOpen, +}: { + thread: TaskCommentThread; + source: CommentSource; + root: ResourceComment; + taskId: string; + members: UserBasic[]; + selected: boolean; + pulsing: boolean; + resolution?: HighlightResolution; + onOpen: () => void; +}) { + const createComment = useCreateComment(source.target, taskId); + const setResolved = useSetCommentResolved(source.target); + + return ( + } + onSelect={onOpen} + onReply={(content, mentions) => + createComment.mutate({ + content, + sourceCommentId: root.id, + context: readCommentContext(root) ?? { anchor: { kind: "document" } }, + mentions, + }) + } + onResolve={(resolved) => setResolved.mutate({ root, resolved })} + /> + ); +} + +/** A GitHub thread. Reply and resolve go to GitHub, not to PostHog. */ +function PrThreadRow({ + thread, + selected, + pulsing, + onOpen, +}: { + thread: TaskCommentThread; + selected: boolean; + pulsing: boolean; + onOpen: () => void; +}) { + const origin = thread.origin; + const prUrl = origin.kind === "resource" ? null : origin.prUrl; + const { reply, resolve } = usePrCommentActions(prUrl); + const [busy, setBusy] = useState(false); + + const run = async (action: () => Promise) => { + setBusy(true); + await action(); + setBusy(false); + }; + + return ( + } + // Only inline review threads accept replies and resolution; conversation + // comments are read here and linked out to GitHub to act on. + canReply={origin.kind === "pr-review"} + canResolve={origin.kind === "pr-review"} + viewHref={origin.kind === "pr-conversation" ? origin.url : undefined} + onSelect={onOpen} + onReply={(content) => + run(() => + origin.kind === "pr-review" + ? reply(origin.rootCommentId, content) + : Promise.resolve(false), + ) + } + onResolve={(resolved) => + run(() => + origin.kind === "pr-review" + ? resolve(origin.threadNodeId, resolved) + : Promise.resolve(false), + ) + } + /> + ); +} + +/** + * Every comment thread on the task: its artifacts, its canvases, its pull + * requests, and the task itself. Selecting one opens where it lives and locates + * it there, which is why no surface carries a thread list of its own. + */ +export function TaskCommentsList({ + task, + timeline, +}: { + task: Task; + timeline: ThreadTimelineRow[]; +}) { + const { runs } = useTaskRuns(task.id); + const { members } = useOrgMembers(); + const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); + const activeArtifactId = useActiveArtifactId(task.id); + const requestCommentFocus = useCommentNavigationStore( + (state) => state.requestCommentFocus, + ); + const focus = useCommentNavigationStore( + (state) => state.focusByTask[task.id], + ); + const resolutionsByTarget = useCommentNavigationStore( + (state) => state.resolutionsByTarget, + ); + const [stateFilter, setStateFilter] = useState("open"); + const [sourceFilter, setSourceFilter] = useState(ALL_SOURCES); + const [pulseThreadId, setPulseThreadId] = useState(null); + const [draft, setDraft] = useState(""); + + const rows = useMemo( + () => buildRows(task, timeline, runs), + [task, timeline, runs], + ); + const sources = useMemo(() => commentSources(task.id, rows), [task.id, rows]); + const targets = useMemo( + () => sources.map((source) => source.target), + [sources], + ); + const commentsQuery = useCommentsForTargetsQuery(targets, { + live: true, + intervalMs: POLL_INTERVAL_MS, + }); + const prUrls = useMemo( + () => rows.flatMap((row) => (row.kind === "pr" ? [row.url] : [])), + [rows], + ); + const prConversation = usePrCommentsForUrls(prUrls); + const prReviews = usePrReviewThreadsForUrls(prUrls); + const prTitles = usePrTitles(prUrls); + + const taskTarget = useMemo(() => taskCommentTarget(task.id), [task.id]); + const taskSourceKey = commentTargetKey(taskTarget); + const createTaskComment = useCreateComment(taskTarget, task.id); + + const threads = useMemo(() => { + const reviewByUrl = new Map(prReviews.byUrl); + const conversationByUrl = new Map(prConversation.byUrl); + const resourceThreads = resourceCommentThreads( + commentsQuery.data ?? EMPTY_COMMENTS, + sources, + ); + const prThreads = prUrls.flatMap((prUrl) => + prCommentThreads( + prUrl, + prTitles[prUrl] ?? `PR #${prUrl.split("/").at(-1)}`, + reviewByUrl.get(prUrl) ?? [], + conversationByUrl.get(prUrl) ?? [], + ), + ); + return [...resourceThreads, ...prThreads].sort(byNewestActivity); + }, [ + commentsQuery.data, + sources, + prUrls, + prTitles, + prReviews.byUrl, + prConversation.byUrl, + ]); + + const sourceOptions = useMemo(() => threadSourceOptions(threads), [threads]); + const sourceLabel = + sourceFilter === ALL_SOURCES + ? "All sources" + : (sourceOptions.find((option) => option.key === sourceFilter)?.label ?? + "All sources"); + // Every source that could ever hold a thread, whether or not it has one yet. + // Validating against this rather than the loaded threads lets the filter + // follow an artifact whose comments haven't arrived, and lets the task and + // PR sources stay selectable while empty. + const knownSourceKeys = useMemo(() => { + const keys = new Set( + sources.map((source) => commentTargetKey(source.target)), + ); + for (const prUrl of prUrls) keys.add(prUrl); + return keys; + }, [sources, prUrls]); + + // A source that can't exist any more (its artifact left the task) can't stay + // selected, or the pane reads as empty with no hint why. Adjust during render + // rather than in an effect, so the stale filter never commits first. + if (sourceFilter !== ALL_SOURCES && !knownSourceKeys.has(sourceFilter)) { + setSourceFilter(ALL_SOURCES); + } + + // Follow the artifact on screen until the reader picks a source themselves; + // after that the filter is theirs, not the pane's. + const sourceFilterTouched = useRef(false); + useEffect(() => { + if (sourceFilterTouched.current) return; + setSourceFilter( + activeArtifactId + ? commentTargetKey({ scope: "task_artifact", itemId: activeArtifactId }) + : ALL_SOURCES, + ); + }, [activeArtifactId]); + + const inSource = (thread: TaskCommentThread) => + sourceFilter === ALL_SOURCES || thread.sourceKey === sourceFilter; + const scoped = threads.filter(inSource); + const openCount = scoped.filter((thread) => !thread.resolved).length; + const resolvedCount = scoped.length - openCount; + const visibleThreads = scoped.filter( + (thread) => thread.resolved === (stateFilter === "resolved"), + ); + + // A thread picked on the artifact itself has to surface here, even when a + // filter is hiding it. Each request is honoured once, by nonce: resolving the + // focused thread later must not drag the filters along with it. + const focusedThreadId = focus?.threadId ?? null; + const handledNonceRef = useRef(null); + useEffect(() => { + if (!focus || handledNonceRef.current === focus.nonce) return; + const focused = threads.find((thread) => thread.id === focus.threadId); + // The thread may still be loading, so wait rather than guess its filters. + if (!focused) return; + handledNonceRef.current = focus.nonce; + setStateFilter(focused.resolved ? "resolved" : "open"); + setSourceFilter((current) => + current === ALL_SOURCES || current === focused.sourceKey + ? current + : ALL_SOURCES, + ); + setPulseThreadId(focus.threadId); + requestAnimationFrame(() => { + document + .querySelector( + `[data-comment-thread-id="${CSS.escape(focus.threadId)}"]`, + ) + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + }, [focus, threads]); + // The pulse fades on its own; owning the timer in its own effect keeps it + // cleaned up on the next pulse or on unmount, without a stray ref. + useEffect(() => { + if (!pulseThreadId) return; + const timer = setTimeout(() => setPulseThreadId(null), PULSE_MS); + return () => clearTimeout(timer); + }, [pulseThreadId]); + + const openThread = (thread: TaskCommentThread) => { + const origin = thread.origin; + if (origin.kind === "pr-review" || origin.kind === "pr-conversation") { + openPrInReview(task.id, origin.prUrl); + if (origin.kind === "pr-review") { + // The review pane scrolls by file; a specific comment is as close as it + // gets until it grows a per-thread target. + useReviewNavigationStore + .getState() + .requestScrollToFile(task.id, origin.filePath); + } + return; + } + const { source, root } = origin; + if (source.kind === "canvas") { + // Canvas comment surfaces land with the canvas work; until then this + // opens the canvas itself rather than a dead deep link. + openCanvasFromUrl(source.url)?.(); + return; + } + // A thread on the task itself has nowhere else to open — it lives here. + if (source.kind === "task" || !source.runId) return; + openArtifactTab(task.id, { + runId: source.runId, + artifactId: source.target.itemId, + name: source.name, + }); + requestCommentFocus(task.id, source.target, root.id); + }; + + const loading = + commentsQuery.isLoading || prConversation.isLoading || prReviews.isLoading; + + return ( + // The parent scrolls the middle; the filters and the composer are pinned so + // they stay reachable however long the thread list grows. +
+
+ + + {sourceLabel} + + + } + /> + {/* Wide, single-line rows: the label truncates at the end (with the + full name on hover) and the count is pinned right with the shared + ml-auto idiom, so a long PR title stays legible and aligned. */} + + { + sourceFilterTouched.current = true; + setSourceFilter(value); + }} + > + + + All sources + + {threads.length} + + + {sourceOptions.map((option) => ( + + {sourceIcon(option.kind, option.label)} + {option.label} + + { + threads.filter( + (thread) => thread.sourceKey === option.key, + ).length + } + + + ))} + + + + + + {stateFilter === "open" ? "Open" : "Resolved"} + + + } + /> + + setStateFilter(value as StateFilter)} + > + + Open ({openCount}) + + + Resolved ({resolvedCount}) + + + + +
+
+ {loading && threads.length === 0 ? ( +
+ +
+ ) : visibleThreads.length === 0 ? ( + + + + + + + No {stateFilter === "open" ? "open" : "resolved"} comments + + + {stateFilter === "open" + ? "Comment on the task below, or open an artifact and select text to start a thread there." + : "Resolved threads will appear here."} + + + + ) : ( + visibleThreads.map((thread) => + thread.origin.kind === "resource" ? ( + openThread(thread)} + /> + ) : ( + openThread(thread)} + /> + ), + ) + )} +
+
+ { + createTaskComment.mutate({ + content, + context: { anchor: { kind: "document" } }, + mentions, + }); + setDraft(""); + // Show the thread that was just opened: open state, and a source + // filter that isn't hiding the task's own comments. + setStateFilter("open"); + if ( + sourceFilter !== ALL_SOURCES && + sourceFilter !== taskSourceKey + ) { + setSourceFilter(ALL_SOURCES); + } + }} + members={members} + placeholder="Comment on this task… Type @ to mention someone" + rows={2} + disabled={createTaskComment.isPending} + /> +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/taskArtifactRows.ts b/packages/ui/src/features/canvas/components/taskArtifactRows.ts new file mode 100644 index 0000000000..cd2301410b --- /dev/null +++ b/packages/ui/src/features/canvas/components/taskArtifactRows.ts @@ -0,0 +1,202 @@ +import { + OUTPUT_ARTIFACT_TYPES, + parseRunArtifacts, + type RunArtifact, +} from "@posthog/core/canvas/runArtifactSchemas"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { + type CommentTarget, + commentTargetKey, +} from "@posthog/core/comments/anchors"; +import { readPrUrls } from "@posthog/shared"; +import type { + Task, + TaskRun, + TaskThreadMessage, +} from "@posthog/shared/domain-types"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; + +export type ArtifactRow = + | { kind: "pr"; key: string; url: string } + | { + kind: "canvas"; + key: string; + name: string; + url: string | null; + /** The canvas row id, the stable comment target (never the name). */ + dashboardId: string | null; + } + | { + kind: "file"; + key: string; + artifactId: string | null; + name: string; + runId: string | null; + } + | { kind: "slack"; key: string; url: string }; + +/** + * Somewhere a task's comment threads live. Artifacts and canvases come from the + * task's rows; the task itself is always one, holding the threads that belong + * to the work rather than to any single deliverable. + */ +export type CommentSource = + | { kind: "file"; target: CommentTarget; name: string; runId: string | null } + | { kind: "canvas"; target: CommentTarget; name: string; url: string | null } + | { kind: "task"; target: CommentTarget; name: string }; + +export function taskCommentTarget(taskId: string): CommentTarget { + return { scope: "task", itemId: taskId }; +} + +export function commentSources( + taskId: string, + rows: ArtifactRow[], +): CommentSource[] { + const sources: CommentSource[] = [ + { kind: "task", target: taskCommentTarget(taskId), name: "This task" }, + ]; + const seen = new Set(); + for (const row of rows) { + const target = targetForRow(row); + if (!target || seen.has(commentTargetKey(target))) continue; + seen.add(commentTargetKey(target)); + if (row.kind === "file") { + sources.push({ kind: "file", target, name: row.name, runId: row.runId }); + } else if (row.kind === "canvas") { + sources.push({ kind: "canvas", target, name: row.name, url: row.url }); + } + } + return sources; +} + +/** The canvas's stable row id, recovered from its share link. */ +function canvasDashboardId(url: string | null): string | null { + if (!url) return null; + const parsed = parseHttpsUrl(url); + const target = parsed ? parseShareLink(parsed.href) : null; + return target?.kind === "canvas" ? target.dashboardId : null; +} + +export function openCanvasFromUrl( + url: string | null, +): (() => void) | undefined { + const parsed = url ? parseHttpsUrl(url) : null; + const target = parsed ? parseShareLink(parsed.href) : null; + if (!parsed || !target) return undefined; + return () => { + const currentPostHogUrl = getPostHogUrl("/"); + const currentPostHogOrigin = currentPostHogUrl + ? parseHttpsUrl(currentPostHogUrl)?.origin + : null; + if (parsed.origin === currentPostHogOrigin) { + navigateToShareTarget(target); + } else { + openExternalUrl(parsed.href); + } + }; +} + +/** Where a row's comments live, or null when the row can't carry any. */ +function targetForRow(row: ArtifactRow): CommentTarget | null { + if (row.kind === "file" && row.artifactId) { + return { scope: "task_artifact", itemId: row.artifactId }; + } + if (row.kind === "canvas" && row.dashboardId) { + return { scope: "desktop_canvas", itemId: row.dashboardId }; + } + return null; +} + +/** + * Every commentable resource this task produced, once each. Artifacts and + * canvases share the generic comments API, differing only by scope, so a pane + * can hold one query over all of them — and two timeline messages naming the + * same canvas must not fetch it twice. + */ +export function commentTargets(rows: ArtifactRow[]): CommentTarget[] { + const byKey = new Map(); + for (const row of rows) { + const target = targetForRow(row); + if (target) byKey.set(commentTargetKey(target), target); + } + return [...byKey.values()]; +} + +function readRunOutputs(run: TaskRun): RunArtifact[] { + return parseRunArtifacts( + (run as { artifacts?: unknown }).artifacts, + OUTPUT_ARTIFACT_TYPES, + ); +} + +export function buildRows( + task: Task, + timeline: ThreadTimelineRow[], + runs: TaskRun[], +): ArtifactRow[] { + const rows: ArtifactRow[] = []; + const seenPrUrls = new Set(); + + const addPr = (url: string, key: string) => { + if (seenPrUrls.has(url)) return; + seenPrUrls.add(url); + rows.push({ kind: "pr", key, url }); + }; + + for (const row of timeline) { + if (row.kind !== "artifact") continue; + if (row.artifact.kind === "pr") { + addPr(row.artifact.url, row.message.id); + } else { + const url = row.artifact.url; + rows.push({ + kind: "canvas", + key: row.message.id, + name: row.artifact.name, + url, + dashboardId: canvasDashboardId(url), + }); + } + } + + const allRuns = + runs.length > 0 ? runs : task.latest_run ? [task.latest_run] : []; + + // Re-uploading a file replaces it rather than adding a second one: agents + // revise a deliverable and upload it again under the same name, so keeping + // every copy would bury the current one under its own drafts. + const newestByName = new Map(); + for (const run of allRuns) { + for (const outputPr of readPrUrls(run.output)) { + addPr(outputPr, `output-pr:${outputPr}`); + } + for (const file of readRunOutputs(run)) { + if (!file.name) continue; + const previous = newestByName.get(file.name); + const isNewer = + !previous || + (file.uploaded_at ?? "") >= (previous.file.uploaded_at ?? ""); + if (isNewer) newestByName.set(file.name, { file, runId: run.id }); + } + } + for (const [name, { file, runId }] of newestByName) { + rows.push({ + kind: "file", + key: `file:${file.id ?? file.storage_path ?? name}`, + artifactId: file.id ?? null, + name, + runId, + }); + } + + const slackUrl = task.latest_run?.state?.slack_thread_url; + if (typeof slackUrl === "string" && slackUrl) { + rows.push({ kind: "slack", key: "slack-thread", url: slackUrl }); + } + + return rows; +} diff --git a/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts b/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts new file mode 100644 index 0000000000..12d6a4035d --- /dev/null +++ b/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts @@ -0,0 +1,226 @@ +import type { ResourceComment } from "@posthog/api-client/posthog-client"; +import type { PrConversationComment, PrReviewThread } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import type { CommentSource } from "./taskArtifactRows"; +import { + byNewestActivity, + prCommentThreads, + resourceCommentThreads, + threadSourceOptions, +} from "./taskCommentThreads"; + +const fileSource: CommentSource = { + kind: "file", + target: { scope: "task_artifact", itemId: "a" }, + name: "report.md", + runId: "run-1", +}; +const taskSource: CommentSource = { + kind: "task", + target: { scope: "task", itemId: "task-1" }, + name: "This task", +}; + +function comment(overrides: Partial): ResourceComment { + return { + id: "c1", + created_by: null, + content: "hi", + created_at: "2024-01-01T00:00:00Z", + item_id: "a", + item_context: { anchor: { kind: "document" } }, + scope: "task_artifact", + source_comment: null, + ...overrides, + } as ResourceComment; +} + +describe("resourceCommentThreads", () => { + it("keeps only threads whose resource is present, tagged with it", () => { + const threads = resourceCommentThreads( + [ + comment({ id: "c1", item_id: "a", content: "root" }), + comment({ + id: "r1", + item_id: "a", + source_comment: "c1", + content: "reply", + created_at: "2024-01-01T00:01:00Z", + }), + comment({ id: "orphan", item_id: "gone", content: "no source" }), + ], + [fileSource, taskSource], + ); + + expect(threads).toHaveLength(1); + expect(threads[0].sourceKind).toBe("file"); + expect(threads[0].sourceLabel).toBe("report.md"); + expect(threads[0].entries.map((entry) => entry.body)).toEqual([ + "root", + "reply", + ]); + }); + + // A resolve/reopen reply is thread state, not something anyone said. + it("drops thread-state replies from the visible entries", () => { + const threads = resourceCommentThreads( + [ + comment({ id: "c1", content: "root" }), + comment({ + id: "state", + source_comment: "c1", + content: "Resolved this thread", + created_at: "2024-01-01T00:02:00Z", + item_context: { + anchor: { kind: "document" }, + threadState: "resolved", + }, + }), + ], + [fileSource], + ); + + expect(threads[0].resolved).toBe(true); + expect(threads[0].entries).toHaveLength(1); + }); +}); + +describe("prCommentThreads", () => { + const reviewThread: PrReviewThread = { + nodeId: "node-1", + isResolved: true, + rootId: 501, + filePath: "src/App.tsx", + comments: [ + { + id: 501, + body: "root", + path: "src/App.tsx", + line: 3, + original_line: null, + side: "RIGHT", + start_line: null, + start_side: null, + diff_hunk: "", + user: { login: "octo", avatar_url: "http://x/a.png" }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + subject_type: "line", + }, + { + id: 502, + body: "reply", + path: "src/App.tsx", + line: 3, + original_line: null, + side: "RIGHT", + start_line: null, + start_side: null, + diff_hunk: "", + user: { login: "octo", avatar_url: "" }, + created_at: "2024-01-01T00:05:00Z", + updated_at: "2024-01-01T00:05:00Z", + subject_type: "line", + }, + ], + }; + const conversation: PrConversationComment = { + id: 900, + author: "octo", + avatarUrl: null, + body: "lgtm", + createdAt: "2024-01-02T00:00:00Z", + url: "https://github.com/a/b/pull/7#c", + }; + + it("carries review-thread replies, resolution and the reply/resolve ids", () => { + const [thread] = prCommentThreads("url", "PR #7", [reviewThread], []); + + expect(thread.entries.map((entry) => entry.body)).toEqual([ + "root", + "reply", + ]); + expect(thread.resolved).toBe(true); + expect(thread.origin).toMatchObject({ + kind: "pr-review", + rootCommentId: 501, + threadNodeId: "node-1", + filePath: "src/App.tsx", + }); + expect(thread.lastActivityAt).toBe("2024-01-01T00:05:00Z"); + }); + + it("makes each conversation comment its own unresolvable thread", () => { + const [thread] = prCommentThreads("url", "PR #7", [], [conversation]); + + expect(thread.entries).toHaveLength(1); + expect(thread.resolved).toBe(false); + expect(thread.origin.kind).toBe("pr-conversation"); + }); +}); + +describe("threadSourceOptions / byNewestActivity", () => { + it("lists each source once and sorts newest first", () => { + const threads = [ + ...resourceCommentThreads( + [comment({ id: "c1", content: "old" })], + [fileSource], + ), + ...prCommentThreads( + "url", + "PR #7", + [], + [ + { + id: 1, + author: "octo", + avatarUrl: null, + body: "new", + createdAt: "2025-01-01T00:00:00Z", + url: null, + }, + ], + ), + ].sort(byNewestActivity); + + expect(threads[0].entries[0].body).toBe("new"); + // Options follow list order, which is newest-first, and carry the kind so + // the filter can show a matching icon. + expect( + threadSourceOptions(threads).map((option) => [option.label, option.kind]), + ).toEqual([ + ["PR #7", "pr"], + ["report.md", "file"], + ]); + }); + + // The task is the one source every task has, so it sits at the top of the + // filter regardless of when it was last touched. + it("pins the task source first, keeping the rest newest-first", () => { + const threads = [ + ...resourceCommentThreads( + [ + comment({ + id: "f1", + item_id: "a", + content: "file", + created_at: "2025-01-01T00:00:00Z", + }), + comment({ + id: "t1", + item_id: "task-1", + scope: "task", + content: "task", + created_at: "2024-01-01T00:00:00Z", + }), + ], + [fileSource, taskSource], + ), + ].sort(byNewestActivity); + + expect(threadSourceOptions(threads).map((option) => option.kind)).toEqual([ + "task", + "file", + ]); + }); +}); diff --git a/packages/ui/src/features/canvas/components/taskCommentThreads.ts b/packages/ui/src/features/canvas/components/taskCommentThreads.ts new file mode 100644 index 0000000000..17b11b49c3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/taskCommentThreads.ts @@ -0,0 +1,219 @@ +import type { ResourceComment } from "@posthog/api-client/posthog-client"; +import { commentTargetKey } from "@posthog/core/comments/anchors"; +import type { PrConversationComment, PrReviewThread } from "@posthog/shared"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import type { CommentSource } from "@posthog/ui/features/canvas/components/taskArtifactRows"; +import { + buildCommentThreads, + readCommentContext, +} from "@posthog/ui/features/sessions/components/commentViewTypes"; + +export type CommentEntry = { + id: string; + authorName: string; + /** A PostHog author, whose avatar and hue are the ones they have app-wide. */ + user: UserBasic | null; + /** A GitHub author, who only comes with an avatar url. */ + avatarUrl: string | null; + createdAt: string; + body: string; + /** PostHog comments carry @mention markup; GitHub bodies are markdown. */ + format: "mentions" | "markdown"; +}; + +/** How a thread is opened, replied to and resolved — one case per backend. */ +export type ThreadOrigin = + | { + kind: "resource"; + source: CommentSource; + /** The root comment, needed to reply to and resolve the thread. */ + root: ResourceComment; + } + | { + kind: "pr-review"; + prUrl: string; + filePath: string; + /** GitHub replies target a comment id, resolution a thread node id. */ + rootCommentId: number; + threadNodeId: string; + } + | { kind: "pr-conversation"; prUrl: string; url: string | null }; + +/** + * One thread in the task's comment list, whichever system it came from. The + * list renders and sorts these; only replying, resolving and opening still care + * where a thread lives, which is what `origin` carries. + */ +export type TaskCommentThread = { + /** Stable across refetches: the scroll target and React key. */ + id: string; + /** Groups threads for the source filter. */ + sourceKey: string; + sourceLabel: string; + sourceKind: "file" | "canvas" | "task" | "pr"; + entries: CommentEntry[]; + resolved: boolean; + /** Newest comment in the thread, for ordering the list. */ + lastActivityAt: string; + origin: ThreadOrigin; +}; + +function resourceAuthorName(comment: ResourceComment): string { + const user = comment.created_by; + if (!user) return "You"; + return ( + [user.first_name, user.last_name].filter(Boolean).join(" ") || user.email + ); +} + +function resourceEntry(comment: ResourceComment): CommentEntry { + return { + id: comment.id, + authorName: resourceAuthorName(comment), + user: comment.created_by, + avatarUrl: null, + createdAt: comment.created_at, + body: comment.content ?? "", + format: "mentions", + }; +} + +/** The task's own comment threads, tagged with the resource they belong to. */ +export function resourceCommentThreads( + comments: ResourceComment[], + sources: CommentSource[], +): TaskCommentThread[] { + const byItemId = new Map(); + for (const source of sources) byItemId.set(source.target.itemId, source); + + return buildCommentThreads(comments).flatMap((thread) => { + const source = thread.root.item_id + ? byItemId.get(thread.root.item_id) + : undefined; + if (!source) return []; + // A resolve/reopen reply is thread state, not something anyone said. + const visibleReplies = thread.replies.filter( + (reply) => !readCommentContext(reply)?.threadState, + ); + return [ + { + id: thread.root.id, + sourceKey: commentTargetKey(source.target), + sourceLabel: source.name, + sourceKind: source.kind, + entries: [thread.root, ...visibleReplies].map(resourceEntry), + resolved: thread.resolved, + lastActivityAt: + thread.replies.at(-1)?.created_at ?? thread.root.created_at, + origin: { kind: "resource", source, root: thread.root }, + }, + ]; + }); +} + +/** + * A PR's comments as threads. Inline review threads keep their replies and can + * be resolved; conversation comments (issue chatter, review summaries) are each + * a thread of one, since GitHub gives them neither replies nor resolution. + */ +export function prCommentThreads( + prUrl: string, + prLabel: string, + reviewThreads: PrReviewThread[], + conversation: PrConversationComment[], +): TaskCommentThread[] { + const threads: TaskCommentThread[] = reviewThreads.flatMap((thread) => { + const root = thread.comments[0]; + if (!root) return []; + return [ + { + id: `pr-review-${thread.rootId}`, + sourceKey: prUrl, + sourceLabel: prLabel, + sourceKind: "pr" as const, + entries: thread.comments.map((comment) => ({ + id: `pr-comment-${comment.id}`, + authorName: comment.user.login, + user: null, + avatarUrl: comment.user.avatar_url || null, + createdAt: comment.created_at, + body: comment.body, + format: "markdown" as const, + })), + resolved: thread.isResolved, + lastActivityAt: thread.comments.at(-1)?.created_at ?? root.created_at, + origin: { + kind: "pr-review" as const, + prUrl, + filePath: thread.filePath, + rootCommentId: thread.rootId, + threadNodeId: thread.nodeId, + }, + }, + ]; + }); + + for (const comment of conversation) { + threads.push({ + // Conversation items mix issue comments and review summaries, whose ids + // come from different GitHub id spaces — key on the timestamp too. + id: `pr-conversation-${comment.id}-${comment.createdAt}`, + sourceKey: prUrl, + sourceLabel: prLabel, + sourceKind: "pr", + entries: [ + { + id: `pr-conversation-${comment.id}`, + authorName: comment.author, + user: null, + avatarUrl: comment.avatarUrl, + createdAt: comment.createdAt, + body: comment.body, + format: "markdown", + }, + ], + resolved: false, + lastActivityAt: comment.createdAt, + origin: { kind: "pr-conversation", prUrl, url: comment.url }, + }); + } + + return threads; +} + +export function byNewestActivity( + a: TaskCommentThread, + b: TaskCommentThread, +): number { + return b.lastActivityAt.localeCompare(a.lastActivityAt); +} + +export type SourceKind = TaskCommentThread["sourceKind"]; +export type ThreadSourceOption = { + key: string; + label: string; + kind: SourceKind; +}; + +/** + * The sources present in a set of threads, for the source filter. Newest-first + * like the list, except the task itself sits at the top (just under "All + * sources") since it's the one source every task has. + */ +export function threadSourceOptions( + threads: TaskCommentThread[], +): ThreadSourceOption[] { + const byKey = new Map(); + for (const thread of threads) { + if (!byKey.has(thread.sourceKey)) { + byKey.set(thread.sourceKey, { + key: thread.sourceKey, + label: thread.sourceLabel, + kind: thread.sourceKind, + }); + } + } + return [...byKey.values()].sort( + (a, b) => Number(b.kind === "task") - Number(a.kind === "task"), + ); +} diff --git a/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx b/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx index 8a336f8914..2948dada95 100644 --- a/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx +++ b/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx @@ -1,6 +1,6 @@ import { Check, Code, Copy, Eye } from "@phosphor-icons/react"; import { Flex, IconButton, Text } from "@radix-ui/themes"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { Tooltip } from "../../../primitives/Tooltip"; export function DocumentPreviewHeader({ @@ -8,11 +8,13 @@ export function DocumentPreviewHeader({ content, showRendered, onToggleRendered, + actions, }: { label: string; content: string; showRendered: boolean; onToggleRendered: () => void; + actions?: ReactNode; }) { const [copied, setCopied] = useState(false); @@ -34,6 +36,7 @@ export function DocumentPreviewHeader({ {label} + {actions} void; + onSubmit: ( + startLine: number, + endLine: number, + text: string, + mentions?: number[], + ) => void; onDismiss: () => void; + actionLabel?: string; + placeholder?: string; + showActionText?: boolean; + initiallyExpanded?: boolean; + members?: UserBasic[]; } /** @@ -37,6 +49,11 @@ export function SelectionCommentOverlay({ filePath, onSubmit, onDismiss, + actionLabel = "Add to chat", + placeholder, + showActionText = false, + initiallyExpanded = false, + members, }: SelectionCommentOverlayProps) { if (!open || !selection?.anchor) return null; // Key by the range so a fresh selection remounts the card back to the "+". @@ -49,6 +66,11 @@ export function SelectionCommentOverlay({ filePath={filePath} onSubmit={onSubmit} onDismiss={onDismiss} + actionLabel={actionLabel} + placeholder={placeholder} + showActionText={showActionText} + initiallyExpanded={initiallyExpanded} + members={members} /> ); } @@ -60,30 +82,54 @@ function SelectionComposerCard({ filePath, onSubmit, onDismiss, + actionLabel, + placeholder, + showActionText, + initiallyExpanded, + members, }: { anchor: { top: number; left: number }; fromLine: number; toLine: number; filePath: string; - onSubmit: (startLine: number, endLine: number, text: string) => void; + onSubmit: ( + startLine: number, + endLine: number, + text: string, + mentions?: number[], + ) => void; onDismiss: () => void; + actionLabel: string; + placeholder?: string; + showActionText: boolean; + initiallyExpanded: boolean; + members?: UserBasic[]; }) { - const [expanded, setExpanded] = useState(false); + const [userExpanded, setUserExpanded] = useState(false); + const expanded = initiallyExpanded || userExpanded; + const [draft, setDraft] = useState(""); const style = { top: anchor.top + 4, left: anchor.left }; if (!expanded) { return createPortal( - + , document.body, @@ -95,13 +141,31 @@ function SelectionComposerCard({ className="fixed z-50 w-[420px] max-w-[80vw] rounded-md border border-gray-5 bg-gray-2 shadow-lg" style={style} > - onSubmit(fromLine, toLine, text)} - /> + {members ? ( +
+ { + onSubmit(fromLine, toLine, content, mentions); + onDismiss(); + }} + onCancel={onDismiss} + members={members} + placeholder={placeholder ?? "Add a comment…"} + rows={2} + autoFocus + /> +
+ ) : ( + onSubmit(fromLine, toLine, text)} + /> + )} , document.body, ); diff --git a/packages/ui/src/features/code-review/components/PrCommentThread.tsx b/packages/ui/src/features/code-review/components/PrCommentThread.tsx index 3addfc943c..d7eecbf662 100644 --- a/packages/ui/src/features/code-review/components/PrCommentThread.tsx +++ b/packages/ui/src/features/code-review/components/PrCommentThread.tsx @@ -26,20 +26,13 @@ import { useRef, useState, } from "react"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import type { PluggableList } from "unified"; import { isSendMessageSubmitKey } from "../../../utils/sendMessageKey"; +import { githubRehypePlugins } from "../../editor/components/githubMarkdownPlugins"; import { MarkdownRenderer } from "../../editor/components/MarkdownRenderer"; import { sendPromptToAgent } from "../../sessions/sendPromptToAgent"; import { usePrCommentActions } from "../hooks/usePrCommentActions"; import type { PrCommentMetadata } from "../types"; -const ghRehypePlugins: PluggableList = [ - rehypeRaw, - [rehypeSanitize, defaultSchema], -]; - const MAX_COMMENT_HEIGHT = 120; type ComposerMode = "reply" | "chat"; @@ -261,7 +254,7 @@ function CommentBody({ > {!isExpanded && isOverflowing && ( diff --git a/packages/ui/src/features/editor/components/githubMarkdownPlugins.ts b/packages/ui/src/features/editor/components/githubMarkdownPlugins.ts new file mode 100644 index 0000000000..400a9acf06 --- /dev/null +++ b/packages/ui/src/features/editor/components/githubMarkdownPlugins.ts @@ -0,0 +1,14 @@ +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import type { PluggableList } from "unified"; + +/** + * GitHub comment bodies are GitHub-flavored markdown with raw HTML baked in + * (suggestion blocks, `
`, ``, ``…). react-markdown drops raw + * HTML by default, which leaves stray `` fragments in the text — so parse + * it with rehype-raw and sanitize what comes out. + */ +export const githubRehypePlugins: PluggableList = [ + rehypeRaw, + [rehypeSanitize, defaultSchema], +]; diff --git a/packages/ui/src/features/git-interaction/usePrDetails.ts b/packages/ui/src/features/git-interaction/usePrDetails.ts index 03cc9512e9..c049bb5c8f 100644 --- a/packages/ui/src/features/git-interaction/usePrDetails.ts +++ b/packages/ui/src/features/git-interaction/usePrDetails.ts @@ -47,6 +47,24 @@ export function usePrDetailsMap( }); } +/** PR titles for a set of PRs, off the same cache `usePrDetailsMap` warms. */ +export function usePrTitles(prUrls: string[]): Record { + const trpc = useHostTRPC(); + return useQueries({ + queries: prUrls.map((prUrl) => ({ + ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl }), + staleTime: 60_000, + retry: 1, + })), + combine: (results) => + Object.fromEntries( + results.flatMap((result, index) => + result.data?.title ? [[prUrls[index], result.data.title]] : [], + ), + ), + }); +} + export function usePrDetails( prUrl: string | null, options?: UsePrDetailsOptions, diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts index 4e30b343f9..9f163ec58a 100644 --- a/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -18,7 +18,10 @@ import { createInitialTaskLayout, splitPanelTree, } from "@posthog/core/panels/panelLayoutTransforms"; -import { createFileTabId } from "@posthog/core/panels/panelStoreHelpers"; +import { + activeArtifactId, + createFileTabId, +} from "@posthog/core/panels/panelStoreHelpers"; import { findTabInTree } from "@posthog/core/panels/panelTree"; import { ANALYTICS_EVENTS, getFileExtension } from "@posthog/shared"; import { @@ -528,3 +531,14 @@ export const usePanelLayoutStore = createWithEqualityFn()( }, ), ); + +/** + * The artifact the reader is looking at, so a pane elsewhere (the task's + * comment list) can narrow itself to whatever is on screen. + */ +export function useActiveArtifactId(taskId: string): string | null { + return usePanelLayoutStore((state) => { + const layout = state.taskLayouts[taskId]; + return layout ? activeArtifactId(layout) : null; + }); +} diff --git a/packages/ui/src/features/pr-review/usePrCommentsForUrls.ts b/packages/ui/src/features/pr-review/usePrCommentsForUrls.ts new file mode 100644 index 0000000000..aec5517aef --- /dev/null +++ b/packages/ui/src/features/pr-review/usePrCommentsForUrls.ts @@ -0,0 +1,38 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import type { PrConversationComment } from "@posthog/shared"; +import { useQueries } from "@tanstack/react-query"; + +/** + * Conversation comments for several PRs at once. GitHub has no batch endpoint + * for these, so this is one request per PR — fine for the handful a task + * produces, and cached long enough that revisiting the pane is free. + * + * `byUrl` is a plain array of [url, comments] pairs rather than a Map so + * react-query's structural sharing keeps its identity stable while the data is + * unchanged; a Map would be a fresh object every render and defeat the memos + * that read it. + */ +export function usePrCommentsForUrls(prUrls: string[]) { + const trpc = useHostTRPC(); + return useQueries({ + queries: prUrls.map((prUrl) => ({ + ...trpc.git.getPrComments.queryOptions({ prUrl }), + staleTime: 30_000, + placeholderData: (prev: PrConversationComment[] | null | undefined) => + prev, + retry: 1, + })), + combine: (results) => ({ + // Null from the server means "couldn't fetch", which reads the same as + // "nothing to show" in a list that isn't only about one PR. + byUrl: prUrls.map( + (prUrl, index) => + [prUrl, results[index]?.data ?? []] as [ + string, + PrConversationComment[], + ], + ), + isLoading: results.some((result) => result.isLoading), + }), + }); +} diff --git a/packages/ui/src/features/pr-review/usePrReviewThreadsForUrls.ts b/packages/ui/src/features/pr-review/usePrReviewThreadsForUrls.ts new file mode 100644 index 0000000000..fc9eb4f203 --- /dev/null +++ b/packages/ui/src/features/pr-review/usePrReviewThreadsForUrls.ts @@ -0,0 +1,23 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import type { PrReviewThread } from "@posthog/shared"; +import { useQueries } from "@tanstack/react-query"; + +/** Inline review threads for several PRs at once. See `usePrCommentsForUrls`. */ +export function usePrReviewThreadsForUrls(prUrls: string[]) { + const trpc = useHostTRPC(); + return useQueries({ + queries: prUrls.map((prUrl) => ({ + ...trpc.git.getPrReviewComments.queryOptions({ prUrl }), + staleTime: 30_000, + placeholderData: (prev: PrReviewThread[] | undefined) => prev, + retry: 1, + })), + combine: (results) => ({ + byUrl: prUrls.map( + (prUrl, index) => + [prUrl, results[index]?.data ?? []] as [string, PrReviewThread[]], + ), + isLoading: results.some((result) => result.isLoading), + }), + }); +} diff --git a/packages/ui/src/features/sessions/commentNavigationStore.test.ts b/packages/ui/src/features/sessions/commentNavigationStore.test.ts new file mode 100644 index 0000000000..fa0e2f3a29 --- /dev/null +++ b/packages/ui/src/features/sessions/commentNavigationStore.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCommentNavigationStore } from "./commentNavigationStore"; + +const artifact = { scope: "task_artifact", itemId: "a" } as const; +const canvas = { scope: "desktop_canvas", itemId: "c" } as const; + +describe("commentNavigationStore", () => { + beforeEach(() => { + useCommentNavigationStore.setState({ + focusByTask: {}, + resolutionsByTarget: {}, + }); + }); + + // Picking the same thread again has to scroll again, so the request is the + // nonce rather than the thread id. + it("bumps the nonce for a repeated request", () => { + const { requestCommentFocus } = useCommentNavigationStore.getState(); + + requestCommentFocus("task-1", artifact, "comment-1"); + const first = useCommentNavigationStore.getState().focusByTask["task-1"]; + requestCommentFocus("task-1", artifact, "comment-1"); + const second = useCommentNavigationStore.getState().focusByTask["task-1"]; + + expect(first?.threadId).toBe("comment-1"); + expect(second?.nonce).toBe((first?.nonce ?? 0) + 1); + }); + + it("keeps each task's focus to itself", () => { + const { requestCommentFocus } = useCommentNavigationStore.getState(); + + requestCommentFocus("task-1", artifact, "comment-1"); + requestCommentFocus("task-2", canvas, "comment-2"); + + const { focusByTask } = useCommentNavigationStore.getState(); + expect(focusByTask["task-1"]?.threadId).toBe("comment-1"); + expect(focusByTask["task-1"]?.target).toEqual(artifact); + expect(focusByTask["task-2"]?.threadId).toBe("comment-2"); + }); + + // The surfaces recompute anchors on every scroll and resize; an unchanged + // result must not re-render the list that reads them. + it("ignores a resolution update that changes nothing", () => { + const { setCommentResolutions } = useCommentNavigationStore.getState(); + + setCommentResolutions(artifact, new Map([["comment-1", "exact"]])); + const first = useCommentNavigationStore.getState().resolutionsByTarget; + setCommentResolutions(artifact, new Map([["comment-1", "exact"]])); + const second = useCommentNavigationStore.getState().resolutionsByTarget; + setCommentResolutions(artifact, new Map([["comment-1", "orphaned"]])); + const third = useCommentNavigationStore.getState().resolutionsByTarget; + + expect(second).toBe(first); + expect(third).not.toBe(second); + expect(third["task_artifact:a"]?.get("comment-1")).toBe("orphaned"); + }); +}); diff --git a/packages/ui/src/features/sessions/commentNavigationStore.ts b/packages/ui/src/features/sessions/commentNavigationStore.ts new file mode 100644 index 0000000000..956db37d69 --- /dev/null +++ b/packages/ui/src/features/sessions/commentNavigationStore.ts @@ -0,0 +1,91 @@ +import { + type CommentTarget, + commentTargetKey, +} from "@posthog/core/comments/anchors"; +import type { HighlightResolution } from "@posthog/ui/features/sessions/components/commentViewTypes"; +import { create } from "zustand"; + +/** Which thread the task's comment surfaces are pointed at right now. */ +type CommentFocus = { + target: CommentTarget; + threadId: string; + /** Bumped on every request so re-picking the same thread scrolls again. */ + nonce: number; +}; + +interface CommentNavigationStoreState { + focusByTask: Record; + /** targetKey → threadId → resolution. Only a surface that has rendered can + * know this, so a missing entry means unknown, not "exact". */ + resolutionsByTarget: Record>; +} + +interface CommentNavigationStoreActions { + requestCommentFocus: ( + taskId: string, + target: CommentTarget, + threadId: string, + ) => void; + setCommentResolutions: ( + target: CommentTarget, + resolutions: Map, + ) => void; +} + +type CommentNavigationStore = CommentNavigationStoreState & + CommentNavigationStoreActions; + +let nonce = 0; + +function sameResolutions( + current: Map | undefined, + next: Map, +): boolean { + if (!current || current.size !== next.size) return false; + for (const [id, resolution] of next) { + if (current.get(id) !== resolution) return false; + } + return true; +} + +/** + * The comment list (a tab in the activity sidebar) and the artifact surfaces (a + * tab in the panel layout) live in sibling React trees, so neither can reach + * the other's scroller. This store is the channel between them: the list writes + * a focus request and the surface locates the anchor, and vice versa. + * + * Focus is durable state rather than a consumed event, because an artifact tab + * may mount after the request is made and its comments arrive later still. + */ +export const useCommentNavigationStore = create()( + (set) => ({ + focusByTask: {}, + resolutionsByTarget: {}, + + requestCommentFocus: (taskId, target, threadId) => { + nonce += 1; + set((state) => ({ + focusByTask: { + ...state.focusByTask, + [taskId]: { target, threadId, nonce }, + }, + })); + }, + + // The surfaces recompute anchors on every scroll and resize, so an + // unchanged result must not become a store write the whole list re-renders on. + setCommentResolutions: (target, resolutions) => + set((state) => { + const key = commentTargetKey(target); + if (sameResolutions(state.resolutionsByTarget[key], resolutions)) { + return state; + } + return { + resolutionsByTarget: { + ...state.resolutionsByTarget, + [key]: resolutions, + }, + }; + }), + }), +); diff --git a/packages/ui/src/features/sessions/components/AnnotatedArtifactHtml.tsx b/packages/ui/src/features/sessions/components/AnnotatedArtifactHtml.tsx new file mode 100644 index 0000000000..f694f8e38b --- /dev/null +++ b/packages/ui/src/features/sessions/components/AnnotatedArtifactHtml.tsx @@ -0,0 +1,227 @@ +import type { ResourceComment } from "@posthog/api-client/posthog-client"; +import { + commentAnchorSchema, + type TextCommentAnchor, +} from "@posthog/core/comments/anchors"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import type { EditorSelection } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor"; +import { SelectionCommentOverlay } from "@posthog/ui/features/code-editor/components/SelectionCommentOverlay"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { artifactHtmlDocument } from "./artifactPreviewDocument"; +import { + type CommentLocateRequest, + type HighlightResolution, + readCommentContext, +} from "./commentViewTypes"; + +const BRIDGE_MARKER = "__POSTHOG_ARTIFACT_COMMENT_BRIDGE__"; + +type FrameRect = { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +}; + +function isFrameRect(value: unknown): value is FrameRect { + if (!value || typeof value !== "object") return false; + return ["top", "left", "right", "bottom", "width", "height"].every((key) => { + const field = (value as Record)[key]; + return typeof field === "number" && Number.isFinite(field); + }); +} + +export function AnnotatedArtifactHtml({ + html, + name, + comments, + activeThreadId, + locateRequest, + members, + onActivateThread, + onCreate, + onResolutionsChange, +}: { + html: string; + name: string; + comments: ResourceComment[]; + activeThreadId: string | null; + locateRequest: CommentLocateRequest | null; + members: UserBasic[]; + onActivateThread: (id: string) => void; + onCreate: ( + anchor: TextCommentAnchor, + content: string, + mentions?: number[], + ) => void; + onResolutionsChange: (resolutions: Map) => void; +}) { + const iframeRef = useRef(null); + const channelRef = useRef(`artifact-comments-${crypto.randomUUID()}`); + const [pendingAnchor, setPendingAnchor] = useState( + null, + ); + const [selection, setSelection] = useState(null); + const documentUrl = useMemo(() => { + const document = artifactHtmlDocument(html, channelRef.current); + return URL.createObjectURL(new Blob([document], { type: "text/html" })); + }, [html]); + + useEffect(() => () => URL.revokeObjectURL(documentUrl), [documentUrl]); + + const bridgeItems = useMemo( + () => + comments.flatMap((comment) => { + if (comment.source_comment) return []; + const context = readCommentContext(comment); + return context?.anchor.kind === "text" + ? [ + { + id: comment.id, + anchor: context.anchor, + active: comment.id === activeThreadId, + }, + ] + : []; + }), + [activeThreadId, comments], + ); + + const sendComments = useCallback(() => { + iframeRef.current?.contentWindow?.postMessage( + { + marker: BRIDGE_MARKER, + channel: channelRef.current, + type: "comments", + items: bridgeItems, + }, + "*", + ); + }, [bridgeItems]); + + useEffect(() => { + sendComments(); + }, [sendComments]); + + const sendLocate = useCallback(() => { + if (!locateRequest) return; + iframeRef.current?.contentWindow?.postMessage( + { + marker: BRIDGE_MARKER, + channel: channelRef.current, + type: "locate", + id: locateRequest.id, + }, + "*", + ); + }, [locateRequest]); + + useEffect(() => { + sendLocate(); + }, [sendLocate]); + + const activateThreadRef = useRef(onActivateThread); + activateThreadRef.current = onActivateThread; + const resolutionsChangeRef = useRef(onResolutionsChange); + resolutionsChangeRef.current = onResolutionsChange; + const sendCommentsRef = useRef(sendComments); + sendCommentsRef.current = sendComments; + const sendLocateRef = useRef(sendLocate); + sendLocateRef.current = sendLocate; + + useEffect(() => { + const receive = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) return; + const data = event.data as Record | null; + if ( + !data || + data.marker !== BRIDGE_MARKER || + data.channel !== channelRef.current + ) { + return; + } + if (data.type === "ready") { + sendCommentsRef.current(); + sendLocateRef.current(); + return; + } + if (data.type === "activate" && typeof data.id === "string") { + activateThreadRef.current(data.id); + return; + } + if (data.type === "resolutions" && Array.isArray(data.items)) { + const resolutions = new Map(); + for (const item of data.items) { + if (!item || typeof item !== "object") continue; + const { id, status } = item as Record; + if ( + typeof id === "string" && + (status === "exact" || + status === "reanchored" || + status === "orphaned") + ) { + resolutions.set(id, status); + } + } + resolutionsChangeRef.current(resolutions); + return; + } + if (data.type !== "selection" || !isFrameRect(data.triggerRect)) return; + const parsed = commentAnchorSchema.safeParse(data.anchor); + if (!parsed.success || parsed.data.kind !== "text") return; + const frameBox = iframeRef.current?.getBoundingClientRect(); + if (!frameBox) return; + setPendingAnchor(parsed.data); + setSelection({ + text: parsed.data.quote, + fromLine: parsed.data.start + 1, + toLine: parsed.data.end + 1, + anchor: { + top: frameBox.top + data.triggerRect.bottom, + left: Math.min( + frameBox.left + data.triggerRect.right + 6, + window.innerWidth - 440, + ), + }, + }); + }; + window.addEventListener("message", receive); + return () => window.removeEventListener("message", receive); + }, []); + + const dismiss = () => { + setPendingAnchor(null); + setSelection(null); + }; + + return ( +
+