Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 224 additions & 12 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,10 @@ export class PostHogAPIClient {
throw new Error("No team found for user");
}

async getDefaultProjectId(): Promise<number> {
return this.getTeamId();
}
Comment on lines +1221 to +1223

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.


async getCurrentUser() {
const data = await this.api.get("/api/users/{uuid}/", {
path: { uuid: "@me" },
Expand Down Expand Up @@ -4260,11 +4264,6 @@ export class PostHogAPIClient {
return (await response.json()) as SpendAnalysisResponse;
}

/**
* Lists the team's LLM skills (latest versions, no bodies).
* Returns null when the feature is unavailable for this org (the
* llm-analytics-skills flag gates the endpoint server-side with a 403).
*/
async listLlmSkills(): Promise<LlmSkillListItem[] | null> {
const teamId = await this.getTeamId();
const urlPath = `/api/environments/${teamId}/llm_skills/`;
Expand All @@ -4282,7 +4281,6 @@ export class PostHogAPIClient {
return data.results ?? [];
}

/** Fetches the latest version of a team skill, including body and file manifest. */
async getLlmSkillByName(name: string): Promise<LlmSkill> {
const teamId = await this.getTeamId();
const urlPath = `/api/environments/${teamId}/llm_skills/name/${encodeURIComponent(name)}`;
Expand All @@ -4298,7 +4296,6 @@ export class PostHogAPIClient {
return (await response.json()) as LlmSkill;
}

/** Creates a brand-new team skill (version 1). */
async createLlmSkill(input: {
name: string;
description: string;
Expand Down Expand Up @@ -4326,10 +4323,6 @@ export class PostHogAPIClient {
return (await response.json()) as LlmSkill;
}

/**
* Publishes a new version of an existing team skill. `base_version` must
* match the current latest version (409 otherwise).
*/
async publishLlmSkillVersion(
name: string,
input: {
Expand Down Expand Up @@ -4360,7 +4353,6 @@ export class PostHogAPIClient {
return (await response.json()) as LlmSkill;
}

/** Fetches one companion file of a team skill. */
async getLlmSkillFile(name: string, filePath: string): Promise<LlmSkillFile> {
const teamId = await this.getTeamId();
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
Expand Down Expand Up @@ -5332,4 +5324,224 @@ export class PostHogAPIClient {
nameById,
);
}

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

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

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

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

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

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

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

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

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

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

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

async getPerson(
projectId: string,
distinctId: string,
): Promise<{ name: string } | null> {
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/persons/?distinct_id=${encodeURIComponent(distinctId)}`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as {
results?: Array<{ properties?: { email?: string; name?: string } }>;
};
const person = data.results?.[0];
return {
name: person?.properties?.email || person?.properties?.name || "",
};
}

async getGroup(
projectId: string,
groupCompoundId: string,
): Promise<{ name: string } | null> {
const slashIndex = groupCompoundId.indexOf("/");
if (slashIndex === -1) return null;
const typeIndex = groupCompoundId.slice(0, slashIndex);
const groupKey = groupCompoundId.slice(slashIndex + 1);
const urlPath = `/api/projects/${encodeURIComponent(projectId)}/groups/?group_type_index=${encodeURIComponent(typeIndex)}&group_key=${encodeURIComponent(groupKey)}`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) return null;
const data = (await response.json()) as {
results?: Array<{ group_properties?: { name?: string } }>;
};
const group = data.results?.[0];
return { name: group?.group_properties?.name || "" };
}
}
48 changes: 41 additions & 7 deletions packages/core/src/message-editor/content.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared";
import { parsePostHogUrl } from "./posthogUrl";

export interface MentionChip {
type:
Expand All @@ -9,6 +10,16 @@ export interface MentionChip {
| "experiment"
| "insight"
| "feature_flag"
| "dashboard"
| "recording"
| "error_tracking"
| "survey"
| "notebook"
| "cohort"
| "action"
| "early_access_feature"
| "person"
| "group"
| "github_issue"
| "github_pr";
id: string;
Expand Down Expand Up @@ -67,11 +78,19 @@ export function contentToXml(content: EditorContent): string {
case "error":
return `<error id="${escapedId}" />`;
case "experiment":
return `<experiment id="${escapedId}" />`;
case "insight":
return `<insight id="${escapedId}" />`;
case "feature_flag":
return `<feature_flag id="${escapedId}" />`;
case "dashboard":
case "recording":
case "error_tracking":
case "survey":
case "notebook":
case "cohort":
case "action":
case "early_access_feature":
case "person":
case "group":
return `<${chip.type} id="${escapedId}" label="${escapeXmlAttr(chip.label)}" />`;

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.

case "github_issue":
case "github_pr": {
const labelMatch = chip.label.match(/^#(\d+)(?:\s*-\s*(.*))?$/);
Expand All @@ -97,7 +116,7 @@ export function contentToXml(content: EditorContent): string {
}

const CHIP_TAG_REGEX =
/<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g;
/<(file|folder|error|experiment|insight|feature_flag|dashboard|recording|error_tracking|survey|notebook|cohort|action|early_access_feature|person|group|github_issue|github_pr)\b([^>]*?)\s*\/>/g;
const ATTR_REGEX = /(\w+)="([^"]*)"/g;

export function deriveFileLabel(filePath: string): string {
Expand Down Expand Up @@ -128,13 +147,28 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null {
if (!path) return null;
return { type: "folder", id: path, label: deriveFileLabel(path) };
}
case "error":
case "error": {
const id = attrs.id;
if (!id) return null;
return { type: tag, id, label: id };
}
case "experiment":
case "insight":
case "feature_flag": {
case "feature_flag":
case "dashboard":
case "recording":
case "error_tracking":
case "survey":
case "notebook":
case "cohort":
case "action":
case "early_access_feature":
case "person":
case "group": {
const id = attrs.id;
if (!id) return null;
return { type: tag, id, label: id };
const label = attrs.label || parsePostHogUrl(id)?.label || id;
return { type: tag, id, label };
}
case "github_issue":
case "github_pr": {
Expand Down
Loading