Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/ui/src/features/canvas/components/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<UserPromptRow
message={{
id: "prompt",
text: 'ship the fix\n<user_custom_instructions>\nAlways be brief.\n</user_custom_instructions>\n<channel_context channel="billing">\n# Billing\n</channel_context>',
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(
<UserPromptRow
message={{
id: "prompt",
text: 'review <github_pr number="42" title="Fix login" url="https://github.com/org/repo/pull/42" />',
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();
});
});
27 changes: 20 additions & 7 deletions packages/ui/src/features/canvas/components/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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 (
<ThreadItem>
Expand All @@ -279,8 +287,13 @@ export function UserPromptRow({
/>
)}
</ThreadItemHeader>
<ThreadItemBody className="wrap-break-word whitespace-pre-wrap">
<span className={mentionChipClass}>@agent</span> {promptText}
<ThreadItemBody className="wrap-break-word">
<span className={mentionChipClass}>@agent</span>{" "}
{containsMentions ? (
parseFileMentions(promptText)
) : (
<ChatMarkdown content={promptText} />
)}
</ThreadItemBody>
</ThreadItemContent>
</ThreadItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -12,4 +13,24 @@ describe("ChatMarkdown", () => {
expect(html).not.toContain("<img");
expect(html).not.toContain("http://127.0.0.1/action");
});

it("renders GitHub PR URLs as ref chips, matching the conversation view", () => {
const url = "https://github.com/PostHog/posthog/pull/23985";
const html = renderToStaticMarkup(
<ChatMarkdown content={`See ${url} for context.`} />,
);

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(
<ChatMarkdown content="[docs](https://example.com/docs)" />,
);

expect(html).not.toContain(GITHUB_REF_URL_ATTR);
expect(html).toContain('href="https://example.com/docs"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,16 +68,30 @@ const components: Components = {
p: ({ children }) => (
<Text className="text-sm leading-[1.5]">{children}</Text>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2"
>
{children}
</a>
),
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 (
<GithubRefChip href={githubRef.normalizedUrl} kind={githubRef.kind}>
{label}
</GithubRefChip>
);
}
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2"
>
{children}
</a>
);
},
img: ({ alt }) => (
<Text className="text-muted-foreground text-sm">
Remote image blocked{alt ? `: ${alt}` : ""}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,14 @@ 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,
parseFileMentions,
} 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";
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
// <channel_context>/<canvas_generation_instructions> XML never leaks for
// flag-off viewers. The user's saved personalization
// (<user_custom_instructions>) 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,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<channel_context channel="billing">\n# Billing\n</channel_context>\n<canvas_generation_instructions>\nauthoring contract\n</canvas_generation_instructions>\n<user_custom_instructions>\nAlways be brief.\n</user_custom_instructions>';
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<channel_context channel="billing">\n# Billing\n</channel_context>\n<canvas_generation_instructions>\ncontract\n</canvas_generation_instructions>';
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<user_custom_instructions>\nbe brief\n</user_custom_instructions>";
const result = render(content, true);
expect(result.displayContent).toBe("ship it");
expect(result.showChannelContextTag).toBe(false);
expect(result.showCanvasInstructionsTag).toBe(false);
});
});
Loading
Loading