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