From 39e2405bd21a828609619663b007985c07d04b85 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Mon, 25 May 2026 15:44:48 +0100 Subject: [PATCH 1/5] 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 --- packages/api-client/src/posthog-client.ts | 207 ++++++++++-- packages/core/src/message-editor/content.ts | 42 ++- .../core/src/message-editor/posthogChip.ts | 120 +++++++ .../src/message-editor/posthogUrl.test.ts | 298 ++++++++++++++++++ .../core/src/message-editor/posthogUrl.ts | 119 +++++++ .../editor/components/MarkdownRenderer.tsx | 105 +++++- .../editor/components/PostHogRefChip.tsx | 55 ++++ .../message-editor/tiptap/MentionChipNode.ts | 8 + .../message-editor/tiptap/MentionChipView.tsx | 29 +- .../message-editor/tiptap/useTiptapEditor.ts | 144 +++++++++ 10 files changed, 1090 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/message-editor/posthogChip.ts create mode 100644 packages/core/src/message-editor/posthogUrl.test.ts create mode 100644 packages/core/src/message-editor/posthogUrl.ts create mode 100644 packages/ui/src/features/editor/components/PostHogRefChip.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 7196b9fc31..cd580f9a43 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,4 +1,4 @@ -import "./generated.augment"; +import "./generated.augment"; import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { PermissionMode } from "@posthog/agent/execution-mode"; import type { @@ -253,7 +253,7 @@ export interface SignalSourceConfig { status: "running" | "completed" | "failed" | null; } -// ── Signals scouts ─────────────────────────────────────────────────────────── +// ── Signals scouts ─────────────────────────────────────────────────────────── // Backend: posthog `products/signals/backend/scout_harness/views.py`. // Endpoints live under /api/projects/{id}/signals/scout/ and require the // `signal_scout:read` / `signal_scout:write` scopes. @@ -343,7 +343,7 @@ export interface LinkedSignalReport { /** * One scout finding paired with the inbox report (if any) its signal grouped into. * `report` is null when the finding hasn't grouped into a report yet, was - * de-duplicated away, or its signal was deleted – the link is best effort. + * de-duplicated away, or its signal was deleted – the link is best effort. */ export interface ScoutEmissionReportLink { finding_id: string; @@ -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" }, @@ -1225,13 +1229,13 @@ export class PostHogAPIClient { return data; } - // Desktop file system — the backend surface that backs canvas channels + // Desktop file system — the backend surface that backs canvas channels // (top-level folders) and dashboards. These routes aren't in the generated // OpenAPI client, so we use the raw fetcher. // Channels are top-level folders on the desktop file system. Filtering to // `type=folder` server-side (and requesting a large page) keeps us from // paginating over every dashboard and filed task just to populate the - // sidebar channel list — the bulk of the initial-load cost otherwise. + // sidebar channel list — the bulk of the initial-load cost otherwise. async getDesktopFileSystemChannels(): Promise { const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50; const DESKTOP_FILE_SYSTEM_PAGE_SIZE = 200; @@ -1328,7 +1332,7 @@ export class PostHogAPIClient { } } - // Desktop file system shortcuts — the user-scoped "starred" items on the + // Desktop file system shortcuts — the user-scoped "starred" items on the // desktop surface (e.g. starred channels). Unlike the file system rows above, // shortcuts are per-user, so they back cross-device starring without leaking // one user's stars to their teammates. Not in the generated OpenAPI client, @@ -1521,7 +1525,7 @@ export class PostHogAPIClient { // project so any user sees an in-progress generation (instead of fragile // local state). Keyed on the folder row (which always exists), not the // instructions object (which doesn't until the first version is published). - // Returns null when nothing is generating — or, until the backend ships this + // Returns null when nothing is generating — or, until the backend ships this // endpoint, on 404 (the feature degrades to no shared indicator). async getDesktopFolderGenerationTask( folderId: string, @@ -4260,11 +4264,7 @@ 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 +4282,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 +4297,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 +4324,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 +4354,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 +5325,180 @@ 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 ?? "" }; + } } diff --git a/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts index 07b8646a36..132942ed51 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,14 @@ export interface MentionChip { | "experiment" | "insight" | "feature_flag" + | "dashboard" + | "recording" + | "error_tracking" + | "survey" + | "notebook" + | "cohort" + | "action" + | "early_access_feature" | "github_issue" | "github_pr"; id: string; @@ -67,11 +76,17 @@ export function contentToXml(content: EditorContent): string { case "error": return ``; case "experiment": - return ``; case "insight": - return ``; case "feature_flag": - return ``; + case "dashboard": + case "recording": + case "error_tracking": + case "survey": + case "notebook": + case "cohort": + case "action": + case "early_access_feature": + return `<${chip.type} id="${escapedId}" label="${escapeXmlAttr(chip.label)}" />`; case "github_issue": case "github_pr": { const labelMatch = chip.label.match(/^#(\d+)(?:\s*-\s*(.*))?$/); @@ -97,7 +112,7 @@ export function contentToXml(content: EditorContent): string { } const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; + /<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|github_issue|github_pr)\b([^>]*?)\s*\/>/g; const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { @@ -128,13 +143,26 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { if (!path) return null; return { type: "folder", id: path, label: deriveFileLabel(path) }; } - case "error": + case "error": { + const id = attrs.id; + if (!id) return null; + return { type: tag, id, label: id }; + } case "experiment": case "insight": - case "feature_flag": { + case "feature_flag": + case "dashboard": + case "recording": + case "error_tracking": + case "survey": + case "notebook": + case "cohort": + case "action": + case "early_access_feature": { const id = attrs.id; if (!id) return null; - return { type: tag, id, label: id }; + const label = attrs.label || parsePostHogUrl(id)?.label || id; + return { type: tag, id, label }; } case "github_issue": case "github_pr": { diff --git a/packages/core/src/message-editor/posthogChip.ts b/packages/core/src/message-editor/posthogChip.ts new file mode 100644 index 0000000000..fb08d49d33 --- /dev/null +++ b/packages/core/src/message-editor/posthogChip.ts @@ -0,0 +1,120 @@ +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", +}; + +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/packages/core/src/message-editor/posthogUrl.test.ts b/packages/core/src/message-editor/posthogUrl.test.ts new file mode 100644 index 0000000000..bf932efd0f --- /dev/null +++ b/packages/core/src/message-editor/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/packages/core/src/message-editor/posthogUrl.ts b/packages/core/src/message-editor/posthogUrl.ts new file mode 100644 index 0000000000..fea825aabe --- /dev/null +++ b/packages/core/src/message-editor/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, + }; +} diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index e024b2d08c..2e88636bb2 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 { 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 { 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 { + buildResolvedLabel, + fetchPostHogResourceTitle, +} from "@posthog/core/message-editor/posthogChip"; +import { + type ParsedPostHogUrl, + parsePostHogUrl, +} from "@posthog/core/message-editor/posthogUrl"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; +import { useHostTRPC } from "@posthog/host-router/react"; +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,54 @@ 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.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}, @@ -77,15 +151,30 @@ export const baseComponents: Components = { const githubRef = href ? parseGithubIssueUrl(href) : null; if (githubRef) { const isAutoLink = typeof children === "string" && children === href; - const label = isAutoLink - ? `${githubRef.owner}/${githubRef.repo}#${githubRef.number}` - : children; + if (isAutoLink) { + return ; + } return ( - {label} + {children} ); } + const posthogRef = href ? parsePostHogUrl(href) : null; + if (posthogRef) { + const isAutoLink = typeof children === "string" && children === href; + if (isAutoLink) { + return ; + } + return ( + + {children} + + ); + } const isDeeplink = isPostHogCodeDeeplink(href); return ( +> = { + feature_flag: FlagIcon, + experiment: FlaskIcon, + insight: ChartLineIcon, + dashboard: SquaresFourIcon, + error_tracking: BugIcon, + recording: VideoIcon, + survey: ClipboardTextIcon, + notebook: NotebookIcon, + cohort: UsersThreeIcon, + action: LightningIcon, + early_access_feature: RocketLaunchIcon, +}; + +export function PostHogRefChip({ + href, + resourceType, + children, +}: { + href: string; + resourceType: PostHogResourceType; + children: ReactNode; +}) { + const Icon = resourceIconMap[resourceType]; + return ( + window.open(href, "_blank")} + className="cli-file-mention mx-0.5 max-w-full cursor-pointer! whitespace-nowrap pl-1 align-middle active:translate-y-0" + > + + {children} + + ); +} diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index 107eaa32a3..c3d8ca8ddf 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/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/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 24f13336a2..5e42bb926e 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -1,12 +1,20 @@ 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/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 3bc8b7e7b3..7802599544 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -35,6 +35,13 @@ import { persistTextContent, resolveAndAttachDroppedFiles, } from "../utils/persistFile"; +import { + buildPostHogPlaceholderLabel, + buildResolvedLabel, + fetchPostHogResourceTitle, + posthogResourceToMentionChip, +} from "@posthog/core/message-editor/posthogChip"; +import { type ParsedPostHogUrl, parsePostHogUrl } from "@posthog/core/message-editor/posthogUrl"; export interface UseTiptapEditorOptions { sessionId: string; @@ -100,6 +107,78 @@ 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 +240,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 +523,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; From dd080d503d09d6f2d85f718876b6cc1bfc22ddf5 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 00:08:32 +0100 Subject: [PATCH 2/5] feat: Add person and group resource types to PostHog URL rich previews Extend the PostHog URL parser to support /persons/ and /groups// URL patterns. Groups use a compound 2-segment ID which required restructuring the parser matching logic to support variable-length resource IDs via idSegmentCount. Adds getPerson and getGroup API client methods for title resolution, UserIcon/BuildingsIcon for chip rendering, and new test cases covering both resource types in short/long URL formats. --- packages/api-client/src/posthog-client.ts | 44 +++++++++++++ packages/core/src/message-editor/content.ts | 10 ++- .../core/src/message-editor/posthogChip.ts | 10 +++ .../src/message-editor/posthogUrl.test.ts | 64 +++++++++++++++++++ .../core/src/message-editor/posthogUrl.ts | 31 ++++++--- .../editor/components/PostHogRefChip.tsx | 4 ++ .../message-editor/tiptap/MentionChipNode.ts | 2 + .../message-editor/tiptap/MentionChipView.tsx | 4 ++ 8 files changed, 158 insertions(+), 11 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index cd580f9a43..c152828af6 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -5501,4 +5501,48 @@ export class PostHogAPIClient { const data = (await response.json()) as { name?: string }; return { name: data.name ?? "" }; } + + async getPerson( + projectId: string, + distinctId: string, + ): Promise<{ name: string } | null> { + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/persons/?distinct_id=${encodeURIComponent(distinctId)}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ properties?: { email?: string; name?: string } }>; + }; + const person = data.results?.[0]; + return { + name: person?.properties?.email || person?.properties?.name || "", + }; + } + + async getGroup( + projectId: string, + groupCompoundId: string, + ): Promise<{ name: string } | null> { + const slashIndex = groupCompoundId.indexOf("/"); + if (slashIndex === -1) return null; + const typeIndex = groupCompoundId.slice(0, slashIndex); + const groupKey = groupCompoundId.slice(slashIndex + 1); + const urlPath = `/api/projects/${encodeURIComponent(projectId)}/groups/?group_type_index=${encodeURIComponent(typeIndex)}&group_key=${encodeURIComponent(groupKey)}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ group_properties?: { name?: string } }>; + }; + const group = data.results?.[0]; + return { name: group?.group_properties?.name || "" }; + } } diff --git a/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts index 132942ed51..79f154bc6b 100644 --- a/packages/core/src/message-editor/content.ts +++ b/packages/core/src/message-editor/content.ts @@ -18,6 +18,8 @@ export interface MentionChip { | "cohort" | "action" | "early_access_feature" + | "person" + | "group" | "github_issue" | "github_pr"; id: string; @@ -86,6 +88,8 @@ export function contentToXml(content: EditorContent): string { case "cohort": case "action": case "early_access_feature": + case "person": + case "group": return `<${chip.type} id="${escapedId}" label="${escapeXmlAttr(chip.label)}" />`; case "github_issue": case "github_pr": { @@ -112,7 +116,7 @@ export function contentToXml(content: EditorContent): string { } const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|github_issue|github_pr)\b([^>]*?)\s*\/>/g; + /<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|person|group|github_issue|github_pr)\b([^>]*?)\s*\/>/g; const ATTR_REGEX = /(\w+)="([^"]*)"/g; export function deriveFileLabel(filePath: string): string { @@ -158,7 +162,9 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { case "notebook": case "cohort": case "action": - case "early_access_feature": { + case "early_access_feature": + case "person": + case "group": { const id = attrs.id; if (!id) return null; const label = attrs.label || parsePostHogUrl(id)?.label || id; diff --git a/packages/core/src/message-editor/posthogChip.ts b/packages/core/src/message-editor/posthogChip.ts index fb08d49d33..f78d629cdc 100644 --- a/packages/core/src/message-editor/posthogChip.ts +++ b/packages/core/src/message-editor/posthogChip.ts @@ -24,6 +24,8 @@ const LABEL_PREFIXES: Record = { cohort: "Cohort", action: "Action", early_access_feature: "Early Access Feature", + person: "Person", + group: "Group", }; function formatDisplayId(resourceId: string, prefix: string): string { @@ -111,6 +113,14 @@ export async function fetchPostHogResourceTitle( ); return eaf?.name || null; } + case "person": { + const person = await client.getPerson(projectId, parsed.resourceId); + return person?.name || null; + } + case "group": { + const group = await client.getGroup(projectId, parsed.resourceId); + return group?.name || null; + } default: return null; } diff --git a/packages/core/src/message-editor/posthogUrl.test.ts b/packages/core/src/message-editor/posthogUrl.test.ts index bf932efd0f..1840d50964 100644 --- a/packages/core/src/message-editor/posthogUrl.test.ts +++ b/packages/core/src/message-editor/posthogUrl.test.ts @@ -252,6 +252,66 @@ describe("parsePostHogUrl", () => { label: "Survey 019d1c79-170c-0000-b8dc-6880403ecae9", }, }, + + // --- Person --- + { + name: "person (short)", + input: "https://us.posthog.com/persons/user_abc123", + expected: { + resourceType: "person", + projectId: "", + resourceId: "user_abc123", + normalizedUrl: "https://us.posthog.com/persons/user_abc123", + label: "Person user_abc123", + }, + }, + { + name: "person (long)", + input: "https://us.posthog.com/project/1/persons/user_abc123", + expected: { + resourceType: "person", + projectId: "1", + resourceId: "user_abc123", + normalizedUrl: "https://us.posthog.com/project/1/persons/user_abc123", + label: "Person user_abc123", + }, + }, + + // --- Group (compound ID: type-index/group-key) --- + { + name: "group (short)", + input: "https://us.posthog.com/groups/0/my-company-name", + expected: { + resourceType: "group", + projectId: "", + resourceId: "0/my-company-name", + normalizedUrl: "https://us.posthog.com/groups/0/my-company-name", + label: "Group 0/my-company-name", + }, + }, + { + name: "group (long)", + input: "https://us.posthog.com/project/1/groups/0/my-company-name", + expected: { + resourceType: "group", + projectId: "1", + resourceId: "0/my-company-name", + normalizedUrl: + "https://us.posthog.com/project/1/groups/0/my-company-name", + label: "Group 0/my-company-name", + }, + }, + { + name: "group with numeric key", + input: "https://eu.posthog.com/groups/1/12345", + expected: { + resourceType: "group", + projectId: "", + resourceId: "1/12345", + normalizedUrl: "https://eu.posthog.com/groups/1/12345", + label: "Group 1/12345", + }, + }, ]; it.each(accepts)("accepts $name", ({ input, expected }) => { @@ -290,6 +350,10 @@ describe("parsePostHogUrl", () => { name: "single segment (not a resource detail)", input: "https://us.posthog.com/feature_flags", }, + { + name: "groups with only type index (missing group key)", + input: "https://us.posthog.com/groups/0", + }, ]; it.each(rejects)("rejects $name", ({ input }) => { diff --git a/packages/core/src/message-editor/posthogUrl.ts b/packages/core/src/message-editor/posthogUrl.ts index fea825aabe..3aa6580bfe 100644 --- a/packages/core/src/message-editor/posthogUrl.ts +++ b/packages/core/src/message-editor/posthogUrl.ts @@ -9,7 +9,9 @@ export type PostHogResourceType = | "notebook" | "cohort" | "action" - | "early_access_feature"; + | "early_access_feature" + | "person" + | "group"; export interface ParsedPostHogUrl { resourceType: PostHogResourceType; @@ -29,6 +31,7 @@ const RESOURCE_PATTERNS: Array<{ pathSegments: string[]; type: PostHogResourceType; labelPrefix: string; + idSegmentCount?: number; }> = [ { pathSegments: ["feature_flags"], @@ -61,6 +64,13 @@ const RESOURCE_PATTERNS: Array<{ type: "early_access_feature", labelPrefix: "Early Access Feature", }, + { pathSegments: ["persons"], type: "person", labelPrefix: "Person" }, + { + pathSegments: ["groups"], + type: "group", + labelPrefix: "Group", + idSegmentCount: 2, + }, ]; export function parsePostHogUrl(text: string): ParsedPostHogUrl | null { @@ -91,16 +101,19 @@ export function parsePostHogUrl(text: string): ParsedPostHogUrl | null { if (resourceSegments.length < 2) return null; - const resourceId = resourceSegments[resourceSegments.length - 1]; - const pathParts = resourceSegments.slice(0, -1); + const match = RESOURCE_PATTERNS.find((p) => { + const idCount = p.idSegmentCount ?? 1; + const expectedLength = p.pathSegments.length + idCount; + if (resourceSegments.length !== expectedLength) return false; + return p.pathSegments.every((seg, i) => seg === resourceSegments[i]); + }); + + if (!match) return null; - const match = RESOURCE_PATTERNS.find( - (p) => - p.pathSegments.length === pathParts.length && - p.pathSegments.every((seg, i) => seg === pathParts[i]), - ); + const idSegments = resourceSegments.slice(match.pathSegments.length); + const resourceId = idSegments.join("/"); - if (!match || !resourceId) return null; + if (!resourceId) return null; const projectPrefix = projectId ? `/project/${projectId}` : ""; const resourcePath = match.pathSegments.join("/"); diff --git a/packages/ui/src/features/editor/components/PostHogRefChip.tsx b/packages/ui/src/features/editor/components/PostHogRefChip.tsx index 565058c470..ec168d1155 100644 --- a/packages/ui/src/features/editor/components/PostHogRefChip.tsx +++ b/packages/ui/src/features/editor/components/PostHogRefChip.tsx @@ -1,6 +1,7 @@ import type { PostHogResourceType } from "@posthog/core/message-editor/posthogUrl"; import { BugIcon, + BuildingsIcon, ChartLineIcon, ClipboardTextIcon, FlagIcon, @@ -9,6 +10,7 @@ import { NotebookIcon, RocketLaunchIcon, SquaresFourIcon, + UserIcon, UsersThreeIcon, VideoIcon, } from "@phosphor-icons/react"; @@ -30,6 +32,8 @@ const resourceIconMap: Record< cohort: UsersThreeIcon, action: LightningIcon, early_access_feature: RocketLaunchIcon, + person: UserIcon, + group: BuildingsIcon, }; export function PostHogRefChip({ diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index c3d8ca8ddf..eafa704507 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts @@ -18,6 +18,8 @@ export type ChipType = | "cohort" | "action" | "early_access_feature" + | "person" + | "group" | "github_issue" | "github_pr"; diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 5e42bb926e..53e78daa0a 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -1,5 +1,6 @@ import { BugIcon, + BuildingsIcon, ChartLineIcon, ClipboardTextIcon, FileTextIcon, @@ -13,6 +14,7 @@ import { RocketLaunchIcon, SquaresFourIcon, TerminalIcon, + UserIcon, UsersThreeIcon, VideoIcon, WarningIcon, @@ -49,6 +51,8 @@ const typeIconMap: Record> = { cohort: UsersThreeIcon, action: LightningIcon, early_access_feature: RocketLaunchIcon, + person: UserIcon, + group: BuildingsIcon, }; function IconCloseButton({ From 38f407de02e04cee71b7aef16aab5af5a16022fd Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Tue, 26 May 2026 12:38:55 +0100 Subject: [PATCH 3/5] fix: resolve PostHog resource titles using configured project, not URL project ID Long-format URLs (with /project/{id}/) were failing to resolve titles because they used the project ID from the URL directly. If that ID differed from the project PostHog Code is authenticated against, the API call would 404 silently. Short-format URLs (without /project/{id}/) fell through to getDefaultProjectId(), which returns the authenticated project, and consistently succeeded. Fix: always use getDefaultProjectId() for both URL formats. The project ID in the URL is navigation context, not an instruction to call an arbitrary project's API. Also deduplicate the React Query cache key in SmartPostHogRefChip to use normalizedUrl instead of separate type/projectId/resourceId fields. --- packages/core/src/message-editor/posthogChip.ts | 8 ++------ .../src/features/editor/components/MarkdownRenderer.tsx | 7 +------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/core/src/message-editor/posthogChip.ts b/packages/core/src/message-editor/posthogChip.ts index f78d629cdc..e9329bb568 100644 --- a/packages/core/src/message-editor/posthogChip.ts +++ b/packages/core/src/message-editor/posthogChip.ts @@ -48,11 +48,7 @@ export function buildResolvedLabel( return title ? `${base} - ${title}` : base; } -async function resolveProjectId( - client: PostHogAPIClient, - parsed: ParsedPostHogUrl, -): Promise { - if (parsed.projectId) return parsed.projectId; +async function resolveProjectId(client: PostHogAPIClient): Promise { return String(await client.getDefaultProjectId()); } @@ -61,7 +57,7 @@ export async function fetchPostHogResourceTitle( parsed: ParsedPostHogUrl, ): Promise { try { - const projectId = await resolveProjectId(client, parsed); + const projectId = await resolveProjectId(client); switch (parsed.resourceType) { case "feature_flag": { const flag = await client.getFeatureFlag(projectId, parsed.resourceId); diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index 2e88636bb2..2131564cc3 100644 --- a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -89,12 +89,7 @@ function SmartGithubRefChip({ parsed }: { parsed: ParsedGithubIssueUrl }) { function SmartPostHogRefChip({ parsed }: { parsed: ParsedPostHogUrl }) { const { data: title } = useAuthenticatedQuery( - [ - "posthog-resource", - parsed.resourceType, - parsed.projectId, - parsed.resourceId, - ], + ["posthog-resource", parsed.normalizedUrl], (client) => fetchPostHogResourceTitle(client, parsed), { staleTime: 60_000 }, ); From 86fe0133507221ed2f5d08ba53f9b5975c6db3a5 Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Fri, 19 Jun 2026 07:12:28 +0100 Subject: [PATCH 4/5] fix: restore Unicode characters corrupted by encoding bug in posthog-client.ts --- packages/api-client/src/posthog-client.ts | 15 +++++++------- .../message-editor/tiptap/useTiptapEditor.ts | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index c152828af6..d03be941bb 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,4 +1,4 @@ -import "./generated.augment"; +import "./generated.augment"; import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { PermissionMode } from "@posthog/agent/execution-mode"; import type { @@ -253,7 +253,7 @@ export interface SignalSourceConfig { status: "running" | "completed" | "failed" | null; } -// ── Signals scouts ─────────────────────────────────────────────────────────── +// ── Signals scouts ─────────────────────────────────────────────────────────── // Backend: posthog `products/signals/backend/scout_harness/views.py`. // Endpoints live under /api/projects/{id}/signals/scout/ and require the // `signal_scout:read` / `signal_scout:write` scopes. @@ -343,7 +343,7 @@ export interface LinkedSignalReport { /** * One scout finding paired with the inbox report (if any) its signal grouped into. * `report` is null when the finding hasn't grouped into a report yet, was - * de-duplicated away, or its signal was deleted – the link is best effort. + * de-duplicated away, or its signal was deleted – the link is best effort. */ export interface ScoutEmissionReportLink { finding_id: string; @@ -1229,13 +1229,13 @@ export class PostHogAPIClient { return data; } - // Desktop file system — the backend surface that backs canvas channels + // Desktop file system — the backend surface that backs canvas channels // (top-level folders) and dashboards. These routes aren't in the generated // OpenAPI client, so we use the raw fetcher. // Channels are top-level folders on the desktop file system. Filtering to // `type=folder` server-side (and requesting a large page) keeps us from // paginating over every dashboard and filed task just to populate the - // sidebar channel list — the bulk of the initial-load cost otherwise. + // sidebar channel list — the bulk of the initial-load cost otherwise. async getDesktopFileSystemChannels(): Promise { const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50; const DESKTOP_FILE_SYSTEM_PAGE_SIZE = 200; @@ -1332,7 +1332,7 @@ export class PostHogAPIClient { } } - // Desktop file system shortcuts — the user-scoped "starred" items on the + // Desktop file system shortcuts — the user-scoped "starred" items on the // desktop surface (e.g. starred channels). Unlike the file system rows above, // shortcuts are per-user, so they back cross-device starring without leaking // one user's stars to their teammates. Not in the generated OpenAPI client, @@ -1525,7 +1525,7 @@ export class PostHogAPIClient { // project so any user sees an in-progress generation (instead of fragile // local state). Keyed on the folder row (which always exists), not the // instructions object (which doesn't until the first version is published). - // Returns null when nothing is generating — or, until the backend ships this + // Returns null when nothing is generating — or, until the backend ships this // endpoint, on 404 (the feature degrades to no shared indicator). async getDesktopFolderGenerationTask( folderId: string, @@ -4264,7 +4264,6 @@ export class PostHogAPIClient { return (await response.json()) as SpendAnalysisResponse; } - async listLlmSkills(): Promise { const teamId = await this.getTeamId(); const urlPath = `/api/environments/${teamId}/llm_skills/`; diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 7802599544..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"; @@ -35,13 +47,6 @@ import { persistTextContent, resolveAndAttachDroppedFiles, } from "../utils/persistFile"; -import { - buildPostHogPlaceholderLabel, - buildResolvedLabel, - fetchPostHogResourceTitle, - posthogResourceToMentionChip, -} from "@posthog/core/message-editor/posthogChip"; -import { type ParsedPostHogUrl, parsePostHogUrl } from "@posthog/core/message-editor/posthogUrl"; export interface UseTiptapEditorOptions { sessionId: string; @@ -178,7 +183,6 @@ function buildMixedPasteContent( }; } - function insertGithubRefPlaceholder( view: EditorView, parsed: ParsedGithubIssueUrl, From c3ca2f69b135f083a03ef66593c301808026a55d Mon Sep 17 00:00:00 2001 From: Basit Balogun Date: Fri, 19 Jun 2026 10:47:03 +0100 Subject: [PATCH 5/5] fix(ui): restore PostHog chip rendering lost during conflict resolution Re-extended MENTION_TAG_REGEX in parseFileMentions.tsx to cover all 13 PostHog resource types so they render as PostHogRefChip instead of raw XML in the user message view. Added a preprocessMarkdown step in MarkdownRenderer.tsx that converts <{type} id=url label=text /> chip tags into markdown links before react-markdown parses them, so the existing `a` handler renders them as PostHogRefChip in the agent response view. --- .../editor/components/MarkdownRenderer.tsx | 20 +++++++++---------- .../session-update/parseFileMentions.tsx | 19 ++++++++++++++++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index 2131564cc3..d53c979ece 100644 --- a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -1,24 +1,24 @@ +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 { 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 { - buildResolvedLabel, - fetchPostHogResourceTitle, -} from "@posthog/core/message-editor/posthogChip"; -import { - type ParsedPostHogUrl, - parsePostHogUrl, -} from "@posthog/core/message-editor/posthogUrl"; -import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; -import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; 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;