diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 7196b9fc31..d03be941bb 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1218,6 +1218,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" }, @@ -4260,11 +4264,6 @@ export class PostHogAPIClient { return (await response.json()) as SpendAnalysisResponse; } - /** - * Lists the team's LLM skills (latest versions, no bodies). - * Returns null when the feature is unavailable for this org (the - * llm-analytics-skills flag gates the endpoint server-side with a 403). - */ async listLlmSkills(): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/environments/${teamId}/llm_skills/`; @@ -4282,7 +4281,6 @@ export class PostHogAPIClient { return data.results ?? []; } - /** Fetches the latest version of a team skill, including body and file manifest. */ async getLlmSkillByName(name: string): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/environments/${teamId}/llm_skills/name/${encodeURIComponent(name)}`; @@ -4298,7 +4296,6 @@ export class PostHogAPIClient { return (await response.json()) as LlmSkill; } - /** Creates a brand-new team skill (version 1). */ async createLlmSkill(input: { name: string; description: string; @@ -4326,10 +4323,6 @@ export class PostHogAPIClient { return (await response.json()) as LlmSkill; } - /** - * Publishes a new version of an existing team skill. `base_version` must - * match the current latest version (409 otherwise). - */ async publishLlmSkillVersion( name: string, input: { @@ -4360,7 +4353,6 @@ export class PostHogAPIClient { return (await response.json()) as LlmSkill; } - /** Fetches one companion file of a team skill. */ async getLlmSkillFile(name: string, filePath: string): Promise { const teamId = await this.getTeamId(); const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); @@ -5332,4 +5324,224 @@ export class PostHogAPIClient { nameById, ); } + + 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 ?? "" }; + } + + 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/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts index 07b8646a36..79f154bc6b 100644 --- a/packages/core/src/message-editor/content.ts +++ b/packages/core/src/message-editor/content.ts @@ -1,4 +1,5 @@ import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared"; +import { parsePostHogUrl } from "./posthogUrl"; export interface MentionChip { type: @@ -9,6 +10,16 @@ export interface MentionChip { | "experiment" | "insight" | "feature_flag" + | "dashboard" + | "recording" + | "error_tracking" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature" + | "person" + | "group" | "github_issue" | "github_pr"; id: string; @@ -67,11 +78,19 @@ 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": + case "person": + case "group": + 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 +116,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|person|group|github_issue|github_pr)\b([^>]*?)\s*\/>/g; const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { @@ -128,13 +147,28 @@ 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": + case "person": + case "group": { 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/packages/core/src/message-editor/posthogChip.ts b/packages/core/src/message-editor/posthogChip.ts new file mode 100644 index 0000000000..e9329bb568 --- /dev/null +++ b/packages/core/src/message-editor/posthogChip.ts @@ -0,0 +1,126 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +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", + person: "Person", + group: "Group", +}; + +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): Promise { + return String(await client.getDefaultProjectId()); +} + +export async function fetchPostHogResourceTitle( + client: PostHogAPIClient, + parsed: ParsedPostHogUrl, +): Promise { + try { + const projectId = await resolveProjectId(client); + 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; + } + 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; + } + } catch { + return null; + } +} diff --git a/packages/core/src/message-editor/posthogUrl.test.ts b/packages/core/src/message-editor/posthogUrl.test.ts new file mode 100644 index 0000000000..1840d50964 --- /dev/null +++ b/packages/core/src/message-editor/posthogUrl.test.ts @@ -0,0 +1,362 @@ +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", + }, + }, + + // --- 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 }) => { + 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", + }, + { + name: "groups with only type index (missing group key)", + input: "https://us.posthog.com/groups/0", + }, + ]; + + it.each(rejects)("rejects $name", ({ input }) => { + expect(parsePostHogUrl(input)).toBeNull(); + }); +}); diff --git a/packages/core/src/message-editor/posthogUrl.ts b/packages/core/src/message-editor/posthogUrl.ts new file mode 100644 index 0000000000..3aa6580bfe --- /dev/null +++ b/packages/core/src/message-editor/posthogUrl.ts @@ -0,0 +1,132 @@ +export type PostHogResourceType = + | "feature_flag" + | "experiment" + | "insight" + | "dashboard" + | "error_tracking" + | "recording" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature" + | "person" + | "group"; + +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; + idSegmentCount?: number; +}> = [ + { + 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", + }, + { pathSegments: ["persons"], type: "person", labelPrefix: "Person" }, + { + pathSegments: ["groups"], + type: "group", + labelPrefix: "Group", + idSegmentCount: 2, + }, +]; + +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 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 idSegments = resourceSegments.slice(match.pathSegments.length); + const resourceId = idSegments.join("/"); + + if (!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, + }; +} diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index e024b2d08c..d53c979ece 100644 --- a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -1,17 +1,32 @@ -import { isPostHogCodeDeeplink } from "@posthog/shared"; +import { + buildResolvedLabel, + fetchPostHogResourceTitle, +} from "@posthog/core/message-editor/posthogChip"; +import { + type ParsedPostHogUrl, + parsePostHogUrl, +} from "@posthog/core/message-editor/posthogUrl"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { isPostHogCodeDeeplink, unescapeXmlAttr } from "@posthog/shared"; import { GithubRefChip } from "@posthog/ui/features/editor/components/GithubRefChip"; -import { parseGithubIssueUrl } from "@posthog/ui/features/message-editor/githubIssueUrl"; +import { + type ParsedGithubIssueUrl, + parseGithubIssueUrl, +} from "@posthog/ui/features/message-editor/githubIssueUrl"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; import { Divider } from "@posthog/ui/primitives/Divider"; import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; import { List, ListItem } from "@posthog/ui/primitives/List"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { PluggableList } from "unified"; import { openExternalUrl } from "../../../shell/openExternal"; +import { PostHogRefChip } from "./PostHogRefChip"; interface MarkdownRendererProps { content: string; @@ -20,10 +35,21 @@ interface MarkdownRendererProps { rehypePlugins?: PluggableList; } +const POSTHOG_CHIP_TAG_REGEX = + /<(feature_flag|experiment|insight|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|person|group)\s+id="([^"]+)"(?:\s+label="([^"]*)")?\s*\/>/g; + // Preprocessor to prevent setext heading interpretation of horizontal rules -// Ensures `---`, `***`, `___` are preceded by a blank line +// Ensures `---`, `***`, `___` are preceded by a blank line. +// Also converts PostHog XML chip tags to markdown links so the `a` component +// handler can render them as PostHogRefChip. function preprocessMarkdown(content: string): string { - return content.replace(/\n([^\n].*)\n(---+|___+|\*\*\*+)\n/g, "\n$1\n\n$2\n"); + return content + .replace(/\n([^\n].*)\n(---+|___+|\*\*\*+)\n/g, "\n$1\n\n$2\n") + .replace(POSTHOG_CHIP_TAG_REGEX, (_, _type, id, label) => { + const url = unescapeXmlAttr(id); + const text = label ? unescapeXmlAttr(label) : url; + return `[${text}](${url})`; + }); } function markdownUrlTransform(value: string): string { @@ -37,6 +63,49 @@ const HeadingText = ({ children }: { children: React.ReactNode }) => ( ); +function SmartGithubRefChip({ parsed }: { parsed: ParsedGithubIssueUrl }) { + const trpc = useHostTRPC(); + 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.normalizedUrl], + (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}, @@ -77,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} + + ); + } 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, + person: UserIcon, + group: BuildingsIcon, +}; + +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/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index 107eaa32a3..eafa704507 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts @@ -10,6 +10,16 @@ export type ChipType = | "experiment" | "insight" | "feature_flag" + | "dashboard" + | "recording" + | "error_tracking" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature" + | "person" + | "group" | "github_issue" | "github_pr"; diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 24f13336a2..53e78daa0a 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -1,12 +1,22 @@ import { + BugIcon, + BuildingsIcon, ChartLineIcon, + ClipboardTextIcon, FileTextIcon, FlagIcon, FlaskIcon, FolderIcon, GithubLogoIcon, GitPullRequestIcon, + LightningIcon, + NotebookIcon, + RocketLaunchIcon, + SquaresFourIcon, TerminalIcon, + UserIcon, + UsersThreeIcon, + VideoIcon, WarningIcon, XIcon, } from "@phosphor-icons/react"; @@ -33,6 +43,16 @@ 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, + person: UserIcon, + group: BuildingsIcon, }; function IconCloseButton({ @@ -82,17 +102,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/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 3bc8b7e7b3..95bb14edec 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -17,10 +17,22 @@ import { isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; +import { + buildPostHogPlaceholderLabel, + buildResolvedLabel, + fetchPostHogResourceTitle, + posthogResourceToMentionChip, +} from "@posthog/core/message-editor/posthogChip"; +import { + type ParsedPostHogUrl, + parsePostHogUrl, +} from "@posthog/core/message-editor/posthogUrl"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { toast } from "@posthog/ui/primitives/toast"; import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey"; +import { Fragment, type Node as PmNode, Slice } from "@tiptap/pm/model"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; import type React from "react"; @@ -100,6 +112,77 @@ async function pasteTextAsFile( view.focus(); } +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, @@ -161,6 +244,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 = @@ -402,6 +527,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/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx index f50e9bde9d..561f3b5b43 100644 --- a/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx +++ b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx @@ -1,4 +1,5 @@ import { File, Folder, Warning } from "@phosphor-icons/react"; +import type { PostHogResourceType } from "@posthog/core/message-editor/posthogUrl"; import { unescapeXmlAttr } from "@posthog/shared"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; @@ -10,11 +11,12 @@ import { baseComponents, defaultRemarkPlugins, } from "../../../editor/components/MarkdownRenderer"; +import { PostHogRefChip } from "../../../editor/components/PostHogRefChip"; const MENTION_TAG_REGEX = - /|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|[\s\S]*?<\/error_context>|/g; + /|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|[\s\S]*?<\/error_context>||<(feature_flag|experiment|insight|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|person|group)\s+id="([^"]+)"(?:\s+label="([^"]*)")?\s*\/>/g; const MENTION_TAG_TEST = - /<(?:file\s+path|folder\s+path|github_issue\s+number|github_pr\s+number|error_context\s+label)="[^"]+"/; + /<(?:file\s+path|folder\s+path|github_issue\s+number|github_pr\s+number|error_context\s+label|(?:feature_flag|experiment|insight|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|person|group)\s+id)="[^"]+"/; const SLASH_COMMAND_START = /^\/([a-zA-Z][\w-]*)(?=\s|$)/; const inlineComponents: Components = { @@ -162,6 +164,19 @@ export function parseMentionTags(content: string): ReactNode[] { label={folderName} />, ); + } else if (match[8]) { + const resourceType = match[8] as PostHogResourceType; + const id = unescapeXmlAttr(match[9]); + const label = match[10] ? unescapeXmlAttr(match[10]) : id; + parts.push( + + {label} + , + ); } lastIndex = matchIndex + match[0].length;