From 4a7897153b76b4f82ddad0fa85dd7f10921f6e6e Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 25 May 2026 15:44:48 +0100 Subject: [PATCH 1/3] feat: Render PostHog URLs as rich preview chips with title resolution Adds rich chip rendering for PostHog resource URLs, mirroring the existing GitHub URL chip pattern. PostHog URLs in both agent messages (MarkdownRenderer) and pasted into the editor (Tiptap) now render as interactive chips with resource-type icons and resolved titles. Key additions: - URL parser supporting 11 resource types (feature flags, experiments, insights, dashboards, error tracking, recordings, surveys, notebooks, cohorts, actions, early access features) - Both long (/project/{id}/...) and short (no project prefix) URL formats - Async title resolution via PostHog API (shows "Loading..." placeholder, resolves to actual resource name) - Chips persist labels through XML round-trip (fixes raw XML showing in user message blocks) - Multi-URL paste support for mixed GitHub + PostHog URLs Closes #1977 --- apps/code/src/renderer/api/posthogClient.ts | 180 +++++++++++ .../editor/components/MarkdownRenderer.tsx | 88 +++++- .../editor/components/PostHogRefChip.tsx | 55 ++++ .../message-editor/tiptap/MentionChipNode.ts | 8 + .../message-editor/tiptap/MentionChipView.tsx | 29 +- .../message-editor/tiptap/useTiptapEditor.ts | 145 +++++++++ .../features/message-editor/utils/content.ts | 42 ++- .../message-editor/utils/posthogChip.ts | 120 +++++++ .../message-editor/utils/posthogUrl.test.ts | 298 ++++++++++++++++++ .../message-editor/utils/posthogUrl.ts | 119 +++++++ 10 files changed, 1069 insertions(+), 15 deletions(-) create mode 100644 apps/code/src/renderer/features/editor/components/PostHogRefChip.tsx create mode 100644 apps/code/src/renderer/features/message-editor/utils/posthogChip.ts create mode 100644 apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts create mode 100644 apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index dc4721d056..114e0ddbeb 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -602,6 +602,10 @@ export class PostHogAPIClient { throw new Error("No team found for user"); } + async getDefaultProjectId(): Promise { + return this.getTeamId(); + } + async getCurrentUser() { const data = await this.api.get("/api/users/{uuid}/", { path: { uuid: "@me" }, @@ -2929,4 +2933,180 @@ export class PostHogAPIClient { } return (await response.json()) as SpendAnalysisResponse; } + + async getFeatureFlag( + projectId: string, + flagId: string, + ): Promise<{ name: string; key: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/feature_flags/${encodeURIComponent(flagId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string; key?: string }; + return { name: data.name ?? "", key: data.key ?? "" }; + } + + async getExperiment( + projectId: string, + experimentId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/experiments/${encodeURIComponent(experimentId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getInsight( + projectId: string, + insightId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/insights/${encodeURIComponent(insightId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getDashboard( + projectId: string, + dashboardId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/dashboards/${encodeURIComponent(dashboardId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getErrorTrackingGroup( + projectId: string, + groupId: string, + ): Promise<{ title: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/error_tracking/${encodeURIComponent(groupId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { title?: string }; + return { title: data.title ?? "" }; + } + + async getRecording( + projectId: string, + recordingId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/session_recordings/${encodeURIComponent(recordingId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getSurvey( + projectId: string, + surveyId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/surveys/${encodeURIComponent(surveyId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getNotebook( + projectId: string, + notebookId: string, + ): Promise<{ title: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/notebooks/${encodeURIComponent(notebookId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { title?: string }; + return { title: data.title ?? "" }; + } + + async getCohort( + projectId: string, + cohortId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/cohorts/${encodeURIComponent(cohortId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getAction( + projectId: string, + actionId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/actions/${encodeURIComponent(actionId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } + + async getEarlyAccessFeature( + projectId: string, + featureId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/early_access_feature/${encodeURIComponent(featureId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { name?: string }; + return { name: data.name ?? "" }; + } } diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index 050e10e003..ef063b4980 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -2,9 +2,23 @@ import { CodeBlock } from "@components/CodeBlock"; import { Divider } from "@components/Divider"; import { HighlightedCode } from "@components/HighlightedCode"; import { List, ListItem } from "@components/List"; -import { parseGithubIssueUrl } from "@features/message-editor/utils/githubIssueUrl"; +import { + type ParsedGithubIssueUrl, + parseGithubIssueUrl, +} from "@features/message-editor/utils/githubIssueUrl"; +import { + buildResolvedLabel, + fetchPostHogResourceTitle, +} from "@features/message-editor/utils/posthogChip"; +import { + type ParsedPostHogUrl, + parsePostHogUrl, +} from "@features/message-editor/utils/posthogUrl"; +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; +import { useTRPC } from "@renderer/trpc/client"; +import { useQuery } from "@tanstack/react-query"; import { isPostHogCodeDeeplink } from "@shared/deeplink"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; @@ -12,6 +26,7 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { PluggableList } from "unified"; import { GithubRefChip } from "./GithubRefChip"; +import { PostHogRefChip } from "./PostHogRefChip"; interface MarkdownRendererProps { content: string; @@ -37,6 +52,54 @@ const HeadingText = ({ children }: { children: React.ReactNode }) => ( ); +function SmartGithubRefChip({ parsed }: { parsed: ParsedGithubIssueUrl }) { + const trpc = useTRPC(); + const input = { + owner: parsed.owner, + repo: parsed.repo, + number: parsed.number, + }; + const options = + parsed.kind === "pr" + ? trpc.git.getGithubPullRequest.queryOptions(input) + : trpc.git.getGithubIssue.queryOptions(input); + const { data } = useQuery({ ...options, staleTime: 60_000 }); + + const label = data?.title + ? `#${parsed.number} - ${data.title}` + : `${parsed.owner}/${parsed.repo}#${parsed.number}`; + + return ( + + {label} + + ); +} + +function SmartPostHogRefChip({ parsed }: { parsed: ParsedPostHogUrl }) { + const { data: title } = useAuthenticatedQuery( + [ + "posthog-resource", + parsed.resourceType, + parsed.projectId, + parsed.resourceId, + ], + (client) => fetchPostHogResourceTitle(client, parsed), + { staleTime: 60_000 }, + ); + + const label = buildResolvedLabel(parsed, title ?? null); + + return ( + + {label} + + ); +} + export const baseComponents: Components = { h1: ({ children }) => {children}, h2: ({ children }) => {children}, @@ -90,15 +153,30 @@ export const baseComponents: Components = { const githubRef = href ? parseGithubIssueUrl(href) : null; if (githubRef) { const isAutoLink = typeof children === "string" && children === href; - const label = isAutoLink - ? `${githubRef.owner}/${githubRef.repo}#${githubRef.number}` - : children; + if (isAutoLink) { + return ; + } return ( - {label} + {children} ); } + const posthogRef = href ? parsePostHogUrl(href) : null; + if (posthogRef) { + const isAutoLink = typeof children === "string" && children === href; + if (isAutoLink) { + return ; + } + return ( + + {children} + + ); + } const isDeeplink = isPostHogCodeDeeplink(href); return ( +> = { + feature_flag: FlagIcon, + experiment: FlaskIcon, + insight: ChartLineIcon, + dashboard: SquaresFourIcon, + error_tracking: BugIcon, + recording: VideoIcon, + survey: ClipboardTextIcon, + notebook: NotebookIcon, + cohort: UsersThreeIcon, + action: LightningIcon, + early_access_feature: RocketLaunchIcon, +}; + +export function PostHogRefChip({ + href, + resourceType, + children, +}: { + href: string; + resourceType: PostHogResourceType; + children: ReactNode; +}) { + const Icon = resourceIconMap[resourceType]; + return ( + window.open(href, "_blank")} + className="cli-file-mention mx-0.5 max-w-full cursor-pointer! whitespace-nowrap pl-1 align-middle active:translate-y-0" + > + + {children} + + ); +} diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts index 107eaa32a3..c3d8ca8ddf 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts @@ -10,6 +10,14 @@ export type ChipType = | "experiment" | "insight" | "feature_flag" + | "dashboard" + | "recording" + | "error_tracking" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature" | "github_issue" | "github_pr"; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx index 3d87a65da0..039fb5e604 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx @@ -1,14 +1,22 @@ import { Tooltip } from "@components/ui/Tooltip"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; import { + BugIcon, ChartLineIcon, + ClipboardTextIcon, FileTextIcon, FlagIcon, FlaskIcon, FolderIcon, GithubLogoIcon, GitPullRequestIcon, + LightningIcon, + NotebookIcon, + RocketLaunchIcon, + SquaresFourIcon, TerminalIcon, + UsersThreeIcon, + VideoIcon, WarningIcon, XIcon, } from "@phosphor-icons/react"; @@ -33,6 +41,14 @@ const typeIconMap: Record> = { experiment: FlaskIcon, insight: ChartLineIcon, feature_flag: FlagIcon, + dashboard: SquaresFourIcon, + recording: VideoIcon, + error_tracking: BugIcon, + survey: ClipboardTextIcon, + notebook: NotebookIcon, + cohort: UsersThreeIcon, + action: LightningIcon, + early_access_feature: RocketLaunchIcon, }; function IconCloseButton({ @@ -82,17 +98,24 @@ function DefaultChip({ const isFile = type === "file"; const isFolder = type === "folder"; const isGithubRef = type === "github_issue" || type === "github_pr"; - const canOpenUrl = isGithubRef && /^https:\/\//.test(id); + const isPostHogRef = + type !== "file" && + type !== "folder" && + type !== "command" && + type !== "error" && + !isGithubRef; + const isUrlChip = isGithubRef || isPostHogRef; + const canOpenUrl = isUrlChip && /^https?:\/\//.test(id); const chipContent = ( window.open(id, "_blank") : undefined} - className={`${chipBase} max-w-full whitespace-nowrap ${isGithubRef ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`} + className={`${chipBase} max-w-full whitespace-nowrap ${isUrlChip ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`} > - {isGithubRef ? ( + {isUrlChip ? ( {label} ) : ( `${prefix}${label}` diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 7df2b75f70..d6464206f5 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,7 +1,9 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; import { trpc } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; +import { Fragment, type Node as PmNode, Slice } from "@tiptap/pm/model"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; import { queryClient } from "@utils/queryClient"; @@ -24,6 +26,13 @@ import { persistTextContent, resolveAndAttachDroppedFiles, } from "../utils/persistFile"; +import { + buildPostHogPlaceholderLabel, + buildResolvedLabel, + fetchPostHogResourceTitle, + posthogResourceToMentionChip, +} from "../utils/posthogChip"; +import { type ParsedPostHogUrl, parsePostHogUrl } from "../utils/posthogUrl"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -104,6 +113,77 @@ function buildGithubRefPlaceholderChip( : githubIssueToMentionChip(source); } +interface MixedPasteResult { + fragment: Fragment; + githubRefs: ParsedGithubIssueUrl[]; + posthogRefs: ParsedPostHogUrl[]; +} + +const URL_INLINE_REGEX = /https?:\/\/\S+/g; + +function buildMixedPasteContent( + view: EditorView, + text: string, +): MixedPasteResult | null { + const schema = view.state.schema; + const nodes: PmNode[] = []; + const githubRefs: ParsedGithubIssueUrl[] = []; + const posthogRefs: ParsedPostHogUrl[] = []; + let lastIndex = 0; + let hasChip = false; + + for (const match of text.matchAll(URL_INLINE_REGEX)) { + const url = match[0]; + const matchIndex = match.index; + + const githubRef = parseGithubIssueUrl(url); + const posthogRef = parsePostHogUrl(url); + + if (!githubRef && !posthogRef) continue; + + hasChip = true; + + if (matchIndex > lastIndex) { + nodes.push(schema.text(text.slice(lastIndex, matchIndex))); + } + + if (githubRef) { + const chip = buildGithubRefPlaceholderChip(githubRef); + nodes.push( + schema.nodes.mentionChip.create({ + pastedText: false, + ...chip, + }), + ); + githubRefs.push(githubRef); + } else if (posthogRef) { + const chip = posthogResourceToMentionChip(posthogRef); + chip.label = buildPostHogPlaceholderLabel(posthogRef); + nodes.push( + schema.nodes.mentionChip.create({ + pastedText: false, + ...chip, + }), + ); + posthogRefs.push(posthogRef); + } + + lastIndex = matchIndex + url.length; + } + + if (!hasChip) return null; + + if (lastIndex < text.length) { + nodes.push(schema.text(text.slice(lastIndex))); + } + + return { + fragment: Fragment.from(nodes), + githubRefs, + posthogRefs, + }; +} + function insertGithubRefPlaceholder( view: EditorView, parsed: ParsedGithubIssueUrl, @@ -171,6 +251,48 @@ async function resolveGithubRefChip( if (updated) view.dispatch(tr); } +function insertPostHogRefPlaceholder( + view: EditorView, + parsed: ParsedPostHogUrl, +): void { + const chip = posthogResourceToMentionChip(parsed); + chip.label = buildPostHogPlaceholderLabel(parsed); + insertChipWithTrailingSpace(view, chip); +} + +async function resolvePostHogRefChip( + view: EditorView, + parsed: ParsedPostHogUrl, +): Promise { + const placeholderLabel = buildPostHogPlaceholderLabel(parsed); + const client = await getAuthenticatedClient(); + const title = client ? await fetchPostHogResourceTitle(client, parsed) : null; + const resolvedLabel = buildResolvedLabel(parsed, title); + + if (view.isDestroyed) return; + + const { doc, tr } = view.state; + let updated = false; + doc.descendants((node, pos) => { + if ( + node.type.name !== "mentionChip" || + node.attrs.type !== parsed.resourceType || + node.attrs.id !== parsed.normalizedUrl || + node.attrs.label !== placeholderLabel + ) { + return true; + } + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + label: resolvedLabel, + }); + updated = true; + return false; + }); + + if (updated) view.dispatch(tr); +} + function showPasteHint(message: string, description: string): void { const store = useFeatureSettingsStore.getState(); const key = @@ -414,6 +536,29 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { void resolveGithubRefChip(view, parsedRef); return true; } + + const parsedPostHog = parsePostHogUrl(trimmedClipboardText); + if (parsedPostHog) { + event.preventDefault(); + insertPostHogRefPlaceholder(view, parsedPostHog); + void resolvePostHogRefChip(view, parsedPostHog); + return true; + } + + const mixed = buildMixedPasteContent(view, trimmedClipboardText); + if (mixed) { + event.preventDefault(); + const { tr } = view.state; + tr.replaceSelection(new Slice(mixed.fragment, 0, 0)); + view.dispatch(tr); + for (const ref of mixed.githubRefs) { + void resolveGithubRefChip(view, ref); + } + for (const ref of mixed.posthogRefs) { + void resolvePostHogRefChip(view, ref); + } + return true; + } } const items = event.clipboardData?.items; diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/apps/code/src/renderer/features/message-editor/utils/content.ts index f0da426568..74cd18377d 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -1,4 +1,5 @@ import { escapeXmlAttr, unescapeXmlAttr } from "@utils/xml"; +import { parsePostHogUrl } from "./posthogUrl"; export interface MentionChip { type: @@ -9,6 +10,14 @@ export interface MentionChip { | "experiment" | "insight" | "feature_flag" + | "dashboard" + | "recording" + | "error_tracking" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature" | "github_issue" | "github_pr"; id: string; @@ -67,11 +76,17 @@ export function contentToXml(content: EditorContent): string { case "error": return ``; case "experiment": - return ``; case "insight": - return ``; case "feature_flag": - return ``; + case "dashboard": + case "recording": + case "error_tracking": + case "survey": + case "notebook": + case "cohort": + case "action": + case "early_access_feature": + return `<${chip.type} id="${escapedId}" label="${escapeXmlAttr(chip.label)}" />`; case "github_issue": case "github_pr": { const labelMatch = chip.label.match(/^#(\d+)(?:\s*-\s*(.*))?$/); @@ -97,7 +112,7 @@ export function contentToXml(content: EditorContent): string { } const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; + /<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|github_issue|github_pr)\b([^>]*?)\s*\/>/g; const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { @@ -128,13 +143,26 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { if (!path) return null; return { type: "folder", id: path, label: deriveFileLabel(path) }; } - case "error": + case "error": { + const id = attrs.id; + if (!id) return null; + return { type: tag, id, label: id }; + } case "experiment": case "insight": - case "feature_flag": { + case "feature_flag": + case "dashboard": + case "recording": + case "error_tracking": + case "survey": + case "notebook": + case "cohort": + case "action": + case "early_access_feature": { const id = attrs.id; if (!id) return null; - return { type: tag, id, label: id }; + const label = attrs.label || parsePostHogUrl(id)?.label || id; + return { type: tag, id, label }; } case "github_issue": case "github_pr": { diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts b/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts new file mode 100644 index 0000000000..5035174a2f --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts @@ -0,0 +1,120 @@ +import type { PostHogAPIClient } from "@renderer/api/posthogClient"; +import type { MentionChip } from "./content"; +import type { ParsedPostHogUrl, PostHogResourceType } from "./posthogUrl"; + +export function posthogResourceToMentionChip( + parsed: ParsedPostHogUrl, +): MentionChip { + return { + type: parsed.resourceType, + id: parsed.normalizedUrl, + label: parsed.label, + }; +} + +const LABEL_PREFIXES: Record = { + feature_flag: "Feature Flag", + experiment: "Experiment", + insight: "Insight", + dashboard: "Dashboard", + error_tracking: "Error", + recording: "Recording", + survey: "Survey", + notebook: "Notebook", + cohort: "Cohort", + action: "Action", + early_access_feature: "Early Access Feature", +}; + +function formatDisplayId(resourceId: string, prefix: string): string { + const displayId = /^\d+$/.test(resourceId) ? `#${resourceId}` : resourceId; + return `${prefix} ${displayId}`; +} + +export function buildPostHogPlaceholderLabel(parsed: ParsedPostHogUrl): string { + return `${formatDisplayId(parsed.resourceId, LABEL_PREFIXES[parsed.resourceType])} - Loading...`; +} + +export function buildResolvedLabel( + parsed: ParsedPostHogUrl, + title: string | null, +): string { + const base = formatDisplayId( + parsed.resourceId, + LABEL_PREFIXES[parsed.resourceType], + ); + return title ? `${base} - ${title}` : base; +} + +async function resolveProjectId( + client: PostHogAPIClient, + parsed: ParsedPostHogUrl, +): Promise { + if (parsed.projectId) return parsed.projectId; + return String(await client.getDefaultProjectId()); +} + +export async function fetchPostHogResourceTitle( + client: PostHogAPIClient, + parsed: ParsedPostHogUrl, +): Promise { + try { + const projectId = await resolveProjectId(client, parsed); + switch (parsed.resourceType) { + case "feature_flag": { + const flag = await client.getFeatureFlag(projectId, parsed.resourceId); + return flag?.name || flag?.key || null; + } + case "experiment": { + const exp = await client.getExperiment(projectId, parsed.resourceId); + return exp?.name || null; + } + case "insight": { + const insight = await client.getInsight(projectId, parsed.resourceId); + return insight?.name || null; + } + case "dashboard": { + const dash = await client.getDashboard(projectId, parsed.resourceId); + return dash?.name || null; + } + case "error_tracking": { + const group = await client.getErrorTrackingGroup( + projectId, + parsed.resourceId, + ); + return group?.title || null; + } + case "recording": { + const rec = await client.getRecording(projectId, parsed.resourceId); + return rec?.name || null; + } + case "survey": { + const survey = await client.getSurvey(projectId, parsed.resourceId); + return survey?.name || null; + } + case "notebook": { + const nb = await client.getNotebook(projectId, parsed.resourceId); + return nb?.title || null; + } + case "cohort": { + const cohort = await client.getCohort(projectId, parsed.resourceId); + return cohort?.name || null; + } + case "action": { + const action = await client.getAction(projectId, parsed.resourceId); + return action?.name || null; + } + case "early_access_feature": { + const eaf = await client.getEarlyAccessFeature( + projectId, + parsed.resourceId, + ); + return eaf?.name || null; + } + default: + return null; + } + } catch { + return null; + } +} diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts new file mode 100644 index 0000000000..bf932efd0f --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "vitest"; +import { type ParsedPostHogUrl, parsePostHogUrl } from "./posthogUrl"; + +describe("parsePostHogUrl", () => { + const accepts: Array<{ + name: string; + input: string; + expected: ParsedPostHogUrl; + }> = [ + // --- Long format: /project/{id}/... --- + { + name: "US cloud feature flag (long)", + input: "https://us.posthog.com/project/1/feature_flags/42", + expected: { + resourceType: "feature_flag", + projectId: "1", + resourceId: "42", + normalizedUrl: "https://us.posthog.com/project/1/feature_flags/42", + label: "Feature Flag #42", + }, + }, + { + name: "EU cloud experiment (long)", + input: "https://eu.posthog.com/project/99/experiments/7", + expected: { + resourceType: "experiment", + projectId: "99", + resourceId: "7", + normalizedUrl: "https://eu.posthog.com/project/99/experiments/7", + label: "Experiment #7", + }, + }, + { + name: "localhost insight with alphanumeric ID", + input: "http://localhost:8010/project/1/insights/abc123", + expected: { + resourceType: "insight", + projectId: "1", + resourceId: "abc123", + normalizedUrl: "http://localhost:8010/project/1/insights/abc123", + label: "Insight abc123", + }, + }, + { + name: "dashboard (long)", + input: "https://us.posthog.com/project/5/dashboard/10", + expected: { + resourceType: "dashboard", + projectId: "5", + resourceId: "10", + normalizedUrl: "https://us.posthog.com/project/5/dashboard/10", + label: "Dashboard #10", + }, + }, + { + name: "error tracking (long)", + input: "https://us.posthog.com/project/1/error_tracking/abc-def-123", + expected: { + resourceType: "error_tracking", + projectId: "1", + resourceId: "abc-def-123", + normalizedUrl: + "https://us.posthog.com/project/1/error_tracking/abc-def-123", + label: "Error abc-def-123", + }, + }, + { + name: "recording (replay, long)", + input: "https://eu.posthog.com/project/2/replay/019012ab-cd34-ef56", + expected: { + resourceType: "recording", + projectId: "2", + resourceId: "019012ab-cd34-ef56", + normalizedUrl: + "https://eu.posthog.com/project/2/replay/019012ab-cd34-ef56", + label: "Recording 019012ab-cd34-ef56", + }, + }, + { + name: "trailing slash is stripped", + input: "https://us.posthog.com/project/1/feature_flags/42/", + expected: { + resourceType: "feature_flag", + projectId: "1", + resourceId: "42", + normalizedUrl: "https://us.posthog.com/project/1/feature_flags/42", + label: "Feature Flag #42", + }, + }, + { + name: "query params are stripped", + input: "https://us.posthog.com/project/1/experiments/3?tab=results", + expected: { + resourceType: "experiment", + projectId: "1", + resourceId: "3", + normalizedUrl: "https://us.posthog.com/project/1/experiments/3", + label: "Experiment #3", + }, + }, + { + name: "fragment is stripped", + input: "https://us.posthog.com/project/1/dashboard/5#section", + expected: { + resourceType: "dashboard", + projectId: "1", + resourceId: "5", + normalizedUrl: "https://us.posthog.com/project/1/dashboard/5", + label: "Dashboard #5", + }, + }, + { + name: "surrounding whitespace", + input: " https://us.posthog.com/project/1/feature_flags/42 \n", + expected: { + resourceType: "feature_flag", + projectId: "1", + resourceId: "42", + normalizedUrl: "https://us.posthog.com/project/1/feature_flags/42", + label: "Feature Flag #42", + }, + }, + + // --- Short format (no /project/{id}/) --- + { + name: "short feature flag", + input: "https://us.posthog.com/feature_flags/619272", + expected: { + resourceType: "feature_flag", + projectId: "", + resourceId: "619272", + normalizedUrl: "https://us.posthog.com/feature_flags/619272", + label: "Feature Flag #619272", + }, + }, + { + name: "short experiment", + input: "https://us.posthog.com/experiments/373424", + expected: { + resourceType: "experiment", + projectId: "", + resourceId: "373424", + normalizedUrl: "https://us.posthog.com/experiments/373424", + label: "Experiment #373424", + }, + }, + { + name: "short insight (alphanumeric ID)", + input: "https://us.posthog.com/insights/KP8iqi6E", + expected: { + resourceType: "insight", + projectId: "", + resourceId: "KP8iqi6E", + normalizedUrl: "https://us.posthog.com/insights/KP8iqi6E", + label: "Insight KP8iqi6E", + }, + }, + { + name: "short dashboard", + input: "https://us.posthog.com/dashboard/944836", + expected: { + resourceType: "dashboard", + projectId: "", + resourceId: "944836", + normalizedUrl: "https://us.posthog.com/dashboard/944836", + label: "Dashboard #944836", + }, + }, + + // --- New resource types --- + { + name: "survey (short, UUID)", + input: + "https://us.posthog.com/surveys/019d1c79-170c-0000-b8dc-6880403ecae9", + expected: { + resourceType: "survey", + projectId: "", + resourceId: "019d1c79-170c-0000-b8dc-6880403ecae9", + normalizedUrl: + "https://us.posthog.com/surveys/019d1c79-170c-0000-b8dc-6880403ecae9", + label: "Survey 019d1c79-170c-0000-b8dc-6880403ecae9", + }, + }, + { + name: "notebook (short)", + input: "https://us.posthog.com/notebooks/wkGd", + expected: { + resourceType: "notebook", + projectId: "", + resourceId: "wkGd", + normalizedUrl: "https://us.posthog.com/notebooks/wkGd", + label: "Notebook wkGd", + }, + }, + { + name: "cohort (long)", + input: "https://us.posthog.com/project/1/cohorts/55", + expected: { + resourceType: "cohort", + projectId: "1", + resourceId: "55", + normalizedUrl: "https://us.posthog.com/project/1/cohorts/55", + label: "Cohort #55", + }, + }, + { + name: "action (nested path, long)", + input: "https://us.posthog.com/project/1/data-management/actions/99", + expected: { + resourceType: "action", + projectId: "1", + resourceId: "99", + normalizedUrl: + "https://us.posthog.com/project/1/data-management/actions/99", + label: "Action #99", + }, + }, + { + name: "action (nested path, short)", + input: "https://us.posthog.com/data-management/actions/99", + expected: { + resourceType: "action", + projectId: "", + resourceId: "99", + normalizedUrl: "https://us.posthog.com/data-management/actions/99", + label: "Action #99", + }, + }, + { + name: "early access feature (long)", + input: + "https://us.posthog.com/project/1/early_access_features/abc-123-def", + expected: { + resourceType: "early_access_feature", + projectId: "1", + resourceId: "abc-123-def", + normalizedUrl: + "https://us.posthog.com/project/1/early_access_features/abc-123-def", + label: "Early Access Feature abc-123-def", + }, + }, + { + name: "survey (long)", + input: + "https://us.posthog.com/project/1/surveys/019d1c79-170c-0000-b8dc-6880403ecae9", + expected: { + resourceType: "survey", + projectId: "1", + resourceId: "019d1c79-170c-0000-b8dc-6880403ecae9", + normalizedUrl: + "https://us.posthog.com/project/1/surveys/019d1c79-170c-0000-b8dc-6880403ecae9", + label: "Survey 019d1c79-170c-0000-b8dc-6880403ecae9", + }, + }, + ]; + + it.each(accepts)("accepts $name", ({ input, expected }) => { + expect(parsePostHogUrl(input)).toEqual(expected); + }); + + const rejects: Array<{ name: string; input: string }> = [ + { + name: "non-PostHog host", + input: "https://example.com/project/1/feature_flags/42", + }, + { name: "github URL", input: "https://github.com/PostHog/code/issues/1" }, + { name: "non-URL text", input: "not a url" }, + { name: "empty string", input: "" }, + { + name: "search/filter URL without resource ID", + input: "https://us.posthog.com/project/1/feature_flags?search=my-flag", + }, + { + name: "org-level billing URL", + input: "https://us.posthog.com/organization/billing/overview", + }, + { + name: "feature flags index without ID (long)", + input: "https://us.posthog.com/project/1/feature_flags", + }, + { + name: "unknown resource type", + input: "https://us.posthog.com/project/1/unknown_thing/42", + }, + { + name: "bare host with no path", + input: "https://us.posthog.com/", + }, + { + name: "single segment (not a resource detail)", + input: "https://us.posthog.com/feature_flags", + }, + ]; + + it.each(rejects)("rejects $name", ({ input }) => { + expect(parsePostHogUrl(input)).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts new file mode 100644 index 0000000000..fea825aabe --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts @@ -0,0 +1,119 @@ +export type PostHogResourceType = + | "feature_flag" + | "experiment" + | "insight" + | "dashboard" + | "error_tracking" + | "recording" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature"; + +export interface ParsedPostHogUrl { + resourceType: PostHogResourceType; + projectId: string; + resourceId: string; + normalizedUrl: string; + label: string; +} + +const POSTHOG_HOSTS = new Set([ + "us.posthog.com", + "eu.posthog.com", + "localhost:8010", +]); + +const RESOURCE_PATTERNS: Array<{ + pathSegments: string[]; + type: PostHogResourceType; + labelPrefix: string; +}> = [ + { + pathSegments: ["feature_flags"], + type: "feature_flag", + labelPrefix: "Feature Flag", + }, + { + pathSegments: ["experiments"], + type: "experiment", + labelPrefix: "Experiment", + }, + { pathSegments: ["insights"], type: "insight", labelPrefix: "Insight" }, + { pathSegments: ["dashboard"], type: "dashboard", labelPrefix: "Dashboard" }, + { + pathSegments: ["error_tracking"], + type: "error_tracking", + labelPrefix: "Error", + }, + { pathSegments: ["replay"], type: "recording", labelPrefix: "Recording" }, + { pathSegments: ["surveys"], type: "survey", labelPrefix: "Survey" }, + { pathSegments: ["notebooks"], type: "notebook", labelPrefix: "Notebook" }, + { pathSegments: ["cohorts"], type: "cohort", labelPrefix: "Cohort" }, + { + pathSegments: ["data-management", "actions"], + type: "action", + labelPrefix: "Action", + }, + { + pathSegments: ["early_access_features"], + type: "early_access_feature", + labelPrefix: "Early Access Feature", + }, +]; + +export function parsePostHogUrl(text: string): ParsedPostHogUrl | null { + const trimmed = text.trim(); + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + + if (!POSTHOG_HOSTS.has(url.host)) return null; + + const segments = url.pathname.split("/").filter(Boolean); + + let projectId = ""; + let resourceSegments: string[]; + + // Long format: /project/{projectId}/{resourcePath}/{resourceId} + if (segments.length >= 2 && segments[0] === "project") { + projectId = segments[1]; + resourceSegments = segments.slice(2); + } else { + // Short format: /{resourcePath}/{resourceId} + resourceSegments = segments; + } + + if (resourceSegments.length < 2) return null; + + const resourceId = resourceSegments[resourceSegments.length - 1]; + const pathParts = resourceSegments.slice(0, -1); + + const match = RESOURCE_PATTERNS.find( + (p) => + p.pathSegments.length === pathParts.length && + p.pathSegments.every((seg, i) => seg === pathParts[i]), + ); + + if (!match || !resourceId) return null; + + const projectPrefix = projectId ? `/project/${projectId}` : ""; + const resourcePath = match.pathSegments.join("/"); + const normalizedUrl = `${url.protocol}//${url.host}${projectPrefix}/${resourcePath}/${resourceId}`; + + const displayId = /^\d+$/.test(resourceId) ? `#${resourceId}` : resourceId; + const label = `${match.labelPrefix} ${displayId}`; + + return { + resourceType: match.type, + projectId, + resourceId, + normalizedUrl, + label, + }; +} From 60e8babed9accd165e27fccc2fa4469bac464596 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 00:08:32 +0100 Subject: [PATCH 2/3] feat: Add person and group resource types to PostHog URL rich previews Extend the PostHog URL parser to support /persons/ and /groups// URL patterns. Groups use a compound 2-segment ID which required restructuring the parser matching logic to support variable-length resource IDs via idSegmentCount. Adds getPerson and getGroup API client methods for title resolution, UserIcon/BuildingsIcon for chip rendering, and new test cases covering both resource types in short/long URL formats. --- apps/code/src/renderer/api/posthogClient.ts | 44 +++++++++++++ .../editor/components/PostHogRefChip.tsx | 4 ++ .../message-editor/tiptap/MentionChipNode.ts | 2 + .../message-editor/tiptap/MentionChipView.tsx | 4 ++ .../features/message-editor/utils/content.ts | 10 ++- .../message-editor/utils/posthogChip.ts | 10 +++ .../message-editor/utils/posthogUrl.test.ts | 64 +++++++++++++++++++ .../message-editor/utils/posthogUrl.ts | 31 ++++++--- 8 files changed, 158 insertions(+), 11 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 114e0ddbeb..9f1b38d473 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -3109,4 +3109,48 @@ export class PostHogAPIClient { const data = (await response.json()) as { name?: string }; return { name: data.name ?? "" }; } + + async getPerson( + projectId: string, + distinctId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/persons/?distinct_id=${encodeURIComponent(distinctId)}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ properties?: { email?: string; name?: string } }>; + }; + const person = data.results?.[0]; + return { + name: person?.properties?.email || person?.properties?.name || "", + }; + } + + async getGroup( + projectId: string, + groupCompoundId: string, + ): Promise<{ name: string } | null> { + const slashIndex = groupCompoundId.indexOf("/"); + if (slashIndex === -1) return null; + const typeIndex = groupCompoundId.slice(0, slashIndex); + const groupKey = groupCompoundId.slice(slashIndex + 1); + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/groups/?group_type_index=${encodeURIComponent(typeIndex)}&group_key=${encodeURIComponent(groupKey)}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ group_properties?: { name?: string } }>; + }; + const group = data.results?.[0]; + return { name: group?.group_properties?.name || "" }; + } } diff --git a/apps/code/src/renderer/features/editor/components/PostHogRefChip.tsx b/apps/code/src/renderer/features/editor/components/PostHogRefChip.tsx index 52c91051c1..2769b76919 100644 --- a/apps/code/src/renderer/features/editor/components/PostHogRefChip.tsx +++ b/apps/code/src/renderer/features/editor/components/PostHogRefChip.tsx @@ -1,6 +1,7 @@ import type { PostHogResourceType } from "@features/message-editor/utils/posthogUrl"; import { BugIcon, + BuildingsIcon, ChartLineIcon, ClipboardTextIcon, FlagIcon, @@ -9,6 +10,7 @@ import { NotebookIcon, RocketLaunchIcon, SquaresFourIcon, + UserIcon, UsersThreeIcon, VideoIcon, } from "@phosphor-icons/react"; @@ -30,6 +32,8 @@ const resourceIconMap: Record< cohort: UsersThreeIcon, action: LightningIcon, early_access_feature: RocketLaunchIcon, + person: UserIcon, + group: BuildingsIcon, }; export function PostHogRefChip({ diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts index c3d8ca8ddf..eafa704507 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts @@ -18,6 +18,8 @@ export type ChipType = | "cohort" | "action" | "early_access_feature" + | "person" + | "group" | "github_issue" | "github_pr"; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx index 039fb5e604..8a4a609661 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx +++ b/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx @@ -2,6 +2,7 @@ import { Tooltip } from "@components/ui/Tooltip"; import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; import { BugIcon, + BuildingsIcon, ChartLineIcon, ClipboardTextIcon, FileTextIcon, @@ -15,6 +16,7 @@ import { RocketLaunchIcon, SquaresFourIcon, TerminalIcon, + UserIcon, UsersThreeIcon, VideoIcon, WarningIcon, @@ -49,6 +51,8 @@ const typeIconMap: Record> = { cohort: UsersThreeIcon, action: LightningIcon, early_access_feature: RocketLaunchIcon, + person: UserIcon, + group: BuildingsIcon, }; function IconCloseButton({ diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/apps/code/src/renderer/features/message-editor/utils/content.ts index 74cd18377d..1a20d7ec69 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/apps/code/src/renderer/features/message-editor/utils/content.ts @@ -18,6 +18,8 @@ export interface MentionChip { | "cohort" | "action" | "early_access_feature" + | "person" + | "group" | "github_issue" | "github_pr"; id: string; @@ -86,6 +88,8 @@ export function contentToXml(content: EditorContent): string { case "cohort": case "action": case "early_access_feature": + case "person": + case "group": return `<${chip.type} id="${escapedId}" label="${escapeXmlAttr(chip.label)}" />`; case "github_issue": case "github_pr": { @@ -112,7 +116,7 @@ export function contentToXml(content: EditorContent): string { } const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|github_issue|github_pr)\b([^>]*?)\s*\/>/g; + /<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|person|group|github_issue|github_pr)\b([^>]*?)\s*\/>/g; const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { @@ -158,7 +162,9 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { case "notebook": case "cohort": case "action": - case "early_access_feature": { + case "early_access_feature": + case "person": + case "group": { const id = attrs.id; if (!id) return null; const label = attrs.label || parsePostHogUrl(id)?.label || id; diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts b/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts index 5035174a2f..b930a4efc1 100644 --- a/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts +++ b/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts @@ -24,6 +24,8 @@ const LABEL_PREFIXES: Record = { cohort: "Cohort", action: "Action", early_access_feature: "Early Access Feature", + person: "Person", + group: "Group", }; function formatDisplayId(resourceId: string, prefix: string): string { @@ -111,6 +113,14 @@ export async function fetchPostHogResourceTitle( ); return eaf?.name || null; } + case "person": { + const person = await client.getPerson(projectId, parsed.resourceId); + return person?.name || null; + } + case "group": { + const group = await client.getGroup(projectId, parsed.resourceId); + return group?.name || null; + } default: return null; } diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts index bf932efd0f..1840d50964 100644 --- a/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts +++ b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.test.ts @@ -252,6 +252,66 @@ describe("parsePostHogUrl", () => { label: "Survey 019d1c79-170c-0000-b8dc-6880403ecae9", }, }, + + // --- Person --- + { + name: "person (short)", + input: "https://us.posthog.com/persons/user_abc123", + expected: { + resourceType: "person", + projectId: "", + resourceId: "user_abc123", + normalizedUrl: "https://us.posthog.com/persons/user_abc123", + label: "Person user_abc123", + }, + }, + { + name: "person (long)", + input: "https://us.posthog.com/project/1/persons/user_abc123", + expected: { + resourceType: "person", + projectId: "1", + resourceId: "user_abc123", + normalizedUrl: "https://us.posthog.com/project/1/persons/user_abc123", + label: "Person user_abc123", + }, + }, + + // --- Group (compound ID: type-index/group-key) --- + { + name: "group (short)", + input: "https://us.posthog.com/groups/0/my-company-name", + expected: { + resourceType: "group", + projectId: "", + resourceId: "0/my-company-name", + normalizedUrl: "https://us.posthog.com/groups/0/my-company-name", + label: "Group 0/my-company-name", + }, + }, + { + name: "group (long)", + input: "https://us.posthog.com/project/1/groups/0/my-company-name", + expected: { + resourceType: "group", + projectId: "1", + resourceId: "0/my-company-name", + normalizedUrl: + "https://us.posthog.com/project/1/groups/0/my-company-name", + label: "Group 0/my-company-name", + }, + }, + { + name: "group with numeric key", + input: "https://eu.posthog.com/groups/1/12345", + expected: { + resourceType: "group", + projectId: "", + resourceId: "1/12345", + normalizedUrl: "https://eu.posthog.com/groups/1/12345", + label: "Group 1/12345", + }, + }, ]; it.each(accepts)("accepts $name", ({ input, expected }) => { @@ -290,6 +350,10 @@ describe("parsePostHogUrl", () => { name: "single segment (not a resource detail)", input: "https://us.posthog.com/feature_flags", }, + { + name: "groups with only type index (missing group key)", + input: "https://us.posthog.com/groups/0", + }, ]; it.each(rejects)("rejects $name", ({ input }) => { diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts index fea825aabe..3aa6580bfe 100644 --- a/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts +++ b/apps/code/src/renderer/features/message-editor/utils/posthogUrl.ts @@ -9,7 +9,9 @@ export type PostHogResourceType = | "notebook" | "cohort" | "action" - | "early_access_feature"; + | "early_access_feature" + | "person" + | "group"; export interface ParsedPostHogUrl { resourceType: PostHogResourceType; @@ -29,6 +31,7 @@ const RESOURCE_PATTERNS: Array<{ pathSegments: string[]; type: PostHogResourceType; labelPrefix: string; + idSegmentCount?: number; }> = [ { pathSegments: ["feature_flags"], @@ -61,6 +64,13 @@ const RESOURCE_PATTERNS: Array<{ type: "early_access_feature", labelPrefix: "Early Access Feature", }, + { pathSegments: ["persons"], type: "person", labelPrefix: "Person" }, + { + pathSegments: ["groups"], + type: "group", + labelPrefix: "Group", + idSegmentCount: 2, + }, ]; export function parsePostHogUrl(text: string): ParsedPostHogUrl | null { @@ -91,16 +101,19 @@ export function parsePostHogUrl(text: string): ParsedPostHogUrl | null { if (resourceSegments.length < 2) return null; - const resourceId = resourceSegments[resourceSegments.length - 1]; - const pathParts = resourceSegments.slice(0, -1); + const match = RESOURCE_PATTERNS.find((p) => { + const idCount = p.idSegmentCount ?? 1; + const expectedLength = p.pathSegments.length + idCount; + if (resourceSegments.length !== expectedLength) return false; + return p.pathSegments.every((seg, i) => seg === resourceSegments[i]); + }); + + if (!match) return null; - const match = RESOURCE_PATTERNS.find( - (p) => - p.pathSegments.length === pathParts.length && - p.pathSegments.every((seg, i) => seg === pathParts[i]), - ); + const idSegments = resourceSegments.slice(match.pathSegments.length); + const resourceId = idSegments.join("/"); - if (!match || !resourceId) return null; + if (!resourceId) return null; const projectPrefix = projectId ? `/project/${projectId}` : ""; const resourcePath = match.pathSegments.join("/"); From d4e59c4274731fe5443ed4ba8c1bf4001ac5f0c7 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 12:38:55 +0100 Subject: [PATCH 3/3] fix: resolve PostHog resource titles using configured project, not URL project ID Long-format URLs (with /project/{id}/) were failing to resolve titles because they used the project ID from the URL directly. If that ID differed from the project PostHog Code is authenticated against, the API call would 404 silently. Short-format URLs (without /project/{id}/) fell through to getDefaultProjectId(), which returns the authenticated project, and consistently succeeded. Fix: always use getDefaultProjectId() for both URL formats. The project ID in the URL is navigation context, not an instruction to call an arbitrary project's API. Also deduplicate the React Query cache key in SmartPostHogRefChip to use normalizedUrl instead of separate type/projectId/resourceId fields. --- .../features/editor/components/MarkdownRenderer.tsx | 12 +++--------- .../features/message-editor/utils/posthogChip.ts | 8 ++------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx index ef063b4980..14dd098d11 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -16,10 +16,9 @@ import { } from "@features/message-editor/utils/posthogUrl"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { useTRPC } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; +import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { isPostHogCodeDeeplink } from "@shared/deeplink"; +import { useQuery } from "@tanstack/react-query"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; @@ -78,12 +77,7 @@ function SmartGithubRefChip({ parsed }: { parsed: ParsedGithubIssueUrl }) { function SmartPostHogRefChip({ parsed }: { parsed: ParsedPostHogUrl }) { const { data: title } = useAuthenticatedQuery( - [ - "posthog-resource", - parsed.resourceType, - parsed.projectId, - parsed.resourceId, - ], + ["posthog-resource", parsed.normalizedUrl], (client) => fetchPostHogResourceTitle(client, parsed), { staleTime: 60_000 }, ); diff --git a/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts b/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts index b930a4efc1..a332bb58fe 100644 --- a/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts +++ b/apps/code/src/renderer/features/message-editor/utils/posthogChip.ts @@ -48,11 +48,7 @@ export function buildResolvedLabel( return title ? `${base} - ${title}` : base; } -async function resolveProjectId( - client: PostHogAPIClient, - parsed: ParsedPostHogUrl, -): Promise { - if (parsed.projectId) return parsed.projectId; +async function resolveProjectId(client: PostHogAPIClient): Promise { return String(await client.getDefaultProjectId()); } @@ -61,7 +57,7 @@ export async function fetchPostHogResourceTitle( parsed: ParsedPostHogUrl, ): Promise { try { - const projectId = await resolveProjectId(client, parsed); + const projectId = await resolveProjectId(client); switch (parsed.resourceType) { case "feature_flag": { const flag = await client.getFeatureFlag(projectId, parsed.resourceId);