Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/shared/src/domain-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ export interface ChannelFeedMessage {
export interface TaskThreadMessage {
id: string;
task: string;
/** Who authored the row; agent rows are server-emitted announcements. Absent on older backends. */
author_kind?: "human" | "system" | "agent";
/** Stable event key for non-human rows (e.g. "canvas_created", "turn_complete"). */
event?: string;
/** Structured event payload; turn_complete carries `{ run_id }` so a client rendering a run's live agent turns can dedupe the durable row. */
payload?: Record<string, unknown>;
content: string;
created_at: string;
author?: UserBasic | null;
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/features/canvas/components/MentionText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ describe("MentionText", () => {
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
});

it("keeps agent text inside a markdown link label", () => {
render(
<MentionText content="[My @agent report](https://posthog.com/report) has been created" />,
);

expect(
screen.getByRole("link", { name: "My @agent report" }),
).toHaveAttribute("href", "https://posthog.com/report");
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
});

it("inherits the surrounding message text size", () => {
render(<MentionText content="A thread reply" />);

Expand Down
38 changes: 24 additions & 14 deletions packages/ui/src/features/canvas/components/MentionText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,40 @@ export function MentionText({
entries.push({ segment, key: `${offset}` });
offset += length;
};
const pushText = (text: string) => {
const pushAgentMentions = (text: string) => {
let cursor = 0;
for (const match of text.matchAll(/(^|\s)(@agent)\b/gi)) {
const mentionStart = (match.index ?? 0) + match[1].length;
for (const part of splitLinkSegments(
text.slice(cursor, mentionStart),
)) {
push(part, part.text.length);
if (mentionStart > cursor) {
push(
{ type: "text", text: text.slice(cursor, mentionStart) },
mentionStart - cursor,
);
}
push({ type: "agent", text: match[2] }, match[2].length);
cursor = mentionStart + match[2].length;
}
for (const part of splitLinkSegments(text.slice(cursor))) {
push(part, part.text.length);
if (cursor < text.length) {
push({ type: "text", text: text.slice(cursor) }, text.length - cursor);
}
};
const pushMentions = (text: string) => {
for (const segment of splitMentionSegments(text)) {
if (segment.type === "mention") {
push(
{ type: "mention", name: segment.name, email: segment.email },
segment.text.length,
);
} else {
pushAgentMentions(segment.text);
}
}
};
for (const segment of splitMentionSegments(content)) {
if (segment.type === "mention") {
push(
{ type: "mention", name: segment.name, email: segment.email },
segment.text.length,
);
for (const segment of splitLinkSegments(content)) {
if (segment.type === "link") {
push(segment, segment.text.length);
} else {
pushText(segment.text);
pushMentions(segment.text);
}
}
return entries;
Expand Down
84 changes: 83 additions & 1 deletion packages/ui/src/features/canvas/components/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AgentStatusLine, UserPromptRow } from "./ThreadPanel";
import {
AgentStatusLine,
ThreadMessageRow,
UserPromptRow,
} from "./ThreadPanel";
import { agentTurns } from "./threadAgentTurns";

describe("agentTurns", () => {
Expand Down Expand Up @@ -45,6 +49,84 @@ describe("AgentStatusLine", () => {
});
});

describe("ThreadMessageRow", () => {
it("renders backend-authored agent announcements as Agent", () => {
render(
<ThreadMessageRow
message={{
id: "announcement",
task: "task",
author_kind: "agent",
event: "canvas_created",
payload: {},
content: "Canvas created",
created_at: "2026-07-17T00:00:00Z",
author: null,
}}
isTaskAuthor={false}
isOwnMessage={false}
canForward={false}
onSendToAgent={() => {}}
onDelete={() => {}}
/>,
);

expect(screen.getByText("Agent")).toBeInTheDocument();
expect(screen.queryByText("Unknown")).not.toBeInTheDocument();
});

it("renders system announcements as System without human actions", () => {
render(
<ThreadMessageRow
message={{
id: "system-announcement",
task: "task",
author_kind: "system",
event: "status_changed",
payload: {},
content: "Status changed",
created_at: "2026-07-17T00:00:00Z",
author: null,
}}
isTaskAuthor
isOwnMessage={false}
canForward
onSendToAgent={() => {}}
onDelete={() => {}}
/>,
);

expect(screen.getByText("System")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Message actions" }),
).not.toBeInTheDocument();
});

it("keeps legacy authorless rows as human messages", () => {
render(
<ThreadMessageRow
message={{
id: "legacy-message",
task: "task",
content: "Author removed",
created_at: "2026-07-17T00:00:00Z",
author: null,
}}
isTaskAuthor
isOwnMessage={false}
canForward
onSendToAgent={() => {}}
onDelete={() => {}}
/>,
);

expect(screen.getByText("Unknown")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Message actions" }),
).toBeInTheDocument();
});
});

describe("UserPromptRow", () => {
it("prefixes direct task prompts with @agent", () => {
render(
Expand Down
26 changes: 22 additions & 4 deletions packages/ui/src/features/canvas/components/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import { track } from "@posthog/ui/shell/analytics";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

function ThreadMessageRow({
export function ThreadMessageRow({
message,
isTaskAuthor,
isOwnMessage,
Expand All @@ -105,18 +105,36 @@ function ThreadMessageRow({
onDelete: () => void;
}) {
const forwarded = !!message.forwarded_to_agent_at;
const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage;
const authorKind = message.author_kind ?? "human";
const isAgent = authorKind === "agent";
const isSystem = authorKind === "system";
const showMenu =
authorKind === "human" && ((isTaskAuthor && !forwarded) || isOwnMessage);

return (
<ThreadItem>
<ThreadItemGutter>
<Avatar size="lg" className="sticky top-2">
<AvatarFallback>{getUserInitials(message.author)}</AvatarFallback>
<AvatarFallback>
{isAgent ? (
<RobotIcon size={14} />
) : isSystem ? (
"S"
) : (
getUserInitials(message.author)
)}
</AvatarFallback>
</Avatar>
</ThreadItemGutter>
<ThreadItemContent>
<ThreadItemHeader>
<ThreadItemAuthor>{userDisplayName(message.author)}</ThreadItemAuthor>
<ThreadItemAuthor>
{isAgent
? "Agent"
: isSystem
? "System"
: userDisplayName(message.author)}
</ThreadItemAuthor>
<ThreadTimestamp dateTime={message.created_at} />
</ThreadItemHeader>
<ThreadItemBody>
Expand Down
27 changes: 27 additions & 0 deletions packages/ui/src/features/canvas/utils/linkify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,33 @@ describe("splitLinkSegments", () => {
],
["not a url scheme", "ftp://example.com", [text("ftp://example.com")]],
["empty string", "", []],
[
"markdown link uses its label",
"[Signups](https://us.posthog.com/code/canvas/c/d) has been created",
[
{
type: "link",
text: "Signups",
href: "https://us.posthog.com/code/canvas/c/d",
},
text(" has been created"),
],
],
[
"markdown link alongside a bare url",
"see [docs](https://a.com) or https://b.com",
[
text("see "),
{ type: "link", text: "docs", href: "https://a.com" },
text(" or "),
link("https://b.com"),
],
],
[
"markdown label without a url stays prose",
"[not a link](just text)",
[text("[not a link](just text)")],
],
])("%s", (_label, input, expected) => {
expect(splitLinkSegments(input)).toEqual(expected);
});
Expand Down
33 changes: 32 additions & 1 deletion packages/ui/src/features/canvas/utils/linkify.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
const URL_PATTERN = /https?:\/\/[^\s<>]+/gi;

// Named links written as markdown `[label](https://url)` — the shape auto-posted
// thread messages use. Label excludes brackets and the URL excludes parens so
// the token boundaries are unambiguous (same reasoning as MENTION_PATTERN).
const MARKDOWN_LINK_PATTERN = /\[([^\][\n]+)\]\((https?:\/\/[^\s()]+)\)/gi;
Comment thread
k11kirky marked this conversation as resolved.

export interface LinkTextSegment {
type: "text";
text: string;
Expand Down Expand Up @@ -39,8 +44,34 @@ function trimTrailingPunctuation(url: string): string {
return url.slice(0, end);
}

/** Split plain text into text and http(s) link segments, in document order. */
/**
* Split plain text into text and http(s) link segments, in document order.
* Markdown-style `[label](url)` tokens become links titled by their label;
* bare URLs in the remaining text link as themselves.
*/
export function splitLinkSegments(text: string): LinkSegment[] {
const segments: LinkSegment[] = [];
let lastIndex = 0;
MARKDOWN_LINK_PATTERN.lastIndex = 0;
for (const match of text.matchAll(MARKDOWN_LINK_PATTERN)) {
const index = match.index ?? 0;
if (index > lastIndex) {
segments.push(...splitBareUrlSegments(text.slice(lastIndex, index)));
}
segments.push({
type: "link",
text: match[1] ?? "",
href: match[2] ?? "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Deceptive external links

A collaborator can post [https://us.posthog.com](https://attacker.example) so the thread displays the trusted PostHog URL while clicking opens the attacker-controlled site. Restrict labeled-link rendering to server-authored announcements, or expose the destination host for human-authored messages.

});
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
segments.push(...splitBareUrlSegments(text.slice(lastIndex)));
}
return segments;
}

function splitBareUrlSegments(text: string): LinkSegment[] {
const segments: LinkSegment[] = [];
let lastIndex = 0;
URL_PATTERN.lastIndex = 0;
Expand Down
Loading