From 2ef43deb29e16af402ea19522c5818f3e62d2a60 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 17 Jul 2026 16:02:43 +0100 Subject: [PATCH 1/5] Auto-post canvas creation and turn-complete updates into the task thread When a canvas-mode generation finishes, the client that started it now posts into the task's thread: a one-time "[Canvas name](link) has been created" comment when the run built a new canvas, plus a turn-complete note @-mentioning the task creator. Thread rendering learns markdown-style [label](url) links so the created comment reads as a named link. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- packages/shared/src/analytics-events.ts | 5 +- .../freeform/canvasThreadAutoPost.test.ts | 105 ++++++++++++++++++ .../canvas/freeform/canvasThreadAutoPost.ts | 57 ++++++++++ .../freeform/useCanvasGenerationToasts.ts | 82 ++++++++++++-- .../canvas/hooks/useGenerateFreeformCanvas.ts | 10 +- .../stores/canvasGenerationTrackerStore.ts | 2 + .../src/features/canvas/utils/linkify.test.ts | 27 +++++ .../ui/src/features/canvas/utils/linkify.ts | 33 +++++- 8 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts create mode 100644 packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index efe590fb7f..2fe30f28a0 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -880,7 +880,8 @@ export type ChannelActionType = | "mention_member" | "view_activity" | "open_mention" - | "canvas_mode_toggle"; + | "canvas_mode_toggle" + | "thread_auto_post"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -899,6 +900,8 @@ export interface ChannelActionProperties { suggestion_label?: string; /** For canvas_mode_toggle: whether canvas mode is being armed. */ armed?: boolean; + /** For thread_auto_post: which auto-comment was posted. */ + auto_post_kind?: "canvas_created" | "turn_complete"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } diff --git a/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts b/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts new file mode 100644 index 0000000000..e3ea16bad8 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts @@ -0,0 +1,105 @@ +import type { UserBasic } from "@posthog/shared/domain-types"; +import { useAuthStore } from "@posthog/ui/features/auth/store"; +import type { TrackedCanvasGeneration } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; +import { beforeEach, describe, expect, it } from "vitest"; +import { buildCanvasGenerationThreadPosts } from "./canvasThreadAutoPost"; + +const entry: TrackedCanvasGeneration = { + taskId: "t1", + dashboardId: "d1", + channelId: "c1", + name: "Signups overview", + createsCanvas: true, +}; + +const creator: UserBasic = { + id: 1, + uuid: "u1", + email: "raquel@posthog.com", + first_name: "Raquel", + last_name: "Smith", +}; + +function setCloudRegion(region: "us" | null) { + useAuthStore.setState((s) => ({ + authState: { ...s.authState, cloudRegion: region }, + })); +} + +describe("buildCanvasGenerationThreadPosts", () => { + beforeEach(() => setCloudRegion("us")); + + it("posts the created comment and a creator-tagging turn-complete note", () => { + const posts = buildCanvasGenerationThreadPosts(entry, "completed", creator); + expect(posts).toEqual([ + { + kind: "canvas_created", + content: + "[Signups overview](https://us.posthog.com/code/canvas/c1/d1) has been created", + }, + { + kind: "turn_complete", + content: + "@[Raquel Smith](raquel@posthog.com) Turn complete — the agent finished generating Signups overview.", + }, + ]); + }); + + it("skips the created comment when the run edits an existing canvas", () => { + const posts = buildCanvasGenerationThreadPosts( + { ...entry, createsCanvas: false }, + "completed", + creator, + ); + expect(posts.map((p) => p.kind)).toEqual(["turn_complete"]); + }); + + it("skips the created comment on failure and reports it in the note", () => { + const posts = buildCanvasGenerationThreadPosts(entry, "failed", creator); + expect(posts).toEqual([ + { + kind: "turn_complete", + content: + "@[Raquel Smith](raquel@posthog.com) Turn complete — the agent couldn't finish generating Signups overview.", + }, + ]); + }); + + it("stays silent on cancellation", () => { + expect( + buildCanvasGenerationThreadPosts(entry, "cancelled", creator), + ).toEqual([]); + }); + + it("omits the mention when the creator is unknown", () => { + const posts = buildCanvasGenerationThreadPosts(entry, "completed", null); + expect(posts[1]?.content).toBe( + "Turn complete — the agent finished generating Signups overview.", + ); + }); + + it("sanitizes names that would break the link token and falls back when empty", () => { + const posts = buildCanvasGenerationThreadPosts( + { ...entry, name: "[Q3] KPIs" }, + "completed", + null, + ); + expect(posts[0]?.content).toBe( + "[Q3 KPIs](https://us.posthog.com/code/canvas/c1/d1) has been created", + ); + const unnamed = buildCanvasGenerationThreadPosts( + { ...entry, name: " " }, + "completed", + null, + ); + expect(unnamed[0]?.content).toBe( + "[Canvas](https://us.posthog.com/code/canvas/c1/d1) has been created", + ); + }); + + it("degrades to plain text when no share link can be built", () => { + setCloudRegion(null); + const posts = buildCanvasGenerationThreadPosts(entry, "completed", null); + expect(posts[0]?.content).toBe("Signups overview has been created"); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts b/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts new file mode 100644 index 0000000000..4aaa2cd4e9 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts @@ -0,0 +1,57 @@ +import { formatMention } from "@posthog/shared"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import type { CanvasTerminalStatus } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; +import type { TrackedCanvasGeneration } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { canvasShareUrl } from "@posthog/ui/utils/posthogLinks"; + +export interface CanvasThreadAutoPost { + kind: "canvas_created" | "turn_complete"; + content: string; +} + +function canvasDisplayName(entry: TrackedCanvasGeneration): string { + // Brackets and newlines would break the `[label](url)` token parsing. + return entry.name.replace(/[[\]\n]/g, " ").trim() || "Canvas"; +} + +/** + * The messages a finished canvas generation drops into its task's thread: a + * one-time "[name](link) has been created" comment when the run built a brand + * new canvas, plus a turn-complete note mentioning the task creator. Cancelled + * runs stay silent — cancellation is user-initiated. + */ +export function buildCanvasGenerationThreadPosts( + entry: TrackedCanvasGeneration, + status: CanvasTerminalStatus, + creator: UserBasic | null | undefined, +): CanvasThreadAutoPost[] { + if (status === "cancelled") return []; + + const name = canvasDisplayName(entry); + const posts: CanvasThreadAutoPost[] = []; + + if (status === "completed" && entry.createsCanvas) { + const url = canvasShareUrl(entry.channelId, entry.dashboardId); + posts.push({ + kind: "canvas_created", + content: url + ? `[${name}](${url}) has been created` + : `${name} has been created`, + }); + } + + const mention = creator + ? `${formatMention(userDisplayName(creator), creator.email)} ` + : ""; + const outcome = + status === "completed" + ? `finished generating ${name}` + : `couldn't finish generating ${name}`; + posts.push({ + kind: "turn_complete", + content: `${mention}Turn complete — the agent ${outcome}.`, + }); + + return posts; +} diff --git a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts index dc2abbe844..655dd01b98 100644 --- a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts +++ b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts @@ -1,15 +1,29 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; import { useServiceOptional } from "@posthog/di/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { type CanvasTerminalStatus, hasCanvasGenerationStarted, isCanvasGenerating, resolveCanvasGenerationStatus, } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; -import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; +import { buildCanvasGenerationThreadPosts } from "@posthog/ui/features/canvas/freeform/canvasThreadAutoPost"; +import { taskThreadQueryKey } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { + type TrackedCanvasGeneration, + useCanvasGenerationTrackerStore, +} from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; -import { useQueries } from "@tanstack/react-query"; +import { track } from "@posthog/ui/shell/analytics"; +import { + type QueryClient, + useQueries, + useQueryClient, +} from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; // Poll cadence for the run status of a tracked generation task. Matches the @@ -52,6 +66,38 @@ function emitCanvasGenerationNotification( // "cancelled" is user-initiated — stay silent. } +// Drop the finished generation's updates into the task's thread: the one-time +// "[name](link) has been created" comment and a turn-complete note tagging the +// task creator. Posted by the client that started the generation (the thread +// API has no agent author), best-effort per message. +async function postThreadUpdates( + client: PostHogAPIClient, + queryClient: QueryClient, + entry: TrackedCanvasGeneration, + status: CanvasTerminalStatus, + creator: UserBasic | null | undefined, +): Promise { + for (const post of buildCanvasGenerationThreadPosts(entry, status, creator)) { + let success = true; + try { + await client.createTaskThreadMessage(entry.taskId, post.content); + } catch { + success = false; + } + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "thread_auto_post", + surface: "canvas", + channel_id: entry.channelId, + task_id: entry.taskId, + auto_post_kind: post.kind, + success, + }); + } + void queryClient.invalidateQueries({ + queryKey: taskThreadQueryKey(entry.taskId), + }); +} + // Watches every canvas generation started in this client (registered in the // tracker store) and fires a toast — with a link to the canvas — the moment each // one stops generating. Mounted on the persistent channel layout so it keeps @@ -70,6 +116,10 @@ export function useCanvasGenerationToasts(): void { const bus = useServiceOptional(NotificationBus); const busRef = useRef(bus); busRef.current = bus; + const client = useOptionalAuthenticatedClient(); + const clientRef = useRef(client); + clientRef.current = client; + const queryClient = useQueryClient(); const taskIds = useMemo(() => Object.keys(tracked), [tracked]); @@ -96,7 +146,7 @@ export function useCanvasGenerationToasts(): void { latestRun, session, }); - return { id, generating, latestRun, session }; + return { id, generating, latestRun, session, task: details[i]?.data }; }); // A stable signature so the transition effect only runs on real changes. @@ -139,15 +189,23 @@ export function useCanvasGenerationToasts(): void { toastedRef.current.add(st.id); const entry = useCanvasGenerationTrackerStore.getState().tracked[st.id]; - if (entry && busRef.current) { - emitCanvasGenerationNotification( - busRef.current, - entry, - resolveCanvasGenerationStatus({ - latestRun: st.latestRun, - session: st.session, - }), - ); + if (entry) { + const status = resolveCanvasGenerationStatus({ + latestRun: st.latestRun, + session: st.session, + }); + if (busRef.current) { + emitCanvasGenerationNotification(busRef.current, entry, status); + } + if (clientRef.current) { + void postThreadUpdates( + clientRef.current, + queryClient, + entry, + status, + st.task?.created_by, + ); + } } // Stop tracking (and polling) this task now that it's done. untrack(st.id); diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index 3ae5404d7b..cd0671e48c 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -178,9 +178,13 @@ export function useGenerateFreeformCanvas(args: { await setGenerationTask(dashboardId, task.id).catch(() => {}); // Track this run so a toast (with a link back here) fires when it // finishes, even after the user navigates to another canvas. - useCanvasGenerationTrackerStore - .getState() - .track({ taskId: task.id, dashboardId, channelId, name }); + useCanvasGenerationTrackerStore.getState().track({ + taskId: task.id, + dashboardId, + channelId, + name, + createsCanvas: !currentCode?.trim(), + }); // Refresh the workspace cache so the new cloud workspace row appears and // the task view resolves the cloud run instead of the repo-picker prompt. void queryClient.invalidateQueries({ diff --git a/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts b/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts index 62b6780c1d..aafc735c6f 100644 --- a/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts +++ b/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts @@ -10,6 +10,8 @@ export interface TrackedCanvasGeneration { dashboardId: string; channelId: string; name: string; + /** True when this run builds the canvas for the first time (not an edit). */ + createsCanvas: boolean; } interface CanvasGenerationTrackerState { 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; From dd391e3c4d8d4df5f47cae7a341a35192f8b5d9c Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 17 Jul 2026 16:02:45 +0100 Subject: [PATCH 2/5] Move thread auto-posts server-side; keep client rendering support The canvas-created and turn-complete thread posts now come from the PostHog backend (PostHog/posthog#70371) so they land even when no client is open. This PR keeps only the rendering side: markdown [label](url) links in thread messages, and authorless messages shown as "Agent" with a robot avatar. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- packages/shared/src/analytics-events.ts | 5 +- .../freeform/canvasThreadAutoPost.test.ts | 105 ------------------ .../canvas/freeform/canvasThreadAutoPost.ts | 57 ---------- .../freeform/useCanvasGenerationToasts.ts | 82 ++------------ .../canvas/hooks/useGenerateFreeformCanvas.ts | 10 +- .../stores/canvasGenerationTrackerStore.ts | 2 - 6 files changed, 16 insertions(+), 245 deletions(-) delete mode 100644 packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts delete mode 100644 packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 2fe30f28a0..efe590fb7f 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -880,8 +880,7 @@ export type ChannelActionType = | "mention_member" | "view_activity" | "open_mention" - | "canvas_mode_toggle" - | "thread_auto_post"; + | "canvas_mode_toggle"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -900,8 +899,6 @@ export interface ChannelActionProperties { suggestion_label?: string; /** For canvas_mode_toggle: whether canvas mode is being armed. */ armed?: boolean; - /** For thread_auto_post: which auto-comment was posted. */ - auto_post_kind?: "canvas_created" | "turn_complete"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } diff --git a/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts b/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts deleted file mode 100644 index e3ea16bad8..0000000000 --- a/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { UserBasic } from "@posthog/shared/domain-types"; -import { useAuthStore } from "@posthog/ui/features/auth/store"; -import type { TrackedCanvasGeneration } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; -import { beforeEach, describe, expect, it } from "vitest"; -import { buildCanvasGenerationThreadPosts } from "./canvasThreadAutoPost"; - -const entry: TrackedCanvasGeneration = { - taskId: "t1", - dashboardId: "d1", - channelId: "c1", - name: "Signups overview", - createsCanvas: true, -}; - -const creator: UserBasic = { - id: 1, - uuid: "u1", - email: "raquel@posthog.com", - first_name: "Raquel", - last_name: "Smith", -}; - -function setCloudRegion(region: "us" | null) { - useAuthStore.setState((s) => ({ - authState: { ...s.authState, cloudRegion: region }, - })); -} - -describe("buildCanvasGenerationThreadPosts", () => { - beforeEach(() => setCloudRegion("us")); - - it("posts the created comment and a creator-tagging turn-complete note", () => { - const posts = buildCanvasGenerationThreadPosts(entry, "completed", creator); - expect(posts).toEqual([ - { - kind: "canvas_created", - content: - "[Signups overview](https://us.posthog.com/code/canvas/c1/d1) has been created", - }, - { - kind: "turn_complete", - content: - "@[Raquel Smith](raquel@posthog.com) Turn complete — the agent finished generating Signups overview.", - }, - ]); - }); - - it("skips the created comment when the run edits an existing canvas", () => { - const posts = buildCanvasGenerationThreadPosts( - { ...entry, createsCanvas: false }, - "completed", - creator, - ); - expect(posts.map((p) => p.kind)).toEqual(["turn_complete"]); - }); - - it("skips the created comment on failure and reports it in the note", () => { - const posts = buildCanvasGenerationThreadPosts(entry, "failed", creator); - expect(posts).toEqual([ - { - kind: "turn_complete", - content: - "@[Raquel Smith](raquel@posthog.com) Turn complete — the agent couldn't finish generating Signups overview.", - }, - ]); - }); - - it("stays silent on cancellation", () => { - expect( - buildCanvasGenerationThreadPosts(entry, "cancelled", creator), - ).toEqual([]); - }); - - it("omits the mention when the creator is unknown", () => { - const posts = buildCanvasGenerationThreadPosts(entry, "completed", null); - expect(posts[1]?.content).toBe( - "Turn complete — the agent finished generating Signups overview.", - ); - }); - - it("sanitizes names that would break the link token and falls back when empty", () => { - const posts = buildCanvasGenerationThreadPosts( - { ...entry, name: "[Q3] KPIs" }, - "completed", - null, - ); - expect(posts[0]?.content).toBe( - "[Q3 KPIs](https://us.posthog.com/code/canvas/c1/d1) has been created", - ); - const unnamed = buildCanvasGenerationThreadPosts( - { ...entry, name: " " }, - "completed", - null, - ); - expect(unnamed[0]?.content).toBe( - "[Canvas](https://us.posthog.com/code/canvas/c1/d1) has been created", - ); - }); - - it("degrades to plain text when no share link can be built", () => { - setCloudRegion(null); - const posts = buildCanvasGenerationThreadPosts(entry, "completed", null); - expect(posts[0]?.content).toBe("Signups overview has been created"); - }); -}); diff --git a/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts b/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts deleted file mode 100644 index 4aaa2cd4e9..0000000000 --- a/packages/ui/src/features/canvas/freeform/canvasThreadAutoPost.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { formatMention } from "@posthog/shared"; -import type { UserBasic } from "@posthog/shared/domain-types"; -import type { CanvasTerminalStatus } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; -import type { TrackedCanvasGeneration } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; -import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { canvasShareUrl } from "@posthog/ui/utils/posthogLinks"; - -export interface CanvasThreadAutoPost { - kind: "canvas_created" | "turn_complete"; - content: string; -} - -function canvasDisplayName(entry: TrackedCanvasGeneration): string { - // Brackets and newlines would break the `[label](url)` token parsing. - return entry.name.replace(/[[\]\n]/g, " ").trim() || "Canvas"; -} - -/** - * The messages a finished canvas generation drops into its task's thread: a - * one-time "[name](link) has been created" comment when the run built a brand - * new canvas, plus a turn-complete note mentioning the task creator. Cancelled - * runs stay silent — cancellation is user-initiated. - */ -export function buildCanvasGenerationThreadPosts( - entry: TrackedCanvasGeneration, - status: CanvasTerminalStatus, - creator: UserBasic | null | undefined, -): CanvasThreadAutoPost[] { - if (status === "cancelled") return []; - - const name = canvasDisplayName(entry); - const posts: CanvasThreadAutoPost[] = []; - - if (status === "completed" && entry.createsCanvas) { - const url = canvasShareUrl(entry.channelId, entry.dashboardId); - posts.push({ - kind: "canvas_created", - content: url - ? `[${name}](${url}) has been created` - : `${name} has been created`, - }); - } - - const mention = creator - ? `${formatMention(userDisplayName(creator), creator.email)} ` - : ""; - const outcome = - status === "completed" - ? `finished generating ${name}` - : `couldn't finish generating ${name}`; - posts.push({ - kind: "turn_complete", - content: `${mention}Turn complete — the agent ${outcome}.`, - }); - - return posts; -} diff --git a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts index 655dd01b98..dc2abbe844 100644 --- a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts +++ b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts @@ -1,29 +1,15 @@ -import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; import { useServiceOptional } from "@posthog/di/react"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import type { UserBasic } from "@posthog/shared/domain-types"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { type CanvasTerminalStatus, hasCanvasGenerationStarted, isCanvasGenerating, resolveCanvasGenerationStatus, } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; -import { buildCanvasGenerationThreadPosts } from "@posthog/ui/features/canvas/freeform/canvasThreadAutoPost"; -import { taskThreadQueryKey } from "@posthog/ui/features/canvas/hooks/useTaskThread"; -import { - type TrackedCanvasGeneration, - useCanvasGenerationTrackerStore, -} from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; +import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; -import { track } from "@posthog/ui/shell/analytics"; -import { - type QueryClient, - useQueries, - useQueryClient, -} from "@tanstack/react-query"; +import { useQueries } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; // Poll cadence for the run status of a tracked generation task. Matches the @@ -66,38 +52,6 @@ function emitCanvasGenerationNotification( // "cancelled" is user-initiated — stay silent. } -// Drop the finished generation's updates into the task's thread: the one-time -// "[name](link) has been created" comment and a turn-complete note tagging the -// task creator. Posted by the client that started the generation (the thread -// API has no agent author), best-effort per message. -async function postThreadUpdates( - client: PostHogAPIClient, - queryClient: QueryClient, - entry: TrackedCanvasGeneration, - status: CanvasTerminalStatus, - creator: UserBasic | null | undefined, -): Promise { - for (const post of buildCanvasGenerationThreadPosts(entry, status, creator)) { - let success = true; - try { - await client.createTaskThreadMessage(entry.taskId, post.content); - } catch { - success = false; - } - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "thread_auto_post", - surface: "canvas", - channel_id: entry.channelId, - task_id: entry.taskId, - auto_post_kind: post.kind, - success, - }); - } - void queryClient.invalidateQueries({ - queryKey: taskThreadQueryKey(entry.taskId), - }); -} - // Watches every canvas generation started in this client (registered in the // tracker store) and fires a toast — with a link to the canvas — the moment each // one stops generating. Mounted on the persistent channel layout so it keeps @@ -116,10 +70,6 @@ export function useCanvasGenerationToasts(): void { const bus = useServiceOptional(NotificationBus); const busRef = useRef(bus); busRef.current = bus; - const client = useOptionalAuthenticatedClient(); - const clientRef = useRef(client); - clientRef.current = client; - const queryClient = useQueryClient(); const taskIds = useMemo(() => Object.keys(tracked), [tracked]); @@ -146,7 +96,7 @@ export function useCanvasGenerationToasts(): void { latestRun, session, }); - return { id, generating, latestRun, session, task: details[i]?.data }; + return { id, generating, latestRun, session }; }); // A stable signature so the transition effect only runs on real changes. @@ -189,23 +139,15 @@ export function useCanvasGenerationToasts(): void { toastedRef.current.add(st.id); const entry = useCanvasGenerationTrackerStore.getState().tracked[st.id]; - if (entry) { - const status = resolveCanvasGenerationStatus({ - latestRun: st.latestRun, - session: st.session, - }); - if (busRef.current) { - emitCanvasGenerationNotification(busRef.current, entry, status); - } - if (clientRef.current) { - void postThreadUpdates( - clientRef.current, - queryClient, - entry, - status, - st.task?.created_by, - ); - } + if (entry && busRef.current) { + emitCanvasGenerationNotification( + busRef.current, + entry, + resolveCanvasGenerationStatus({ + latestRun: st.latestRun, + session: st.session, + }), + ); } // Stop tracking (and polling) this task now that it's done. untrack(st.id); diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index cd0671e48c..3ae5404d7b 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -178,13 +178,9 @@ export function useGenerateFreeformCanvas(args: { await setGenerationTask(dashboardId, task.id).catch(() => {}); // Track this run so a toast (with a link back here) fires when it // finishes, even after the user navigates to another canvas. - useCanvasGenerationTrackerStore.getState().track({ - taskId: task.id, - dashboardId, - channelId, - name, - createsCanvas: !currentCode?.trim(), - }); + useCanvasGenerationTrackerStore + .getState() + .track({ taskId: task.id, dashboardId, channelId, name }); // Refresh the workspace cache so the new cloud workspace row appears and // the task view resolves the cloud run instead of the repo-picker prompt. void queryClient.invalidateQueries({ diff --git a/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts b/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts index aafc735c6f..62b6780c1d 100644 --- a/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts +++ b/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts @@ -10,8 +10,6 @@ export interface TrackedCanvasGeneration { dashboardId: string; channelId: string; name: string; - /** True when this run builds the canvas for the first time (not an edit). */ - createsCanvas: boolean; } interface CanvasGenerationTrackerState { From 5ce84d02c008f8be65d10ecf6dfb322467bf6ea2 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 17 Jul 2026 16:02:47 +0100 Subject: [PATCH 3/5] Adopt structured agent thread rows (author_kind/event/payload) Types the new TaskThreadMessage fields from PostHog/posthog#70371 (stacked on #70320) and keys agent rendering on author_kind, keeping the authorless fallback for older backends. payload.run_id on turn_complete rows is the dedupe key for live session-derived agent turn views. Generated-By: PostHog Code Task-Id: ea09a661-1f6d-4bd8-a046-24be60909d28 --- packages/shared/src/domain-types.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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; From 911f0d9ac836cc34ad5e622bb2b7497b071cbd2c Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 17 Jul 2026 16:03:03 +0100 Subject: [PATCH 4/5] Adapt agent thread announcements to current thread UI Render structured and legacy authorless agent announcements with the Agent identity in the Quill thread row, and keep message actions limited to human messages. Add regression coverage for the current component. Generated-By: PostHog Code Task-Id: 152cf34b-23c8-4e87-be9e-12d727e08edd --- .../canvas/components/ThreadPanel.test.tsx | 33 ++++++++++++++++++- .../canvas/components/ThreadPanel.tsx | 19 ++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx index 18bb9017db..b53833fa67 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,33 @@ 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(); + }); +}); + 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..102a5887c0 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,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); + const showMenu = !isAgent && ((isTaskAuthor && !forwarded) || isOwnMessage); return ( - {getUserInitials(message.author)} + + {isAgent ? ( + + ) : ( + getUserInitials(message.author) + )} + - {userDisplayName(message.author)} + + {isAgent ? "Agent" : userDisplayName(message.author)} + From 90c7a61238ced5a8da5673b9203eb5f84654e9ce Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 17 Jul 2026 16:16:00 +0100 Subject: [PATCH 5/5] Respect thread author kinds and markdown link boundaries Treat structured author kinds as authoritative so system rows render as System and legacy authorless rows retain human behavior. Parse markdown links before mentions so labels containing @agent remain intact, with regression coverage for both review findings. Generated-By: PostHog Code Task-Id: 152cf34b-23c8-4e87-be9e-12d727e08edd --- .../canvas/components/MentionText.test.tsx | 11 ++++ .../canvas/components/MentionText.tsx | 38 +++++++++----- .../canvas/components/ThreadPanel.test.tsx | 51 +++++++++++++++++++ .../canvas/components/ThreadPanel.tsx | 17 +++++-- 4 files changed, 98 insertions(+), 19 deletions(-) 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 b53833fa67..89aca9895a 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -74,6 +74,57 @@ describe("ThreadMessageRow", () => { 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", () => { diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 102a5887c0..fba801b8f1 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -105,10 +105,11 @@ export function ThreadMessageRow({ onDelete: () => void; }) { const forwarded = !!message.forwarded_to_agent_at; - const isAgent = - message.author_kind === "agent" || - (!message.author_kind && !message.author); - const showMenu = !isAgent && ((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 ( @@ -117,6 +118,8 @@ export function ThreadMessageRow({ {isAgent ? ( + ) : isSystem ? ( + "S" ) : ( getUserInitials(message.author) )} @@ -126,7 +129,11 @@ export function ThreadMessageRow({ - {isAgent ? "Agent" : userDisplayName(message.author)} + {isAgent + ? "Agent" + : isSystem + ? "System" + : userDisplayName(message.author)}