Skip to content

feat: Render PostHog URLs as rich preview chips#2355

Open
Basit-Balogun10 wants to merge 3 commits into
PostHog:mainfrom
Basit-Balogun10:claude/competent-montalcini-7eba82
Open

feat: Render PostHog URLs as rich preview chips#2355
Basit-Balogun10 wants to merge 3 commits into
PostHog:mainfrom
Basit-Balogun10:claude/competent-montalcini-7eba82

Conversation

@Basit-Balogun10
Copy link
Copy Markdown

@Basit-Balogun10 Basit-Balogun10 commented May 25, 2026

Problem

When PostHog resource URLs (dashboards, feature flags, insights, experiments, etc.) appear in agent responses or are pasted into the editor, they render as plain text links — unlike GitHub URLs which already get rich chip rendering with icons and resolved titles. This makes it harder to quickly identify and interact with referenced PostHog resources.

Closes #1977

Changes

Mirrors the GitHub URL → chip pipeline for PostHog Cloud URLs across both the MarkdownRenderer (agent messages) and the Tiptap editor (paste handling).

New files:

  • posthogUrl.ts — URL parser supporting both long (/project/{id}/...) and short (no project prefix) URL formats. Handles us.posthog.com, eu.posthog.com, and localhost:8010.
  • posthogUrl.test.ts — 37 tests covering all 13 resource types, both URL formats, edge cases (trailing slashes, query params, fragments), and rejection cases.
  • PostHogRefChip.tsx — Read-only chip component for agent messages with per-type Phosphor icons.
  • posthogChip.ts — Chip builder + async title resolution via PostHog API (feature flag names, experiment names, dashboard titles, etc.)

Modified files:

  • MarkdownRenderer.tsx — Adds SmartPostHogRefChip that resolves resource titles via useAuthenticatedQuery, same pattern as SmartGithubRefChip.
  • useTiptapEditor.ts — Paste-time title resolution with "Loading..." placeholder → resolved label. Works for single URLs, multi-URL pastes, and mixed GitHub + PostHog URL pastes.
  • content.ts — Persists chip labels in XML (<feature_flag id="..." label="..." />) so they survive the user message round-trip. Old messages without a label attr gracefully fall back to URL-derived labels.
  • MentionChipNode.ts, MentionChipView.tsx — Extended ChipType union and icon map for all new resource types.
  • posthogClient.ts — 13 new API methods for fetching resource metadata, plus getDefaultProjectId() for short URL support.

Supported resource types

Resource URL path segment Icon Example
Feature Flag /feature_flags/{id} 🚩 FlagIcon Feature Flag #619272 - beta-rollout
Experiment /experiments/{id} 🧪 FlaskIcon Experiment #373424 - signup-test
Insight /insights/{id} 📈 ChartLineIcon Insight KP8iqi6E - Weekly DAU
Dashboard /dashboard/{id} ◻️ SquaresFourIcon Dashboard #944836 - Product KPIs
Error Tracking /error_tracking/{id} 🐛 BugIcon Error abc-def-123 - TypeError...
Recording /replay/{id} 🎬 VideoIcon Recording 019012ab...
Survey /surveys/{id} 📋 ClipboardTextIcon Survey 019d1c79... - NPS Q2
Notebook /notebooks/{id} 📓 NotebookIcon Notebook wkGd - Sprint retro
Cohort /cohorts/{id} 👥 UsersThreeIcon Cohort #55 - Power users
Action /data-management/actions/{id} ⚡ LightningIcon Action #99 - Signed up
Early Access Feature /early_access_features/{id} 🚀 RocketLaunchIcon Early Access Feature abc-123...
Person /persons/{id} 👤 UserIcon Person user_abc123 - jane@example.com
Group /groups/{type-index}/{group-key} 🏢 BuildingsIcon Group 0/my-company-name - Acme Corp

All 13 resource types support both long (/project/{id}/...) and short (no project prefix) URL formats. Groups use a compound 2-segment ID (type-index/group-key).

What's NOT matched

Org-level and non-resource PostHog URLs render as normal clickable links — no chip, no icon, no metadata resolution:

  • /organization/billing/overview
  • /settings/...
  • /feature_flags (index/list page, no specific resource ID)
  • /project/{id}/feature_flags?search=my-flag (search/filter page)
  • Any URL that doesn't resolve to a specific resource detail page with an ID

These are intentionally excluded — chips are only shown when we can identify a discrete resource to link to.

How did you test this?

Automated:

  • 37 new unit tests for the URL parser (all pass)
  • 25 existing content.ts round-trip tests (all pass)
  • Full typecheck passes
  • Biome lint passes

Manual testing steps

Demo 1 — Resource chip rendering & title resolution

Pasted/referenced all 13 supported resource types (Feature Flag → Group) covering both long (/project/{id}/...) and short (no project prefix) URL formats.

  • URLs with real, valid resource IDs belonging to the authenticated org resolve and display the resource title in the chip (e.g. Feature Flag #619272 - beta-rollout)
  • URLs with fake/test IDs, or IDs belonging to orgs the user doesn't have access to, render the chip with the ID only — no title — which is the expected graceful fallback behavior
valid-posthog-resource-urls-rendering-posthog-code.webm

Demo 2 — Non-resource URLs & GitHub interop

Verified that org-level and list/index PostHog URLs are intentionally excluded from chip rendering and fall back to plain clickable links:

  • https://us.posthog.com/feature_flags → plain link
  • https://us.posthog.com/organization/billing/overview → plain link
  • https://us.posthog.com/data-warehouse → plain link
  • https://us.posthog.com/project/99271/feature_flags?search=my-flag → plain link

Also confirmed that existing GitHub URL chip rendering is unaffected:

  • https://github.com/PostHog/code/issues/2360 → renders as GitHub chip as before
org-level-and-non-resource-urls-shouldnt-render-as-chips-posthog-code.webm

Publish to changelog?

no

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 25, 2026

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
apps/code/src/renderer/features/message-editor/utils/content.ts:81
**"Loading…" placeholder can be persisted in XML**

`chip.label` is written verbatim into the `label` attribute. If the user pastes a PostHog URL and submits the message before `resolvePostHogRefChip` fires (typical API round-trip of 100–500 ms), the label stored in the XML will be `"Feature Flag #42 - Loading..."`. Any later rendering of that message from history will show "Loading…" permanently. The GitHub path avoids this by serialising `number` and `title` separately — a missing title just produces an empty `title` attr and the label is reconstructed cleanly. PostHog chips should apply the same guard, e.g. strip the ` - Loading...` suffix (or fall back to the ID-only label) before persisting.

### Issue 2 of 4
apps/code/src/renderer/api/posthogClient.ts:2891-3070
**Repeated fetch pattern violates OnceAndOnlyOnce**

All 11 new methods share an identical 6-line body: build `urlPath`, construct a `URL`, call `this.api.fetcher.fetch`, return `null` on non-ok, cast the JSON, and return the relevant field. A private helper (e.g. `fetchProjectResource(endpoint, projectId, resourceId): Promise<unknown>`) would let each public method reduce to a one-liner field-pluck, making future endpoint additions or error-handling changes apply in one place.

### Issue 3 of 4
apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx:101-106
`isPostHogRef` is defined by exclusion — it is true for every chip type that is not `file`, `folder`, `command`, `error`, or a GitHub ref. Any new chip type added in the future that is not a URL-based resource would silently be treated as a clickable PostHog chip, triggering a `window.open` with a non-URL `id`. A positive definition against the known PostHog resource types is safer.

```suggestion
  const POSTHOG_CHIP_TYPES = new Set<string>([
    "experiment",
    "insight",
    "feature_flag",
    "dashboard",
    "recording",
    "error_tracking",
    "survey",
    "notebook",
    "cohort",
    "action",
    "early_access_feature",
  ]);
  const isPostHogRef = POSTHOG_CHIP_TYPES.has(type);
```

### Issue 4 of 4
apps/code/src/renderer/api/posthogClient.ts:603-605
`getDefaultProjectId` does nothing but delegate to `getTeamId`. The one caller in `posthogChip.ts` could call `getTeamId()` directly (with its own `String()` cast), or `getDefaultProjectId` could at least drop the unnecessary `async` keyword since it is a single `return` of an already-`Promise`-returning method.

```suggestion
  /** Returns the numeric team/project ID to use when a PostHog URL omits the project segment. */
  getDefaultProjectId(): Promise<number> {
    return this.getTeamId();
  }
```

Reviews (1): Last reviewed commit: "feat: Render PostHog URLs as rich previe..." | Re-trigger Greptile

case "cohort":
case "action":
case "early_access_feature":
return `<${chip.type} id="${escapedId}" label="${escapeXmlAttr(chip.label)}" />`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "Loading…" placeholder can be persisted in XML

chip.label is written verbatim into the label attribute. If the user pastes a PostHog URL and submits the message before resolvePostHogRefChip fires (typical API round-trip of 100–500 ms), the label stored in the XML will be "Feature Flag #42 - Loading...". Any later rendering of that message from history will show "Loading…" permanently. The GitHub path avoids this by serialising number and title separately — a missing title just produces an empty title attr and the label is reconstructed cleanly. PostHog chips should apply the same guard, e.g. strip the - Loading... suffix (or fall back to the ID-only label) before persisting.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/message-editor/utils/content.ts
Line: 81

Comment:
**"Loading…" placeholder can be persisted in XML**

`chip.label` is written verbatim into the `label` attribute. If the user pastes a PostHog URL and submits the message before `resolvePostHogRefChip` fires (typical API round-trip of 100–500 ms), the label stored in the XML will be `"Feature Flag #42 - Loading..."`. Any later rendering of that message from history will show "Loading…" permanently. The GitHub path avoids this by serialising `number` and `title` separately — a missing title just produces an empty `title` attr and the label is reconstructed cleanly. PostHog chips should apply the same guard, e.g. strip the ` - Loading...` suffix (or fall back to the ID-only label) before persisting.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 2891 to 3070
}
return (await response.json()) as SpendAnalysisResponse;
}

async getFeatureFlag(
projectId: string,
flagId: string,
): Promise<{ name: string; key: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/feature_flags/${encodeURIComponent(flagId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string; key?: string };
return { name: data.name ?? "", key: data.key ?? "" };
}

async getExperiment(
projectId: string,
experimentId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/experiments/${encodeURIComponent(experimentId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getInsight(
projectId: string,
insightId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/insights/${encodeURIComponent(insightId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getDashboard(
projectId: string,
dashboardId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/dashboards/${encodeURIComponent(dashboardId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getErrorTrackingGroup(
projectId: string,
groupId: string,
): Promise<{ title: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/error_tracking/${encodeURIComponent(groupId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { title?: string };
return { title: data.title ?? "" };
}

async getRecording(
projectId: string,
recordingId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/session_recordings/${encodeURIComponent(recordingId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getSurvey(
projectId: string,
surveyId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/surveys/${encodeURIComponent(surveyId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getNotebook(
projectId: string,
notebookId: string,
): Promise<{ title: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/notebooks/${encodeURIComponent(notebookId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { title?: string };
return { title: data.title ?? "" };
}

async getCohort(
projectId: string,
cohortId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/cohorts/${encodeURIComponent(cohortId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getAction(
projectId: string,
actionId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/actions/${encodeURIComponent(actionId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}

async getEarlyAccessFeature(
projectId: string,
featureId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/early_access_feature/${encodeURIComponent(featureId)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as { name?: string };
return { name: data.name ?? "" };
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Repeated fetch pattern violates OnceAndOnlyOnce

All 11 new methods share an identical 6-line body: build urlPath, construct a URL, call this.api.fetcher.fetch, return null on non-ok, cast the JSON, and return the relevant field. A private helper (e.g. fetchProjectResource(endpoint, projectId, resourceId): Promise<unknown>) would let each public method reduce to a one-liner field-pluck, making future endpoint additions or error-handling changes apply in one place.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/api/posthogClient.ts
Line: 2891-3070

Comment:
**Repeated fetch pattern violates OnceAndOnlyOnce**

All 11 new methods share an identical 6-line body: build `urlPath`, construct a `URL`, call `this.api.fetcher.fetch`, return `null` on non-ok, cast the JSON, and return the relevant field. A private helper (e.g. `fetchProjectResource(endpoint, projectId, resourceId): Promise<unknown>`) would let each public method reduce to a one-liner field-pluck, making future endpoint additions or error-handling changes apply in one place.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +101 to +106
const isPostHogRef =
type !== "file" &&
type !== "folder" &&
type !== "command" &&
type !== "error" &&
!isGithubRef;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isPostHogRef is defined by exclusion — it is true for every chip type that is not file, folder, command, error, or a GitHub ref. Any new chip type added in the future that is not a URL-based resource would silently be treated as a clickable PostHog chip, triggering a window.open with a non-URL id. A positive definition against the known PostHog resource types is safer.

Suggested change
const isPostHogRef =
type !== "file" &&
type !== "folder" &&
type !== "command" &&
type !== "error" &&
!isGithubRef;
const POSTHOG_CHIP_TYPES = new Set<string>([
"experiment",
"insight",
"feature_flag",
"dashboard",
"recording",
"error_tracking",
"survey",
"notebook",
"cohort",
"action",
"early_access_feature",
]);
const isPostHogRef = POSTHOG_CHIP_TYPES.has(type);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx
Line: 101-106

Comment:
`isPostHogRef` is defined by exclusion — it is true for every chip type that is not `file`, `folder`, `command`, `error`, or a GitHub ref. Any new chip type added in the future that is not a URL-based resource would silently be treated as a clickable PostHog chip, triggering a `window.open` with a non-URL `id`. A positive definition against the known PostHog resource types is safer.

```suggestion
  const POSTHOG_CHIP_TYPES = new Set<string>([
    "experiment",
    "insight",
    "feature_flag",
    "dashboard",
    "recording",
    "error_tracking",
    "survey",
    "notebook",
    "cohort",
    "action",
    "early_access_feature",
  ]);
  const isPostHogRef = POSTHOG_CHIP_TYPES.has(type);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +603 to +605
async getDefaultProjectId(): Promise<number> {
return this.getTeamId();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 getDefaultProjectId does nothing but delegate to getTeamId. The one caller in posthogChip.ts could call getTeamId() directly (with its own String() cast), or getDefaultProjectId could at least drop the unnecessary async keyword since it is a single return of an already-Promise-returning method.

Suggested change
async getDefaultProjectId(): Promise<number> {
return this.getTeamId();
}
/** Returns the numeric team/project ID to use when a PostHog URL omits the project segment. */
getDefaultProjectId(): Promise<number> {
return this.getTeamId();
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/api/posthogClient.ts
Line: 603-605

Comment:
`getDefaultProjectId` does nothing but delegate to `getTeamId`. The one caller in `posthogChip.ts` could call `getTeamId()` directly (with its own `String()` cast), or `getDefaultProjectId` could at least drop the unnecessary `async` keyword since it is a single `return` of an already-`Promise`-returning method.

```suggestion
  /** Returns the numeric team/project ID to use when a PostHog URL omits the project segment. */
  getDefaultProjectId(): Promise<number> {
    return this.getTeamId();
  }
```

How can I resolve this? If you propose a fix, please make it concise.

@Basit-Balogun10 Basit-Balogun10 force-pushed the claude/competent-montalcini-7eba82 branch from 5a07b0e to f47aa5b Compare May 25, 2026 15:01
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 PostHog#1977
Extend the PostHog URL parser to support /persons/<distinct-id> and
/groups/<type-index>/<group-key> 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.
@Basit-Balogun10 Basit-Balogun10 force-pushed the claude/competent-montalcini-7eba82 branch from 8d05240 to 94083cf Compare May 26, 2026 03:19
…L 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render PostHog URLs with rich previews like GitHub URLs

1 participant