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;
+}