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