From 89ae939705bf4bd11c33cf7d807dc0e3ecb04d44 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 16:03:54 -0400 Subject: [PATCH] fix(ui): render injected XML blocks and PR refs like the rest of the app in thread UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas Thread side panel (ThreadPanel) rendered user prompts as plain pre-wrap text, so injected context blocks (, , ) and inline mention chips (, , …) leaked as raw XML tags. Its agent prose also went through ChatMarkdown, which rendered GitHub PR/issue URLs as plain anchors instead of the GithubRefChip the conversation view uses. - ChatMarkdown: render GitHub issue/PR URLs as GithubRefChip (matches MarkdownRenderer), so PR refs in agent prose get their chip rendering in both the main ChatThread and the canvas ThreadPanel. - Extract the duplicated "strip channel_context → canvas_instructions → custom_instructions" chain (previously copy-pasted in UserMessage and UserBubble) into a shared usePromptDisplayContent hook; adopt it in both. - ThreadPanel.UserPromptRow: use the hook to strip the injected blocks (always-on; the panel has no split-tab tags so the flag arg is false) and parse mention chips via parseFileMentions, falling back to ChatMarkdown — matching how UserMessage/UserBubble render prompts. Generated-By: PostHog Code Task-Id: 2e8b4b7c-4b0e-41cb-9c19-429b773df630 --- .../canvas/components/ThreadPanel.test.tsx | 36 ++++++++++++ .../canvas/components/ThreadPanel.tsx | 27 ++++++--- .../chat-thread/ChatMarkdown.test.tsx | 21 +++++++ .../components/chat-thread/ChatMarkdown.tsx | 36 ++++++++---- .../components/chat-thread/ChatThread.tsx | 34 +++-------- .../components/session-update/UserMessage.tsx | 45 +++----------- .../usePromptDisplayContent.test.ts | 51 ++++++++++++++++ .../session-update/usePromptDisplayContent.ts | 58 +++++++++++++++++++ 8 files changed, 229 insertions(+), 79 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.test.ts create mode 100644 packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.ts diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 89aca9895a..2b3db53ad3 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -156,4 +156,40 @@ describe("UserPromptRow", () => { expect(screen.getByText("which model are you?")).toBeInTheDocument(); expect(screen.queryByText(/Thread comment from/)).not.toBeInTheDocument(); }); + + it("strips injected custom-instructions and channel-context XML", () => { + render( + \nAlways be brief.\n\n\n# Billing\n', + timestamp: 1, + }} + author={{ id: 1, uuid: "user", email: "user@example.com" }} + />, + ); + + expect(screen.getByText("ship the fix")).toBeInTheDocument(); + expect( + screen.queryByText(/user_custom_instructions/), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/channel_context/)).not.toBeInTheDocument(); + expect(screen.queryByText("Always be brief.")).not.toBeInTheDocument(); + }); + + it("renders an injected PR chip instead of its raw XML", () => { + render( + ', + timestamp: 1, + }} + author={{ id: 1, uuid: "user", email: "user@example.com" }} + />, + ); + + expect(screen.queryByText(/github_pr/)).not.toBeInTheDocument(); + expect(screen.getByText("#42 - Fix login")).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index fba801b8f1..97991cfa1f 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -76,7 +76,11 @@ import { ChatMarkdown, ChatStreamingMarkdown, } from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; -import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; +import { + hasFileMentions, + parseFileMentions, +} from "@posthog/ui/features/sessions/components/session-update/parseFileMentions"; +import { usePromptDisplayContent } from "@posthog/ui/features/sessions/components/session-update/usePromptDisplayContent"; import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; @@ -188,9 +192,7 @@ 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(); + const text = item.content.trim(); if (!text) continue; prompts.push({ id: item.id, text, timestamp: item.timestamp }); } @@ -261,7 +263,13 @@ export function UserPromptRow({ message: ThreadAgentMessage; author: TaskThreadMessage["author"]; }) { - const promptText = normalizeAgentPromptText(message.text); + // Strip the injected context blocks (always-on — the thread panel has no + // split-tab tags, so the flag arg is false just to skip them) before dropping + // the leading @agent / thread-comment attribution. Mention chips parse to + // chips like the conversation view. + const { displayContent } = usePromptDisplayContent(message.text, false); + const promptText = normalizeAgentPromptText(displayContent); + const containsMentions = hasFileMentions(promptText); return ( @@ -279,8 +287,13 @@ export function UserPromptRow({ /> )} - - @agent {promptText} + + @agent{" "} + {containsMentions ? ( + parseFileMentions(promptText) + ) : ( + + )} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.test.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.test.tsx index bdca003797..f043350fe9 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.test.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.test.tsx @@ -1,5 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { GITHUB_REF_URL_ATTR } from "../../../editor/components/GithubRefChip"; import { ChatMarkdown } from "./ChatMarkdown"; describe("ChatMarkdown", () => { @@ -12,4 +13,24 @@ describe("ChatMarkdown", () => { expect(html).not.toContain(" { + const url = "https://github.com/PostHog/posthog/pull/23985"; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(GITHUB_REF_URL_ATTR); + expect(html).toContain(url); + expect(html).toContain("PostHog/posthog#23985"); + }); + + it("leaves non-GitHub links as plain anchors", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain(GITHUB_REF_URL_ATTR); + expect(html).toContain('href="https://example.com/docs"'); + }); }); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx index eb475ad569..8a5247e4e2 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatMarkdown.tsx @@ -10,10 +10,12 @@ import { TableRow, Text, } from "@posthog/quill"; +import { GithubRefChip } from "@posthog/ui/features/editor/components/GithubRefChip"; import { parseOpenFence, splitMarkdownBlocks, } from "@posthog/ui/features/editor/components/splitMarkdownBlocks"; +import { parseGithubIssueUrl } from "@posthog/ui/features/message-editor/githubIssueUrl"; import { BareFileLink, hasDirectoryPath, @@ -66,16 +68,30 @@ const components: Components = { p: ({ children }) => ( {children} ), - a: ({ children, href }) => ( - - {children} - - ), + a: ({ children, href }) => { + const githubRef = href ? parseGithubIssueUrl(href) : null; + if (githubRef) { + const isAutoLink = typeof children === "string" && children === href; + const label = isAutoLink + ? `${githubRef.owner}/${githubRef.repo}#${githubRef.number}` + : children; + return ( + + {label} + + ); + } + return ( + + {children} + + ); + }, img: ({ alt }) => ( Remote image blocked{alt ? `: ${alt}` : ""} diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx index 75a79c707a..0a296e296f 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx @@ -57,9 +57,6 @@ import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/ import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; -import { extractCanvasInstructions } from "@posthog/ui/features/sessions/components/session-update/canvasInstructions"; -import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; -import { extractCustomInstructions } from "@posthog/ui/features/sessions/components/session-update/customInstructions"; import { hasFileMentions, MentionChip, @@ -67,6 +64,7 @@ import { } from "@posthog/ui/features/sessions/components/session-update/parseFileMentions"; import { SessionUpdateView } from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView"; import { UserShellExecuteView } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; +import { usePromptDisplayContent } from "@posthog/ui/features/sessions/components/session-update/usePromptDisplayContent"; import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; import { DIFFS_HIGHLIGHTER_OPTIONS } from "@posthog/ui/features/sessions/diffHighlighterOptions"; import { useAgentConversationItems } from "@posthog/ui/features/sessions/hooks/useAgentConversationItems"; @@ -284,29 +282,13 @@ function UserBubble({ PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, ); - const channelContext = useMemo( - () => extractChannelContext(content), - [content], - ); - const afterChannelContext = channelContext - ? channelContext.stripped - : content; - const canvasInstructions = useMemo( - () => extractCanvasInstructions(afterChannelContext), - [afterChannelContext], - ); - const afterCanvasInstructions = canvasInstructions - ? canvasInstructions.stripped - : afterChannelContext; - const customInstructions = useMemo( - () => extractCustomInstructions(afterCanvasInstructions), - [afterCanvasInstructions], - ); - const displayContent = customInstructions - ? customInstructions.stripped - : afterCanvasInstructions; - const showChannelContextTag = !!channelContext && bluebirdEnabled; - const showCanvasInstructionsTag = !!canvasInstructions && bluebirdEnabled; + const { + displayContent, + channelContext, + canvasInstructions, + showChannelContextTag, + showCanvasInstructionsTag, + } = usePromptDisplayContent(content, bluebirdEnabled); const showHeaderChips = showChannelContextTag || showCanvasInstructionsTag; const taskId = useSessionTaskId(); const openChannelContextInSplit = usePanelLayoutStore( diff --git a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx index e6478da0e3..d36664e5e8 100644 --- a/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx @@ -9,21 +9,19 @@ import { import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { Box, Flex, IconButton } from "@radix-ui/themes"; import { motion } from "framer-motion"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Tooltip } from "../../../../primitives/Tooltip"; import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; import { useFeatureFlag } from "../../../feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; import type { UserMessageAttachment } from "../../userMessageTypes"; import { CollapsibleMessageContent } from "./CollapsibleMessageContent"; -import { extractCanvasInstructions } from "./canvasInstructions"; -import { extractChannelContext } from "./channelContext"; -import { extractCustomInstructions } from "./customInstructions"; import { hasFileMentions, MentionChip, parseFileMentions, } from "./parseFileMentions"; +import { usePromptDisplayContent } from "./usePromptDisplayContent"; interface UserMessageProps { content: string; @@ -61,42 +59,17 @@ export const UserMessage = memo(function UserMessage({ taskId, keyboardFocused = false, }: UserMessageProps) { - // A channel's CONTEXT.md and the canvas generation instructions, if injected - // into this prompt, are each collapsed into a clickable tag instead of - // rendered inline; the rest of the prompt renders normally. Clicking a tag - // opens the snapshot as a split tab. The clickable tag + split tab is a - // project-bluebird feature, but we always strip the blocks so the raw - // / XML never leaks for - // flag-off viewers. The user's saved personalization - // () is always-on background, not contextual to this - // message, so it's stripped without a tag. const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, ); - const channelContext = useMemo( - () => extractChannelContext(content), - [content], - ); - const afterChannelContext = channelContext - ? channelContext.stripped - : content; - const canvasInstructions = useMemo( - () => extractCanvasInstructions(afterChannelContext), - [afterChannelContext], - ); - const afterCanvasInstructions = canvasInstructions - ? canvasInstructions.stripped - : afterChannelContext; - const customInstructions = useMemo( - () => extractCustomInstructions(afterCanvasInstructions), - [afterCanvasInstructions], - ); - const displayContent = customInstructions - ? customInstructions.stripped - : afterCanvasInstructions; - const showChannelContextTag = !!channelContext && bluebirdEnabled; - const showCanvasInstructionsTag = !!canvasInstructions && bluebirdEnabled; + const { + displayContent, + channelContext, + canvasInstructions, + showChannelContextTag, + showCanvasInstructionsTag, + } = usePromptDisplayContent(content, bluebirdEnabled); const openChannelContextInSplit = usePanelLayoutStore( (s) => s.openChannelContextInSplit, ); diff --git a/packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.test.ts b/packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.test.ts new file mode 100644 index 0000000000..77dca66ccf --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.test.ts @@ -0,0 +1,51 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePromptDisplayContent } from "./usePromptDisplayContent"; + +function render(content: string, bluebirdEnabled = false) { + return renderHook( + ({ content, bluebirdEnabled }) => + usePromptDisplayContent(content, bluebirdEnabled), + { initialProps: { content, bluebirdEnabled } }, + ).result.current; +} + +describe("usePromptDisplayContent", () => { + it("passes plain prompts through unchanged", () => { + const { displayContent, channelContext, canvasInstructions } = + render("ship the fix"); + expect(displayContent).toBe("ship the fix"); + expect(channelContext).toBeNull(); + expect(canvasInstructions).toBeNull(); + }); + + it("strips every injected block from the display text", () => { + const content = + 'do the thing\n\n# Billing\n\n\nauthoring contract\n\n\nAlways be brief.\n'; + const result = render(content); + expect(result.displayContent).toBe("do the thing"); + }); + + it("gates the tags on the bluebird flag while always stripping", () => { + const content = + 'do the thing\n\n# Billing\n\n\ncontract\n'; + const off = render(content, false); + expect(off.showChannelContextTag).toBe(false); + expect(off.showCanvasInstructionsTag).toBe(false); + expect(off.displayContent).toBe("do the thing"); + + const on = render(content, true); + expect(on.showChannelContextTag).toBe(true); + expect(on.showCanvasInstructionsTag).toBe(true); + expect(on.channelContext?.mention.name).toBe("billing"); + }); + + it("strips custom instructions without surfacing a tag", () => { + const content = + "ship it\n\nbe brief\n"; + const result = render(content, true); + expect(result.displayContent).toBe("ship it"); + expect(result.showChannelContextTag).toBe(false); + expect(result.showCanvasInstructionsTag).toBe(false); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.ts b/packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.ts new file mode 100644 index 0000000000..24ac4881ae --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/usePromptDisplayContent.ts @@ -0,0 +1,58 @@ +import { useMemo } from "react"; +import { extractCanvasInstructions } from "./canvasInstructions"; +import { extractChannelContext } from "./channelContext"; +import { extractCustomInstructions } from "./customInstructions"; + +export interface PromptDisplayContent { + /** Prompt text with every injected XML block stripped. */ + displayContent: string; + /** Parsed channel-context mention, or null when none was injected. */ + channelContext: ReturnType; + /** Parsed canvas-instructions block, or null when none was injected. */ + canvasInstructions: ReturnType; + /** Whether the channel-context tag should render (flag-gated). */ + showChannelContextTag: boolean; + /** Whether the canvas-instructions tag should render (flag-gated). */ + showCanvasInstructionsTag: boolean; +} + +// A prompt may carry up to three injected XML blocks that the conversation UI +// must never render inline: a channel's CONTEXT.md, the canvas authoring +// contract, and the user's saved personalization. Each is stripped in turn so +// the user's own request renders cleanly; the channel-context and +// canvas-instructions bodies are also surfaced as clickable tags (gated on the +// project-bluebird flag so flag-off viewers get the strip without the tag). +// custom-instructions is always-on background, so it's stripped with no tag. +export function usePromptDisplayContent( + content: string, + bluebirdEnabled: boolean, +): PromptDisplayContent { + const channelContext = useMemo( + () => extractChannelContext(content), + [content], + ); + const afterChannelContext = channelContext + ? channelContext.stripped + : content; + const canvasInstructions = useMemo( + () => extractCanvasInstructions(afterChannelContext), + [afterChannelContext], + ); + const afterCanvasInstructions = canvasInstructions + ? canvasInstructions.stripped + : afterChannelContext; + const customInstructions = useMemo( + () => extractCustomInstructions(afterCanvasInstructions), + [afterCanvasInstructions], + ); + const displayContent = customInstructions + ? customInstructions.stripped + : afterCanvasInstructions; + return { + displayContent, + channelContext, + canvasInstructions, + showChannelContextTag: !!channelContext && bluebirdEnabled, + showCanvasInstructionsTag: !!canvasInstructions && bluebirdEnabled, + }; +}