From 13322a1fd2dc89952ce2b4009f766d7dbaf51920 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 13:40:51 -0400 Subject: [PATCH 01/12] Attribute channel task rows to the human who started them Channel feed task rows rendered every message as "PostHog" with a robot avatar and an "Agent" badge, then repeated the starter's name inline in the body ("@Name started a new task"). This made the feed hard to scan and misattributed human-initiated tasks to the agent. Channel-started tasks (origin_product === "user_created") now show the starter as the sender: their initials in the avatar, their display name as the author, no "Agent" badge, and a plain "started a new task" body. Tasks from other origins (Slack, automations) keep the agent attribution so genuine agent-authored messages remain distinguishable at a glance. Mirrors the existing SystemFeedRow author-vs-agent pattern in the same file. Generated-By: PostHog Code Task-Id: 4b3b11b7-5066-448f-a721-fe8532ce15d1 --- .../canvas/components/ChannelFeedView.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index af127b60ab..da61fee23b 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -40,7 +40,6 @@ import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; -import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; @@ -424,20 +423,28 @@ const FeedItem = memo(function FeedItem({ onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; }) { + // Only attribute channel-started tasks to a human: other origins (Slack, + // automations) carry a created_by who didn't start it here, so those stay + // agent-attributed to leave room for genuine agent-authored messages. + const starter = + task.origin_product === "user_created" ? task.created_by : null; + return ( - + {starter ? getUserInitials(starter) : } - PostHog - Agent + + {starter ? userDisplayName(starter) : "PostHog"} + + {!starter && Agent} @@ -446,20 +453,7 @@ const FeedItem = memo(function FeedItem({ - {/* Only attribute channel-started tasks: other origins (Slack, - automations) carry a created_by who didn't start it here. */} - {task.origin_product === "user_created" && task.created_by ? ( - <> - {/* Mention-styled but rendered inert: the starter shouldn't be - notified about their own task. */} - - @{userDisplayName(task.created_by)} - {" "} - started a new task - - ) : ( - "A new task was started" - )} + {starter ? "started a new task" : "A new task was started"} Date: Fri, 17 Jul 2026 13:46:19 -0400 Subject: [PATCH 02/12] Add Storybook story for channel task feed row Extract the pure presentational TaskFeedRow (avatar + attribution header + body) out of FeedItem so it can be storied; the data-fetching TaskCard and ReplyFooter stay in the FeedItem container and are passed in as children. Adds ChannelFeedView.stories.tsx covering the attribution states so the human-vs-agent sender rendering is easy to validate: human-started, human-with-email-only (initials/name fallback), non-user origin (stays agent-attributed), and user_created with no starter. Generated-By: PostHog Code Task-Id: 4b3b11b7-5066-448f-a721-fe8532ce15d1 --- .../components/ChannelFeedView.stories.tsx | 105 +++++++++++++++++ .../canvas/components/ChannelFeedView.tsx | 106 ++++++++++++------ 2 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx new file mode 100644 index 0000000000..b2b2b0a228 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx @@ -0,0 +1,105 @@ +import type { Task, UserBasic } from "@posthog/shared/domain-types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TaskFeedRow } from "./ChannelFeedView"; + +// A stand-in for the real TaskCard, which fetches its own status/PR data and so +// renders empty in Storybook. The story only needs something card-shaped under +// the attribution for the row to read realistically. +function MockTaskCard({ title }: { title: string }) { + return ( +
+
+ {title} + + Ready + +
+
+ ); +} + +const user = (overrides: Partial = {}): UserBasic => ({ + id: 1, + uuid: "user-1", + email: "adam@posthog.com", + first_name: "Adam", + last_name: "Bowker", + ...overrides, +}); + +const task = (overrides: Partial = {}): Task => ({ + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Add feedback modal to channels view", + description: "", + // A fixed timestamp keeps the relative-time label stable for visual review. + created_at: "2026-07-17T12:00:00.000Z", + updated_at: "2026-07-17T12:00:00.000Z", + origin_product: "user_created", + created_by: user(), + ...overrides, +}); + +const meta: Meta = { + title: "Channels/TaskFeedRow", + component: TaskFeedRow, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** + * A channel-started task, attributed to the human who started it: initials + * avatar, their display name, no "Agent" badge, and a plain "started a new + * task" body. + */ +export const HumanStarted: Story = { + args: { + task: task(), + children: , + }, +}; + +/** + * A human with no name set falls back to the email-derived initials in the + * avatar and the email as the display name. + */ +export const HumanEmailOnly: Story = { + args: { + task: task({ + created_by: user({ first_name: undefined, last_name: undefined }), + }), + children: , + }, +}; + +/** + * A non-user origin (e.g. Slack) stays agent-attributed — robot avatar, + * "PostHog" name, "Agent" badge — even though created_by is set, because that + * person didn't start the task in the channel. + */ +export const AgentOrigin: Story = { + args: { + task: task({ origin_product: "slack", title: "Investigate signup drop-off" }), + children: , + }, +}; + +/** + * A user_created task with no created_by has no human to attribute, so it also + * falls back to the agent identity. + */ +export const NoStarter: Story = { + args: { + task: task({ created_by: null, title: "Untitled task" }), + children: , + }, +}; diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index da61fee23b..f13595b4ee 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -37,7 +37,11 @@ import { useChatMessageScroller, } from "@posthog/quill"; import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; -import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; +import type { + Task, + TaskRunStatus, + UserBasic, +} from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; @@ -410,24 +414,31 @@ function ReplyFooter({ ); } -const FeedItem = memo(function FeedItem({ +// The human who kicked a task off in the channel, or null when the row should +// stay agent-attributed. Only channel-started tasks (user_created) are a +// person's doing; other origins (Slack, automations) carry a created_by who +// didn't start it here, so they keep the agent identity — which also leaves +// that identity free for genuine agent-authored messages in the future. +function channelTaskStarter(task: Task): UserBasic | null { + return task.origin_product === "user_created" + ? (task.created_by ?? null) + : null; +} + +// The presentational feed row: avatar + attribution header + body. The task +// card and reply footer are supplied as `children` (they fetch their own data, +// so keeping them out of here leaves the row pure and storyable); `actions` is +// the hover toolbar. +export function TaskFeedRow({ task, - channelId, - inView, - onOpenTask, - onOpenThread, + actions, + children, }: { task: Task; - channelId: string; - inView: boolean; - onOpenTask: (task: Task) => void; - onOpenThread: (task: Task) => void; + actions?: ReactNode; + children?: ReactNode; }) { - // Only attribute channel-started tasks to a human: other origins (Slack, - // automations) carry a created_by who didn't start it here, so those stay - // agent-attributed to leave room for genuine agent-authored messages. - const starter = - task.origin_product === "user_created" ? task.created_by : null; + const starter = channelTaskStarter(task); return ( @@ -456,30 +467,55 @@ const FeedItem = memo(function FeedItem({ {starter ? "started a new task" : "A new task was started"} - onOpenThread(task)} - /> - onOpenThread(task)} - /> + {children}
- {/* Replying now lives in the always-visible ReplyFooter, so the hover - toolbar only carries the distinct "Open task" action. Actions anchor - to the row's top-right corner; a top tooltip there overhangs the panel - edge and gets clipped by the scroll container, so open tooltips toward - the content instead. */} - - onOpenTask(task)}> - - - + {actions}
); +} + +const FeedItem = memo(function FeedItem({ + task, + channelId, + inView, + onOpenTask, + onOpenThread, +}: { + task: Task; + channelId: string; + inView: boolean; + onOpenTask: (task: Task) => void; + onOpenThread: (task: Task) => void; +}) { + return ( + + onOpenTask(task)}> + + + + } + > + onOpenThread(task)} + /> + onOpenThread(task)} + /> + + ); }); // One feed row: owns the scroller item (the `content-visibility` boundary, so From abd493cc5fa374d568e5866662fa26575cde2eae Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 14:10:00 -0400 Subject: [PATCH 03/12] Format story file to satisfy Biome The AgentOrigin story's task() call exceeded the line width; wrap it so `biome check` (the CI quality gate, which runs the formatter) passes. Generated-By: PostHog Code Task-Id: 4b3b11b7-5066-448f-a721-fe8532ce15d1 --- .../features/canvas/components/ChannelFeedView.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx index b2b2b0a228..c07284f60d 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx @@ -88,7 +88,10 @@ export const HumanEmailOnly: Story = { */ export const AgentOrigin: Story = { args: { - task: task({ origin_product: "slack", title: "Investigate signup drop-off" }), + task: task({ + origin_product: "slack", + title: "Investigate signup drop-off", + }), children: , }, }; From eaeb807e521b3a4474a8a8704bb06ac25a810ac5 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 16:06:34 -0400 Subject: [PATCH 04/12] Drop explanatory comments added in this PR Remove the doc comments I introduced on channelTaskStarter and TaskFeedRow, and all comments in the new story file, keeping only the pre-existing "Replying now lives" comment that predates this change. Generated-By: PostHog Code Task-Id: 4b3b11b7-5066-448f-a721-fe8532ce15d1 --- .../components/ChannelFeedView.stories.tsx | 22 ------------------- .../canvas/components/ChannelFeedView.tsx | 9 -------- 2 files changed, 31 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx index c07284f60d..ffc591b353 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx @@ -2,9 +2,6 @@ import type { Task, UserBasic } from "@posthog/shared/domain-types"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { TaskFeedRow } from "./ChannelFeedView"; -// A stand-in for the real TaskCard, which fetches its own status/PR data and so -// renders empty in Storybook. The story only needs something card-shaped under -// the attribution for the row to read realistically. function MockTaskCard({ title }: { title: string }) { return (
@@ -33,7 +30,6 @@ const task = (overrides: Partial = {}): Task => ({ slug: "task-1", title: "Add feedback modal to channels view", description: "", - // A fixed timestamp keeps the relative-time label stable for visual review. created_at: "2026-07-17T12:00:00.000Z", updated_at: "2026-07-17T12:00:00.000Z", origin_product: "user_created", @@ -56,11 +52,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** - * A channel-started task, attributed to the human who started it: initials - * avatar, their display name, no "Agent" badge, and a plain "started a new - * task" body. - */ export const HumanStarted: Story = { args: { task: task(), @@ -68,10 +59,6 @@ export const HumanStarted: Story = { }, }; -/** - * A human with no name set falls back to the email-derived initials in the - * avatar and the email as the display name. - */ export const HumanEmailOnly: Story = { args: { task: task({ @@ -81,11 +68,6 @@ export const HumanEmailOnly: Story = { }, }; -/** - * A non-user origin (e.g. Slack) stays agent-attributed — robot avatar, - * "PostHog" name, "Agent" badge — even though created_by is set, because that - * person didn't start the task in the channel. - */ export const AgentOrigin: Story = { args: { task: task({ @@ -96,10 +78,6 @@ export const AgentOrigin: Story = { }, }; -/** - * A user_created task with no created_by has no human to attribute, so it also - * falls back to the agent identity. - */ export const NoStarter: Story = { args: { task: task({ created_by: null, title: "Untitled task" }), diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index f13595b4ee..109a60ccaf 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -414,21 +414,12 @@ function ReplyFooter({ ); } -// The human who kicked a task off in the channel, or null when the row should -// stay agent-attributed. Only channel-started tasks (user_created) are a -// person's doing; other origins (Slack, automations) carry a created_by who -// didn't start it here, so they keep the agent identity — which also leaves -// that identity free for genuine agent-authored messages in the future. function channelTaskStarter(task: Task): UserBasic | null { return task.origin_product === "user_created" ? (task.created_by ?? null) : null; } -// The presentational feed row: avatar + attribution header + body. The task -// card and reply footer are supplied as `children` (they fetch their own data, -// so keeping them out of here leaves the row pure and storyable); `actions` is -// the hover toolbar. export function TaskFeedRow({ task, actions, From 6a879c803b6ddc192ccaf4ac37a8a845370eb29f Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 17 Jul 2026 16:24:19 -0400 Subject: [PATCH 05/12] Show a truncated prompt preview in channel feed rows Instead of the generic "started a new task" body, feed rows now show a two-line preview of the user's original prompt (task.description, run through xmlToPlainText to resolve chip tags), matching how the optimistic pending row already previews the prompt. Falls back to the prior text when a task has no description. Adds LongPrompt and NoPrompt stories to cover truncation and the fallback. Generated-By: PostHog Code Task-Id: 4b3b11b7-5066-448f-a721-fe8532ce15d1 --- .../components/ChannelFeedView.stories.tsx | 31 +++++++++++++++++-- .../canvas/components/ChannelFeedView.tsx | 10 ++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx index ffc591b353..8ff99ea14a 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx @@ -29,7 +29,8 @@ const task = (overrides: Partial = {}): Task => ({ task_number: 1, slug: "task-1", title: "Add feedback modal to channels view", - description: "", + description: + "Add a feedback modal to the channels view so people can share thoughts without leaving the feed", created_at: "2026-07-17T12:00:00.000Z", updated_at: "2026-07-17T12:00:00.000Z", origin_product: "user_created", @@ -63,6 +64,8 @@ export const HumanEmailOnly: Story = { args: { task: task({ created_by: user({ first_name: undefined, last_name: undefined }), + title: "Make background color configurable", + description: "Make the channel background color configurable in settings", }), children: , }, @@ -73,14 +76,36 @@ export const AgentOrigin: Story = { task: task({ origin_product: "slack", title: "Investigate signup drop-off", + description: "Investigate the signup drop-off we saw over the weekend", }), children: , }, }; -export const NoStarter: Story = { +export const LongPrompt: Story = { + args: { + task: task({ + description: + "Rework the channel feed so each row reads as the person who started the task rather than the agent, show a preview of their prompt under the header, keep the task card below, and make sure long prompts truncate cleanly instead of pushing the card down the feed", + }), + children: , + }, +}; + +export const NoPrompt: Story = { args: { - task: task({ created_by: null, title: "Untitled task" }), + task: task({ description: "", title: "Untitled task" }), children: , }, }; + +export const NoStarter: Story = { + args: { + task: task({ + created_by: null, + title: "Untitled task", + description: "Summarize this week's shipped changes", + }), + children: , + }, +}; diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 109a60ccaf..1905a1aa95 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -5,6 +5,7 @@ import { RobotIcon, } from "@phosphor-icons/react"; import { taskFeedRunStatus } from "@posthog/core/canvas/channelFeed"; +import { xmlToPlainText } from "@posthog/core/message-editor/content"; import { Avatar, AvatarFallback, @@ -430,6 +431,10 @@ export function TaskFeedRow({ children?: ReactNode; }) { const starter = channelTaskStarter(task); + const prompt = useMemo( + () => xmlToPlainText(task.description ?? "").trim(), + [task.description], + ); return ( @@ -454,8 +459,9 @@ export function TaskFeedRow({ - - {starter ? "started a new task" : "A new task was started"} + + {prompt || + (starter ? "started a new task" : "A new task was started")} {children} From 2b253237d4942710b2f34115a20a7bcb4a616117 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:29:33 -0400 Subject: [PATCH 06/12] fix(canvas): navigate share links in-app instead of via the browser Clicking a canvas/channel share link in a channel thread bounced out to the browser (PostHog Cloud interstitial) which then deep-linked back into the app. The thread feed renders server-emitted announcement messages whose content embeds a portable https share link; those links were rendered as plain external anchors with no in-app interception. Add a client-side parser that recognizes our own share-link URLs (/code/canvas/... and /code/channel/...) and navigates via the in-app router, short-circuiting the browser round-trip. Wired into MentionText (thread + channel surfaces) and MarkdownRenderer (agent chat). External links are untouched. Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- .../canvas/components/MentionText.test.tsx | 39 +++++- .../canvas/components/MentionText.tsx | 2 + .../editor/components/MarkdownRenderer.tsx | 2 + packages/ui/src/utils/shareLinks.test.ts | 111 ++++++++++++++++++ packages/ui/src/utils/shareLinks.ts | 107 +++++++++++++++++ 5 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/utils/shareLinks.test.ts create mode 100644 packages/ui/src/utils/shareLinks.ts diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index 6c9588e4d3..bf01e82421 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -1,7 +1,20 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MentionText } from "./MentionText"; +const navigateToChannelDashboard = vi.fn(); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: vi.fn(), + navigateToChannelDashboard: (...args: unknown[]) => + navigateToChannelDashboard(...args), + navigateToChannelTask: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("MentionText", () => { it("uses the shared mention styles and emphasizes the current user", () => { render( @@ -42,6 +55,28 @@ describe("MentionText", () => { expect(screen.queryByText("@agent")).not.toBeInTheDocument(); }); + it("navigates in-app instead of the browser for a canvas share link", () => { + render( + , + ); + + const link = screen.getByRole("link", { name: "Signups" }); + const defaultAllowed = fireEvent.click(link); + + expect(defaultAllowed).toBe(false); // preventDefault was called + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); + }); + + it("leaves an external link opening in the browser", () => { + render(); + + const link = screen.getByRole("link", { name: "Docs" }); + const defaultAllowed = fireEvent.click(link); + + expect(defaultAllowed).toBe(true); // default not prevented + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + }); + 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 41214cf80e..8fbe4bea7f 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -1,5 +1,6 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { Fragment, useMemo } from "react"; import "./mention-chip.css"; @@ -105,6 +106,7 @@ export function MentionText({ handleShareLinkClick(segment.href, event)} target="_blank" rel="noopener noreferrer" className="text-[var(--accent-11)] underline underline-offset-2 hover:text-[var(--accent-12)]" diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index e024b2d08c..32a43862a0 100644 --- a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -5,6 +5,7 @@ import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; import { Divider } from "@posthog/ui/primitives/Divider"; import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; import { List, ListItem } from "@posthog/ui/primitives/List"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; @@ -91,6 +92,7 @@ export const baseComponents: Components = { { + if (handleShareLinkClick(href, event)) return; if (!isDeeplink || !href) return; event.preventDefault(); openExternalUrl(href); diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts new file mode 100644 index 0000000000..ca9b064cc7 --- /dev/null +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -0,0 +1,111 @@ +import { + handleShareLinkClick, + parseShareLink, +} from "@posthog/ui/utils/shareLinks"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const navigateToChannel = vi.fn(); +const navigateToChannelDashboard = vi.fn(); +const navigateToChannelTask = vi.fn(); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: (...args: unknown[]) => navigateToChannel(...args), + navigateToChannelDashboard: (...args: unknown[]) => + navigateToChannelDashboard(...args), + navigateToChannelTask: (...args: unknown[]) => navigateToChannelTask(...args), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("parseShareLink", () => { + it.each([ + [ + "canvas link", + "https://us.posthog.com/code/canvas/chan1/dash1", + { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, + ], + [ + "canvas link with encoded ids", + "https://us.posthog.com/code/canvas/chan%2F1/dash%202", + { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, + ], + [ + "channel link on the eu host", + "https://eu.posthog.com/code/channel/chan1", + { kind: "channel", channelId: "chan1" }, + ], + [ + "channel thread link", + "https://us.posthog.com/code/channel/chan1/tasks/task1", + { kind: "channel", channelId: "chan1", taskId: "task1" }, + ], + ])("parses a %s", (_label, href, expected) => { + expect(parseShareLink(href)).toEqual(expected); + }); + + it.each([ + ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], + [ + "an unrelated PostHog path", + "https://us.posthog.com/project/2/dashboard/1", + ], + [ + "a canvas link missing the dashboard id", + "https://us.posthog.com/code/canvas/chan1", + ], + [ + "a channel thread link with a malformed tail", + "https://us.posthog.com/code/channel/chan1/foo/task1", + ], + ["a malformed url", "not a url"], + ])("returns null for %s", (_label, href) => { + expect(parseShareLink(href)).toBeNull(); + }); +}); + +describe("handleShareLinkClick", () => { + it("navigates in-app and cancels the default open for a share link", () => { + const event = { preventDefault: vi.fn() }; + + const handled = handleShareLinkClick( + "https://us.posthog.com/code/canvas/chan1/dash1", + event, + ); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); + }); + + it("routes a channel thread link to the task navigator", () => { + const event = { preventDefault: vi.fn() }; + + handleShareLinkClick( + "https://us.posthog.com/code/channel/chan1/tasks/task1", + event, + ); + + expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1"); + }); + + it("leaves an external link alone", () => { + const event = { preventDefault: vi.fn() }; + + const handled = handleShareLinkClick("https://example.com/docs", event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToChannel).not.toHaveBeenCalled(); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + expect(navigateToChannelTask).not.toHaveBeenCalled(); + }); + + it("returns false for a missing href", () => { + const event = { preventDefault: vi.fn() }; + + expect(handleShareLinkClick(undefined, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts new file mode 100644 index 0000000000..73673231cb --- /dev/null +++ b/packages/ui/src/utils/shareLinks.ts @@ -0,0 +1,107 @@ +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; +import { + navigateToChannel, + navigateToChannelDashboard, + navigateToChannelTask, +} from "@posthog/ui/router/navigationBridge"; + +// The in-app destination a PostHog Code share link points at. The inverse of the +// `canvasShareUrl` / `channelShareUrl` builders in `posthogLinks.ts`. +export type ShareLinkTarget = + | { kind: "canvas"; channelId: string; dashboardId: string } + | { kind: "channel"; channelId: string; taskId?: string }; + +const REGIONS: CloudRegion[] = ["us", "eu", "dev"]; + +// Hosts we recognise as PostHog share-link origins. We match every region (not +// just the signed-in one) so a link works in-app regardless of which instance +// it was minted on — the inbound deep-link handlers already navigate by id +// against the current session, so bouncing through the browser buys nothing. +const POSTHOG_HOSTS = new Set( + REGIONS.map((region) => { + try { + return new URL(getCloudUrlFromRegion(region)).host; + } catch { + return ""; + } + }).filter(Boolean), +); + +/** + * Parse a PostHog Code share link into its in-app navigation target, or `null` + * if it isn't one. Recognises `/code/canvas//` and + * `/code/channel/[/tasks/]` on a known PostHog host. The + * host check keeps us from hijacking unrelated links that happen to share the + * path shape. + */ +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (!POSTHOG_HOSTS.has(url.host)) return null; + + // Split the still-encoded pathname first, then decode each segment, so an id + // containing an encoded slash (`%2F`) stays a single segment. + const segments = url.pathname + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); + + if (segments[0] !== "code") return null; + + if (segments[1] === "canvas" && segments.length === 4) { + return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; + } + + if (segments[1] === "channel") { + if (segments.length === 3) { + return { kind: "channel", channelId: segments[2] }; + } + if (segments.length === 5 && segments[3] === "tasks") { + return { kind: "channel", channelId: segments[2], taskId: segments[4] }; + } + } + + return null; +} + +export function navigateToShareTarget(target: ShareLinkTarget): void { + switch (target.kind) { + case "canvas": + navigateToChannelDashboard(target.channelId, target.dashboardId); + break; + case "channel": + if (target.taskId) { + navigateToChannelTask(target.channelId, target.taskId); + } else { + navigateToChannel(target.channelId); + } + break; + } +} + +/** + * If `href` is a PostHog Code share link, navigate to it in-app and return true + * (cancelling the click's default open-in-browser). Otherwise return false so + * the caller lets the link open externally as usual. + */ +export function handleShareLinkClick( + href: string | undefined, + event: { preventDefault: () => void }, +): boolean { + if (!href) return false; + const target = parseShareLink(href); + if (!target) return false; + event.preventDefault(); + navigateToShareTarget(target); + return true; +} From 3e645f823f46780fb50b1b026879cc94cdc58ba2 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:36:26 -0400 Subject: [PATCH 07/12] refactor(canvas): co-locate share-link parser with its builders Move parseShareLink next to canvasShareUrl/channelShareUrl in posthogLinks.ts so the /code/canvas and /code/channel path shapes live in one place (the parser is the inverse of the builders). Derive the recognized-host set from REGION_LABELS instead of a hardcoded region list. shareLinks.ts is now just the navigation glue (navigateToShareTarget/handleShareLinkClick). Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/posthogLinks.test.ts | 47 +++++++++++++ packages/ui/src/utils/posthogLinks.ts | 76 +++++++++++++++++++++- packages/ui/src/utils/shareLinks.test.ts | 51 +-------------- packages/ui/src/utils/shareLinks.ts | 74 ++------------------- 4 files changed, 127 insertions(+), 121 deletions(-) diff --git a/packages/ui/src/utils/posthogLinks.test.ts b/packages/ui/src/utils/posthogLinks.test.ts index cc3acef9d9..0d84e0a7f8 100644 --- a/packages/ui/src/utils/posthogLinks.test.ts +++ b/packages/ui/src/utils/posthogLinks.test.ts @@ -1,6 +1,7 @@ import { canvasShareUrl, errorTrackingIssueUrl, + parseShareLink, } from "@posthog/ui/utils/posthogLinks"; import { describe, expect, it, vi } from "vitest"; @@ -16,6 +17,52 @@ describe("canvasShareUrl", () => { }); }); +describe("parseShareLink", () => { + it.each([ + [ + "canvas link", + "https://us.posthog.com/code/canvas/chan1/dash1", + { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, + ], + [ + "canvas link with encoded ids", + "https://us.posthog.com/code/canvas/chan%2F1/dash%202", + { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, + ], + [ + "channel link on the eu host", + "https://eu.posthog.com/code/channel/chan1", + { kind: "channel", channelId: "chan1" }, + ], + [ + "channel thread link", + "https://us.posthog.com/code/channel/chan1/tasks/task1", + { kind: "channel", channelId: "chan1", taskId: "task1" }, + ], + ])("parses a %s", (_label, href, expected) => { + expect(parseShareLink(href)).toEqual(expected); + }); + + it.each([ + ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], + [ + "an unrelated PostHog path", + "https://us.posthog.com/project/2/dashboard/1", + ], + [ + "a canvas link missing the dashboard id", + "https://us.posthog.com/code/canvas/chan1", + ], + [ + "a channel thread link with a malformed tail", + "https://us.posthog.com/code/channel/chan1/foo/task1", + ], + ["a malformed url", "not a url"], + ])("returns null for %s", (_label, href) => { + expect(parseShareLink(href)).toBeNull(); + }); +}); + describe("errorTrackingIssueUrl", () => { it("links to the issue when no fingerprint is provided", () => { expect( diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index b5df06abf1..2e16408984 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -1,4 +1,8 @@ -import type { CloudRegion } from "@posthog/shared"; +import { + type CloudRegion, + getCloudUrlFromRegion, + REGION_LABELS, +} from "@posthog/shared"; import { useAuthStore } from "@posthog/ui/features/auth/store"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -117,6 +121,76 @@ export function channelShareUrl( ); } +/** + * The in-app destination a PostHog Code share link points at — the inverse of + * the `canvasShareUrl` / `channelShareUrl` builders above. + */ +export type ShareLinkTarget = + | { kind: "canvas"; channelId: string; dashboardId: string } + | { kind: "channel"; channelId: string; taskId?: string }; + +// Hosts we recognise as PostHog share-link origins, one per cloud region. The +// host check keeps `parseShareLink` from hijacking unrelated links that happen +// to share the `/code/...` path shape. +const POSTHOG_HOSTS = new Set( + (Object.keys(REGION_LABELS) as CloudRegion[]) + .map((region) => { + try { + return new URL(getCloudUrlFromRegion(region)).host; + } catch { + return ""; + } + }) + .filter(Boolean), +); + +/** + * Parse a PostHog Code share link into its in-app navigation target, or `null` + * if it isn't one. Recognises the `/code/canvas/...` and `/code/channel/...` + * links built above, on any region's host — the inbound deep-link handlers + * navigate by id against the current session, so bouncing through the browser + * to reach the app buys nothing. + */ +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (!POSTHOG_HOSTS.has(url.host)) return null; + + // Split the still-encoded pathname first, then decode each segment, so an id + // containing an encoded slash (`%2F`) stays a single segment. + const segments = url.pathname + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); + + if (segments[0] !== "code") return null; + + if (segments[1] === "canvas" && segments.length === 4) { + return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; + } + + if (segments[1] === "channel") { + if (segments.length === 3) { + return { kind: "channel", channelId: segments[2] }; + } + if (segments.length === 5 && segments[3] === "tasks") { + return { kind: "channel", channelId: segments[2], taskId: segments[4] }; + } + } + + return null; +} + export function errorTrackingIssueUrl( issueId: string, overrides?: ErrorTrackingIssueLinkOverrides, diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts index ca9b064cc7..2bad7e8da4 100644 --- a/packages/ui/src/utils/shareLinks.test.ts +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -1,7 +1,4 @@ -import { - handleShareLinkClick, - parseShareLink, -} from "@posthog/ui/utils/shareLinks"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { beforeEach, describe, expect, it, vi } from "vitest"; const navigateToChannel = vi.fn(); @@ -19,52 +16,6 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("parseShareLink", () => { - it.each([ - [ - "canvas link", - "https://us.posthog.com/code/canvas/chan1/dash1", - { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, - ], - [ - "canvas link with encoded ids", - "https://us.posthog.com/code/canvas/chan%2F1/dash%202", - { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, - ], - [ - "channel link on the eu host", - "https://eu.posthog.com/code/channel/chan1", - { kind: "channel", channelId: "chan1" }, - ], - [ - "channel thread link", - "https://us.posthog.com/code/channel/chan1/tasks/task1", - { kind: "channel", channelId: "chan1", taskId: "task1" }, - ], - ])("parses a %s", (_label, href, expected) => { - expect(parseShareLink(href)).toEqual(expected); - }); - - it.each([ - ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], - [ - "an unrelated PostHog path", - "https://us.posthog.com/project/2/dashboard/1", - ], - [ - "a canvas link missing the dashboard id", - "https://us.posthog.com/code/canvas/chan1", - ], - [ - "a channel thread link with a malformed tail", - "https://us.posthog.com/code/channel/chan1/foo/task1", - ], - ["a malformed url", "not a url"], - ])("returns null for %s", (_label, href) => { - expect(parseShareLink(href)).toBeNull(); - }); -}); - describe("handleShareLinkClick", () => { it("navigates in-app and cancels the default open for a share link", () => { const event = { preventDefault: vi.fn() }; diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts index 73673231cb..75e89481e7 100644 --- a/packages/ui/src/utils/shareLinks.ts +++ b/packages/ui/src/utils/shareLinks.ts @@ -1,78 +1,12 @@ -import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; import { navigateToChannel, navigateToChannelDashboard, navigateToChannelTask, } from "@posthog/ui/router/navigationBridge"; - -// The in-app destination a PostHog Code share link points at. The inverse of the -// `canvasShareUrl` / `channelShareUrl` builders in `posthogLinks.ts`. -export type ShareLinkTarget = - | { kind: "canvas"; channelId: string; dashboardId: string } - | { kind: "channel"; channelId: string; taskId?: string }; - -const REGIONS: CloudRegion[] = ["us", "eu", "dev"]; - -// Hosts we recognise as PostHog share-link origins. We match every region (not -// just the signed-in one) so a link works in-app regardless of which instance -// it was minted on — the inbound deep-link handlers already navigate by id -// against the current session, so bouncing through the browser buys nothing. -const POSTHOG_HOSTS = new Set( - REGIONS.map((region) => { - try { - return new URL(getCloudUrlFromRegion(region)).host; - } catch { - return ""; - } - }).filter(Boolean), -); - -/** - * Parse a PostHog Code share link into its in-app navigation target, or `null` - * if it isn't one. Recognises `/code/canvas//` and - * `/code/channel/[/tasks/]` on a known PostHog host. The - * host check keeps us from hijacking unrelated links that happen to share the - * path shape. - */ -export function parseShareLink(href: string): ShareLinkTarget | null { - let url: URL; - try { - url = new URL(href); - } catch { - return null; - } - if (!POSTHOG_HOSTS.has(url.host)) return null; - - // Split the still-encoded pathname first, then decode each segment, so an id - // containing an encoded slash (`%2F`) stays a single segment. - const segments = url.pathname - .split("/") - .filter(Boolean) - .map((segment) => { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } - }); - - if (segments[0] !== "code") return null; - - if (segments[1] === "canvas" && segments.length === 4) { - return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; - } - - if (segments[1] === "channel") { - if (segments.length === 3) { - return { kind: "channel", channelId: segments[2] }; - } - if (segments.length === 5 && segments[3] === "tasks") { - return { kind: "channel", channelId: segments[2], taskId: segments[4] }; - } - } - - return null; -} +import { + parseShareLink, + type ShareLinkTarget, +} from "@posthog/ui/utils/posthogLinks"; export function navigateToShareTarget(target: ShareLinkTarget): void { switch (target.kind) { From 089ba240c02c44a74bd5b7bc16bb32948918409b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:39:09 -0400 Subject: [PATCH 08/12] chore(canvas): drop comments from share-link code Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- .../canvas/components/MentionText.test.tsx | 4 ++-- packages/ui/src/utils/posthogLinks.ts | 16 ---------------- packages/ui/src/utils/shareLinks.ts | 5 ----- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index bf01e82421..e924736b4e 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -63,7 +63,7 @@ describe("MentionText", () => { const link = screen.getByRole("link", { name: "Signups" }); const defaultAllowed = fireEvent.click(link); - expect(defaultAllowed).toBe(false); // preventDefault was called + expect(defaultAllowed).toBe(false); expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); }); @@ -73,7 +73,7 @@ describe("MentionText", () => { const link = screen.getByRole("link", { name: "Docs" }); const defaultAllowed = fireEvent.click(link); - expect(defaultAllowed).toBe(true); // default not prevented + expect(defaultAllowed).toBe(true); expect(navigateToChannelDashboard).not.toHaveBeenCalled(); }); diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index 2e16408984..723aa92862 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -121,17 +121,10 @@ export function channelShareUrl( ); } -/** - * The in-app destination a PostHog Code share link points at — the inverse of - * the `canvasShareUrl` / `channelShareUrl` builders above. - */ export type ShareLinkTarget = | { kind: "canvas"; channelId: string; dashboardId: string } | { kind: "channel"; channelId: string; taskId?: string }; -// Hosts we recognise as PostHog share-link origins, one per cloud region. The -// host check keeps `parseShareLink` from hijacking unrelated links that happen -// to share the `/code/...` path shape. const POSTHOG_HOSTS = new Set( (Object.keys(REGION_LABELS) as CloudRegion[]) .map((region) => { @@ -144,13 +137,6 @@ const POSTHOG_HOSTS = new Set( .filter(Boolean), ); -/** - * Parse a PostHog Code share link into its in-app navigation target, or `null` - * if it isn't one. Recognises the `/code/canvas/...` and `/code/channel/...` - * links built above, on any region's host — the inbound deep-link handlers - * navigate by id against the current session, so bouncing through the browser - * to reach the app buys nothing. - */ export function parseShareLink(href: string): ShareLinkTarget | null { let url: URL; try { @@ -160,8 +146,6 @@ export function parseShareLink(href: string): ShareLinkTarget | null { } if (!POSTHOG_HOSTS.has(url.host)) return null; - // Split the still-encoded pathname first, then decode each segment, so an id - // containing an encoded slash (`%2F`) stays a single segment. const segments = url.pathname .split("/") .filter(Boolean) diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts index 75e89481e7..323872a182 100644 --- a/packages/ui/src/utils/shareLinks.ts +++ b/packages/ui/src/utils/shareLinks.ts @@ -23,11 +23,6 @@ export function navigateToShareTarget(target: ShareLinkTarget): void { } } -/** - * If `href` is a PostHog Code share link, navigate to it in-app and return true - * (cancelling the click's default open-in-browser). Otherwise return false so - * the caller lets the link open externally as usual. - */ export function handleShareLinkClick( href: string | undefined, event: { preventDefault: () => void }, From 6b0e4e793b33bd5cb9f2cf07a1cb3b5e83ea94cc Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:44:05 -0400 Subject: [PATCH 09/12] refactor(canvas): split share-link parsing into per-type helpers Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/posthogLinks.ts | 50 ++++++++++++++++----------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index 723aa92862..f7f48acc27 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -137,16 +137,8 @@ const POSTHOG_HOSTS = new Set( .filter(Boolean), ); -export function parseShareLink(href: string): ShareLinkTarget | null { - let url: URL; - try { - url = new URL(href); - } catch { - return null; - } - if (!POSTHOG_HOSTS.has(url.host)) return null; - - const segments = url.pathname +function decodePathSegments(pathname: string): string[] { + return pathname .split("/") .filter(Boolean) .map((segment) => { @@ -156,23 +148,39 @@ export function parseShareLink(href: string): ShareLinkTarget | null { return segment; } }); +} - if (segments[0] !== "code") return null; +function parseCanvasShareLink(segments: string[]): ShareLinkTarget | null { + const [root, kind, channelId, dashboardId] = segments; + if (root === "code" && kind === "canvas" && segments.length === 4) { + return { kind: "canvas", channelId, dashboardId }; + } + return null; +} - if (segments[1] === "canvas" && segments.length === 4) { - return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] }; +function parseChannelShareLink(segments: string[]): ShareLinkTarget | null { + const [root, kind, channelId, maybeTasks, taskId] = segments; + if (root !== "code" || kind !== "channel") return null; + if (segments.length === 3) { + return { kind: "channel", channelId }; + } + if (segments.length === 5 && maybeTasks === "tasks") { + return { kind: "channel", channelId, taskId }; } + return null; +} - if (segments[1] === "channel") { - if (segments.length === 3) { - return { kind: "channel", channelId: segments[2] }; - } - if (segments.length === 5 && segments[3] === "tasks") { - return { kind: "channel", channelId: segments[2], taskId: segments[4] }; - } +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; } + if (!POSTHOG_HOSTS.has(url.host)) return null; - return null; + const segments = decodePathSegments(url.pathname); + return parseCanvasShareLink(segments) ?? parseChannelShareLink(segments); } export function errorTrackingIssueUrl( From 4ce850e7299da227676267dc21a21f3cffc77ad0 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 09:47:51 -0400 Subject: [PATCH 10/12] refactor(canvas): match share links against a route table Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/posthogLinks.ts | 61 +++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index f7f48acc27..4db4fb3aec 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -137,6 +137,30 @@ const POSTHOG_HOSTS = new Set( .filter(Boolean), ); +interface ShareLinkRoute { + pattern: string[]; + build: (params: Record) => ShareLinkTarget; +} + +const SHARE_LINK_ROUTES: ShareLinkRoute[] = [ + { + pattern: ["code", "canvas", ":channelId", ":dashboardId"], + build: ({ channelId, dashboardId }) => ({ + kind: "canvas", + channelId, + dashboardId, + }), + }, + { + pattern: ["code", "channel", ":channelId"], + build: ({ channelId }) => ({ kind: "channel", channelId }), + }, + { + pattern: ["code", "channel", ":channelId", "tasks", ":taskId"], + build: ({ channelId, taskId }) => ({ kind: "channel", channelId, taskId }), + }, +]; + function decodePathSegments(pathname: string): string[] { return pathname .split("/") @@ -150,24 +174,21 @@ function decodePathSegments(pathname: string): string[] { }); } -function parseCanvasShareLink(segments: string[]): ShareLinkTarget | null { - const [root, kind, channelId, dashboardId] = segments; - if (root === "code" && kind === "canvas" && segments.length === 4) { - return { kind: "canvas", channelId, dashboardId }; +function matchRoute( + segments: string[], + route: ShareLinkRoute, +): ShareLinkTarget | null { + if (segments.length !== route.pattern.length) return null; + const params: Record = {}; + for (const [index, token] of route.pattern.entries()) { + const segment = segments[index]; + if (token.startsWith(":")) { + params[token.slice(1)] = segment; + } else if (token !== segment) { + return null; + } } - return null; -} - -function parseChannelShareLink(segments: string[]): ShareLinkTarget | null { - const [root, kind, channelId, maybeTasks, taskId] = segments; - if (root !== "code" || kind !== "channel") return null; - if (segments.length === 3) { - return { kind: "channel", channelId }; - } - if (segments.length === 5 && maybeTasks === "tasks") { - return { kind: "channel", channelId, taskId }; - } - return null; + return route.build(params); } export function parseShareLink(href: string): ShareLinkTarget | null { @@ -180,7 +201,11 @@ export function parseShareLink(href: string): ShareLinkTarget | null { if (!POSTHOG_HOSTS.has(url.host)) return null; const segments = decodePathSegments(url.pathname); - return parseCanvasShareLink(segments) ?? parseChannelShareLink(segments); + for (const route of SHARE_LINK_ROUTES) { + const target = matchRoute(segments, route); + if (target) return target; + } + return null; } export function errorTrackingIssueUrl( From 58672bb3b77d20c60d8041584bdcd7aa4a762d27 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:50:24 +0000 Subject: [PATCH 11/12] chore(visual): update storybook baselines 12 updated Run: 3d040bb6-1c65-415b-8860-4c73932ce6b8 Co-authored-by: adboio <23323033+adboio@users.noreply.github.com> --- apps/code/snapshots.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index cfbdd290b1..b7488a0054 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -116,6 +116,30 @@ snapshots: hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw billing-usagemeter--zero-spend-limit--light: hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c + channels-taskfeedrow--agent-origin--dark: + hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.-SVboDRIZ_-nh4PtMliCv7e7iCtdUm-1m2xQ9Xzcdfw + channels-taskfeedrow--agent-origin--light: + hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f._KPUvj46p33u7A4Xndz-git-4rp7VKca1MpeTztlB6w + channels-taskfeedrow--human-email-only--dark: + hash: v1.k4693efd2.0f4dece4643b7375b77746347658f4fe65b37817cdfa9ad1ba697067e619f428.f0jKZ4OYqsmeJjXffjt-amK9cwE81JF0lgSMqtn37PA + channels-taskfeedrow--human-email-only--light: + hash: v1.k4693efd2.14db0510f7ea5c57985cf0cb60ab08f5e146c7243eeb1b0b963f74e85c7d60f4.ijHQLK0lZjjUlOQuzYzisImU9DWsNmZM7i5GYfWU4E0 + channels-taskfeedrow--human-started--dark: + hash: v1.k4693efd2.e0abdae2e8ac2ef29793ea7afc2a2580c6610e07a3c640ad782af7fd3c12592a.YUgF_BTPzZoqnWXvyLBRs3w9C_P3Yfh4-cP4mUqs2NQ + channels-taskfeedrow--human-started--light: + hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM + channels-taskfeedrow--long-prompt--dark: + hash: v1.k4693efd2.2547e88d76889aa7290c5221ff5ecb674cdb1cd9f1ae5efd211fb648a88037b6.4c9RO1upuz3VijYp2xU2MTNrW_87zWQLX5kRKCYABq4 + channels-taskfeedrow--long-prompt--light: + hash: v1.k4693efd2.6b0f609e0155bc3b6e3149714b70d33ef83f7c2ca7b72e6d3a495b8db36828d0.eC5ZQDilDw7Y9EJqhfIdlk186GJjgGbYolZ9cm9Xtt0 + channels-taskfeedrow--no-prompt--dark: + hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU + channels-taskfeedrow--no-prompt--light: + hash: v1.k4693efd2.a02339aecdb6fc327fdf6586e28ceeebf8490442fe217eb8bb46f1ce66bff78a.2l7XdvdW3D0GlLmPwDdTFgjZWdav68bZX_LYResVKwo + channels-taskfeedrow--no-starter--dark: + hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.RiectZxFTqk1husLYI-UV5ko1uZ4lpbjWstE7Hy3MJE + channels-taskfeedrow--no-starter--light: + hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.OhGj64l-o7iWtxzJo1qt0A_CU9ME0ZRr_5e455RGUJQ components-permissions-permissionselector--create-new-file--dark: hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos components-permissions-permissionselector--create-new-file--light: From 01765468f44be2c33dd5498a77e0db8669a29c84 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 10:19:45 -0400 Subject: [PATCH 12/12] fix(canvas): let modified clicks on share links open in a new tab A Cmd/Ctrl/Shift/middle click on a share link is explicit intent to open it elsewhere; don't hijack it for in-app navigation. handleShareLinkClick now bails on modified clicks so the anchor's target="_blank" default runs. Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827 --- packages/ui/src/utils/shareLinks.test.ts | 21 +++++++++++++++++++++ packages/ui/src/utils/shareLinks.ts | 23 +++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts index 2bad7e8da4..7184867d6a 100644 --- a/packages/ui/src/utils/shareLinks.test.ts +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -41,6 +41,27 @@ describe("handleShareLinkClick", () => { expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1"); }); + it.each([ + ["meta", { metaKey: true }], + ["ctrl", { ctrlKey: true }], + ["shift", { shiftKey: true }], + ["a middle button", { button: 1 }], + ])( + "leaves a %s-modified click to open in a new tab/window", + (_label, modifier) => { + const event = { preventDefault: vi.fn(), ...modifier }; + + const handled = handleShareLinkClick( + "https://us.posthog.com/code/canvas/chan1/dash1", + event, + ); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + }, + ); + it("leaves an external link alone", () => { const event = { preventDefault: vi.fn() }; diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts index 323872a182..62f0b4b17c 100644 --- a/packages/ui/src/utils/shareLinks.ts +++ b/packages/ui/src/utils/shareLinks.ts @@ -23,11 +23,30 @@ export function navigateToShareTarget(target: ShareLinkTarget): void { } } +interface ShareLinkClickEvent { + preventDefault: () => void; + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + button?: number; +} + +function isModifiedClick(event: ShareLinkClickEvent): boolean { + return Boolean( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + (event.button != null && event.button !== 0), + ); +} + export function handleShareLinkClick( href: string | undefined, - event: { preventDefault: () => void }, + event: ShareLinkClickEvent, ): boolean { - if (!href) return false; + if (!href || isModifiedClick(event)) return false; const target = parseShareLink(href); if (!target) return false; event.preventDefault();