diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 358fa23864..4625973c73 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -86,6 +86,12 @@ export interface TaskChannel { 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/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 47e5ef82ea..615b414994 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -65,16 +65,23 @@ function ThreadMessageRow({ }) { const forwarded = !!message.forwarded_to_agent_at; const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage; + // Agent rows are server-emitted announcements (canvas created, turn complete). + // author_kind is authoritative; authorless is the pre-author_kind fallback. + const isAgent = + message.author_kind === "agent" || + (!message.author_kind && !message.author); return (
- {getUserInitials(message.author)} + + {isAgent ? : getUserInitials(message.author)} +
- {userDisplayName(message.author)} + {isAgent ? "Agent" : userDisplayName(message.author)} {formatRelativeTimeShort(message.created_at)} 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;