diff --git a/apps/code/src/main/services/inbox-link/service.test.ts b/apps/code/src/main/services/inbox-link/service.test.ts index 844a47eaa..747b23e5f 100644 --- a/apps/code/src/main/services/inbox-link/service.test.ts +++ b/apps/code/src/main/services/inbox-link/service.test.ts @@ -91,6 +91,15 @@ describe("InboxLinkService", () => { expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); }); + it("ignores a trailing slug segment after the report id", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + deepLinkService.trigger("inbox", "abc-123/fix-inbox--Add-foo"); + + expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); + }); + it("returns false and does not emit when the path is empty", () => { const listener = vi.fn(); service.on(InboxLinkEvent.OpenReport, listener); diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 51672c9af..257496a21 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -36,7 +36,7 @@ import { } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; import { EXTERNAL_LINKS } from "@renderer/utils/links"; -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { buildInboxDeeplink } from "@shared/deeplink"; import type { ActionabilityJudgmentArtefact, ActionabilityJudgmentContent, @@ -480,7 +480,9 @@ export function ReportDetailPane({ onClick={async () => { try { await navigator.clipboard.writeText( - `${getDeeplinkProtocol(import.meta.env.DEV)}://inbox/${report.id}`, + buildInboxDeeplink(report.id, report.title, { + isDevBuild: import.meta.env.DEV, + }), ); fireDetailAction("copy_link"); toast.success("Link copied"); diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts index b81a1e28c..e5c30206e 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts @@ -106,6 +106,7 @@ export function useDiscussReport({ const prompt = buildDiscussReportPrompt({ reportId, + reportTitle, question, isDevBuild: import.meta.env.DEV, }); diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts index 389e0dadc..01dced1b2 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts @@ -44,4 +44,22 @@ describe("buildDiscussReportPrompt", () => { }); expect(prompt).toContain("brief readout"); }); + + it("appends a slugified title suffix to the deep link", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + reportTitle: "fix(inbox): Add foo", + isDevBuild: false, + }); + expect(prompt).toContain("posthog-code://inbox/abc123/fix-inbox--Add-foo"); + }); + + it("omits the slug suffix when the title is blank", () => { + const prompt = buildDiscussReportPrompt({ + reportId: "abc123", + reportTitle: " ", + isDevBuild: false, + }); + expect(prompt).toContain("posthog-code://inbox/abc123)"); + }); }); diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts index bb4361402..84faa2dd4 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts +++ b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts @@ -1,18 +1,20 @@ -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { buildInboxDeeplink } from "@shared/deeplink"; interface BuildDiscussReportPromptOptions { reportId: string; + reportTitle?: string | null; question?: string; isDevBuild: boolean; } export function buildDiscussReportPrompt({ reportId, + reportTitle, question, isDevBuild, }: BuildDiscussReportPromptOptions): string { const trimmedQuestion = question?.trim(); - const reportLink = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; + const reportLink = buildInboxDeeplink(reportId, reportTitle, { isDevBuild }); const intro = `Discuss PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report,`; return trimmedQuestion ? `${intro} then answer this first: ${trimmedQuestion}` diff --git a/apps/code/src/shared/deeplink.test.ts b/apps/code/src/shared/deeplink.test.ts new file mode 100644 index 000000000..dfd168f84 --- /dev/null +++ b/apps/code/src/shared/deeplink.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildInboxDeeplink } from "./deeplink"; + +describe("buildInboxDeeplink", () => { + it("returns just the UUID when no title is given", () => { + expect(buildInboxDeeplink("abc-123", null, { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + expect( + buildInboxDeeplink("abc-123", undefined, { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123"); + expect(buildInboxDeeplink("abc-123", "", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + }); + + it("emits `--` for runs that mix a colon with other unsafe chars", () => { + expect( + buildInboxDeeplink("abc-123", "fix(inbox): Add foo", { + isDevBuild: false, + }), + ).toBe("posthog-code://inbox/abc-123/fix-inbox--Add-foo"); + }); + + it("emits a single `-` for a colon-only run", () => { + expect( + buildInboxDeeplink("abc-123", "feat:bar", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/feat-bar"); + }); + + it("omits the slug when the title slugifies to empty", () => { + expect(buildInboxDeeplink("abc-123", ":::", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + expect(buildInboxDeeplink("abc-123", " ", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + }); + + it("uses the dev scheme when isDevBuild is true", () => { + expect( + buildInboxDeeplink("abc-123", "Hello World", { isDevBuild: true }), + ).toBe("posthog-code-dev://inbox/abc-123/Hello-World"); + }); + + it("preserves URL-unreserved punctuation (- _ . ~)", () => { + expect( + buildInboxDeeplink("abc-123", "v1.2.3_final~ish", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/v1.2.3_final~ish"); + }); + + it("collapses runs of unsafe punctuation into a single hyphen", () => { + expect( + buildInboxDeeplink("abc-123", "Cost $5, 50% off!", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/Cost-5-50-off"); + }); + + it("folds accented Latin letters to their ASCII base", () => { + expect( + buildInboxDeeplink("abc-123", "café résumé naïve", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/cafe-resume-naive"); + }); + + it("hyphenizes non-Latin scripts that have no ASCII fold", () => { + expect( + buildInboxDeeplink("abc-123", "Hello Привет world", { + isDevBuild: false, + }), + ).toBe("posthog-code://inbox/abc-123/Hello-world"); + }); +}); diff --git a/apps/code/src/shared/deeplink.ts b/apps/code/src/shared/deeplink.ts index 00a81f603..9b0787f8d 100644 --- a/apps/code/src/shared/deeplink.ts +++ b/apps/code/src/shared/deeplink.ts @@ -23,3 +23,38 @@ export function isPostHogCodeDeeplink( return false; } } + +/** + * Build the deep link URL for an inbox report. The optional title is slugified + * and appended as a trailing path segment for human-readable sharing; the + * receiver only reads the UUID, so the slug is purely cosmetic. + * + * Slug rules: + * - Accented Latin letters are folded to their ASCII base (`café` → `cafe`) + * via NFD decomposition + combining-mark stripping. + * - Letters, digits, and the URL-unreserved punctuation `_ . ~` are kept + * verbatim (case preserved). + * - Any run of other characters collapses to a single `-`, except runs that + * mix a colon with other unsafe chars collapse to `--`. This preserves the + * title-like break in `fix(inbox): Add foo` → `fix-inbox--Add-foo` while + * keeping standalone colons compact (`feat:bar` → `feat-bar`) and unrelated + * runs single (`Cost $5, 50% off` → `Cost-5-50-off`). + * - Leading and trailing hyphens are stripped. + */ +export function buildInboxDeeplink( + reportId: string, + title: string | null | undefined, + { isDevBuild }: { isDevBuild: boolean }, +): string { + const base = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; + const slug = title + ? title + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^a-zA-Z0-9_.~]+/g, (run) => + run.includes(":") && /[^:]/.test(run) ? "--" : "-", + ) + .replace(/^-+|-+$/g, "") + : ""; + return slug ? `${base}/${slug}` : base; +}