From f47aa5b20bf8fc473b9af285e227d98f3938c185 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 25 May 2026 15:44:48 +0100 Subject: [PATCH] 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 2006cbf17..fa0451a5c 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -600,6 +600,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" }, @@ -2887,4 +2891,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 52c1bc5df..7fe852ae4 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -2,14 +2,29 @@ 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 { useTRPC } from "@renderer/trpc/client"; +import { useQuery } from "@tanstack/react-query"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown 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; @@ -30,6 +45,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}, @@ -83,15 +146,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} + + ); + } 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 880bf8acc..bea0163d7 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 3d87a65da..039fb5e60 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 9dbe099ac..26556b9ab 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 1b65fc612..ccabdbe5c 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; @@ -59,11 +68,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*(.*))?$/); @@ -89,7 +104,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 { @@ -120,13 +135,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 000000000..5035174a2 --- /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 000000000..bf932efd0 --- /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 000000000..fea825aab --- /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, + }; +}