Skip to content

feat: Render PostHog URLs as rich preview chips#2355

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

feat: Render PostHog URLs as rich preview chips#2355
Basit-Balogun10 wants to merge 1 commit 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 — 31 tests covering all 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 — 11 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...

What's NOT matched

Org-level and non-resource PostHog URLs render as normal links (no chip, 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:

  • 31 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:

  1. Agent response chips — Start a session and ask the agent to echo PostHog links. Verify they render as chips with correct icons and resolved titles (not plain links).

  2. Single URL paste — Copy https://us.posthog.com/feature_flags/619272 and paste into the editor. Should show "Feature Flag #619272 - Loading..." then resolve.

  3. Short URL format — Paste https://us.posthog.com/insights/KP8iqi6E (no /project/{id}/). Should parse and chip correctly.

  4. Multi-URL paste — Paste a block containing both PostHog and GitHub URLs. Both should render as chips with resolved titles.

  5. User message round-trip — Send a message containing a PostHog chip. Chip should display correctly in the user message block (not raw XML).

  6. Graceful fallback — Paste a PostHog URL to a resource you can't access. Should fall back to URL-derived label without errors.

  7. Org-level URLs — Paste https://us.posthog.com/organization/billing/overview. Should render as a normal link, NOT a chip.

  8. New resource types — Test with survey, notebook, cohort, action, and early access feature URLs.

Real PostHog URLs for testing:

  • Feature flag: https://us.posthog.com/feature_flags/619272
  • Experiment: https://us.posthog.com/experiments/373424
  • Insight: https://us.posthog.com/insights/KP8iqi6E
  • Dashboard: https://us.posthog.com/dashboard/944836
  • Survey: https://us.posthog.com/surveys/019d1c79-170c-0000-b8dc-6880403ecae9
  • Notebook: https://us.posthog.com/notebooks/wkGd

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.

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
@Basit-Balogun10 Basit-Balogun10 force-pushed the claude/competent-montalcini-7eba82 branch from 5a07b0e to f47aa5b Compare May 25, 2026 15:01
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