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
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ const conversationItems = [
},
];

function renderTimeline(canOpenInPlace?: boolean) {
function renderTimeline(canOpenInPlace?: boolean, items = conversationItems) {
return render(
<ActivityTimeline
task={task}
timeline={[]}
// biome-ignore lint/suspicious/noExplicitAny: narrow fixture for the rows under test
conversationItems={conversationItems as any}
conversationItems={items as any}
isTaskAuthor
canForward={false}
canOpenInPlace={canOpenInPlace}
Expand Down Expand Up @@ -88,6 +88,44 @@ describe("ActivityTimeline", () => {
renderTimeline();

expect(screen.queryAllByRole("button")).toHaveLength(0);
expect(screen.getByText(/first thing/)).toBeInTheDocument();
const body = screen.getByText(/first thing/).closest("[data-slot]");
expect(body).toHaveClass("whitespace-pre-wrap", "break-words");
expect(body).not.toHaveClass("line-clamp-1");
});

it("renders structured references natively in conversation previews", () => {
renderTimeline(false, [
{
type: "user_message",
id: "pr-message",
content:
'<github_pr number="73874" title="Loading…" url="https://github.com/PostHog/posthog/pull/73874" />',
timestamp: Date.parse("2026-07-17T09:05:00Z"),
},
]);

expect(screen.getByText("#73874 - Loading…")).toBeInTheDocument();
expect(screen.queryByText(/<github_pr/)).toBeNull();
});

it("folds injected channel context by default", () => {
renderTimeline(true, [
{
type: "user_message",
id: "context-message",
content:
'Review this\n\n<channel_context channel="code">Saved workspace context</channel_context>',
timestamp: Date.parse("2026-07-17T09:05:00Z"),
},
]);

expect(screen.getByText("Review this")).toBeInTheDocument();
expect(screen.queryByText(/<channel_context/)).toBeNull();
expect(screen.queryByText("Saved workspace context")).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "#code CONTEXT.md" }));

expect(screen.getByText("Saved workspace context")).toBeVisible();
expect(useThreadNavigationStore.getState().scrollRequests).toEqual({});
});
});
37 changes: 33 additions & 4 deletions packages/ui/src/features/canvas/components/ActivityTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {
CheckCircleIcon,
FileTextIcon,
PlusCircleIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
cn,
ThreadItem,
ThreadItemAuthor,
Expand All @@ -21,13 +25,15 @@ import type {
} from "@posthog/shared/domain-types";
import { isTerminalStatus } from "@posthog/shared/domain-types";
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
import { MentionText } from "@posthog/ui/features/canvas/components/MentionText";
import {
ThreadArtifactRow,
ThreadMessageRow,
} from "@posthog/ui/features/canvas/components/ThreadPanel";
import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp";
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
import type { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems";
import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext";
import { useThreadNavigationStore } from "@posthog/ui/features/sessions/threadNavigationStore";
import { Fragment, type KeyboardEvent, type ReactNode, useMemo } from "react";

Expand Down Expand Up @@ -73,6 +79,11 @@ function UserMessageRow({
onSelect?: () => void;
}) {
const name = author ? userDisplayName(author) : "You";
const channelContext = useMemo(
() => extractChannelContext(content),
[content],
);
const displayContent = channelContext?.stripped ?? content;
// The row itself is the hit target. `ThreadItem` renders an <article>, which a
// <button> may not wrap and which can't become one (quill's primitive takes no
// `render`), so it carries the button role and its own key handling.
Expand Down Expand Up @@ -108,10 +119,28 @@ function UserMessageRow({
<ThreadItemAuthor className="text-[13px]">{name}</ThreadItemAuthor>
<ThreadTimestamp dateTime={timestamp} />
</ThreadItemHeader>
{/* `whitespace-pre-wrap` makes the clamp land on the first *written*
line rather than the first wrapped one. */}
<ThreadItemBody className="mt-1.5 line-clamp-1 whitespace-pre-wrap text-[13px]">
{content}
<ThreadItemBody className="mt-1.5 whitespace-pre-wrap break-words text-[13px]">
<MentionText content={displayContent} />
{channelContext && (
<Collapsible className="mt-2 min-w-0 bg-transparent hover:bg-transparent data-open:bg-transparent">
<CollapsibleTrigger
className="min-h-0 w-full bg-transparent px-0 py-1 text-left hover:bg-transparent aria-expanded:bg-transparent"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<FileTextIcon size={12} />
<span className="truncate text-xs">
{channelContext.mention.name
? `#${channelContext.mention.name} `
: ""}
CONTEXT.md
</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-muted p-2 text-muted-foreground text-xs">
{channelContext.mention.body}
</CollapsibleContent>
</Collapsible>
)}
</ThreadItemBody>
</ThreadItemContent>
</ThreadItem>
Expand Down
34 changes: 34 additions & 0 deletions packages/ui/src/features/canvas/components/MentionText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,38 @@ describe("MentionText", () => {

expect(screen.getByText("A thread reply")).not.toHaveClass("text-xs");
});

it("renders structured references as inline native chips", () => {
const { container } = render(
<MentionText
content={
'Review <github_pr number="73874" title="Loading…" url="https://github.com/PostHog/posthog/pull/73874" /> with <file path="src/loading.tsx" /> using <skill name="review" source="user" path="/skills/review/SKILL.md" />'
}
/>,
);

expect(screen.getByText("#73874 - Loading…")).toBeInTheDocument();
expect(screen.getByText("@src/loading.tsx")).toBeInTheDocument();
expect(screen.getByText("/review")).toBeInTheDocument();
expect(container.querySelector("p")).toBeNull();
expect(screen.getByText("@src/loading.tsx").parentElement).toHaveClass(
"inline-flex",
"whitespace-nowrap",
);
});

it("leaves malformed and unsafe structured references as readable text", () => {
render(
<MentionText
content={
'<github_pr number="12" title="Unsafe" url="javascript:alert(1)" /> and <github_pr title="Incomplete" />'
}
/>,
);

expect(screen.getByText(/#12 - Unsafe/).parentElement).toHaveTextContent(
'@#12 - Unsafe and <github_pr title="Incomplete" />',
);
expect(screen.queryByRole("button")).toBeNull();
});
});
74 changes: 68 additions & 6 deletions packages/ui/src/features/canvas/components/MentionText.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import {
ChartLineIcon,
FileTextIcon,
FlagIcon,
FlaskIcon,
FolderIcon,
TerminalIcon,
WarningIcon,
} from "@phosphor-icons/react";
import type { MentionChip } from "@posthog/core/message-editor/content";
import { xmlToContent } from "@posthog/core/message-editor/content";
import { Chip } from "@posthog/quill";
import { splitMentionSegments } from "@posthog/shared";
import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify";
import { GithubRefChip } from "@posthog/ui/features/editor/components/GithubRefChip";
import { parseGithubIssueUrl } from "@posthog/ui/features/message-editor/githubIssueUrl";
import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks";
import { Fragment, useMemo } from "react";
import "./mention-chip.css";
Expand All @@ -8,7 +22,46 @@ type RenderSegment =
| { type: "text"; text: string }
| { type: "link"; text: string; href: string }
| { type: "agent"; text: string }
| { type: "mention"; name: string; email: string };
| { type: "mention"; name: string; email: string }
| { type: "chip"; chip: MentionChip };

const chipIcons = {
file: FileTextIcon,
folder: FolderIcon,
command: TerminalIcon,
error: WarningIcon,
experiment: FlaskIcon,
insight: ChartLineIcon,
feature_flag: FlagIcon,
} as const;

function StructuredChip({ chip }: { chip: MentionChip }) {
if (chip.type === "github_issue" || chip.type === "github_pr") {
const githubRef = parseGithubIssueUrl(chip.id);
if (githubRef) {
return (
<GithubRefChip href={githubRef.normalizedUrl} kind={githubRef.kind}>
{chip.label}
</GithubRefChip>
);
}
}

const Icon = chipIcons[chip.type as keyof typeof chipIcons];
if (!Icon) return <>@{chip.label}</>;
return (
<Chip
size="xs"
className="mx-0.5 inline-flex max-w-full whitespace-nowrap align-middle"
>
<Icon size={10} />
<span className="min-w-0 truncate">
{chip.type === "command" ? "/" : "@"}
{chip.label}
</span>
</Chip>
);
}

// The plain (not-the-viewer) mention chip look, also used by surfaces that
// render a mention-styled name without real mention semantics (e.g. the
Expand Down Expand Up @@ -66,11 +119,17 @@ export function MentionText({
}
}
};
for (const segment of splitLinkSegments(content)) {
if (segment.type === "link") {
push(segment, segment.text.length);
} else {
pushMentions(segment.text);
for (const contentSegment of xmlToContent(content).segments) {
if (contentSegment.type === "chip") {
push({ type: "chip", chip: contentSegment.chip }, 1);
continue;
}
for (const segment of splitLinkSegments(contentSegment.text)) {
if (segment.type === "link") {
push(segment, segment.text.length);
} else {
pushMentions(segment.text);
}
}
}
return entries;
Expand All @@ -79,6 +138,9 @@ export function MentionText({
return (
<span className={className}>
{segments.map(({ segment, key }) => {
if (segment.type === "chip") {
return <StructuredChip key={key} chip={segment.chip} />;
}
if (segment.type === "agent") {
return (
<span key={key} className={mentionChipClass}>
Expand Down
26 changes: 24 additions & 2 deletions packages/ui/src/features/canvas/components/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ describe("ThreadMessageRow", () => {

const multiline = "First line\n\nSecond line with more detail";

// `preview` only adds a CSS clamp, so the full message is in the DOM either
// way. What's worth pinning is that no code path slices the text away.
it("renders the whole message, so a comment is never cut short", () => {
render(
<ThreadMessageRow
Expand All @@ -96,6 +94,30 @@ describe("ThreadMessageRow", () => {
screen.getByText(/Second line with more detail/),
).toBeInTheDocument();
});

it("shows the full message in timeline previews", () => {
render(
<ThreadMessageRow
message={{
id: "m1",
task: "task",
content: multiline,
created_at: "2026-07-17T00:00:00Z",
author: null,
}}
isTaskAuthor
isOwnMessage={false}
canForward
preview
onSendToAgent={() => {}}
onDelete={() => {}}
/>,
);

const body = screen.getByText(/Second line with more detail/).parentElement;
expect(body).toHaveClass("whitespace-pre-wrap", "break-words");
expect(body).not.toHaveClass("line-clamp-1");
});
});

describe("ThreadArtifactRow", () => {
Expand Down
6 changes: 2 additions & 4 deletions packages/ui/src/features/canvas/components/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function ThreadMessageRow({
isOwnMessage: boolean;
currentUserEmail?: string | null;
canForward: boolean;
/** Timeline rows show one truncated line; the Comments tab shows it all. */
/** Timeline rows preserve authored whitespace while showing the full message. */
preview?: boolean;
onSendToAgent: () => void;
onDelete: () => void;
Expand All @@ -101,9 +101,7 @@ export function ThreadMessageRow({
<ThreadItemBody
className={cn(
"mt-1.5 text-[13px]",
// `whitespace-pre-wrap` makes the clamp land on the first *written*
// line rather than the first wrapped one.
preview && "line-clamp-1 whitespace-pre-wrap",
preview && "whitespace-pre-wrap break-words",
)}
>
<MentionText
Expand Down
Loading