From fc5dab5b94dd1bb9c9a96c02ff6880b2b17fa93f Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 23 Jul 2026 17:49:02 +0200 Subject: [PATCH 1/3] feat(canvas): show only human messages and artifacts in thread timeline Drop agent turn rows and forwarded user prompts from the task thread panel. The timeline now renders human-to-human messages plus artifact rows for canvases (canvas_created) and pull requests (pr_created) announced by the backend. Canvas artifacts navigate in-app via share links; PR artifacts open externally with live PR state. Generated-By: PostHog Code Task-Id: 4ca58c61-95e3-4b36-a0df-474345b61483 --- .../core/src/canvas/threadTimeline.test.ts | 224 ++++++++++----- packages/core/src/canvas/threadTimeline.ts | 138 ++++----- .../canvas/components/ThreadPanel.test.tsx | 173 +++++------ .../canvas/components/ThreadPanel.tsx | 272 ++++++++---------- .../canvas/components/threadAgentTurns.ts | 33 --- 5 files changed, 409 insertions(+), 431 deletions(-) delete mode 100644 packages/ui/src/features/canvas/components/threadAgentTurns.ts diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts index a3b3b355f3..a3b75dfbc6 100644 --- a/packages/core/src/canvas/threadTimeline.test.ts +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -3,8 +3,8 @@ import { buildThreadTimeline, deriveThreadAgentStatus, hasAgentMention, - normalizeAgentPromptText, shouldSuspendThreadSession, + threadMessageArtifact, } from "./threadTimeline"; describe("hasAgentMention", () => { @@ -19,96 +19,176 @@ describe("hasAgentMention", () => { }); }); -describe("normalizeAgentPromptText", () => { +describe("threadMessageArtifact", () => { + it("maps a canvas_created message to a canvas artifact", () => { + expect( + threadMessageArtifact({ + id: "m1", + content: + "[Signups](https://us.posthog.com/code/canvas/c/d) has been created", + created_at: "2026-07-17T00:00:00Z", + author_kind: "agent", + event: "canvas_created", + payload: { + canvas_name: "Signups", + canvas_url: "https://us.posthog.com/code/canvas/c/d", + }, + }), + ).toEqual({ + kind: "canvas", + name: "Signups", + url: "https://us.posthog.com/code/canvas/c/d", + }); + }); + + it("falls back to a default canvas name and no url", () => { + expect( + threadMessageArtifact({ + id: "m1", + content: "Canvas has been created", + created_at: "2026-07-17T00:00:00Z", + author_kind: "agent", + event: "canvas_created", + payload: {}, + }), + ).toEqual({ kind: "canvas", name: "Canvas", url: null }); + }); + + it("maps a pr_created message to a pr artifact", () => { + expect( + threadMessageArtifact({ + id: "m2", + content: "Pull request opened", + created_at: "2026-07-17T00:00:00Z", + author_kind: "agent", + event: "pr_created", + payload: { pr_url: "https://github.com/org/repo/pull/123" }, + }), + ).toEqual({ kind: "pr", url: "https://github.com/org/repo/pull/123" }); + }); + it.each([ [ - "forwarded thread comment", - "[Thread comment from Peter Kirkham] @agent which model are you?", - "which model are you?", + "a pr_created message without a url", + { + id: "m3", + content: "Pull request opened", + created_at: "2026-07-17T00:00:00Z", + author_kind: "agent" as const, + event: "pr_created", + payload: {}, + }, ], - ["direct prompt", "which model are you?", "which model are you?"], [ - "direct prompt with mention", - "@agent which model are you?", - "which model are you?", + "a turn_complete message", + { + id: "m4", + content: "@[Casey](casey@example.com) Done.", + created_at: "2026-07-17T00:00:00Z", + author_kind: "agent" as const, + event: "turn_complete", + payload: { run_id: "run" }, + }, ], - ])("normalizes a %s", (_name, content, expected) => { - expect(normalizeAgentPromptText(content)).toBe(expected); + [ + "a human message", + { + id: "m5", + content: "Looks good", + created_at: "2026-07-17T00:00:00Z", + }, + ], + ])("returns no artifact for %s", (_name, message) => { + expect(threadMessageArtifact(message)).toBeNull(); }); }); describe("buildThreadTimeline", () => { - it("omits the session echo of a forwarded thread message", () => { - const timeline = buildThreadTimeline({ - prompts: [ - { - id: "prompt", - text: "[Thread comment from Peter Kirkham] @agent which model are you?", - timestamp: 200, - }, - ], - humanMessages: [ - { - id: "human", - content: "@agent which model are you?", - createdAt: "1970-01-01T00:00:00.100Z", - forwardedToAgent: true, - }, - ], - agentMessages: [], - }); + it("keeps only human messages and artifacts", () => { + const timeline = buildThreadTimeline([ + { + id: "human", + content: "Kicking this off", + created_at: "1970-01-01T00:00:00.100Z", + }, + { + id: "turn", + content: "@[Casey](casey@example.com) Shipped it.", + created_at: "1970-01-01T00:00:00.200Z", + author_kind: "agent", + event: "turn_complete", + payload: { run_id: "run" }, + }, + { + id: "system", + content: "Status changed", + created_at: "1970-01-01T00:00:00.250Z", + author_kind: "system", + event: "status_changed", + }, + { + id: "canvas", + content: "Canvas has been created", + created_at: "1970-01-01T00:00:00.300Z", + author_kind: "agent", + event: "canvas_created", + payload: { canvas_name: "Signups", canvas_url: null }, + }, + ]); - expect(timeline.map((row) => row.kind)).toEqual(["human"]); + expect(timeline.map((row) => row.kind)).toEqual(["human", "artifact"]); }); - it("keeps a thread-comment prompt without a matching forwarded message", () => { - const timeline = buildThreadTimeline({ - prompts: [ - { - id: "prompt", - text: "[Thread comment from Peter Kirkham] @agent which model are you?", - timestamp: 200, - }, - ], - humanMessages: [], - agentMessages: [], - }); + it("orders human messages and artifacts chronologically", () => { + const timeline = buildThreadTimeline([ + { + id: "pr", + content: "Pull request opened", + created_at: "1970-01-01T00:00:00.200Z", + author_kind: "agent", + event: "pr_created", + payload: { pr_url: "https://github.com/org/repo/pull/1" }, + }, + { + id: "human", + content: "Reply", + created_at: "1970-01-01T00:00:00.100Z", + }, + ]); - expect(timeline.map((row) => row.kind)).toEqual(["prompt"]); + expect(timeline.map((row) => row.message.id)).toEqual(["human", "pr"]); }); - it("interleaves prompts, human replies, and agent turns chronologically", () => { - const timeline = buildThreadTimeline({ - prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], - humanMessages: [ - { - id: "human", - content: "Reply", - createdAt: "1970-01-01T00:00:00.150Z", - }, - ], - agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], - }); - - expect(timeline.map((row) => row.kind)).toEqual([ - "prompt", - "human", - "agent", + it("keeps malformed timestamps at the end", () => { + const timeline = buildThreadTimeline([ + { id: "broken", content: "Reply", created_at: "invalid" }, + { + id: "human", + content: "Reply", + created_at: "1970-01-01T00:00:00.100Z", + }, ]); - }); - it("keeps malformed timestamps at the end", () => { - const timeline = buildThreadTimeline({ - prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], - humanMessages: [{ id: "human", content: "Reply", createdAt: "invalid" }], - agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], - }); + expect(timeline.map((row) => row.message.id)).toEqual(["human", "broken"]); + }); - expect(timeline.map((row) => row.kind)).toEqual([ - "prompt", - "agent", - "human", + it("exposes the artifact and the source message on artifact rows", () => { + const [row] = buildThreadTimeline([ + { + id: "pr", + content: "Pull request opened", + created_at: "1970-01-01T00:00:00.200Z", + author_kind: "agent", + event: "pr_created", + payload: { pr_url: "https://github.com/org/repo/pull/1" }, + }, ]); + + expect(row).toMatchObject({ + kind: "artifact", + artifact: { kind: "pr", url: "https://github.com/org/repo/pull/1" }, + message: { id: "pr" }, + }); }); }); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts index a440d4e08d..ea45bfdeed 100644 --- a/packages/core/src/canvas/threadTimeline.ts +++ b/packages/core/src/canvas/threadTimeline.ts @@ -1,76 +1,77 @@ -export interface ThreadAgentMessage { - id: string; - text: string; - timestamp?: number; -} - -export interface ThreadHumanMessage { +/** + * Minimal shape of a task thread message the timeline needs — structurally + * satisfied by `TaskThreadMessage` without coupling to the full domain type. + */ +export interface ThreadMessageLike { id: string; content: string; - createdAt: string; - forwardedToAgent?: boolean; - value?: T; + created_at: string; + author_kind?: "human" | "system" | "agent"; + event?: string; + payload?: Record; } -export type ThreadTimelineRow = - | { kind: "prompt"; timestamp: number; message: ThreadAgentMessage } - | { kind: "agent"; timestamp: number; message: ThreadAgentMessage } - | { kind: "human"; timestamp: number; message: ThreadHumanMessage }; +export type ThreadArtifact = + | { kind: "canvas"; name: string; url: string | null } + | { kind: "pr"; url: string }; -function validTimestamp(timestamp: number | undefined): number { - return timestamp !== undefined && Number.isFinite(timestamp) - ? timestamp - : Number.MAX_SAFE_INTEGER; -} +export type ThreadTimelineRow = + | { kind: "human"; timestamp: number; message: T } + | { + kind: "artifact"; + timestamp: number; + message: T; + artifact: ThreadArtifact; + }; function parsedTimestamp(timestamp: string): number { const parsed = Date.parse(timestamp); return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; } -export function buildThreadTimeline({ - prompts, - agentMessages, - humanMessages, -}: { - prompts: ThreadAgentMessage[]; - agentMessages: ThreadAgentMessage[]; - humanMessages: ThreadHumanMessage[]; -}): ThreadTimelineRow[] { - const forwardedHumanContent = new Set( - humanMessages - .filter((message) => message.forwardedToAgent) - .map((message) => normalizeAgentPromptText(message.content)), - ); - const visiblePrompts = prompts.filter( - (message) => - !isThreadCommentPrompt(message.text) || - !forwardedHumanContent.has(normalizeAgentPromptText(message.text)), - ); +/** + * The artifact an agent-authored thread message announces, or `null` when the + * message isn't an artifact announcement (e.g. `turn_complete`). + */ +export function threadMessageArtifact( + message: ThreadMessageLike, +): ThreadArtifact | null { + const payload = message.payload ?? {}; + if (message.event === "canvas_created") { + const name = + typeof payload.canvas_name === "string" && payload.canvas_name.trim() + ? payload.canvas_name + : "Canvas"; + const url = + typeof payload.canvas_url === "string" ? payload.canvas_url : null; + return { kind: "canvas", name, url }; + } + if (message.event === "pr_created") { + const url = typeof payload.pr_url === "string" ? payload.pr_url : null; + return url ? { kind: "pr", url } : null; + } + return null; +} - return [ - ...visiblePrompts.map( - (message): ThreadTimelineRow => ({ - kind: "prompt", - timestamp: validTimestamp(message.timestamp), - message, - }), - ), - ...humanMessages.map( - (message): ThreadTimelineRow => ({ - kind: "human", - timestamp: parsedTimestamp(message.createdAt), - message, - }), - ), - ...agentMessages.map( - (message): ThreadTimelineRow => ({ - kind: "agent", - timestamp: validTimestamp(message.timestamp), - message, - }), - ), - ].sort((left, right) => left.timestamp - right.timestamp); +/** + * The thread is a human-to-human surface: only human messages and the + * artifacts the agent produced (canvases, pull requests) appear. Agent turn + * messages and forwarded prompts are omitted. + */ +export function buildThreadTimeline( + messages: T[], +): ThreadTimelineRow[] { + const rows: ThreadTimelineRow[] = []; + for (const message of messages) { + const timestamp = parsedTimestamp(message.created_at); + const artifact = threadMessageArtifact(message); + if (artifact) { + rows.push({ kind: "artifact", timestamp, message, artifact }); + } else if ((message.author_kind ?? "human") === "human") { + rows.push({ kind: "human", timestamp, message }); + } + } + return rows.sort((left, right) => left.timestamp - right.timestamp); } export type ThreadAgentPhase = "active" | "needs_input" | "error"; @@ -81,26 +82,11 @@ export interface ThreadAgentStatus { } const AGENT_MENTION_PATTERN = /(^|\s)@agent\b/i; -const THREAD_COMMENT_ATTRIBUTION_PATTERN = - /^\[Thread comment from [^\]\r\n]+\]\s*/i; -const LEADING_AGENT_MENTION_PATTERN = /^@agent\b[\s:]*/i; export function hasAgentMention(content: string): boolean { return AGENT_MENTION_PATTERN.test(content); } -export function normalizeAgentPromptText(content: string): string { - return content - .trim() - .replace(THREAD_COMMENT_ATTRIBUTION_PATTERN, "") - .replace(LEADING_AGENT_MENTION_PATTERN, "") - .trim(); -} - -function isThreadCommentPrompt(content: string): boolean { - return THREAD_COMMENT_ATTRIBUTION_PATTERN.test(content.trim()); -} - export function deriveThreadAgentStatus({ hasActivity = false, hasError = false, diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 89aca9895a..ad76abd9f4 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -1,40 +1,31 @@ -import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AgentStatusLine, + ThreadArtifactRow, ThreadMessageRow, - UserPromptRow, } from "./ThreadPanel"; -import { agentTurns } from "./threadAgentTurns"; - -describe("agentTurns", () => { - it("accumulates every text chunk in one agent turn", () => { - const items = [ - { - type: "session_update", - id: "first", - timestamp: 10, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Hello" }, - }, - }, - { - type: "session_update", - id: "second", - timestamp: 20, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: " there" }, - }, - }, - ] as ConversationItem[]; - - expect(agentTurns(items)).toEqual([ - { id: "first", text: "Hello there", timestamp: 10 }, - ]); - }); + +const openExternalUrl = vi.fn(); +const navigateToShareTarget = vi.fn(); + +vi.mock("@posthog/ui/shell/openExternal", () => ({ + openExternalUrl: (url: string) => openExternalUrl(url), +})); + +vi.mock("@posthog/ui/utils/shareLinks", () => ({ + navigateToShareTarget: (target: unknown) => navigateToShareTarget(target), +})); + +vi.mock("@posthog/ui/features/git-interaction/usePrDetails", () => ({ + usePrDetails: () => ({ + meta: { state: "open", merged: false, draft: false }, + }), +})); + +beforeEach(() => { + openExternalUrl.mockClear(); + navigateToShareTarget.mockClear(); }); describe("AgentStatusLine", () => { @@ -50,58 +41,6 @@ describe("AgentStatusLine", () => { }); describe("ThreadMessageRow", () => { - it("renders backend-authored agent announcements as Agent", () => { - render( - {}} - onDelete={() => {}} - />, - ); - - expect(screen.getByText("Agent")).toBeInTheDocument(); - expect(screen.queryByText("Unknown")).not.toBeInTheDocument(); - }); - - it("renders system announcements as System without human actions", () => { - render( - {}} - onDelete={() => {}} - />, - ); - - expect(screen.getByText("System")).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Message actions" }), - ).not.toBeInTheDocument(); - }); - it("keeps legacy authorless rows as human messages", () => { render( { }); }); -describe("UserPromptRow", () => { - it("prefixes direct task prompts with @agent", () => { +describe("ThreadArtifactRow", () => { + it("renders a canvas artifact and navigates in-app to a shareable canvas", () => { + render( + , + ); + + expect(screen.getByText("Signups overview")).toBeInTheDocument(); + expect(screen.getByText(/Canvas/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Signups overview/ })); + + expect(navigateToShareTarget).toHaveBeenCalledWith({ + kind: "canvas", + channelId: "channel-1", + dashboardId: "dash-1", + }); + expect(openExternalUrl).not.toHaveBeenCalled(); + }); + + it("renders a canvas artifact without a link as plain text", () => { render( - , ); - expect(screen.getByText("@agent")).toBeInTheDocument(); - expect(screen.getByText("Investigate this")).toBeInTheDocument(); + expect(screen.getByText("Signups overview")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Signups overview/ }), + ).not.toBeInTheDocument(); }); - it("hides forwarded thread attribution and duplicate agent mentions", () => { + it("renders a pull request artifact and opens it externally", () => { render( - , ); - expect(screen.getAllByText("@agent")).toHaveLength(1); - expect(screen.getByText("which model are you?")).toBeInTheDocument(); - expect(screen.queryByText(/Thread comment from/)).not.toBeInTheDocument(); + expect(screen.getByText("Pull request #123")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Pull request #123/ })); + + expect(openExternalUrl).toHaveBeenCalledWith( + "https://github.com/org/repo/pull/123", + ); + expect(navigateToShareTarget).not.toHaveBeenCalled(); }); }); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 170887d8dd..1dacfb697a 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -11,12 +11,15 @@ import { buildThreadTimeline, deriveThreadAgentStatus, hasAgentMention, - normalizeAgentPromptText, shouldSuspendThreadSession, - type ThreadAgentMessage, type ThreadAgentStatus, + type ThreadArtifact, type ThreadTimelineRow, } from "@posthog/core/canvas/threadTimeline"; +import { + getPrVisualConfig, + parsePrNumber, +} from "@posthog/core/git-interaction/prStatus"; import { Avatar, AvatarFallback, @@ -55,13 +58,10 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; -import { - MentionText, - mentionChipClass, -} from "@posthog/ui/features/canvas/components/MentionText"; +import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; -import { agentTurns } from "@posthog/ui/features/canvas/components/threadAgentTurns"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useDeleteTaskThreadMessage, @@ -71,19 +71,17 @@ import { useTaskThread, } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import { - ChatMarkdown, - ChatStreamingMarkdown, -} from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; -import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; -import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; +import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -105,38 +103,16 @@ export function ThreadMessageRow({ onDelete: () => void; }) { const forwarded = !!message.forwarded_to_agent_at; - const authorKind = message.author_kind ?? "human"; - const isAgent = authorKind === "agent"; - const isSystem = authorKind === "system"; - const showMenu = - authorKind === "human" && ((isTaskAuthor && !forwarded) || isOwnMessage); + const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage; return ( - {isAgent || isSystem ? ( - - - {isAgent ? : "S"} - - - ) : ( - - )} + - - {isAgent - ? "Agent" - : isSystem - ? "System" - : userDisplayName(message.author)} - + {userDisplayName(message.author)} @@ -186,19 +162,6 @@ export function ThreadMessageRow({ ); } -function agentPrompts(items: ConversationItem[]): ThreadAgentMessage[] { - const prompts: ThreadAgentMessage[] = []; - for (const item of items) { - if (item.type !== "user_message") continue; - const text = ( - extractChannelContext(item.content)?.stripped ?? item.content - ).trim(); - if (!text) continue; - prompts.push({ id: item.id, text, timestamp: item.timestamp }); - } - return prompts; -} - export function AgentStatusLine({ status }: { status: ThreadAgentStatus }) { return ( void; }) { + const body = ( + <> + {icon} + {title} + {detail && ( + {detail} + )} + + ); + const cardClass = + "flex w-fit max-w-full items-center gap-2 rounded-md border border-border bg-muted px-2 py-1.5 text-[13px]"; + if (!onOpen) { + return {body}; + } return ( - - - - - - - - - - - Agent - {message.timestamp !== undefined && ( - - )} - - {message.text && ( - -
- {streaming ? ( - - ) : ( - - )} -
-
- )} -
-
+ ); } -export function UserPromptRow({ - message, - author, +function CanvasArtifactCard({ + name, + url, }: { - message: ThreadAgentMessage; - author: TaskThreadMessage["author"]; + name: string; + url: string | null; }) { - const promptText = normalizeAgentPromptText(message.text); + const open = url + ? () => { + const target = parseShareLink(url); + if (target) { + navigateToShareTarget(target); + } else { + openExternalUrl(url); + } + } + : undefined; + return ( + + ); +} + +function PrArtifactCard({ url }: { url: string }) { + const { + meta: { state, merged, draft }, + } = usePrDetails(url); + const config = getPrVisualConfig(state ?? "open", merged, draft); + const PrIcon = getPrVisualIcon(config.icon); + const prNumber = parsePrNumber(url); + return ( + + } + title={prNumber ? `Pull request #${prNumber}` : "Pull request"} + // Only show the resolved state once we have it, to avoid a flash of "Open". + detail={state ? config.label : null} + onOpen={() => openExternalUrl(url)} + /> + ); +} +export function ThreadArtifactRow({ + artifact, + createdAt, +}: { + artifact: ThreadArtifact; + createdAt: string; +}) { return ( - + + + + + - {userDisplayName(author)} - {message.timestamp !== undefined && ( - - )} + + {artifact.kind === "canvas" ? "Canvas" : "Pull request"} + + - - @agent {promptText} + + {artifact.kind === "canvas" ? ( + + ) : ( + + )} @@ -351,25 +364,19 @@ function ThreadHeader({ function ThreadTimeline({ timeline, isReady, - taskAuthor, currentUserUuid, currentUserEmail, isTaskAuthor, canForward, - lastAgentId, - agentActive, onSendToAgent, onDelete, }: { timeline: ThreadTimelineRow[]; isReady: boolean; - taskAuthor: UserBasic | null | undefined; currentUserUuid?: string; currentUserEmail?: string; isTaskAuthor: boolean; canForward: boolean; - lastAgentId?: string; - agentActive: boolean; onSendToAgent: (messageId: string) => void; onDelete: (messageId: string) => void; }) { @@ -383,9 +390,9 @@ function ThreadTimeline({ No messages yet - Discuss this task with your team. The agent's status shows up here - too; messages stay between humans unless the task author sends one - to the agent. + Discuss this task with your team. Canvases and pull requests the + agent creates show up here too; messages stay between humans unless + the task author sends one to the agent. @@ -395,20 +402,13 @@ function ThreadTimeline({ return ( {timeline.map((row) => - row.kind === "prompt" ? ( - - ) : row.kind === "human" ? ( + row.kind === "human" ? ( onDelete(row.message.id)} /> ) : ( - ), )} @@ -526,10 +526,7 @@ function ThreadConversation({ hasSession: Boolean(session), }), }); - const { items } = useConversationItems(events, isPromptPending); const pendingPermissions = usePendingPermissionsForTask(taskId); - const agentMsgs = useMemo(() => agentTurns(items), [items]); - const promptMsgs = useMemo(() => agentPrompts(items), [items]); const agentStatus = useMemo( () => @@ -554,23 +551,7 @@ function ThreadConversation({ ], ); - const timeline = useMemo( - () => - buildThreadTimeline({ - prompts: promptMsgs, - agentMessages: agentMsgs, - humanMessages: messages.map((message) => ({ - id: message.id, - content: message.content, - createdAt: message.created_at, - forwardedToAgent: !!message.forwarded_to_agent_at, - value: message, - })), - }), - [promptMsgs, messages, agentMsgs], - ); - - const lastAgentId = agentMsgs[agentMsgs.length - 1]?.id; + const timeline = useMemo(() => buildThreadTimeline(messages), [messages]); const [draft, setDraft] = useState(""); const scrollRef = useRef(null); @@ -668,13 +649,10 @@ function ThreadConversation({ diff --git a/packages/ui/src/features/canvas/components/threadAgentTurns.ts b/packages/ui/src/features/canvas/components/threadAgentTurns.ts deleted file mode 100644 index ddcc58ec80..0000000000 --- a/packages/ui/src/features/canvas/components/threadAgentTurns.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ThreadAgentMessage } from "@posthog/core/canvas/threadTimeline"; -import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; - -export function agentTurns(items: ConversationItem[]): ThreadAgentMessage[] { - const turns: ThreadAgentMessage[] = []; - let current: ThreadAgentMessage | null = null; - for (const item of items) { - if (item.type === "user_message") { - if (current) turns.push(current); - current = null; - continue; - } - if ( - item.type === "session_update" && - item.update.sessionUpdate === "agent_message_chunk" && - "content" in item.update && - item.update.content.type === "text" && - item.update.content.text.trim() - ) { - if (current) { - current.text += item.update.content.text; - } else { - current = { - id: item.id, - text: item.update.content.text, - timestamp: item.timestamp, - }; - } - } - } - if (current) turns.push(current); - return turns; -} From f619d6f49afe94961080b47dff46e27d3ed03391 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 23 Jul 2026 17:49:04 +0200 Subject: [PATCH 2/3] fix(canvas): validate thread artifact links Generated-By: PostHog Code Task-Id: bc347ad0-25f7-461d-a68a-9c1d8ba94c20 --- .../canvas/components/ThreadPanel.test.tsx | 48 +++++++++++++++++++ .../canvas/components/ThreadPanel.tsx | 30 +++++++++--- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index ad76abd9f4..660f055fc6 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -17,6 +17,10 @@ vi.mock("@posthog/ui/utils/shareLinks", () => ({ navigateToShareTarget: (target: unknown) => navigateToShareTarget(target), })); +vi.mock("@posthog/ui/utils/urls", () => ({ + getPostHogUrl: (path: string) => `https://us.posthog.com${path}`, +})); + vi.mock("@posthog/ui/features/git-interaction/usePrDetails", () => ({ usePrDetails: () => ({ meta: { state: "open", merged: false, draft: false }, @@ -106,6 +110,22 @@ describe("ThreadArtifactRow", () => { ).not.toBeInTheDocument(); }); + it("opens a canvas from another PostHog instance externally", () => { + const url = "https://eu.posthog.com/code/canvas/channel-1/dash-1"; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Signups overview/ })); + + expect(openExternalUrl).toHaveBeenCalledWith(url); + expect(navigateToShareTarget).not.toHaveBeenCalled(); + }); + it("renders a pull request artifact and opens it externally", () => { render( { ); expect(navigateToShareTarget).not.toHaveBeenCalled(); }); + + it.each([ + [ + "canvas", + { kind: "canvas", name: "Unsafe canvas", url: "file:///tmp/canvas" }, + "Unsafe canvas", + ], + [ + "pull request", + { kind: "pr", url: "javascript:alert(1)" }, + "Pull request", + ], + ] as const)( + "renders an unsafe %s artifact without a link", + (_, artifact, title) => { + render( + , + ); + + expect(screen.getAllByText(title).length).toBeGreaterThan(0); + expect( + screen.queryByRole("button", { name: new RegExp(title) }), + ).toBeNull(); + }, + ); }); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 1dacfb697a..40aa8e6241 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -82,6 +82,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -214,6 +215,15 @@ function ArtifactCardButton({ ); } +function parseHttpsUrl(url: string): URL | null { + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol === "https:" ? parsedUrl : null; + } catch { + return null; + } +} + function CanvasArtifactCard({ name, url, @@ -221,13 +231,18 @@ function CanvasArtifactCard({ name: string; url: string | null; }) { - const open = url + const parsedUrl = url ? parseHttpsUrl(url) : null; + const open = parsedUrl ? () => { - const target = parseShareLink(url); - if (target) { + const target = parseShareLink(parsedUrl.href); + const currentPostHogUrl = getPostHogUrl("/"); + const currentPostHogOrigin = currentPostHogUrl + ? parseHttpsUrl(currentPostHogUrl)?.origin + : null; + if (target && parsedUrl.origin === currentPostHogOrigin) { navigateToShareTarget(target); } else { - openExternalUrl(url); + openExternalUrl(parsedUrl.href); } } : undefined; @@ -241,12 +256,13 @@ function CanvasArtifactCard({ } function PrArtifactCard({ url }: { url: string }) { + const safeUrl = parseHttpsUrl(url)?.href ?? null; const { meta: { state, merged, draft }, - } = usePrDetails(url); + } = usePrDetails(safeUrl); const config = getPrVisualConfig(state ?? "open", merged, draft); const PrIcon = getPrVisualIcon(config.icon); - const prNumber = parsePrNumber(url); + const prNumber = safeUrl ? parsePrNumber(safeUrl) : null; return ( openExternalUrl(url)} + onOpen={safeUrl ? () => openExternalUrl(safeUrl) : undefined} /> ); } From 433007576b9d8776521b8d68fbd37b34fd5f24f7 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 23 Jul 2026 19:53:27 +0200 Subject: [PATCH 3/3] fix(canvas): restrict artifact links to trusted hosts Generated-By: PostHog Code Task-Id: b5fd89ce-e130-4108-8726-4373e26a4820 --- .../canvas/components/ThreadPanel.test.tsx | 35 +++++++++++++++++++ .../canvas/components/ThreadPanel.tsx | 31 ++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 660f055fc6..eb3d49c5e1 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -171,4 +171,39 @@ describe("ThreadArtifactRow", () => { ).toBeNull(); }, ); + + it.each([ + [ + "canvas", + { + kind: "canvas", + name: "Spoofed canvas", + url: "https://posthog.com.attacker.example/code/canvas/channel-1/dash-1", + }, + "Spoofed canvas", + ], + [ + "pull request", + { + kind: "pr", + url: "https://github.com.attacker.example/org/repo/pull/123", + }, + "Pull request", + ], + ] as const)( + "renders a %s artifact from a lookalike host without a link", + (_, artifact, title) => { + render( + , + ); + + expect(screen.getAllByText(title).length).toBeGreaterThan(0); + expect( + screen.queryByRole("button", { name: new RegExp(title) }), + ).toBeNull(); + }, + ); }); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 40aa8e6241..8b242509ee 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -232,20 +232,21 @@ function CanvasArtifactCard({ url: string | null; }) { const parsedUrl = url ? parseHttpsUrl(url) : null; - const open = parsedUrl - ? () => { - const target = parseShareLink(parsedUrl.href); - const currentPostHogUrl = getPostHogUrl("/"); - const currentPostHogOrigin = currentPostHogUrl - ? parseHttpsUrl(currentPostHogUrl)?.origin - : null; - if (target && parsedUrl.origin === currentPostHogOrigin) { - navigateToShareTarget(target); - } else { - openExternalUrl(parsedUrl.href); + const target = parsedUrl ? parseShareLink(parsedUrl.href) : null; + const open = + parsedUrl && target + ? () => { + const currentPostHogUrl = getPostHogUrl("/"); + const currentPostHogOrigin = currentPostHogUrl + ? parseHttpsUrl(currentPostHogUrl)?.origin + : null; + if (parsedUrl.origin === currentPostHogOrigin) { + navigateToShareTarget(target); + } else { + openExternalUrl(parsedUrl.href); + } } - } - : undefined; + : undefined; return (