feat(inbox): Allow sharing Inbox reports with posthog-code://inbox/ URL#1757
feat(inbox): Allow sharing Inbox reports with posthog-code://inbox/ URL#1757sortafreel wants to merge 15 commits intomainfrom
posthog-code://inbox/ URL#1757Conversation
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
sortafreel
left a comment
There was a problem hiding this comment.
ReviewHog Report
Infrastructure
Files: apps/code/src/main/services/inbox-link/service.ts, apps/code/src/main/di/tokens.ts, apps/code/src/main/di/container.ts, apps/code/src/main/index.ts, apps/code/src/main/trpc/routers/deep-link.ts
Issues: 4 issues
What were the main changes
- Introduces InboxLinkService to register the inbox deep-link namespace, extract reportId, queue pending links until the renderer subscribes, and focus or restore the main window.
- Wires the new service into the main-process DI tokens, container, and startup path so inbox deep links are initialized alongside the existing task-link flow.
- Extends the deep-link TRPC router with onOpenReport and getPendingReportLink, giving the renderer both live subscriptions and cold-start recovery for shared inbox report URLs.
Full analysis
This infrastructure chunk adds a dedicated main-process deep-link lane for shared Inbox reports. The new InboxLinkService in apps/code/src/main/services/inbox-link/service.ts plugs into the existing DeepLinkService by registering the inbox hostname, so a URL such as posthog-code://inbox/{reportId} is parsed by the shared protocol layer and translated into a strongly typed OpenReport event with a single reportId payload. The service is intentionally parallel to TaskLinkService: it validates that a report id exists, extracts only the first path segment, restores and focuses the main window, emits immediately if the renderer is already listening, and otherwise stores one pending deep link so the app can recover links that arrive during boot.
The DI and startup changes in apps/code/src/main/di/tokens.ts, apps/code/src/main/di/container.ts, and apps/code/src/main/index.ts make that service part of the boot sequence rather than an on-demand utility. That is important because the main Inversify container uses singleton scope, so the InboxLinkService instance created during initializeServices() is the same instance later resolved by the tRPC router; handler registration, listener tracking, and pending-link state therefore live in one shared process-wide object. Eagerly instantiating it before initializeDeepLinks() also avoids a startup race: by the time deep-links.ts replays a cold-start URL from process arguments or a queued macOS open-url event, the inbox handler is already registered and able to capture the report id.
On the integration boundary, apps/code/src/main/trpc/routers/deep-link.ts extends the existing IPC bridge with the same two-phase delivery model already used for task links and other callback-style flows: onOpenReport exposes a live async subscription backed by TypedEventEmitter.toIterable() for warm-start deep links, while getPendingReportLink exposes a drain-once query for cold-start recovery. This keeps deep-link handling modular, with each namespace owning its own payload shape and pending-state semantics instead of forcing a generic deep-link union through the router. The main process stays deliberately lightweight and transport-focused, while downstream concerns such as fetching the report, handling missing or unauthorized reports, resetting Inbox filters, and navigating the UI are left to later renderer-side pieces of the PR. In short, this chunk is the plumbing that teaches the desktop app how to recognize, queue, and forward inbox share links reliably across both startup and already-running states.
Business logic
Files: apps/code/src/renderer/api/posthogClient.ts, apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts, apps/code/src/renderer/hooks/useInboxDeepLink.ts, apps/code/src/renderer/components/MainLayout.tsx
Issues: 5 issues
What were the main changes
- Adds a direct getSignalReport(reportId) client method so deep-link flows can resolve a single report and treat 404/403 as an unavailable report instead of a hard failure.
- Adds resetFilters and a new useInboxDeepLink hook that navigates to Inbox, clears filters that could hide the target report, fetches it by id, and handles not-found or unexpected errors.
- Mounts the deep-link hook in MainLayout, ensuring pending report links are drained after authentication and warm-start report-link events are handled globally while the app is open.
Full analysis
This chunk is the renderer-side execution path for shared Inbox report URLs. It turns the new posthog-code://inbox/{reportId} deep-link events from the Electron main-process pipeline into an authenticated UI flow inside the app shell. useInboxDeepLink is mounted in MainLayout, following the same global-listener pattern already used by useTaskDeepLink, so the handler exists whenever the authenticated renderer is alive. The hook listens for warm-start events over tRPC (deepLink.onOpenReport) and also drains a one-shot pending link (getPendingReportLink) for cold starts, guarded by a hasFetchedPending ref so the pending deep link is only consumed once after auth. When a link arrives, the hook navigates to Inbox and uses the existing Zustand stores for filter and selection state instead of introducing any new route-specific report state.
The main technical addition is a direct by-id fetch path for Inbox reports. PostHogAPIClient.getSignalReport(reportId) adds an authenticated REST call to /api/projects/{teamId}/signals/reports/{reportId}/, which is intentionally different from the paginated getSignalReports list flow. This matters because deep-linked reports may not be present in the current infinite list page or may be hidden by persisted filters. The method treats 404 and 403 as an unavailable report and returns null rather than throwing, which lets the UI distinguish between expected share-link misses (wrong team, deleted, suppressed, inaccessible) and true transport/server failures. useInboxDeepLink then resolves the report through queryClient.fetchQuery using the same detail-query contract consumed by useInboxReportById, so the existing Inbox detail fallback can reuse cached data if the selected report is not currently in the list. That keeps the implementation aligned with the surrounding architecture: tRPC for main-to-renderer signaling, PostHogAPIClient for authenticated API access, TanStack Query for cache/data orchestration, and Zustand for navigation, filters, and report selection.
The filter-store change is a small but important UX safeguard. Inbox visibility is controlled by a mix of server-side filters (status, source_product, suggested_reviewers) and client-side search text, and any persisted combination could make a deep-linked report appear to fail to open. Adding resetFilters to inboxSignalsFilterStore clears only the visibility-affecting filters and search while preserving sort preferences, which matches the Inbox design where ordering is user preference but filters can block discovery. The hook also clears selection and shows a toast on not-found or unexpected failures so the detail pane does not remain on stale state. A notable trade-off is that useInboxDeepLink locally duplicates the detail cache-key shape and relies on a keep-in-sync comment rather than importing a shared key factory, which keeps the change surgical but introduces a soft coupling to useInboxReports.
Frontend
Files: apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts, apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx, apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx
Issues: 6 issues
What were the main changes
- Adds reportKeys.detail and useInboxReportById in useInboxReports.ts so the Inbox UI can load a single report outside the paginated list and reuse a stable cache entry.
- Updates InboxSignalsTab to preserve a single deep-linked report selection even when it is missing from the current page, fetch it by id when needed, and scroll it into view once rendered.
- Adds a copy-link control to ReportDetailPane so users can share posthog-code://inbox/{reportId} URLs directly from the Inbox detail header.
Full analysis
This frontend chunk is the renderer-side piece that makes Inbox reports shareable and re-openable via deep links. In apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts, it extends the existing React Query key factory with a dedicated detail key and adds useInboxReportById, a focused authenticated query around PostHogAPIClient.getSignalReport. That gives the Inbox a canonical cache entry for one report independent of the paginated list, which is important because the deep-link flow elsewhere prefetches the same key before navigating into the Inbox. The practical result is that the UI can resolve a report from posthog-code://inbox/{reportId} even when that item is not present in the currently loaded page, the current client-side search results, or the active filter set.
In apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx, the overall architecture stays the same: the infinite list query remains the browsing source, and the Zustand selection store remains the single source of truth for which reports are selected. What changes is the single-selection path. The component now derives a singleSelectedId, checks whether that report is in the current filtered list, and if not falls back to useInboxReportById so the right-hand detail pane can still render. The selection-pruning effect is also refined: selection is still normally constrained to visible rows, but a single selected id is preserved while the by-id lookup is loading or has succeeded, and only an explicit null result from the API can cause it to be pruned. Because getSignalReport returns null for inaccessible or missing reports, the tab can distinguish “not found” from “still resolving” without adding extra state flags. A second effect scrolls the selected row into view once it is actually rendered, reusing the existing data-report-id contract on list rows and CSS.escape for safe DOM lookup. This is a deliberate trade-off: single-report deep links get a resilient path, while multi-select stays list-bound and avoids fan-out fetches.
In apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx, the user-facing share action is added to the detail header. The new control copies posthog-code://inbox/{report.id} to the clipboard and reuses existing toast conventions for success and failure feedback. Architecturally, this chunk fits cleanly into the surrounding inbox design: Zustand owns transient UI state such as selection and filters, React Query owns remote report state and cache reuse, the authenticated API client remains the only backend boundary, and the copied link plugs into the existing main-process deep-link bridge and renderer hook flow already used to open reports. There are no direct component tests covering this exact behavior in the nearby renderer code; instead, the change leans on established patterns already present in store tests and deep-link service tests, so the main contribution of this chunk is composing those existing mechanisms into a shareable single-report experience rather than introducing a new subsystem.
Prompt To Fix All With AIThis is a comment left during a code review.
Path: apps/code/src/renderer/hooks/useInboxDeepLink.ts
Line: 19-21
Comment:
**Duplicated cache key — OnceAndOnlyOnce violation**
`reportDetailKey` replicates the structure of `reportKeys.detail` from `useInboxReports.ts` (`["inbox", "signal-reports", reportId, "detail"]`). The comment acknowledges the manual sync requirement. Export `reportKeys` (or just `reportKeys.detail`) from `useInboxReports.ts` and import it here to have a single source of truth and eliminate the risk of the two diverging silently.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: apps/code/src/renderer/hooks/useInboxDeepLink.ts
Line: 101
Comment:
**Missing `await` on async call**
`handleOpenReport` returns a `Promise` but is not awaited here. While `handleOpenReport` catches its own errors, the unawaited call means any unhandled rejection leaks out of the `fetchPending` try/catch silently. Awaiting it is consistent with the surrounding `async/await` style and ensures errors are properly contained.
```suggestion
await handleOpenReport(pending.reportId);
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: Stale state for failed deeplink." | Re-trigger Greptile |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Twixes
left a comment
There was a problem hiding this comment.
Pushed a couple of commits to clean up the hooks, hope you don't mind. This works very well, edge cases seem handled too. Nice

Problem
Changes
How did you test this?