diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 97622d89ab..98f9d90ff9 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -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; content: string; created_at: string; author?: UserBasic | null; diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index f9d8a7d396..6c9588e4d3 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -31,6 +31,17 @@ describe("MentionText", () => { expect(screen.queryByText("@agent")).not.toBeInTheDocument(); }); + it("keeps agent text inside a markdown link label", () => { + render( + , + ); + + 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(); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index 39285e57d5..41214cf80e 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -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; diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 18bb9017db..89aca9895a 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -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", () => { @@ -45,6 +49,84 @@ describe("AgentStatusLine", () => { }); }); +describe("ThreadMessageRow", () => { + it("renders backend-authored agent announcements as Agent", () => { + render( + {}} + onDelete={() => {}} + />, + ); + + expect(screen.getByText("Agent")).toBeInTheDocument(); + expect(screen.queryByText("Unknown")).not.toBeInTheDocument(); + }); + + it("renders system announcements as System without human actions", () => { + render( + {}} + onDelete={() => {}} + />, + ); + + expect(screen.getByText("System")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Message actions" }), + ).not.toBeInTheDocument(); + }); + + it("keeps legacy authorless rows as human messages", () => { + render( + {}} + onDelete={() => {}} + />, + ); + + expect(screen.getByText("Unknown")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Message actions" }), + ).toBeInTheDocument(); + }); +}); + describe("UserPromptRow", () => { it("prefixes direct task prompts with @agent", () => { render( diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 2fd35d705e..fba801b8f1 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -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, @@ -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 ( - {getUserInitials(message.author)} + + {isAgent ? ( + + ) : isSystem ? ( + "S" + ) : ( + getUserInitials(message.author) + )} + - {userDisplayName(message.author)} + + {isAgent + ? "Agent" + : isSystem + ? "System" + : userDisplayName(message.author)} + diff --git a/packages/ui/src/features/canvas/utils/linkify.test.ts b/packages/ui/src/features/canvas/utils/linkify.test.ts index 6a4a67f6f7..512521b6d0 100644 --- a/packages/ui/src/features/canvas/utils/linkify.test.ts +++ b/packages/ui/src/features/canvas/utils/linkify.test.ts @@ -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); }); diff --git a/packages/ui/src/features/canvas/utils/linkify.ts b/packages/ui/src/features/canvas/utils/linkify.ts index 476d0917d9..6e1917acab 100644 --- a/packages/ui/src/features/canvas/utils/linkify.ts +++ b/packages/ui/src/features/canvas/utils/linkify.ts @@ -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; + export interface LinkTextSegment { type: "text"; text: string; @@ -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] ?? "", + }); + 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;