Skip to content

Commit 3d788e3

Browse files
authored
Render agent thread updates: markdown links and agent-authored messages (#3380)
1 parent 0f9e76d commit 3d788e3

7 files changed

Lines changed: 205 additions & 20 deletions

File tree

packages/shared/src/domain-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ export interface ChannelFeedMessage {
107107
export interface TaskThreadMessage {
108108
id: string;
109109
task: string;
110+
/** Who authored the row; agent rows are server-emitted announcements. Absent on older backends. */
111+
author_kind?: "human" | "system" | "agent";
112+
/** Stable event key for non-human rows (e.g. "canvas_created", "turn_complete"). */
113+
event?: string;
114+
/** Structured event payload; turn_complete carries `{ run_id }` so a client rendering a run's live agent turns can dedupe the durable row. */
115+
payload?: Record<string, unknown>;
110116
content: string;
111117
created_at: string;
112118
author?: UserBasic | null;

packages/ui/src/features/canvas/components/MentionText.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ describe("MentionText", () => {
3131
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
3232
});
3333

34+
it("keeps agent text inside a markdown link label", () => {
35+
render(
36+
<MentionText content="[My @agent report](https://posthog.com/report) has been created" />,
37+
);
38+
39+
expect(
40+
screen.getByRole("link", { name: "My @agent report" }),
41+
).toHaveAttribute("href", "https://posthog.com/report");
42+
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
43+
});
44+
3445
it("inherits the surrounding message text size", () => {
3546
render(<MentionText content="A thread reply" />);
3647

packages/ui/src/features/canvas/components/MentionText.tsx

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,40 @@ export function MentionText({
3636
entries.push({ segment, key: `${offset}` });
3737
offset += length;
3838
};
39-
const pushText = (text: string) => {
39+
const pushAgentMentions = (text: string) => {
4040
let cursor = 0;
4141
for (const match of text.matchAll(/(^|\s)(@agent)\b/gi)) {
4242
const mentionStart = (match.index ?? 0) + match[1].length;
43-
for (const part of splitLinkSegments(
44-
text.slice(cursor, mentionStart),
45-
)) {
46-
push(part, part.text.length);
43+
if (mentionStart > cursor) {
44+
push(
45+
{ type: "text", text: text.slice(cursor, mentionStart) },
46+
mentionStart - cursor,
47+
);
4748
}
4849
push({ type: "agent", text: match[2] }, match[2].length);
4950
cursor = mentionStart + match[2].length;
5051
}
51-
for (const part of splitLinkSegments(text.slice(cursor))) {
52-
push(part, part.text.length);
52+
if (cursor < text.length) {
53+
push({ type: "text", text: text.slice(cursor) }, text.length - cursor);
54+
}
55+
};
56+
const pushMentions = (text: string) => {
57+
for (const segment of splitMentionSegments(text)) {
58+
if (segment.type === "mention") {
59+
push(
60+
{ type: "mention", name: segment.name, email: segment.email },
61+
segment.text.length,
62+
);
63+
} else {
64+
pushAgentMentions(segment.text);
65+
}
5366
}
5467
};
55-
for (const segment of splitMentionSegments(content)) {
56-
if (segment.type === "mention") {
57-
push(
58-
{ type: "mention", name: segment.name, email: segment.email },
59-
segment.text.length,
60-
);
68+
for (const segment of splitLinkSegments(content)) {
69+
if (segment.type === "link") {
70+
push(segment, segment.text.length);
6171
} else {
62-
pushText(segment.text);
72+
pushMentions(segment.text);
6373
}
6474
}
6575
return entries;

packages/ui/src/features/canvas/components/ThreadPanel.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
22
import { render, screen } from "@testing-library/react";
33
import { describe, expect, it } from "vitest";
4-
import { AgentStatusLine, UserPromptRow } from "./ThreadPanel";
4+
import {
5+
AgentStatusLine,
6+
ThreadMessageRow,
7+
UserPromptRow,
8+
} from "./ThreadPanel";
59
import { agentTurns } from "./threadAgentTurns";
610

711
describe("agentTurns", () => {
@@ -45,6 +49,84 @@ describe("AgentStatusLine", () => {
4549
});
4650
});
4751

52+
describe("ThreadMessageRow", () => {
53+
it("renders backend-authored agent announcements as Agent", () => {
54+
render(
55+
<ThreadMessageRow
56+
message={{
57+
id: "announcement",
58+
task: "task",
59+
author_kind: "agent",
60+
event: "canvas_created",
61+
payload: {},
62+
content: "Canvas created",
63+
created_at: "2026-07-17T00:00:00Z",
64+
author: null,
65+
}}
66+
isTaskAuthor={false}
67+
isOwnMessage={false}
68+
canForward={false}
69+
onSendToAgent={() => {}}
70+
onDelete={() => {}}
71+
/>,
72+
);
73+
74+
expect(screen.getByText("Agent")).toBeInTheDocument();
75+
expect(screen.queryByText("Unknown")).not.toBeInTheDocument();
76+
});
77+
78+
it("renders system announcements as System without human actions", () => {
79+
render(
80+
<ThreadMessageRow
81+
message={{
82+
id: "system-announcement",
83+
task: "task",
84+
author_kind: "system",
85+
event: "status_changed",
86+
payload: {},
87+
content: "Status changed",
88+
created_at: "2026-07-17T00:00:00Z",
89+
author: null,
90+
}}
91+
isTaskAuthor
92+
isOwnMessage={false}
93+
canForward
94+
onSendToAgent={() => {}}
95+
onDelete={() => {}}
96+
/>,
97+
);
98+
99+
expect(screen.getByText("System")).toBeInTheDocument();
100+
expect(
101+
screen.queryByRole("button", { name: "Message actions" }),
102+
).not.toBeInTheDocument();
103+
});
104+
105+
it("keeps legacy authorless rows as human messages", () => {
106+
render(
107+
<ThreadMessageRow
108+
message={{
109+
id: "legacy-message",
110+
task: "task",
111+
content: "Author removed",
112+
created_at: "2026-07-17T00:00:00Z",
113+
author: null,
114+
}}
115+
isTaskAuthor
116+
isOwnMessage={false}
117+
canForward
118+
onSendToAgent={() => {}}
119+
onDelete={() => {}}
120+
/>,
121+
);
122+
123+
expect(screen.getByText("Unknown")).toBeInTheDocument();
124+
expect(
125+
screen.getByRole("button", { name: "Message actions" }),
126+
).toBeInTheDocument();
127+
});
128+
});
129+
48130
describe("UserPromptRow", () => {
49131
it("prefixes direct task prompts with @agent", () => {
50132
render(

packages/ui/src/features/canvas/components/ThreadPanel.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ import { track } from "@posthog/ui/shell/analytics";
8787
import { useQuery } from "@tanstack/react-query";
8888
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8989

90-
function ThreadMessageRow({
90+
export function ThreadMessageRow({
9191
message,
9292
isTaskAuthor,
9393
isOwnMessage,
@@ -105,18 +105,36 @@ function ThreadMessageRow({
105105
onDelete: () => void;
106106
}) {
107107
const forwarded = !!message.forwarded_to_agent_at;
108-
const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage;
108+
const authorKind = message.author_kind ?? "human";
109+
const isAgent = authorKind === "agent";
110+
const isSystem = authorKind === "system";
111+
const showMenu =
112+
authorKind === "human" && ((isTaskAuthor && !forwarded) || isOwnMessage);
109113

110114
return (
111115
<ThreadItem>
112116
<ThreadItemGutter>
113117
<Avatar size="lg" className="sticky top-2">
114-
<AvatarFallback>{getUserInitials(message.author)}</AvatarFallback>
118+
<AvatarFallback>
119+
{isAgent ? (
120+
<RobotIcon size={14} />
121+
) : isSystem ? (
122+
"S"
123+
) : (
124+
getUserInitials(message.author)
125+
)}
126+
</AvatarFallback>
115127
</Avatar>
116128
</ThreadItemGutter>
117129
<ThreadItemContent>
118130
<ThreadItemHeader>
119-
<ThreadItemAuthor>{userDisplayName(message.author)}</ThreadItemAuthor>
131+
<ThreadItemAuthor>
132+
{isAgent
133+
? "Agent"
134+
: isSystem
135+
? "System"
136+
: userDisplayName(message.author)}
137+
</ThreadItemAuthor>
120138
<ThreadTimestamp dateTime={message.created_at} />
121139
</ThreadItemHeader>
122140
<ThreadItemBody>

packages/ui/src/features/canvas/utils/linkify.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ describe("splitLinkSegments", () => {
5454
],
5555
["not a url scheme", "ftp://example.com", [text("ftp://example.com")]],
5656
["empty string", "", []],
57+
[
58+
"markdown link uses its label",
59+
"[Signups](https://us.posthog.com/code/canvas/c/d) has been created",
60+
[
61+
{
62+
type: "link",
63+
text: "Signups",
64+
href: "https://us.posthog.com/code/canvas/c/d",
65+
},
66+
text(" has been created"),
67+
],
68+
],
69+
[
70+
"markdown link alongside a bare url",
71+
"see [docs](https://a.com) or https://b.com",
72+
[
73+
text("see "),
74+
{ type: "link", text: "docs", href: "https://a.com" },
75+
text(" or "),
76+
link("https://b.com"),
77+
],
78+
],
79+
[
80+
"markdown label without a url stays prose",
81+
"[not a link](just text)",
82+
[text("[not a link](just text)")],
83+
],
5784
])("%s", (_label, input, expected) => {
5885
expect(splitLinkSegments(input)).toEqual(expected);
5986
});

packages/ui/src/features/canvas/utils/linkify.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
const URL_PATTERN = /https?:\/\/[^\s<>]+/gi;
22

3+
// Named links written as markdown `[label](https://url)` — the shape auto-posted
4+
// thread messages use. Label excludes brackets and the URL excludes parens so
5+
// the token boundaries are unambiguous (same reasoning as MENTION_PATTERN).
6+
const MARKDOWN_LINK_PATTERN = /\[([^\][\n]+)\]\((https?:\/\/[^\s()]+)\)/gi;
7+
38
export interface LinkTextSegment {
49
type: "text";
510
text: string;
@@ -39,8 +44,34 @@ function trimTrailingPunctuation(url: string): string {
3944
return url.slice(0, end);
4045
}
4146

42-
/** Split plain text into text and http(s) link segments, in document order. */
47+
/**
48+
* Split plain text into text and http(s) link segments, in document order.
49+
* Markdown-style `[label](url)` tokens become links titled by their label;
50+
* bare URLs in the remaining text link as themselves.
51+
*/
4352
export function splitLinkSegments(text: string): LinkSegment[] {
53+
const segments: LinkSegment[] = [];
54+
let lastIndex = 0;
55+
MARKDOWN_LINK_PATTERN.lastIndex = 0;
56+
for (const match of text.matchAll(MARKDOWN_LINK_PATTERN)) {
57+
const index = match.index ?? 0;
58+
if (index > lastIndex) {
59+
segments.push(...splitBareUrlSegments(text.slice(lastIndex, index)));
60+
}
61+
segments.push({
62+
type: "link",
63+
text: match[1] ?? "",
64+
href: match[2] ?? "",
65+
});
66+
lastIndex = index + match[0].length;
67+
}
68+
if (lastIndex < text.length) {
69+
segments.push(...splitBareUrlSegments(text.slice(lastIndex)));
70+
}
71+
return segments;
72+
}
73+
74+
function splitBareUrlSegments(text: string): LinkSegment[] {
4475
const segments: LinkSegment[] = [];
4576
let lastIndex = 0;
4677
URL_PATTERN.lastIndex = 0;

0 commit comments

Comments
 (0)