Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
33 changes: 32 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,33 @@ 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();
});
});

describe("UserPromptRow", () => {
it("prefixes direct task prompts with @agent", () => {
render(
Expand Down
19 changes: 15 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,29 @@ function ThreadMessageRow({
onDelete: () => void;
}) {
const forwarded = !!message.forwarded_to_agent_at;
const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage;
const isAgent =
message.author_kind === "agent" ||
(!message.author_kind && !message.author);
Comment thread
k11kirky marked this conversation as resolved.
Outdated
const showMenu = !isAgent && ((isTaskAuthor && !forwarded) || isOwnMessage);

return (
<ThreadItem>
<ThreadItemGutter>
<Avatar size="lg" className="sticky top-2">
<AvatarFallback>{getUserInitials(message.author)}</AvatarFallback>
<AvatarFallback>
{isAgent ? (
<RobotIcon size={14} />
) : (
getUserInitials(message.author)
)}
</AvatarFallback>
</Avatar>
</ThreadItemGutter>
<ThreadItemContent>
<ThreadItemHeader>
<ThreadItemAuthor>{userDisplayName(message.author)}</ThreadItemAuthor>
<ThreadItemAuthor>
{isAgent ? "Agent" : 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