diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml
index 1a1c7bd850..ba1c2d231f 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:
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..8ff99ea14a
--- /dev/null
+++ b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx
@@ -0,0 +1,111 @@
+import type { Task, UserBasic } from "@posthog/shared/domain-types";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { TaskFeedRow } from "./ChannelFeedView";
+
+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:
+ "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",
+ created_by: user(),
+ ...overrides,
+});
+
+const meta: Meta = {
+ title: "Channels/TaskFeedRow",
+ component: TaskFeedRow,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const HumanStarted: Story = {
+ args: {
+ task: task(),
+ children: ,
+ },
+};
+
+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: ,
+ },
+};
+
+export const AgentOrigin: Story = {
+ args: {
+ task: task({
+ origin_product: "slack",
+ title: "Investigate signup drop-off",
+ description: "Investigate the signup drop-off we saw over the weekend",
+ }),
+ children: ,
+ },
+};
+
+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({ 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 af127b60ab..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,
@@ -37,10 +38,13 @@ 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 { 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";
@@ -411,33 +415,43 @@ function ReplyFooter({
);
}
-const FeedItem = memo(function FeedItem({
+function channelTaskStarter(task: Task): UserBasic | null {
+ return task.origin_product === "user_created"
+ ? (task.created_by ?? null)
+ : null;
+}
+
+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;
}) {
+ const starter = channelTaskStarter(task);
+ const prompt = useMemo(
+ () => xmlToPlainText(task.description ?? "").trim(),
+ [task.description],
+ );
+
return (
-
+ {starter ? getUserInitials(starter) : }
- PostHog
- Agent
+
+ {starter ? userDisplayName(starter) : "PostHog"}
+
+ {!starter && Agent}
@@ -445,47 +459,60 @@ 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"
- )}
+
+ {prompt ||
+ (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
diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx
index 6c9588e4d3..e924736b4e 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);
+ 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);
+ 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/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..4db4fb3aec 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,93 @@ export function channelShareUrl(
);
}
+export type ShareLinkTarget =
+ | { kind: "canvas"; channelId: string; dashboardId: string }
+ | { kind: "channel"; channelId: string; taskId?: string };
+
+const POSTHOG_HOSTS = new Set(
+ (Object.keys(REGION_LABELS) as CloudRegion[])
+ .map((region) => {
+ try {
+ return new URL(getCloudUrlFromRegion(region)).host;
+ } catch {
+ return "";
+ }
+ })
+ .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("/")
+ .filter(Boolean)
+ .map((segment) => {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+ });
+}
+
+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 route.build(params);
+}
+
+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 = decodePathSegments(url.pathname);
+ for (const route of SHARE_LINK_ROUTES) {
+ const target = matchRoute(segments, route);
+ if (target) return target;
+ }
+ 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
new file mode 100644
index 0000000000..7184867d6a
--- /dev/null
+++ b/packages/ui/src/utils/shareLinks.test.ts
@@ -0,0 +1,83 @@
+import { handleShareLinkClick } 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("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.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() };
+
+ 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..62f0b4b17c
--- /dev/null
+++ b/packages/ui/src/utils/shareLinks.ts
@@ -0,0 +1,55 @@
+import {
+ navigateToChannel,
+ navigateToChannelDashboard,
+ navigateToChannelTask,
+} from "@posthog/ui/router/navigationBridge";
+import {
+ parseShareLink,
+ type ShareLinkTarget,
+} from "@posthog/ui/utils/posthogLinks";
+
+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;
+ }
+}
+
+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: ShareLinkClickEvent,
+): boolean {
+ if (!href || isModifiedClick(event)) return false;
+ const target = parseShareLink(href);
+ if (!target) return false;
+ event.preventDefault();
+ navigateToShareTarget(target);
+ return true;
+}