From 23a41947ef81280d7d8c3da400d738c730c7a9ed Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 7 Aug 2026 06:16:51 +0200 Subject: [PATCH 01/36] feat(comments): desktop comment engine, artifact and activity UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2 of 3 (stacked on posthog-code/comments-backend). Anchored comment primitives (text/region/document), the composer, thread cards, artifact and document comment surfaces, mentions, comment navigation, and the merged task + comment Activity feed. Consumes the generated types from layer 1. Flag-gating on posthog-code-comments is a required follow-up before ready-for-review — see the PR description checklist. Generated-By: PostHog Code Task-Id: f7eb12dd-00a4-4293-a013-8156f71c4e2a --- .../packages/api-client/src/posthog-client.ts | 77 +- .../core/src/canvas/taskActivity.test.ts | 19 + .../packages/core/src/canvas/taskActivity.ts | 15 +- .../core/src/comments/anchors.test.ts | 123 +++ .../packages/core/src/comments/anchors.ts | 189 +++++ .../core/src/panels/panelStoreHelpers.ts | 29 + .../core/src/sessions/sessionService.ts | 64 ++ .../packages/shared/src/domain-types.ts | 6 + .../desktop/packages/shared/src/git-domain.ts | 2 + .../canvas/components/ActivityHoverCard.tsx | 2 +- .../canvas/components/ActivityPanel.test.tsx | 170 ++++ .../canvas/components/ActivityPanel.tsx | 45 +- .../canvas/components/ActivityView.test.tsx | 96 ++- .../canvas/components/ActivityView.tsx | 66 +- .../canvas/components/MentionComposer.tsx | 4 + .../components/TaskArtifactsList.test.tsx | 60 +- .../canvas/components/TaskArtifactsList.tsx | 191 ++--- .../components/TaskCommentsList.test.tsx | 703 +++++++++++++++++ .../canvas/components/TaskCommentsList.tsx | 723 ++++++++++++++++++ .../canvas/components/activityFeed.ts | 1 + .../canvas/components/taskArtifactRows.ts | 199 +++++ .../components/taskCommentThreads.test.ts | 260 +++++++ .../canvas/components/taskCommentThreads.ts | 231 ++++++ .../canvas/hooks/useMarkTaskActivityRead.ts | 28 +- .../canvas/hooks/useTaskActivity.test.tsx | 53 ++ .../features/canvas/hooks/useTaskActivity.ts | 6 +- .../components/DocumentPreviewHeader.tsx | 83 +- .../SelectionCommentOverlay.test.tsx | 97 +++ .../components/SelectionCommentOverlay.tsx | 138 +++- .../components/PrCommentThread.tsx | 13 +- .../components/githubMarkdownPlugins.ts | 14 + .../features/git-interaction/usePrDetails.ts | 18 + .../src/features/panels/panelLayoutStore.ts | 16 +- .../pr-review/usePrCommentsForUrls.ts | 41 + .../pr-review/usePrReviewThreadsForUrls.ts | 24 + .../sessions/commentNavigationStore.test.ts | 74 ++ .../sessions/commentNavigationStore.ts | 110 +++ .../components/AnnotatedArtifactHtml.tsx | 233 ++++++ .../components/AnnotatedArtifactImage.tsx | 211 +++++ .../ArtifactDocumentCommentAction.tsx | 62 ++ .../components/ArtifactPreview.test.tsx | 502 +++++++++++- .../sessions/components/ArtifactPreview.tsx | 287 ++++++- .../components/ArtifactTextAnnotations.tsx | 356 +++++++++ .../components/CommentComposer.test.ts | 20 + .../components/CommentComposer.test.tsx | 55 ++ .../sessions/components/CommentComposer.tsx | 83 ++ .../sessions/components/CommentThreadCard.tsx | 208 +++++ .../components/artifactHtmlCommentBridge.ts | 56 ++ .../components/artifactPreviewDocument.ts | 25 +- .../sessions/components/commentMentions.ts | 15 + .../components/commentViewTypes.test.ts | 43 ++ .../sessions/components/commentViewTypes.ts | 49 ++ .../sessions/components/useComments.test.ts | 54 ++ .../sessions/components/useComments.ts | 272 +++++++ .../features/sessions/mentionAvailability.tsx | 24 + .../ui/src/primitives/SafeImagePreview.tsx | 25 +- .../desktop/packages/ui/src/test/setup.ts | 12 + .../src/services/git/schemas.ts | 1 + .../src/services/git/service.ts | 18 +- 59 files changed, 6329 insertions(+), 272 deletions(-) create mode 100644 products/desktop/packages/core/src/comments/anchors.test.ts create mode 100644 products/desktop/packages/core/src/comments/anchors.ts create mode 100644 products/desktop/packages/ui/src/features/canvas/components/ActivityPanel.test.tsx create mode 100644 products/desktop/packages/ui/src/features/canvas/components/TaskCommentsList.test.tsx create mode 100644 products/desktop/packages/ui/src/features/canvas/components/TaskCommentsList.tsx create mode 100644 products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts create mode 100644 products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts create mode 100644 products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.ts create mode 100644 products/desktop/packages/ui/src/features/code-editor/components/SelectionCommentOverlay.test.tsx create mode 100644 products/desktop/packages/ui/src/features/editor/components/githubMarkdownPlugins.ts create mode 100644 products/desktop/packages/ui/src/features/pr-review/usePrCommentsForUrls.ts create mode 100644 products/desktop/packages/ui/src/features/pr-review/usePrReviewThreadsForUrls.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/commentNavigationStore.test.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/commentNavigationStore.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/AnnotatedArtifactHtml.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/AnnotatedArtifactImage.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/ArtifactDocumentCommentAction.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/ArtifactTextAnnotations.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/CommentComposer.test.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/CommentComposer.test.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/CommentComposer.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/CommentThreadCard.tsx create mode 100644 products/desktop/packages/ui/src/features/sessions/components/artifactHtmlCommentBridge.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/commentMentions.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/commentViewTypes.test.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/commentViewTypes.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/useComments.test.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/useComments.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/mentionAvailability.tsx diff --git a/products/desktop/packages/api-client/src/posthog-client.ts b/products/desktop/packages/api-client/src/posthog-client.ts index 7ccda6971e06..3760927ab62c 100644 --- a/products/desktop/packages/api-client/src/posthog-client.ts +++ b/products/desktop/packages/api-client/src/posthog-client.ts @@ -209,6 +209,29 @@ export interface TaskSessionStorageAccess { content_sha256: string | null; } +/** + * The commentable resources this client knows how to address. `scope` is a + * free-form column on the backend `Comment` model, so adding a resource is a + * new member here plus a caller — no migration and no endpoint. + */ +export type CommentScope = "task_artifact" | "desktop_canvas" | "task"; + +/** Named `Resource*` so it never collides with the DOM's global `Comment`. + * Optimistic rows do not have a server version yet, while item_context is a + * real JSON value despite the generated serializer's historically narrow type. */ +export type ResourceComment = Omit & { + version?: number; +}; + +export interface CreateResourceCommentRequest { + scope: CommentScope; + itemId: string; + content: string; + context: unknown; + sourceCommentId?: string; + mentions?: number[]; +} + /** Thrown when the backend rejects a cloud run with a 429 usage-limit error. */ export class CloudUsageLimitError extends Error { limitType: UsageLimitType; @@ -2708,8 +2731,7 @@ export class PostHogAPIClient { return (await response.json()) as TaskMention[]; } - // Tasks the current user is involved in (created, mentioned, or messaged), - // one row per task, newest activity first. + // Task lifecycle and individual comment activity, newest first. async getTaskActivity(options?: { before?: string; beforeId?: string; @@ -2732,8 +2754,7 @@ export class PostHogAPIClient { return (await response.json()) as TaskActivityPage; } - // Read state is per task, so callers name the tasks the user has seen rather than - // clearing the whole feed. + // Task lifecycle activity clears by task timestamp; comment activity clears by row id. async markTaskActivityRead( activities: TaskActivityReadMarker[], ): Promise { @@ -3227,6 +3248,54 @@ export class PostHogAPIClient { return data.url; } + async getResourceComments( + scope: CommentScope, + itemId: string, + taskId: string, + ): Promise { + const MAX_COMMENT_PAGES = 50; + const teamId = await this.getTeamId(); + const comments: ResourceComment[] = []; + let cursor: string | undefined; + for (let pageIndex = 0; pageIndex < MAX_COMMENT_PAGES; pageIndex++) { + const page = await this.api.get("/api/projects/{project_id}/comments/", { + path: { project_id: String(teamId) }, + query: { scope, item_id: itemId, task_id: taskId, cursor }, + }); + comments.push(...page.results); + cursor = page.next + ? (new URL(page.next).searchParams.get("cursor") ?? undefined) + : undefined; + if (!cursor) return comments; + } + log.warn( + `getResourceComments hit MAX_PAGES (${MAX_COMMENT_PAGES}); returning partial results`, + { scope, itemId, returned: comments.length }, + ); + return comments; + } + + async createResourceComment( + request: CreateResourceCommentRequest, + ): Promise { + const teamId = await this.getTeamId(); + const payload = { + content: request.content, + scope: request.scope, + item_id: request.itemId, + item_context: request.context, + source_comment: request.sourceCommentId ?? null, + mentions: request.mentions ?? [], + // Resolution is represented by a thread-state reply so this stays on the + // same PAT-compatible write path as ordinary comments. + is_task: false, + }; + return await this.api.post("/api/projects/{project_id}/comments/", { + path: { project_id: String(teamId) }, + body: payload as unknown as Schemas.Comment, + }); + } + async getTaskSessionStorageAccess( taskId: string, runId: string, diff --git a/products/desktop/packages/core/src/canvas/taskActivity.test.ts b/products/desktop/packages/core/src/canvas/taskActivity.test.ts index 09a00c8d3ca0..ebd8f1aa8d4d 100644 --- a/products/desktop/packages/core/src/canvas/taskActivity.test.ts +++ b/products/desktop/packages/core/src/canvas/taskActivity.test.ts @@ -40,11 +40,30 @@ describe("toTaskActivityItems", () => { snippet: "ping @[Me](me@posthog.com)", author: ann, messageId: "m1", + commentId: null, + commentTarget: null, isUnread: true, }, ]); }); + it("maps a comment activity target for exact navigation", () => { + const [item] = toTaskActivityItems([ + activity({ + latest_message_id: null, + latest_comment_id: "comment-1", + latest_comment_scope: "task_artifact", + latest_comment_item_id: "artifact-1", + }), + ]); + + expect(item.commentId).toBe("comment-1"); + expect(item.commentTarget).toEqual({ + scope: "task_artifact", + itemId: "artifact-1", + }); + }); + it("labels untitled tasks and tolerates missing optional values", () => { const [item] = toTaskActivityItems([ activity({ diff --git a/products/desktop/packages/core/src/canvas/taskActivity.ts b/products/desktop/packages/core/src/canvas/taskActivity.ts index 0d4b9ab422e1..d251ef43708d 100644 --- a/products/desktop/packages/core/src/canvas/taskActivity.ts +++ b/products/desktop/packages/core/src/canvas/taskActivity.ts @@ -3,12 +3,13 @@ import type { TaskActivityKind, UserBasic, } from "@posthog/shared/domain-types"; +import type { CommentTarget } from "../comments/anchors"; /** * The Activity feed — tasks the current user is involved in (created, mentioned * in, or messaged in) — as served by the backend task-activity index - * (`getTaskActivity`). One row per task, newest activity first; the client only - * maps DTOs to items. + * (`getTaskActivity`). Task state collapses per task, while comment notifications + * are individual entries; the client only maps DTOs to items. */ export interface TaskActivityItem { @@ -25,6 +26,8 @@ export interface TaskActivityItem { snippet: string; author: UserBasic | null; messageId: string | null; + commentId?: string | null; + commentTarget?: CommentTarget | null; isUnread: boolean; } @@ -43,6 +46,14 @@ export function toTaskActivityItems( snippet: row.snippet, author: row.latest_author ?? null, messageId: row.latest_message_id ?? null, + commentId: row.latest_comment_id ?? null, + commentTarget: + row.latest_comment_scope && row.latest_comment_item_id + ? { + scope: row.latest_comment_scope as CommentTarget["scope"], + itemId: row.latest_comment_item_id, + } + : null, isUnread: row.is_unread, })); } diff --git a/products/desktop/packages/core/src/comments/anchors.test.ts b/products/desktop/packages/core/src/comments/anchors.test.ts new file mode 100644 index 000000000000..2da4660705ec --- /dev/null +++ b/products/desktop/packages/core/src/comments/anchors.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + createTextCommentAnchor, + isThreadResolved, + parseCommentContext, + resolveTextCommentAnchor, +} from "./anchors"; + +describe("artifact text anchors", () => { + it("creates and resolves a verified positional anchor", () => { + const text = "Before selected words after"; + const anchor = createTextCommentAnchor(text, 7, 21); + + if (!anchor) throw new Error("Expected an anchor"); + expect(resolveTextCommentAnchor(text, anchor)).toEqual({ + start: 7, + end: 21, + status: "exact", + }); + }); + + it("reanchors a quote after surrounding content changes", () => { + const original = "Before selected words after"; + const anchor = createTextCommentAnchor(original, 7, 21); + if (!anchor) throw new Error("Expected an anchor"); + const changed = `New introduction. ${original}`; + + expect(resolveTextCommentAnchor(changed, anchor)).toEqual({ + start: 25, + end: 39, + status: "reanchored", + }); + }); + + it("uses context to disambiguate repeated quotes", () => { + const original = "first repeated phrase then second repeated phrase end"; + const start = original.lastIndexOf("repeated phrase"); + const anchor = createTextCommentAnchor( + original, + start, + start + "repeated phrase".length, + ); + if (!anchor) throw new Error("Expected an anchor"); + const changed = `prefix ${original}`; + + expect(resolveTextCommentAnchor(changed, anchor)?.start).toBe( + changed.lastIndexOf("repeated phrase"), + ); + }); + + it("orphans deleted and ambiguous text instead of guessing", () => { + const deleted = createTextCommentAnchor("unique text", 0, 6); + if (!deleted) throw new Error("Expected an anchor"); + expect(resolveTextCommentAnchor("replacement", deleted)).toBeNull(); + + const ambiguous = { + kind: "text" as const, + quote: "same", + prefix: "", + suffix: "", + start: 100, + end: 104, + }; + expect(resolveTextCommentAnchor("same x same", ambiguous)).toBeNull(); + }); + + it("rejects whitespace-only selections", () => { + expect(createTextCommentAnchor("a b", 1, 4)).toBeNull(); + }); + + it("rejects selections larger than the persisted anchor contract", () => { + const text = "x".repeat(10_001); + expect(createTextCommentAnchor(text, 0, text.length)).toBeNull(); + }); + + it("validates versioned comment context and anchor bounds", () => { + expect( + parseCommentContext({ + anchor: { kind: "document" }, + canvasVersionId: "version-2", + }), + ).toEqual({ + anchor: { kind: "document" }, + canvasVersionId: "version-2", + }); + expect( + parseCommentContext({ + anchor: { + kind: "text", + quote: "x".repeat(10_001), + prefix: "", + suffix: "", + start: 0, + end: 10_001, + }, + }), + ).toBeNull(); + }); + + it("uses the latest thread-state event for resolution", () => { + const root = { completed_at: null }; + const event = (state: "resolved" | "open", created_at: string) => ({ + created_at, + item_context: { + anchor: { kind: "document" as const }, + threadState: state, + }, + }); + + expect( + isThreadResolved(root, [ + event("resolved", "2026-01-01T00:00:00Z"), + event("open", "2026-01-01T00:01:00Z"), + ]), + ).toBe(false); + expect( + isThreadResolved(root, [ + event("open", "2026-01-01T00:00:00Z"), + event("resolved", "2026-01-01T00:01:00Z"), + ]), + ).toBe(true); + }); +}); diff --git a/products/desktop/packages/core/src/comments/anchors.ts b/products/desktop/packages/core/src/comments/anchors.ts new file mode 100644 index 000000000000..ff3458986a00 --- /dev/null +++ b/products/desktop/packages/core/src/comments/anchors.ts @@ -0,0 +1,189 @@ +import type { CommentScope } from "@posthog/api-client/posthog-client"; +import { z } from "zod"; + +const CONTEXT_LENGTH = 32; +const MAX_QUOTE_LENGTH = 10_000; + +/** + * 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 textCommentAnchorDataSchema = z.object({ + quote: z.string().min(1).max(MAX_QUOTE_LENGTH), + prefix: z.string().max(CONTEXT_LENGTH), + suffix: z.string().max(CONTEXT_LENGTH), + start: z.number().int().nonnegative(), + end: z.number().int().positive(), +}); + +export const textCommentAnchorSchema = textCommentAnchorDataSchema + .extend({ + kind: z.literal("text"), + }) + .refine(({ start, end }) => end > start, { + message: "Text anchor end must follow its start", + }); + +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(), + /** Immutable canvas source version rendered when the comment was made. */ + canvasVersionId: z.string().min(1).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() || quote.length > MAX_QUOTE_LENGTH) 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, + }; +} + +/** + * 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) => { + const end = start + anchor.quote.length; + const prefix = text.slice( + Math.max(0, start - anchor.prefix.length), + start, + ); + 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 { start, score }; + }) + .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/products/desktop/packages/core/src/panels/panelStoreHelpers.ts b/products/desktop/packages/core/src/panels/panelStoreHelpers.ts index 2b41453240bf..aaff4ad4b2ad 100644 --- a/products/desktop/packages/core/src/panels/panelStoreHelpers.ts +++ b/products/desktop/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/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index cf59a5d3196e..2e39fe537410 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/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, @@ -7495,6 +7500,65 @@ export class SessionService { } } + async getResourceComments( + target: CommentTarget, + taskId: string, + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") return []; + return authStatus.auth.client.getResourceComments( + target.scope, + target.itemId, + taskId, + ); + } + + /** + * 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[], + taskId: string, + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready" || targets.length === 0) return []; + const client = authStatus.auth.client; + const pages: ResourceComment[][] = Array.from( + { length: targets.length }, + () => [], + ); + let nextIndex = 0; + const worker = async () => { + while (nextIndex < targets.length) { + const index = nextIndex++; + const target = targets[index]; + pages[index] = await client.getResourceComments( + target.scope, + target.itemId, + taskId, + ); + } + }; + await Promise.all( + Array.from({ length: Math.min(4, targets.length) }, worker), + ); + 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/products/desktop/packages/shared/src/domain-types.ts b/products/desktop/packages/shared/src/domain-types.ts index 648357416236..8e5d8c19269e 100644 --- a/products/desktop/packages/shared/src/domain-types.ts +++ b/products/desktop/packages/shared/src/domain-types.ts @@ -172,6 +172,8 @@ export type TaskActivityKind = | "completed" | "message" | "mention" + | "thread_reply" + | "owned_item_comment" | "created"; /** @@ -190,6 +192,9 @@ export interface TaskActivity { snippet: string; latest_author?: UserBasic | null; latest_message_id?: string | null; + latest_comment_id?: string | null; + latest_comment_scope?: string | null; + latest_comment_item_id?: string | null; is_unread: boolean; } @@ -204,6 +209,7 @@ export interface TaskActivityPage { export interface TaskActivityReadMarker { task_id: string; seen_before: string; + activity_id?: string; } export interface TaskActivityMarkReadResult { diff --git a/products/desktop/packages/shared/src/git-domain.ts b/products/desktop/packages/shared/src/git-domain.ts index 24cb09add12a..934652b4aed9 100644 --- a/products/desktop/packages/shared/src/git-domain.ts +++ b/products/desktop/packages/shared/src/git-domain.ts @@ -5,6 +5,7 @@ import { z } from "zod"; export const prReviewCommentUserSchema = z.object({ login: z.string(), avatar_url: z.string(), + isBot: z.boolean().optional(), }); export const prReviewCommentSchema = z.object({ @@ -156,6 +157,7 @@ export type GetPrChecksOutput = z.infer; export const prConversationCommentSchema = z.object({ id: z.number(), author: z.string(), + isBot: z.boolean().optional(), avatarUrl: z.string().nullable(), body: z.string(), createdAt: z.string(), diff --git a/products/desktop/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx b/products/desktop/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx index 82097a9e9723..87f102e2e1da 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ActivityHoverCard.tsx @@ -114,7 +114,7 @@ export function ActivityHoverCard({
{items.map((item) => ( diff --git a/products/desktop/packages/ui/src/features/canvas/components/ActivityPanel.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ActivityPanel.test.tsx new file mode 100644 index 000000000000..202ba3bca543 --- /dev/null +++ b/products/desktop/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/products/desktop/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/products/desktop/packages/ui/src/features/canvas/components/ActivityPanel.tsx index c59a26979b89..3d212e4cd56c 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ActivityPanel.tsx +++ b/products/desktop/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,55 @@ 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], + ); + const acknowledgeCommentsTabOpen = useCommentNavigationStore( + (state) => state.acknowledgeCommentsTabOpen, + ); + // 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: string; nonce: number | null }>({ + taskId, + nonce: null, + }); + useEffect(() => { + if (seenFocus.current.taskId !== taskId) { + seenFocus.current = { taskId, nonce: null }; + return; + } + if ( + commentFocus?.openCommentsTab && + commentFocus.nonce !== seenFocus.current.nonce + ) { + seenFocus.current = { taskId, nonce: commentFocus.nonce }; + // Not handleTabChange: a programmatic switch isn't a user tab change. + setTab("comments"); + } + }, [commentFocus, taskId]); + useEffect(() => { + if (tab === "comments" && commentFocus?.openCommentsTab) { + acknowledgeCommentsTabOpen(taskId, commentFocus.nonce); + } + }, [acknowledgeCommentsTabOpen, commentFocus, tab, taskId]); + 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 ( ({ + toChannelDashboard: vi.fn(), + toChannelTask: vi.fn(), + toTaskDetail: vi.fn(), +})); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannelDashboard: navigation.toChannelDashboard, + navigateToChannelTask: navigation.toChannelTask, + navigateToTaskDetail: navigation.toTaskDetail, +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); + +import { useCommentNavigationStore } from "@posthog/ui/features/sessions/commentNavigationStore"; +import { ActivityRow, activityHeadline } from "./ActivityView"; function item(overrides: Partial): TaskActivityItem { return { @@ -21,6 +36,15 @@ function item(overrides: Partial): TaskActivityItem { } describe("activityHeadline", () => { + beforeEach(() => { + navigation.toChannelTask.mockReset(); + navigation.toChannelDashboard.mockReset(); + navigation.toTaskDetail.mockReset(); + useCommentNavigationStore.setState({ + focusByTask: {}, + resolutionsByTarget: {}, + }); + }); it.each([ [ "completed run", @@ -28,6 +52,33 @@ describe("activityHeadline", () => { "The agent completed this task", ], ["agent reply", item({ activityKind: "message" }), "The agent replied"], + [ + "thread reply", + item({ + activityKind: "thread_reply", + author: { + id: 2, + uuid: "author", + email: "author@posthog.com", + first_name: "Ann", + }, + }), + "replied to a thread you participated in", + ], + [ + "canvas owner comment", + item({ + activityKind: "owned_item_comment", + commentTarget: { scope: "desktop_canvas", itemId: "canvas-1" }, + author: { + id: 2, + uuid: "author", + email: "author@posthog.com", + first_name: "Ann", + }, + }), + "commented on your canvas", + ], [ "own reply", item({ @@ -59,4 +110,43 @@ describe("activityHeadline", () => { ); expect(getByText("#me")).toBeInTheDocument(); }); + + it("opens an activity mention at its exact comment thread", () => { + const activity = item({ + activityKind: "mention", + channelId: "channel-1", + commentId: "comment-1", + commentTarget: { scope: "desktop_canvas", itemId: "canvas-1" }, + author: { + id: 2, + uuid: "author", + email: "author@posthog.com", + first_name: "Ann", + }, + }); + + render( + , + ); + const activityButton = screen.getByText("mentioned you").closest("button"); + if (!activityButton) throw new Error("Expected activity row button"); + fireEvent.click(activityButton); + + expect(navigation.toChannelDashboard).toHaveBeenCalledWith( + "channel-1", + "canvas-1", + ); + expect(navigation.toChannelTask).not.toHaveBeenCalled(); + expect(useCommentNavigationStore.getState().focusByTask["task-1"]).toEqual({ + target: { scope: "desktop_canvas", itemId: "canvas-1" }, + threadId: "comment-1", + nonce: expect.any(Number), + openCommentsTab: true, + }); + }); }); diff --git a/products/desktop/packages/ui/src/features/canvas/components/ActivityView.tsx b/products/desktop/packages/ui/src/features/canvas/components/ActivityView.tsx index cd22d7c9364d..4871cf946fc0 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -28,8 +28,11 @@ import { MentionText } from "@posthog/ui/features/canvas/components/MentionText" import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; +import { useCanvasChatPanelStore } from "@posthog/ui/features/canvas/stores/canvasChatPanelStore"; +import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { useCommentNavigationStore } from "@posthog/ui/features/sessions/commentNavigationStore"; import { PageHeader, PageHeaderActions, @@ -40,6 +43,7 @@ import { PageHeaderTitleRow, } from "@posthog/ui/primitives/PageHeader"; import { + navigateToChannelDashboard, navigateToChannelTask, navigateToTaskDetail, } from "@posthog/ui/router/navigationBridge"; @@ -65,6 +69,17 @@ function ChannelSuffix({ channelName }: { channelName: string | null }) { ); } +function ownedItemName(item: TaskActivityItem): string { + switch (item.commentTarget?.scope) { + case "desktop_canvas": + return "canvas"; + case "task_artifact": + return "artifact"; + default: + return "task"; + } +} + /** The lead line describing what happened, chosen by the row's activity kind. */ export function activityHeadline( item: TaskActivityItem, @@ -112,6 +127,26 @@ export function activityHeadline( ); + case "thread_reply": + return ( + <> + + {userDisplayName(item.author)} + {" "} + replied to a thread you participated in + + + ); + case "owned_item_comment": + return ( + <> + + {userDisplayName(item.author)} + {" "} + commented on your {ownedItemName(item)} + + + ); default: return "You created this task"; } @@ -149,10 +184,23 @@ export function ActivityRow({ task_id: item.taskId, }); onOpen(item); + if (item.commentId && item.commentTarget) { + useCommentNavigationStore + .getState() + .requestCommentFocus(item.taskId, item.commentTarget, item.commentId); + } onNavigate?.(); + if (channelId && item.commentTarget?.scope === "desktop_canvas") { + useCanvasChatPanelStore.getState().openComments(); + navigateToChannelDashboard(channelId, item.commentTarget.itemId); + return; + } // The channel thread route is the deep-link target; unfiled tasks fall // back to the plain task view. if (channelId) { + if (item.commentId) { + useThreadPanelStore.getState().setCollapsed(false); + } navigateToChannelTask(channelId, item.taskId); } else { navigateToTaskDetail(item.taskId); @@ -270,7 +318,13 @@ export function ActivityView() { // reached any other way, so the feed converges either way. const markRead = useCallback( (item: TaskActivityItem) => - markTasksRead([{ task_id: item.taskId, seen_before: item.activityAt }]), + markTasksRead([ + { + task_id: item.taskId, + seen_before: item.activityAt, + ...(item.commentId ? { activity_id: item.id } : {}), + }, + ]), [markTasksRead], ); const markAllRead = useCallback(() => { @@ -311,8 +365,8 @@ export function ActivityView() { No activity yet - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. + Task updates and comment notifications across{" "} + {spacesLayout ? "spaces" : "channels"} appear here. @@ -320,7 +374,7 @@ export function ActivityView() {
{items.map((item) => ( - Tasks you're involved in across spaces. + Task updates and comment notifications across spaces. @@ -379,7 +433,7 @@ export function ActivityView() { Activity - Tasks you're involved in across{" "} + Task updates and comment notifications across{" "} {spacesLayout ? "spaces" : "channels"}.
diff --git a/products/desktop/packages/ui/src/features/canvas/components/MentionComposer.tsx b/products/desktop/packages/ui/src/features/canvas/components/MentionComposer.tsx index df0826b9945c..71b1b95dfdf6 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -29,6 +29,8 @@ interface MentionComposerProps { placeholder?: string; rows?: number; inputClassName?: string; + /** Put the caret in the editor on mount, for a composer the user just opened. */ + autoFocus?: boolean; /** Rendered inside the input group after the editor (send button etc.). */ children?: ReactNode; } @@ -57,6 +59,7 @@ export function MentionComposer({ onValueChange, onSubmit, members, + autoFocus = false, allowAgentMention = false, onMentionInsert, placeholder, @@ -99,6 +102,7 @@ export function MentionComposer({ const editor = useEditor( { + autofocus: autoFocus ? "end" : false, extensions: [ StarterKit.configure({ heading: false, diff --git a/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx index a553cb8db456..b4ced2a81cb2 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -1,5 +1,11 @@ import type { Task, TaskRun, TaskRunArtifact } from "@posthog/shared"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -43,6 +49,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"; @@ -134,15 +170,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 @@ -255,7 +305,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/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index 292db0c34861..b87513565c52 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -1,15 +1,12 @@ import { ArrowSquareOutIcon, + ChatCircleIcon, DownloadSimpleIcon, EyeIcon, 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 { SESSION_SERVICE, @@ -17,6 +14,7 @@ import { } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; import { + Badge, Button, Empty, EmptyDescription, @@ -27,13 +25,12 @@ import { TooltipContent, TooltipTrigger, } 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, +} from "@posthog/ui/features/canvas/components/taskArtifactRows"; import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; import { canvasArtifactOpenHandler } from "@posthog/ui/features/canvas/utils/canvasArtifactNavigation"; import { openPrInReview } from "@posthog/ui/features/code-review/openPrInReview"; @@ -41,98 +38,14 @@ import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifac 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 { toast } from "@posthog/ui/primitives/toast"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; 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, @@ -146,7 +59,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 @@ -279,13 +192,30 @@ function PrRow({ ); } -function CanvasRow({ name, url }: { name: string; url: string | null }) { +function CanvasRow({ + name, + url, + commentCount, +}: { + name: string; + url: string | null; + commentCount: number; +}) { const open = canvasArtifactOpenHandler(url); return ( 0 ? ( + + + {commentCount} + + ) : ( + "Canvas" + ) + } onOpen={open} /> ); @@ -296,13 +226,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 sessionService = useService(SESSION_SERVICE); const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); @@ -349,7 +280,16 @@ function FileRow({ } title={name} - detail={["File", formatFileSize(size)].filter(Boolean).join(" · ")} + detail={ + commentCount > 0 ? ( + + + {commentCount} + + ) : ( + "File" + ) + } onOpen={onOpen} fileActions={ onDownload @@ -376,6 +316,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, task.id); + 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 ( @@ -394,6 +350,20 @@ export function TaskArtifactsList({ ); } + if (commentsQuery.isError) { + return ( + + + + + + Couldn't load comment counts + Refresh the page to try again. + + + ); + } + return (
{rows.map((row) => @@ -404,7 +374,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[], + queriedTargets: [] as unknown[], +})); + +function openThread(body: string): void { + const card = screen.getByText(body).closest("[data-comment-thread-id]"); + expect(card).not.toBeNull(); + fireEvent.click( + within(card as HTMLElement).getByRole("button", { + name: "Open comment thread", + }), + ); +} + +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", () => ({ + isOptimisticComment: (comment: ResourceComment) => + comment.id.startsWith("optimistic-"), + useCommentsQuery: (target: unknown) => { + if (target) mocks.queriedTargets.push([target]); + return { + data: mocks.comments, + isLoading: false, + }; + }, + useCommentsForTargetsQuery: (targets: unknown) => { + if (Array.isArray(targets) && targets.length > 0) { + mocks.queriedTargets.push(targets); + } + return { + data: mocks.comments, + isLoading: false, + }; + }, + useCreateComment: (target: unknown) => { + mocks.createdFor.push(target); + return { mutateAsync: 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.createComment.mockResolvedValue({ id: "created-comment" }); + mocks.setResolved.mockReset(); + mocks.createdFor = []; + mocks.resolvedFor = []; + mocks.queriedTargets = []; + useCommentNavigationStore.setState({ + focusByTask: {}, + resolutionsByTarget: {}, + }); + }); + + it("queries and displays only the current canvas when restricted", async () => { + const onCanvasCommentOpen = vi.fn(); + mocks.comments = [ + comment({ + item_id: "canvas-1", + scope: "desktop_canvas", + content: "Canvas feedback", + item_context: { + anchor: { + kind: "text", + quote: "important copy", + prefix: "", + suffix: "", + start: 0, + end: 14, + }, + canvasVersionId: "version-2", + }, + }), + ]; + + render( + "V2"} + onCanvasCommentOpen={onCanvasCommentOpen} + />, + ); + + expect(mocks.queriedTargets.at(-1)).toEqual([ + { scope: "desktop_canvas", itemId: "canvas-1" }, + ]); + expect(screen.getByText("Canvas feedback")).toBeInTheDocument(); + expect(screen.getByText("“important copy”")).toBeInTheDocument(); + expect(screen.getByText("V2 ·")).toBeInTheDocument(); + expect(screen.queryByText("Selected text")).not.toBeInTheDocument(); + expect(screen.queryByText("Whole canvas")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Filter by source")).not.toBeInTheDocument(); + expect(screen.queryByText("Launch canvas")).not.toBeInTheDocument(); + expect(mocks.createdFor.at(-1)).toEqual({ + scope: "desktop_canvas", + itemId: "canvas-1", + }); + + openThread("Canvas feedback"); + expect(onCanvasCommentOpen).toHaveBeenCalledWith("version-2"); + + await act(async () => { + fireEvent.click(screen.getByText(/Comment on this canvas/)); + }); + expect(mocks.createComment).toHaveBeenCalledWith({ + content: "Composed comment", + context: { + anchor: { kind: "document" }, + canvasVersionId: "version-2", + }, + mentions: [], + }); + }); + + it("shows selected artifact text alongside its source", () => { + mocks.comments = [ + comment({ + item_context: { + anchor: { + kind: "text", + quote: "Purpose", + prefix: "", + suffix: "", + start: 0, + end: 7, + }, + }, + }), + ]; + + render(); + + expect(screen.getByText("report.md")).toBeTruthy(); + expect(screen.getByText("“Purpose”")).toBeTruthy(); + }); + + it("loads canvas comments from a local-development artifact link", () => { + mocks.runs = []; + mocks.comments = [ + comment({ + item_id: "canvas-1", + scope: "desktop_canvas", + content: "Linked canvas feedback", + }), + ]; + + const timeline = [ + { + kind: "artifact", + timestamp: 1, + message: { id: "message-1" }, + artifact: { + kind: "canvas", + name: "Dev Joke Machine", + url: "http://localhost:8000/code/canvas/channel-1/canvas-1", + }, + }, + ] as unknown as ThreadTimelineRow[]; + + render(); + + expect(mocks.queriedTargets.at(-1)).toContainEqual({ + scope: "desktop_canvas", + itemId: "canvas-1", + }); + expect(screen.getByText("Linked canvas feedback")).toBeInTheDocument(); + }); + + // 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(); + + openThread("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), + openCommentsTab: true, + }); + }); + + it("opens an artifact when activity requests its comment thread", () => { + render(); + + act(() => { + useCommentNavigationStore + .getState() + .requestCommentFocus( + "task-1", + { scope: "task_artifact", itemId: "a" }, + "comment-1", + ); + }); + + expect(mocks.openArtifactTab).toHaveBeenCalledWith("task-1", { + runId: "run-1", + artifactId: "a", + name: "report.md", + }); + }); + + it("opens the saved canvas version when activity requests its thread", () => { + const onCanvasCommentOpen = vi.fn(); + mocks.comments = [ + comment({ + item_id: "canvas-1", + scope: "desktop_canvas", + content: "Historical canvas feedback", + item_context: { + anchor: { kind: "document" }, + canvasVersionId: "version-2", + }, + }), + ]; + + render( + , + ); + + act(() => { + useCommentNavigationStore + .getState() + .requestCommentFocus( + "task-1", + { scope: "desktop_canvas", itemId: "canvas-1" }, + "comment-1", + ); + }); + + expect(onCanvasCommentOpen).toHaveBeenCalledWith("version-2"); + }); + + // 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(); + + openThread("Tighten this summary"); + const first = useCommentNavigationStore.getState().focusByTask["task-1"]; + openThread("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("labels an active artifact that has no comments", () => { + mocks.runs = [ + run([ + outputFile({ id: "a", name: "report.md" }), + outputFile({ + id: "empty", + name: "empty.md", + storage_path: "runs/1/empty.md", + }), + ]), + ]; + mocks.comments = [comment({})]; + mocks.activeArtifactId = "empty"; + + render(); + + expect(screen.getByLabelText("Filter by source")).toHaveTextContent( + "empty.md", + ); + expect(screen.queryByText("Tighten this summary")).toBeNull(); + fireEvent.click(screen.getByLabelText("Filter by source")); + const emptySourceOption = screen + .getAllByText("empty.md") + .at(-1) + ?.closest('[role="menuitemradio"]'); + expect(emptySourceOption).toHaveTextContent("0"); + }); + + 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(); + openThread("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", async () => { + render(); + + await act(async () => { + 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/products/desktop/packages/ui/src/features/canvas/components/TaskCommentsList.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskCommentsList.tsx new file mode 100644 index 000000000000..4f08d147baaf --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskCommentsList.tsx @@ -0,0 +1,723 @@ +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, + 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 { canvasArtifactOpenHandler } from "@posthog/ui/features/canvas/utils/canvasArtifactNavigation"; +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 { + isOptimisticComment, + useCommentsForTargetsQuery, + useCommentsQuery, + useCreateComment, + useSetCommentResolved, +} from "@posthog/ui/features/sessions/components/useComments"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; +import { useCallback, 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 = 30_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"} + + )} + + ); +} + +function CommentReference({ + root, + versionLabel, +}: { + root: ResourceComment; + versionLabel?: (versionId: string) => string | null; +}) { + const context = readCommentContext(root); + const version = context?.canvasVersionId + ? versionLabel?.(context.canvasVersionId) + : null; + const anchor = context?.anchor; + const quote = anchor?.kind === "text" ? anchor.quote : null; + if (!version && !quote) return null; + return ( + + {version && {version} ·} + {quote && ( + + “{quote}” + + )} + + ); +} + +/** + * 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, + showSource = true, + commentVersionLabel, +}: { + thread: TaskCommentThread; + source: CommentSource; + root: ResourceComment; + taskId: string; + members: UserBasic[]; + selected: boolean; + pulsing: boolean; + resolution?: HighlightResolution; + onOpen: () => void; + showSource?: boolean; + commentVersionLabel?: (versionId: string) => string | null; +}) { + const createComment = useCreateComment(source.target, taskId); + const setResolved = useSetCommentResolved(source.target); + const rootPending = isOptimisticComment(root); + + return ( + + {showSource && } + + + } + onSelect={onOpen} + canReply={!rootPending} + canResolve={!rootPending} + onReply={async (content, mentions) => { + await createComment.mutateAsync({ + 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); + try { + if (!(await action())) throw new Error("GitHub comment action failed"); + } finally { + 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, + onlySource, + canvasVersionId, + commentVersionLabel, + onCanvasCommentOpen, +}: { + task: Task; + timeline: ThreadTimelineRow[]; + /** Restricts the pane to one resource known by its host, without relying on + * the task timeline to rediscover it. */ + onlySource?: CommentSource; + canvasVersionId?: string | null; + commentVersionLabel?: (versionId: string) => string | null; + onCanvasCommentOpen?: (versionId: string | null) => void; +}) { + const { runs } = useTaskRuns(onlySource ? undefined : 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 sourceFilterTouched = useRef(false); + const previousTaskId = useRef(task.id); + + useEffect(() => { + if (previousTaskId.current === task.id) return; + previousTaskId.current = task.id; + sourceFilterTouched.current = false; + setSourceFilter(ALL_SOURCES); + setDraft(""); + }, [task.id]); + + const rows = useMemo( + () => buildRows(task, timeline, runs), + [task, timeline, runs], + ); + const sources = useMemo( + () => (onlySource ? [onlySource] : commentSources(task.id, rows)), + [task.id, rows, onlySource], + ); + const targets = useMemo( + () => sources.map((source) => source.target), + [sources], + ); + const singleSourceComments = useCommentsQuery( + onlySource?.target ?? null, + task.id, + ); + const taskComments = useCommentsForTargetsQuery( + onlySource ? [] : targets, + task.id, + { + live: true, + intervalMs: POLL_INTERVAL_MS, + }, + ); + const commentsQuery = onlySource ? singleSourceComments : taskComments; + const prUrls = useMemo( + () => + onlySource + ? [] + : rows.flatMap((row) => (row.kind === "pr" ? [row.url] : [])), + [rows, onlySource], + ); + const prConversation = usePrCommentsForUrls(prUrls); + const prReviews = usePrReviewThreadsForUrls(prUrls); + const prTitles = usePrTitles(prUrls); + + const taskTarget = useMemo(() => taskCommentTarget(task.id), [task.id]); + const composerTarget = onlySource?.target ?? taskTarget; + const composerSourceKey = commentTargetKey(composerTarget); + const createComment = useCreateComment(composerTarget, 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, + ]); + + // 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]); + const stateFilteredThreads = useMemo( + () => + threads.filter( + (thread) => thread.resolved === (stateFilter === "resolved"), + ), + [stateFilter, threads], + ); + const sourceOptions = useMemo( + () => + threadSourceOptions(stateFilteredThreads, [ + ...sources.map((source) => ({ + key: commentTargetKey(source.target), + label: source.name, + kind: source.kind, + })), + ...prUrls.map((prUrl) => ({ + key: prUrl, + label: prTitles[prUrl] ?? `PR #${prUrl.split("/").at(-1)}`, + kind: "pr" as const, + })), + ]), + [prTitles, prUrls, sources, stateFilteredThreads], + ); + const effectiveSourceFilter = + sourceFilter === ALL_SOURCES || knownSourceKeys.has(sourceFilter) + ? sourceFilter + : ALL_SOURCES; + const sourceLabel = + effectiveSourceFilter === ALL_SOURCES + ? "All sources" + : (sourceOptions.find((option) => option.key === effectiveSourceFilter) + ?.label ?? "All sources"); + + useEffect(() => { + if (sourceFilter !== effectiveSourceFilter) { + setSourceFilter(effectiveSourceFilter); + } + }, [effectiveSourceFilter, sourceFilter]); + + // Follow the artifact on screen until the reader picks a source themselves; + // after that the filter is theirs, not the pane's. + useEffect(() => { + if (onlySource || sourceFilterTouched.current) return; + setSourceFilter( + activeArtifactId + ? commentTargetKey({ scope: "task_artifact", itemId: activeArtifactId }) + : ALL_SOURCES, + ); + }, [activeArtifactId, onlySource]); + + const inSource = (thread: TaskCommentThread) => + effectiveSourceFilter === ALL_SOURCES || + thread.sourceKey === effectiveSourceFilter; + const scoped = threads.filter(inSource); + const openCount = scoped.filter((thread) => !thread.resolved).length; + const resolvedCount = scoped.length - openCount; + const visibleThreads = scoped.filter((thread) => + stateFilteredThreads.includes(thread), + ); + + const openThread = useCallback( + (thread: TaskCommentThread, requestThreadFocus = true) => { + 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") { + if (requestThreadFocus) { + requestCommentFocus(task.id, source.target, root.id); + } + if (onCanvasCommentOpen) { + onCanvasCommentOpen( + readCommentContext(root)?.canvasVersionId ?? null, + ); + return; + } + canvasArtifactOpenHandler(source.url)?.(); + return; + } + // A thread on the task itself has nowhere else to open because it lives here. + if (source.kind === "task" || !source.runId) return; + openArtifactTab(task.id, { + runId: source.runId, + artifactId: source.target.itemId, + name: source.name, + }); + if (requestThreadFocus) { + requestCommentFocus(task.id, source.target, root.id); + } + }, + [onCanvasCommentOpen, openArtifactTab, requestCommentFocus, task.id], + ); + + // 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 handledFocusRef = useRef(null); + useEffect(() => { + const focusKey = focus ? `${task.id}:${focus.nonce}` : null; + if (!focus || handledFocusRef.current === focusKey) 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; + handledFocusRef.current = focusKey; + setStateFilter(focused.resolved ? "resolved" : "open"); + setSourceFilter((current) => + current === ALL_SOURCES || current === focused.sourceKey + ? current + : ALL_SOURCES, + ); + setPulseThreadId(focus.threadId); + openThread(focused, false); + requestAnimationFrame(() => { + document + .querySelector( + `[data-comment-thread-id="${CSS.escape(focus.threadId)}"]`, + ) + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + }, [focus, openThread, threads, task.id]); + // 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 loading = + commentsQuery.isLoading || prConversation.isLoading || prReviews.isLoading; + const loadFailed = + commentsQuery.isError || prConversation.isError || prReviews.isError; + + return ( + // The parent scrolls the middle; the filters and the composer are pinned so + // they stay reachable however long the thread list grows. +
+
+ {!onlySource && ( + + + {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 + + {stateFilteredThreads.length} + + + {sourceOptions.map((option) => ( + + {sourceIcon(option.kind, option.label)} + {option.label} + + {option.count} + + + ))} + + + + )} + + + {stateFilter === "open" ? "Open" : "Resolved"} + + + } + /> + + setStateFilter(value as StateFilter)} + > + + Open ({openCount}) + + + Resolved ({resolvedCount}) + + + + +
+
+ {loadFailed ? ( + + + + + + Couldn't load comments + + Refresh the page to try again. + + + + ) : loading && threads.length === 0 ? ( +
+ +
+ ) : visibleThreads.length === 0 ? ( + + + + + + + No {stateFilter === "open" ? "open" : "resolved"} comments + + + {stateFilter === "open" + ? onlySource + ? "Comment on this canvas to start a thread." + : "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)} + showSource={!onlySource} + commentVersionLabel={commentVersionLabel} + /> + ) : ( + openThread(thread)} + /> + ), + ) + )} +
+
+ { + await createComment.mutateAsync({ + content, + context: { + anchor: { kind: "document" }, + ...(canvasVersionId ? { canvasVersionId } : {}), + }, + 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 !== composerSourceKey + ) { + setSourceFilter(ALL_SOURCES); + } + }} + members={members} + placeholder={`Comment on this ${onlySource ? "canvas" : "task"}… Type @ to mention someone`} + rows={2} + disabled={createComment.isPending} + /> +
+
+ ); +} diff --git a/products/desktop/packages/ui/src/features/canvas/components/activityFeed.ts b/products/desktop/packages/ui/src/features/canvas/components/activityFeed.ts index 8cb1d13cd415..a0867a55d56b 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/activityFeed.ts +++ b/products/desktop/packages/ui/src/features/canvas/components/activityFeed.ts @@ -10,6 +10,7 @@ export function activityReadPayload(items: TaskActivityItem[]) { return items.map((item) => ({ task_id: item.taskId, seen_before: item.activityAt, + ...(item.commentId ? { activity_id: item.id } : {}), })); } diff --git a/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts b/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts new file mode 100644 index 000000000000..a44423b591ff --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts @@ -0,0 +1,199 @@ +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 { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; + +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; + if (target?.kind === "canvas") return target.dashboardId; + + // Local development emits http:// canvas links, which are deliberately not + // valid external share links. Recover only the exact route's final id here; + // this value is used for an access-checked API query, never for navigation. + try { + const localUrl = new URL(url); + if (localUrl.protocol !== "http:") return null; + const segments = localUrl.pathname.split("/").filter(Boolean); + if ( + segments.length === 4 && + segments[0] === "code" && + segments[1] === "canvas" + ) { + return decodeURIComponent(segments[3]); + } + } catch { + return null; + } + return null; +} + +/** 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/products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts b/products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts new file mode 100644 index 000000000000..624d06996929 --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.test.ts @@ -0,0 +1,260 @@ +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"); + }); + + it("omits GitHub bot comments without hiding human threads", () => { + const botRoot = { + ...reviewThread, + nodeId: "bot-root", + comments: reviewThread.comments.map((comment) => ({ + ...comment, + user: { ...comment.user, isBot: true }, + })), + }; + const humanRootWithBotReply = { + ...reviewThread, + nodeId: "human-root", + comments: [ + reviewThread.comments[0], + { + ...reviewThread.comments[1], + user: { ...reviewThread.comments[1].user, isBot: true }, + }, + ], + }; + + const threads = prCommentThreads( + "url", + "PR #7", + [botRoot, humanRootWithBotReply], + [conversation, { ...conversation, id: 901, isBot: true }], + ); + + expect(threads).toHaveLength(2); + expect( + threads.map((thread) => thread.entries.map((entry) => entry.body)), + ).toEqual([["root"], ["lgtm"]]); + }); +}); + +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/products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.ts b/products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.ts new file mode 100644 index 000000000000..0e59ed6272b1 --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/taskCommentThreads.ts @@ -0,0 +1,231 @@ +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 "Unknown user"; + 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 []; + const humanComments = thread.comments.filter( + (comment) => !comment.user.isBot, + ); + if (humanComments.length === 0) return []; + return [ + { + id: `pr-review-${thread.rootId}`, + sourceKey: prUrl, + sourceLabel: prLabel, + sourceKind: "pr" as const, + entries: humanComments.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: humanComments.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) { + if (comment.isBot) continue; + 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; + count: number; +}; + +/** + * The available sources for the source filter, including those without a + * thread yet. The task itself sits at the top because every task has one. + */ +export function threadSourceOptions( + threads: TaskCommentThread[], + availableSources: Omit[] = [], +): ThreadSourceOption[] { + const byKey = new Map( + availableSources.map((source) => [source.key, { ...source, count: 0 }]), + ); + for (const thread of threads) { + const source = byKey.get(thread.sourceKey); + if (!source) { + byKey.set(thread.sourceKey, { + key: thread.sourceKey, + label: thread.sourceLabel, + kind: thread.sourceKind, + count: 1, + }); + } else { + source.count += 1; + } + } + return [...byKey.values()].sort( + (a, b) => Number(b.kind === "task") - Number(a.kind === "task"), + ); +} diff --git a/products/desktop/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts b/products/desktop/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts index 4845259e8337..09c85a7a1eeb 100644 --- a/products/desktop/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts +++ b/products/desktop/packages/ui/src/features/canvas/hooks/useMarkTaskActivityRead.ts @@ -22,8 +22,17 @@ export function useMarkTaskActivityRead() { return client.markTaskActivityRead(activities); }, onMutate: async (activities: TaskActivityReadMarker[]) => { - const marked = new Map( - activities.map((activity) => [activity.task_id, activity.seen_before]), + const markedTasks = new Map( + activities.flatMap((activity) => + activity.activity_id + ? [] + : [[activity.task_id, activity.seen_before] as const], + ), + ); + const markedCommentActivities = new Set( + activities.flatMap((activity) => + activity.activity_id ? [activity.activity_id] : [], + ), ); queryClient.setQueryData>( TASK_ACTIVITY_QUERY_KEY, @@ -32,9 +41,13 @@ export function useMarkTaskActivityRead() { const clearing = data.pages .flatMap((page) => page.results) .filter((row) => { - const seenBefore = marked.get(row.task_id); + const seenBefore = markedTasks.get(row.task_id); return ( - row.is_unread && seenBefore && row.activity_at <= seenBefore + row.is_unread && + (markedCommentActivities.has(row.id) || + (!row.latest_comment_id && + !!seenBefore && + row.activity_at <= seenBefore)) ); }).length; return { @@ -46,8 +59,11 @@ export function useMarkTaskActivityRead() { ? Math.max(0, page.unread_count - clearing) : page.unread_count, results: page.results.map((row) => { - const seenBefore = marked.get(row.task_id); - return seenBefore && row.activity_at <= seenBefore + const seenBefore = markedTasks.get(row.task_id); + return markedCommentActivities.has(row.id) || + (!row.latest_comment_id && + !!seenBefore && + row.activity_at <= seenBefore) ? { ...row, is_unread: false } : row; }), diff --git a/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx b/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx index c1f4a0075fb6..e35cfc9714b7 100644 --- a/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.test.tsx @@ -150,4 +150,57 @@ describe("task activity hooks", () => { expect(hook.result.current.activity.items).toHaveLength(1); expect(mockClient.getTaskActivity).toHaveBeenCalledOnce(); }); + + it("marks only the selected comment activity read", async () => { + const page: TaskActivityPage = { + results: [ + activity({ + id: "comment-activity-1", + latest_comment_id: "comment-1", + }), + activity({ + id: "comment-activity-2", + latest_comment_id: "comment-2", + }), + activity({ + id: "task-activity", + activity_kind: "awaiting_input", + latest_comment_id: null, + }), + ], + unread_count: 3, + }; + queryClient.setQueryData(TASK_ACTIVITY_QUERY_KEY, { + pages: [page], + pageParams: [undefined], + }); + mockClient.markTaskActivityRead.mockResolvedValue({ + marked_read: 1, + unread_count: 1, + }); + + const hook = renderHook(() => useMarkTaskActivityRead(), { wrapper }); + act(() => { + hook.result.current.mutate([ + { + task_id: "task-1", + seen_before: "2026-07-01T10:00:00Z", + activity_id: "comment-activity-1", + }, + ]); + }); + + await waitFor(() => + expect(mockClient.markTaskActivityRead).toHaveBeenCalledOnce(), + ); + const cached = queryClient.getQueryData<{ + pages: TaskActivityPage[]; + }>(TASK_ACTIVITY_QUERY_KEY); + expect(cached?.pages[0]?.results.map((row) => row.is_unread)).toEqual([ + false, + true, + true, + ]); + expect(cached?.pages[0]?.unread_count).toBe(2); + }); }); diff --git a/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.ts b/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.ts index 5dd3d428ab98..0f219d499dc0 100644 --- a/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.ts +++ b/products/desktop/packages/ui/src/features/canvas/hooks/useTaskActivity.ts @@ -12,9 +12,9 @@ import { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; export { TASK_ACTIVITY_QUERY_KEY } from "../task-activity/taskActivityQuery"; /** - * Tasks the current user is involved in — created, @-mentioned in, or messaged - * in — one row per task, newest activity first, from the backend task-activity - * index. Mount once per surface (sidebar badge, Activity page) — results are + * Task lifecycle and comment activity for the current user, newest first. Task + * lifecycle rows collapse per task while comment notifications remain separate. + * Mount once per surface (sidebar badge, Activity page); results are * shared through the react-query cache. */ export function useTaskActivity(options?: { enabled?: boolean }): { diff --git a/products/desktop/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx b/products/desktop/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx index 8a336f8914e7..dfd7408e6d2a 100644 --- a/products/desktop/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx +++ b/products/desktop/packages/ui/src/features/code-editor/components/DocumentPreviewHeader.tsx @@ -1,18 +1,24 @@ import { Check, Code, Copy, Eye } from "@phosphor-icons/react"; -import { Flex, IconButton, Text } from "@radix-ui/themes"; -import { useState } from "react"; -import { Tooltip } from "../../../primitives/Tooltip"; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import { type ReactNode, useState } from "react"; export function DocumentPreviewHeader({ label, content, showRendered, onToggleRendered, + actions, }: { label: string; content: string; showRendered: boolean; onToggleRendered: () => void; + actions?: ReactNode; }) { const [copied, setCopied] = useState(false); @@ -23,42 +29,45 @@ export function DocumentPreviewHeader({ }; return ( - - +
+ {label} - - - - - {showRendered ? : } - + +
+ {actions} + + + {showRendered ? : } + + } + /> + + {showRendered ? "View source" : "View preview"} + - - - {copied ? : } - + + + {copied ? : } + + } + /> + {copied ? "Copied" : "Copy source"} - - +
+
); } diff --git a/products/desktop/packages/ui/src/features/code-editor/components/SelectionCommentOverlay.test.tsx b/products/desktop/packages/ui/src/features/code-editor/components/SelectionCommentOverlay.test.tsx new file mode 100644 index 000000000000..5acdc056e616 --- /dev/null +++ b/products/desktop/packages/ui/src/features/code-editor/components/SelectionCommentOverlay.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/features/canvas/components/MentionComposer", () => ({ + MentionComposer: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: ReactNode; + }) => ( +
+