From 3d1576dd4d50b21db4422f78953af61a64aac201 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 12:58:41 +0200 Subject: [PATCH 01/15] refactor(inbox): Fetch selected report by id when off the current list page. --- apps/code/src/renderer/api/posthogClient.ts | 18 ++++++++ .../inbox/components/InboxSignalsTab.tsx | 42 ++++++++++++++++--- .../features/inbox/hooks/useInboxReports.ts | 14 +++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index d53ed8779..55fb5a81f 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1201,6 +1201,24 @@ export class PostHogAPIClient { return await response.json(); } + async getSignalReport(reportId: string): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + if (response.status === 404 || response.status === 403) { + return null; + } + if (!response.ok) { + throw new Error(`Failed to fetch signal report: ${response.statusText}`); + } + return (await response.json()) as SignalReport; + } + async getSignalReports( params?: SignalReportsQueryParams, ): Promise { diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index b948c39fa..ad92e0883 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -7,6 +7,7 @@ import { import { InboxSourcesDialog } from "@features/inbox/components/InboxSourcesDialog"; import { useInboxAvailableSuggestedReviewers, + useInboxReportById, useInboxReportsInfinite, useInboxSignalProcessingState, } from "@features/inbox/hooks/useInboxReports"; @@ -163,16 +164,47 @@ export function InboxSignalsTab() { const reportsRef = useRef(reports); reportsRef.current = reports; - // Prune selection when visible reports change (e.g. filter/search) + // When exactly one report is selected and it isn't on the currently loaded + // list page (e.g. opened via a deep link), fall back to a direct by-id fetch to render it. + const singleSelectedId = + selectedReportIds.length === 1 ? selectedReportIds[0] : null; + const selectedReportFromList = useMemo(() => { + if (!singleSelectedId) return null; + return reports.find((r) => r.id === singleSelectedId) ?? null; + }, [reports, singleSelectedId]); + const needsByIdFallback = !!singleSelectedId && !selectedReportFromList; + const { data: byIdReport } = useInboxReportById( + needsByIdFallback ? singleSelectedId : null, + ); + + // Prune selection when visible reports change (e.g. filter/search). + // Preserve any single-selection id that's actively being resolved via the by-id fallback. + useEffect(() => { + const visibleIds = reports.map((report) => report.id); + if ( + singleSelectedId && + !reports.some((r) => r.id === singleSelectedId) && + byIdReport !== null + ) { + visibleIds.push(singleSelectedId); + } + pruneSelection(visibleIds); + }, [reports, pruneSelection, singleSelectedId, byIdReport]); + + // Scroll the singly-selected row into view if it's rendered in the list. useEffect(() => { - pruneSelection(reports.map((report) => report.id)); - }, [reports, pruneSelection]); + if (!singleSelectedId) return; + if (!reports.some((r) => r.id === singleSelectedId)) return; + document + .querySelector(`[data-report-id="${CSS.escape(singleSelectedId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [singleSelectedId, reports]); // The report to show in the detail pane (only when exactly 1 is selected) const selectedReport = useMemo(() => { if (selectedReportIds.length !== 1) return null; - return reports.find((r) => r.id === selectedReportIds[0]) ?? null; - }, [reports, selectedReportIds]); + return selectedReportFromList ?? byIdReport ?? null; + }, [selectedReportIds, selectedReportFromList, byIdReport]); // Reports for the multi-select stack (when 2+ selected) const selectedReports = useMemo(() => { diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts index 312e386d9..083486471 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts @@ -8,6 +8,7 @@ import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { AvailableSuggestedReviewersResponse, SignalProcessingStateResponse, + SignalReport, SignalReportArtefactsResponse, SignalReportSignalsResponse, SignalReportsQueryParams, @@ -23,6 +24,8 @@ const reportKeys = { [...reportKeys.all, "list", params ?? {}] as const, infiniteList: (params?: SignalReportsQueryParams) => [...reportKeys.all, "infinite-list", params ?? {}] as const, + detail: (reportId: string) => + [...reportKeys.all, reportId, "detail"] as const, artefacts: (reportId: string) => [...reportKeys.all, reportId, "artefacts"] as const, signals: (reportId: string) => @@ -164,6 +167,17 @@ export function useInboxSignalProcessingState(options?: { ); } +export function useInboxReportById( + reportId: string | null, + options?: { enabled?: boolean }, +) { + return useAuthenticatedQuery( + reportKeys.detail(reportId ?? ""), + (client) => client.getSignalReport(reportId ?? ""), + { enabled: !!reportId && (options?.enabled ?? true) }, + ); +} + export function useInboxReportArtefacts( reportId: string, options?: { enabled?: boolean }, From c2c5841dfb3239b4e961ad95b27e2b4a0ca290b2 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 12:59:34 +0200 Subject: [PATCH 02/15] feat(inbox): Add resetFilters action to signals filter store --- .../stores/inboxSignalsFilterStore.test.ts | 32 +++++++++++++++++++ .../inbox/stores/inboxSignalsFilterStore.ts | 9 ++++++ 2 files changed, 41 insertions(+) diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts index 38683f452..f48727f6d 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts @@ -103,4 +103,36 @@ describe("inboxSignalsFilterStore", () => { "reviewer-2", ]); }); + + it("resetFilters restores defaults across all filter fields", () => { + const store = useInboxSignalsFilterStore.getState(); + store.setSearchQuery("hello"); + store.setStatusFilter(["ready"]); + store.toggleSourceProduct("github"); + store.setSuggestedReviewerFilter(["reviewer-1"]); + + useInboxSignalsFilterStore.getState().resetFilters(); + + const state = useInboxSignalsFilterStore.getState(); + expect(state.searchQuery).toBe(""); + expect(state.statusFilter).toEqual([ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", + ]); + expect(state.sourceProductFilter).toEqual([]); + expect(state.suggestedReviewerFilter).toEqual([]); + }); + + it("resetFilters preserves sort preferences", () => { + useInboxSignalsFilterStore.getState().setSort("created_at", "asc"); + + useInboxSignalsFilterStore.getState().resetFilters(); + + const state = useInboxSignalsFilterStore.getState(); + expect(state.sortField).toBe("created_at"); + expect(state.sortDirection).toBe("asc"); + }); }); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index 57b6ce395..624deb407 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -48,6 +48,8 @@ interface InboxSignalsFilterActions { toggleSourceProduct: (source: SourceProduct) => void; toggleSuggestedReviewer: (reviewerUuid: string) => void; setSuggestedReviewerFilter: (reviewerUuids: string[]) => void; + /** Reset all filters when a deep link arrives so the linked report isn't hidden. */ + resetFilters: () => void; } type InboxSignalsFilterStore = InboxSignalsFilterState & @@ -93,6 +95,13 @@ export const useInboxSignalsFilterStore = create()( set({ suggestedReviewerFilter: Array.from(new Set(reviewerUuids)), }), + resetFilters: () => + set({ + searchQuery: "", + statusFilter: DEFAULT_STATUS_FILTER, + sourceProductFilter: [], + suggestedReviewerFilter: [], + }), }), { name: "inbox-signals-filter-storage", From cbdaca24aed1b37f719745f5f7c1bc4771243f1a Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 13:02:04 +0200 Subject: [PATCH 03/15] feat(main): Register Inbox deep-link handler --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/index.ts | 2 + .../src/main/services/inbox-link/service.ts | 77 +++++++++++++++++++ apps/code/src/main/trpc/routers/deep-link.ts | 38 ++++++++- 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 apps/code/src/main/services/inbox-link/service.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 3b2d3eb21..b2592d223 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -43,6 +43,7 @@ import { FoldersService } from "../services/folders/service"; import { FsService } from "../services/fs/service"; import { GitService } from "../services/git/service"; import { GitHubIntegrationService } from "../services/github-integration/service"; +import { InboxLinkService } from "../services/inbox-link/service"; import { LinearIntegrationService } from "../services/linear-integration/service"; import { LlmGatewayService } from "../services/llm-gateway/service"; import { McpAppsService } from "../services/mcp-apps/service"; @@ -134,6 +135,7 @@ container.bind(MAIN_TOKENS.ShellService).to(ShellService); container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); +container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index 2613c03e2..62febfd8a 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -70,6 +70,7 @@ export const MAIN_TOKENS = Object.freeze({ UIService: Symbol.for("Main.UIService"), UpdatesService: Symbol.for("Main.UpdatesService"), TaskLinkService: Symbol.for("Main.TaskLinkService"), + InboxLinkService: Symbol.for("Main.InboxLinkService"), WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"), EnvironmentService: Symbol.for("Main.EnvironmentService"), ProvisioningService: Symbol.for("Main.ProvisioningService"), diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 7f5e0d92b..9a9ea2c4e 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -14,6 +14,7 @@ import type { AppLifecycleService } from "./services/app-lifecycle/service"; import type { AuthService } from "./services/auth/service"; import type { ExternalAppsService } from "./services/external-apps/service"; import type { GitHubIntegrationService } from "./services/github-integration/service"; +import type { InboxLinkService } from "./services/inbox-link/service"; import type { NotificationService } from "./services/notification/service"; import type { OAuthService } from "./services/oauth/service"; import { @@ -44,6 +45,7 @@ async function initializeServices(): Promise { container.get(MAIN_TOKENS.NotificationService); container.get(MAIN_TOKENS.UpdatesService); container.get(MAIN_TOKENS.TaskLinkService); + container.get(MAIN_TOKENS.InboxLinkService); container.get(MAIN_TOKENS.GitHubIntegrationService); container.get(MAIN_TOKENS.ExternalAppsService); container.get(MAIN_TOKENS.PosthogPluginService); diff --git a/apps/code/src/main/services/inbox-link/service.ts b/apps/code/src/main/services/inbox-link/service.ts new file mode 100644 index 000000000..8d78e8b40 --- /dev/null +++ b/apps/code/src/main/services/inbox-link/service.ts @@ -0,0 +1,77 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DeepLinkService } from "../deep-link/service"; + +const log = logger.scope("inbox-link-service"); + +export const InboxLinkEvent = { + OpenReport: "openReport", +} as const; + +export interface InboxLinkEvents { + [InboxLinkEvent.OpenReport]: { reportId: string }; +} + +export interface PendingInboxDeepLink { + reportId: string; +} + +@injectable() +export class InboxLinkService extends TypedEventEmitter { + private pendingDeepLink: PendingInboxDeepLink | null = null; + + constructor( + @inject(MAIN_TOKENS.DeepLinkService) + private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, + ) { + super(); + + this.deepLinkService.registerHandler("inbox", (path) => + this.handleInboxLink(path), + ); + } + + private handleInboxLink(path: string): boolean { + // path format: "abc123" from posthog-code://inbox/abc123 + const reportId = path.split("/")[0]; + + if (!reportId) { + log.warn("Inbox link missing report ID"); + return false; + } + + const hasListeners = this.listenerCount(InboxLinkEvent.OpenReport) > 0; + + if (hasListeners) { + log.info(`Emitting inbox link event: reportId=${reportId}`); + this.emit(InboxLinkEvent.OpenReport, { reportId }); + } else { + log.info( + `Queueing inbox link (renderer not ready): reportId=${reportId}`, + ); + this.pendingDeepLink = { reportId }; + } + + log.info("Deep link focusing window", { reportId }); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingDeepLink(): PendingInboxDeepLink | null { + const pending = this.pendingDeepLink; + this.pendingDeepLink = null; + if (pending) { + log.info(`Consumed pending inbox link: reportId=${pending.reportId}`); + } + return pending; + } +} diff --git a/apps/code/src/main/trpc/routers/deep-link.ts b/apps/code/src/main/trpc/routers/deep-link.ts index dbcf0d6b2..7bde40c80 100644 --- a/apps/code/src/main/trpc/routers/deep-link.ts +++ b/apps/code/src/main/trpc/routers/deep-link.ts @@ -1,5 +1,10 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; +import { + InboxLinkEvent, + type InboxLinkService, + type PendingInboxDeepLink, +} from "../../services/inbox-link/service"; import { type PendingDeepLink, TaskLinkEvent, @@ -7,9 +12,12 @@ import { } from "../../services/task-link/service"; import { publicProcedure, router } from "../trpc"; -const getService = () => +const getTaskLinkService = () => container.get(MAIN_TOKENS.TaskLinkService); +const getInboxLinkService = () => + container.get(MAIN_TOKENS.InboxLinkService); + export const deepLinkRouter = router({ /** * Subscribe to task link deep link events. @@ -17,7 +25,7 @@ export const deepLinkRouter = router({ * posthog-code://task/{taskId}/run/{taskRunId} is opened. */ onOpenTask: publicProcedure.subscription(async function* (opts) { - const service = getService(); + const service = getTaskLinkService(); const iterable = service.toIterable(TaskLinkEvent.OpenTask, { signal: opts.signal, }); @@ -31,7 +39,31 @@ export const deepLinkRouter = router({ * This handles the case where the app is launched via deep link. */ getPendingDeepLink: publicProcedure.query((): PendingDeepLink | null => { - const service = getService(); + const service = getTaskLinkService(); return service.consumePendingDeepLink(); }), + + /** + * Subscribe to inbox report deep link events. + * Emits report ID when posthog-code://inbox/{reportId} is opened. + */ + onOpenReport: publicProcedure.subscription(async function* (opts) { + const service = getInboxLinkService(); + const iterable = service.toIterable(InboxLinkEvent.OpenReport, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + /** + * Get any pending inbox deep link that arrived before renderer was ready. + */ + getPendingReportLink: publicProcedure.query( + (): PendingInboxDeepLink | null => { + const service = getInboxLinkService(); + return service.consumePendingDeepLink(); + }, + ), }); From fc174ff98e963439c7747882ba08ef4e13d02ffa Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 13:03:03 +0200 Subject: [PATCH 04/15] feat(inbox): Open reports from posthog-code://inbox/:reportId deep links --- .../src/renderer/components/MainLayout.tsx | 2 + .../src/renderer/hooks/useInboxDeepLink.ts | 127 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 apps/code/src/renderer/hooks/useInboxDeepLink.ts diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index f49e76d66..14525e0b0 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -21,6 +21,7 @@ import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; import { useCallback, useEffect } from "react"; +import { useInboxDeepLink } from "../hooks/useInboxDeepLink"; import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; import { GlobalEventHandlers } from "./GlobalEventHandlers"; @@ -41,6 +42,7 @@ export function MainLayout() { useIntegrations(); useTaskDeepLink(); + useInboxDeepLink(); useEffect(() => { if (tasks) { diff --git a/apps/code/src/renderer/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/hooks/useInboxDeepLink.ts new file mode 100644 index 000000000..210f69bae --- /dev/null +++ b/apps/code/src/renderer/hooks/useInboxDeepLink.ts @@ -0,0 +1,127 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; +import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { trpcClient, useTRPC } from "@renderer/trpc"; +import type { SignalReport } from "@shared/types"; +import { useNavigationStore } from "@stores/navigationStore"; +import { useQueryClient } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { logger } from "@utils/logger"; +import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; + +const log = logger.scope("inbox-deep-link"); + +// Keep in sync with the key in features/inbox/hooks/useInboxReports.ts (`reportKeys.detail`). +const reportDetailKey = (reportId: string) => + ["inbox", "signal-reports", reportId, "detail"] as const; + +/** + * Hook that subscribes to inbox report deep link events (posthog-code://inbox/{reportId}) + * and opens the report in the inbox view. + * + * Behavior on link arrival: + * 1. Reset inbox-local filters so the linked report isn't hidden. + * 2. Navigate to the inbox view. + * 3. Fetch the report by id directly, bypassing the paginated list, and seed + * the TanStack Query cache so the detail pane fallback reuses it. + * - On 404/403 (wrong team / deleted / suppressed): toast "Report not found + * in the current team" and clear selection. + * - On success: set selection to the report id. + */ +export function useInboxDeepLink() { + const trpcReact = useTRPC(); + const navigateToInbox = useNavigationStore((state) => state.navigateToInbox); + const setSelectedReportIds = useInboxReportSelectionStore( + (state) => state.setSelectedReportIds, + ); + const clearSelection = useInboxReportSelectionStore( + (state) => state.clearSelection, + ); + const resetFilters = useInboxSignalsFilterStore( + (state) => state.resetFilters, + ); + const queryClient = useQueryClient(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const client = useOptionalAuthenticatedClient(); + const hasFetchedPending = useRef(false); + + const handleOpenReport = useCallback( + async (reportId: string) => { + log.info(`Opening report from deep link: ${reportId}`); + + if (!client) { + log.warn("Ignoring inbox deep link — not authenticated"); + return; + } + + resetFilters(); + navigateToInbox(); + + try { + const report = await queryClient.fetchQuery({ + queryKey: reportDetailKey(reportId), + queryFn: () => client.getSignalReport(reportId), + }); + + if (!report) { + log.warn(`Report not found or not accessible: ${reportId}`); + toast.error("Report not found in the current team"); + clearSelection(); + return; + } + + setSelectedReportIds([report.id]); + log.info(`Successfully opened report from deep link: ${report.id}`); + } catch (error) { + log.error("Unexpected error opening report from deep link:", error); + toast.error("Failed to open report"); + clearSelection(); + } + }, + [ + navigateToInbox, + setSelectedReportIds, + clearSelection, + resetFilters, + queryClient, + client, + ], + ); + + // Cold start: drain pending deep link that arrived before renderer was ready. + useEffect(() => { + if (!isAuthenticated || hasFetchedPending.current) return; + + const fetchPending = async () => { + hasFetchedPending.current = true; + try { + const pending = await trpcClient.deepLink.getPendingReportLink.query(); + if (pending) { + log.info( + `Found pending inbox deep link: reportId=${pending.reportId}`, + ); + handleOpenReport(pending.reportId); + } + } catch (error) { + log.error("Failed to check for pending inbox deep link:", error); + } + }; + + fetchPending(); + }, [isAuthenticated, handleOpenReport]); + + // Warm start: receive deep link events while the renderer is running. + useSubscription( + trpcReact.deepLink.onOpenReport.subscriptionOptions(undefined, { + onData: (data) => { + log.info(`Received inbox deep link event: reportId=${data.reportId}`); + if (!data?.reportId) return; + handleOpenReport(data.reportId); + }, + }), + ); +} From 792735d3392478ff2324c8a4742a1dcfd8210504 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 13:03:27 +0200 Subject: [PATCH 05/15] feat(inbox): Add shareable-link button to report detail pane --- .../components/detail/ReportDetailPane.tsx | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) 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 6831df750..4ec1b5345 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -18,6 +18,7 @@ import { Cloud as CloudIcon, EyeIcon, GitPullRequestIcon, + LinkSimpleIcon, WarningIcon, XIcon, } from "@phosphor-icons/react"; @@ -323,13 +324,35 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { )} - + + + + + + {/* ── Scrollable detail area ──────────────────────────────── */} From f5fb2592f75aaaddcb40f46db51c32e8123be2ed Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 13:04:33 +0200 Subject: [PATCH 06/15] chore: Tests. --- .../main/services/inbox-link/service.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/code/src/main/services/inbox-link/service.test.ts diff --git a/apps/code/src/main/services/inbox-link/service.test.ts b/apps/code/src/main/services/inbox-link/service.test.ts new file mode 100644 index 000000000..844a47eaa --- /dev/null +++ b/apps/code/src/main/services/inbox-link/service.test.ts @@ -0,0 +1,119 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +import type { DeepLinkHandler, DeepLinkService } from "../deep-link/service"; +import { InboxLinkEvent, InboxLinkService } from "./service"; + +function makeDeepLinkService() { + const handlers = new Map(); + const service = { + registerHandler: vi.fn((key: string, handler: DeepLinkHandler) => { + handlers.set(key, handler); + }), + trigger: (key: string, path: string) => { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for ${key}`); + return handler(path, new URLSearchParams()); + }, + }; + return service as unknown as DeepLinkService & { + trigger: (key: string, path: string) => boolean; + }; +} + +function makeMainWindow() { + return { + focus: vi.fn(), + restore: vi.fn(), + isMinimized: vi.fn().mockReturnValue(false), + } as unknown as IMainWindow & { + focus: ReturnType; + restore: ReturnType; + isMinimized: ReturnType; + }; +} + +describe("InboxLinkService", () => { + let deepLinkService: ReturnType; + let mainWindow: ReturnType; + let service: InboxLinkService; + + beforeEach(() => { + deepLinkService = makeDeepLinkService(); + mainWindow = makeMainWindow(); + service = new InboxLinkService(deepLinkService, mainWindow); + }); + + it("registers an 'inbox' handler on the DeepLinkService", () => { + expect(deepLinkService.registerHandler).toHaveBeenCalledWith( + "inbox", + expect.any(Function), + ); + }); + + it("emits OpenReport when a listener is attached", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + const result = deepLinkService.trigger("inbox", "abc-123"); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); + }); + + it("queues a pending deep link when no listener is attached", () => { + deepLinkService.trigger("inbox", "pending-id"); + + const pending = service.consumePendingDeepLink(); + expect(pending).toEqual({ reportId: "pending-id" }); + + // Draining clears it + expect(service.consumePendingDeepLink()).toBeNull(); + }); + + it("takes only the first path segment as the report id", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + deepLinkService.trigger("inbox", "abc-123/extra/segments"); + + 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); + + const result = deepLinkService.trigger("inbox", ""); + + expect(result).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + it("focuses the main window on link arrival", () => { + deepLinkService.trigger("inbox", "abc-123"); + + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + expect(mainWindow.restore).not.toHaveBeenCalled(); + }); + + it("restores the main window when it is minimized", () => { + mainWindow.isMinimized.mockReturnValue(true); + + deepLinkService.trigger("inbox", "abc-123"); + + expect(mainWindow.restore).toHaveBeenCalledTimes(1); + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + }); +}); From 470f9c28d9a3a30c3d1241c7f785e9ea65ce146e Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 15:21:34 +0200 Subject: [PATCH 07/15] fix: Don't cache deeplink results. --- apps/code/src/renderer/hooks/useInboxDeepLink.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/hooks/useInboxDeepLink.ts index 210f69bae..b59588379 100644 --- a/apps/code/src/renderer/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/hooks/useInboxDeepLink.ts @@ -1,5 +1,8 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + AUTH_SCOPED_QUERY_META, + useAuthStateValue, +} from "@features/auth/hooks/authQueries"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { trpcClient, useTRPC } from "@renderer/trpc"; @@ -65,6 +68,7 @@ export function useInboxDeepLink() { const report = await queryClient.fetchQuery({ queryKey: reportDetailKey(reportId), queryFn: () => client.getSignalReport(reportId), + meta: AUTH_SCOPED_QUERY_META, }); if (!report) { From 6c7f9540c58847afdd35cf404e70841d3efcb80c Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 15:24:38 +0200 Subject: [PATCH 08/15] fix: Avoid clearing filters if report doesn't exist. --- .../src/renderer/hooks/useInboxDeepLink.ts | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/apps/code/src/renderer/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/hooks/useInboxDeepLink.ts index b59588379..43f7661d5 100644 --- a/apps/code/src/renderer/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/hooks/useInboxDeepLink.ts @@ -25,13 +25,13 @@ const reportDetailKey = (reportId: string) => * and opens the report in the inbox view. * * Behavior on link arrival: - * 1. Reset inbox-local filters so the linked report isn't hidden. - * 2. Navigate to the inbox view. - * 3. Fetch the report by id directly, bypassing the paginated list, and seed + * 1. Fetch the report by id directly, bypassing the paginated list, and seed * the TanStack Query cache so the detail pane fallback reuses it. * - On 404/403 (wrong team / deleted / suppressed): toast "Report not found - * in the current team" and clear selection. - * - On success: set selection to the report id. + * in the current team" and leave the current view untouched. + * - On transient failure: toast a generic error and leave state untouched. + * 2. Only on success: reset inbox-local filters (so the report isn't hidden), + * navigate to the inbox view, and select the report id. */ export function useInboxDeepLink() { const trpcReact = useTRPC(); @@ -39,9 +39,6 @@ export function useInboxDeepLink() { const setSelectedReportIds = useInboxReportSelectionStore( (state) => state.setSelectedReportIds, ); - const clearSelection = useInboxReportSelectionStore( - (state) => state.clearSelection, - ); const resetFilters = useInboxSignalsFilterStore( (state) => state.resetFilters, ); @@ -61,9 +58,6 @@ export function useInboxDeepLink() { return; } - resetFilters(); - navigateToInbox(); - try { const report = await queryClient.fetchQuery({ queryKey: reportDetailKey(reportId), @@ -74,26 +68,22 @@ export function useInboxDeepLink() { if (!report) { log.warn(`Report not found or not accessible: ${reportId}`); toast.error("Report not found in the current team"); - clearSelection(); return; } + // Only mutate inbox UI state once we know the report resolves — so a + // bad or wrong-team link doesn't clobber the user's saved filters or + // yank them away from whatever view they were on. + resetFilters(); + navigateToInbox(); setSelectedReportIds([report.id]); log.info(`Successfully opened report from deep link: ${report.id}`); } catch (error) { log.error("Unexpected error opening report from deep link:", error); toast.error("Failed to open report"); - clearSelection(); } }, - [ - navigateToInbox, - setSelectedReportIds, - clearSelection, - resetFilters, - queryClient, - client, - ], + [navigateToInbox, setSelectedReportIds, resetFilters, queryClient, client], ); // Cold start: drain pending deep link that arrived before renderer was ready. From 3253850442411e33aac6f13261b52a544224d8be Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 15:30:51 +0200 Subject: [PATCH 09/15] fix: Process 404/403 properly. --- .../src/renderer/api/posthogClient.test.ts | 63 +++++++++++++++++++ apps/code/src/renderer/api/posthogClient.ts | 28 +++++---- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts index c0c58e2b5..fdfeb4430 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/apps/code/src/renderer/api/posthogClient.test.ts @@ -124,4 +124,67 @@ describe("PostHogAPIClient", () => { expect(post).not.toHaveBeenCalled(); }); + + describe("getSignalReport", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + return client; + } + + it("returns the parsed report on success", async () => { + const fetch = vi.fn().mockResolvedValue({ + json: async () => ({ id: "abc", title: "hi" }), + }); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).resolves.toEqual({ + id: "abc", + title: "hi", + }); + }); + + it("returns null when the shared fetcher throws a 404", async () => { + const fetch = vi + .fn() + .mockRejectedValue( + new Error('Failed request: [404] {"detail":"Not found."}'), + ); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).resolves.toBeNull(); + }); + + it("returns null when the shared fetcher throws a 403", async () => { + const fetch = vi + .fn() + .mockRejectedValue( + new Error('Failed request: [403] {"detail":"Forbidden."}'), + ); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).resolves.toBeNull(); + }); + + it("rethrows non-404/403 errors", async () => { + const fetch = vi + .fn() + .mockRejectedValue(new Error("Failed request: [500] boom")); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).rejects.toThrow("[500]"); + }); + }); }); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 55fb5a81f..6e90a3672 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1205,18 +1205,24 @@ export class PostHogAPIClient { const teamId = await this.getTeamId(); const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; const url = new URL(`${this.api.baseUrl}${path}`); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - if (response.status === 404 || response.status === 403) { - return null; - } - if (!response.ok) { - throw new Error(`Failed to fetch signal report: ${response.statusText}`); + + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + return (await response.json()) as SignalReport; + } catch (error) { + // The shared fetcher throws "Failed request: [] " for any + // non-2xx. Treat missing / forbidden as "not available in the current + // team" and surface other errors to the caller. + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("[404]") || msg.includes("[403]")) { + return null; + } + throw error; } - return (await response.json()) as SignalReport; } async getSignalReports( From 41f7a0caeacc68bb175e96123144e9ed54ed6794 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 15:41:38 +0200 Subject: [PATCH 10/15] fix: Add refetch interval for deeplinks. --- .../features/inbox/components/InboxSignalsTab.tsx | 5 +++++ .../features/inbox/hooks/useInboxReports.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index ad92e0883..a067211a9 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -175,6 +175,11 @@ export function InboxSignalsTab() { const needsByIdFallback = !!singleSelectedId && !selectedReportFromList; const { data: byIdReport } = useInboxReportById( needsByIdFallback ? singleSelectedId : null, + { + refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 12_000, + }, ); // Prune selection when visible reports change (e.g. filter/search). diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts index 083486471..8f332f7ff 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts @@ -169,12 +169,22 @@ export function useInboxSignalProcessingState(options?: { export function useInboxReportById( reportId: string | null, - options?: { enabled?: boolean }, + options?: { + enabled?: boolean; + refetchInterval?: number | false | (() => number | false | undefined); + refetchIntervalInBackground?: boolean; + staleTime?: number; + }, ) { return useAuthenticatedQuery( reportKeys.detail(reportId ?? ""), (client) => client.getSignalReport(reportId ?? ""), - { enabled: !!reportId && (options?.enabled ?? true) }, + { + enabled: !!reportId && (options?.enabled ?? true), + refetchInterval: options?.refetchInterval, + refetchIntervalInBackground: options?.refetchIntervalInBackground, + staleTime: options?.staleTime, + }, ); } From be3e4f833f6688d9ad00e69c5ad3ad7ac2a8aca1 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 15:45:07 +0200 Subject: [PATCH 11/15] fix: Avoid snapping the list back on the scroll. --- .../features/inbox/components/InboxSignalsTab.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index a067211a9..4d9e1aa38 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -196,13 +196,20 @@ export function InboxSignalsTab() { pruneSelection(visibleIds); }, [reports, pruneSelection, singleSelectedId, byIdReport]); - // Scroll the singly-selected row into view if it's rendered in the list. + // Scroll once per selection change; refetches must not snap the list back. + const autoScrolledIdRef = useRef(null); useEffect(() => { - if (!singleSelectedId) return; + if (!singleSelectedId) { + autoScrolledIdRef.current = null; + return; + } + if (autoScrolledIdRef.current === singleSelectedId) return; if (!reports.some((r) => r.id === singleSelectedId)) return; + document .querySelector(`[data-report-id="${CSS.escape(singleSelectedId)}"]`) ?.scrollIntoView({ block: "nearest" }); + autoScrolledIdRef.current = singleSelectedId; }, [singleSelectedId, reports]); // The report to show in the detail pane (only when exactly 1 is selected) From ccd41d9fcfc33ba9bd98b0d095a7aec8e407002c Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 15:48:37 +0200 Subject: [PATCH 12/15] fix: Stale state for failed deeplink. --- .../features/inbox/components/InboxSignalsTab.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 4d9e1aa38..b4342a143 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -173,7 +173,7 @@ export function InboxSignalsTab() { return reports.find((r) => r.id === singleSelectedId) ?? null; }, [reports, singleSelectedId]); const needsByIdFallback = !!singleSelectedId && !selectedReportFromList; - const { data: byIdReport } = useInboxReportById( + const { data: byIdReport, isError: byIdError } = useInboxReportById( needsByIdFallback ? singleSelectedId : null, { refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, @@ -183,18 +183,20 @@ export function InboxSignalsTab() { ); // Prune selection when visible reports change (e.g. filter/search). - // Preserve any single-selection id that's actively being resolved via the by-id fallback. + // Preserve off-list selections that are still loading or resolved via + // the by-id fallback; let prune clear them on confirmed null or on error. useEffect(() => { const visibleIds = reports.map((report) => report.id); if ( singleSelectedId && !reports.some((r) => r.id === singleSelectedId) && + !byIdError && byIdReport !== null ) { visibleIds.push(singleSelectedId); } pruneSelection(visibleIds); - }, [reports, pruneSelection, singleSelectedId, byIdReport]); + }, [reports, pruneSelection, singleSelectedId, byIdReport, byIdError]); // Scroll once per selection change; refetches must not snap the list back. const autoScrolledIdRef = useRef(null); From b276f20b4aca37ed2f2ca4977230a6c8b37714ce Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 21 Apr 2026 16:18:05 +0200 Subject: [PATCH 13/15] fix: Keep in sync. --- .../src/renderer/features/inbox/hooks/useInboxReports.ts | 2 +- apps/code/src/renderer/hooks/useInboxDeepLink.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts index 8f332f7ff..8ba010385 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts @@ -18,7 +18,7 @@ import { useEffect, useMemo } from "react"; const REPORTS_PAGE_SIZE = 100; -const reportKeys = { +export const reportKeys = { all: ["inbox", "signal-reports"] as const, list: (params?: SignalReportsQueryParams) => [...reportKeys.all, "list", params ?? {}] as const, diff --git a/apps/code/src/renderer/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/hooks/useInboxDeepLink.ts index 43f7661d5..267ef0df3 100644 --- a/apps/code/src/renderer/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/hooks/useInboxDeepLink.ts @@ -3,6 +3,7 @@ import { AUTH_SCOPED_QUERY_META, useAuthStateValue, } from "@features/auth/hooks/authQueries"; +import { reportKeys } from "@features/inbox/hooks/useInboxReports"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { trpcClient, useTRPC } from "@renderer/trpc"; @@ -16,10 +17,6 @@ import { toast } from "sonner"; const log = logger.scope("inbox-deep-link"); -// Keep in sync with the key in features/inbox/hooks/useInboxReports.ts (`reportKeys.detail`). -const reportDetailKey = (reportId: string) => - ["inbox", "signal-reports", reportId, "detail"] as const; - /** * Hook that subscribes to inbox report deep link events (posthog-code://inbox/{reportId}) * and opens the report in the inbox view. @@ -60,7 +57,7 @@ export function useInboxDeepLink() { try { const report = await queryClient.fetchQuery({ - queryKey: reportDetailKey(reportId), + queryKey: reportKeys.detail(reportId), queryFn: () => client.getSignalReport(reportId), meta: AUTH_SCOPED_QUERY_META, }); From db54279f7aba7836d4f889ce93bd13e926e79655 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 21 Apr 2026 19:12:23 +0200 Subject: [PATCH 14/15] Extract out useInboxDeepLinkListSync hook --- .../inbox/components/InboxSignalsTab.tsx | 64 ++------------- .../inbox/hooks/useInboxDeepLinkListSync.ts | 78 +++++++++++++++++++ 2 files changed, 84 insertions(+), 58 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index b4342a143..5624a31cc 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -5,9 +5,9 @@ import { WelcomePane, } from "@features/inbox/components/InboxEmptyStates"; import { InboxSourcesDialog } from "@features/inbox/components/InboxSourcesDialog"; +import { useInboxDeepLinkListSync } from "@features/inbox/hooks/useInboxDeepLinkListSync"; import { useInboxAvailableSuggestedReviewers, - useInboxReportById, useInboxReportsInfinite, useInboxSignalProcessingState, } from "@features/inbox/hooks/useInboxReports"; @@ -155,71 +155,19 @@ export function InboxSignalsTab() { const selectExactRange = useInboxReportSelectionStore( (s) => s.selectExactRange, ); - const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection); const clearSelection = useInboxReportSelectionStore((s) => s.clearSelection); + const { selectedReport } = useInboxDeepLinkListSync({ + reports, + inboxPollingActive, + }); + // Stable refs so callbacks don't need re-registration on every render const selectedReportIdsRef = useRef(selectedReportIds); selectedReportIdsRef.current = selectedReportIds; const reportsRef = useRef(reports); reportsRef.current = reports; - // When exactly one report is selected and it isn't on the currently loaded - // list page (e.g. opened via a deep link), fall back to a direct by-id fetch to render it. - const singleSelectedId = - selectedReportIds.length === 1 ? selectedReportIds[0] : null; - const selectedReportFromList = useMemo(() => { - if (!singleSelectedId) return null; - return reports.find((r) => r.id === singleSelectedId) ?? null; - }, [reports, singleSelectedId]); - const needsByIdFallback = !!singleSelectedId && !selectedReportFromList; - const { data: byIdReport, isError: byIdError } = useInboxReportById( - needsByIdFallback ? singleSelectedId : null, - { - refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, - refetchIntervalInBackground: false, - staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 12_000, - }, - ); - - // Prune selection when visible reports change (e.g. filter/search). - // Preserve off-list selections that are still loading or resolved via - // the by-id fallback; let prune clear them on confirmed null or on error. - useEffect(() => { - const visibleIds = reports.map((report) => report.id); - if ( - singleSelectedId && - !reports.some((r) => r.id === singleSelectedId) && - !byIdError && - byIdReport !== null - ) { - visibleIds.push(singleSelectedId); - } - pruneSelection(visibleIds); - }, [reports, pruneSelection, singleSelectedId, byIdReport, byIdError]); - - // Scroll once per selection change; refetches must not snap the list back. - const autoScrolledIdRef = useRef(null); - useEffect(() => { - if (!singleSelectedId) { - autoScrolledIdRef.current = null; - return; - } - if (autoScrolledIdRef.current === singleSelectedId) return; - if (!reports.some((r) => r.id === singleSelectedId)) return; - - document - .querySelector(`[data-report-id="${CSS.escape(singleSelectedId)}"]`) - ?.scrollIntoView({ block: "nearest" }); - autoScrolledIdRef.current = singleSelectedId; - }, [singleSelectedId, reports]); - - // The report to show in the detail pane (only when exactly 1 is selected) - const selectedReport = useMemo(() => { - if (selectedReportIds.length !== 1) return null; - return selectedReportFromList ?? byIdReport ?? null; - }, [selectedReportIds, selectedReportFromList, byIdReport]); - // Reports for the multi-select stack (when 2+ selected) const selectedReports = useMemo(() => { if (selectedReportIds.length < 2) return []; diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts new file mode 100644 index 000000000..f72561aed --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts @@ -0,0 +1,78 @@ +import { useInboxReportById } from "@features/inbox/hooks/useInboxReports"; +import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; +import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; +import type { SignalReport } from "@shared/types"; +import { useEffect, useMemo, useRef } from "react"; + +/** + * Keeps inbox list selection in sync when the selected report is not on the + * current paginated/filtered list (e.g. opened via an inbox deep link): + * by-id fetch for the detail pane, selection pruning as the list changes, and + * scroll-into-view when the row later appears in `reports`. + */ +export function useInboxDeepLinkListSync(options: { + reports: SignalReport[]; + inboxPollingActive: boolean; +}): { selectedReport: SignalReport | null } { + const { reports, inboxPollingActive } = options; + + const selectedReportIds = useInboxReportSelectionStore( + (s) => s.selectedReportIds, + ); + const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection); + + const singleSelectedId = + selectedReportIds.length === 1 ? selectedReportIds[0] : null; + const selectedReportFromList = useMemo(() => { + if (!singleSelectedId) return null; + return reports.find((r) => r.id === singleSelectedId) ?? null; + }, [reports, singleSelectedId]); + const needsByIdFallback = !!singleSelectedId && !selectedReportFromList; + const { data: byIdReport, isError: byIdError } = useInboxReportById( + needsByIdFallback ? singleSelectedId : null, + { + refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 12_000, + }, + ); + + // Prune selection when visible reports change (e.g. filter/search). + // Preserve off-list selections that are still loading or resolved via + // the by-id fallback; let prune clear them on confirmed null or on error. + useEffect(() => { + const visibleIds = reports.map((report) => report.id); + if ( + singleSelectedId && + !reports.some((r) => r.id === singleSelectedId) && + !byIdError && + byIdReport !== null + ) { + visibleIds.push(singleSelectedId); + } + pruneSelection(visibleIds); + }, [reports, pruneSelection, singleSelectedId, byIdReport, byIdError]); + + // Scroll once per selection change; refetches must not snap the list back. + const autoScrolledIdRef = useRef(null); + useEffect(() => { + if (!singleSelectedId) { + autoScrolledIdRef.current = null; + return; + } + if (autoScrolledIdRef.current === singleSelectedId) return; + if (!reports.some((r) => r.id === singleSelectedId)) return; + + document + .querySelector(`[data-report-id="${CSS.escape(singleSelectedId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + autoScrolledIdRef.current = singleSelectedId; + }, [singleSelectedId, reports]); + + const selectedReport = useMemo(() => { + if (selectedReportIds.length !== 1) return null; + return selectedReportFromList ?? byIdReport ?? null; + }, [selectedReportIds, selectedReportFromList, byIdReport]); + + return { selectedReport }; +} From 6985a0655956ae71bd54b890dc5bc715c90ecc56 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 21 Apr 2026 19:15:22 +0200 Subject: [PATCH 15/15] Move useInboxDeepLinkListSync --- .../src/renderer/components/MainLayout.tsx | 2 +- .../inbox}/hooks/useInboxDeepLink.ts | 65 ++++++++----------- 2 files changed, 28 insertions(+), 39 deletions(-) rename apps/code/src/renderer/{ => features/inbox}/hooks/useInboxDeepLink.ts (65%) diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index 14525e0b0..8b307f391 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/components/MainLayout.tsx @@ -7,6 +7,7 @@ import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksVie import { CommandMenu } from "@features/command/components/CommandMenu"; import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; import { InboxView } from "@features/inbox/components/InboxView"; +import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; @@ -21,7 +22,6 @@ import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; import { useCallback, useEffect } from "react"; -import { useInboxDeepLink } from "../hooks/useInboxDeepLink"; import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; import { GlobalEventHandlers } from "./GlobalEventHandlers"; diff --git a/apps/code/src/renderer/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts similarity index 65% rename from apps/code/src/renderer/hooks/useInboxDeepLink.ts rename to apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts index 267ef0df3..77e7b4eec 100644 --- a/apps/code/src/renderer/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts @@ -7,7 +7,6 @@ import { reportKeys } from "@features/inbox/hooks/useInboxReports"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { SignalReport } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; @@ -18,7 +17,8 @@ import { toast } from "sonner"; const log = logger.scope("inbox-deep-link"); /** - * Hook that subscribes to inbox report deep link events (posthog-code://inbox/{reportId}) + * Hook that subscribes to inbox report deep link events (`://inbox/{reportId}`, + * e.g. `posthog-code://…` in production and `posthog-code-dev://…` in local dev) * and opens the report in the inbox view. * * Behavior on link arrival: @@ -32,31 +32,30 @@ const log = logger.scope("inbox-deep-link"); */ export function useInboxDeepLink() { const trpcReact = useTRPC(); - const navigateToInbox = useNavigationStore((state) => state.navigateToInbox); - const setSelectedReportIds = useInboxReportSelectionStore( - (state) => state.setSelectedReportIds, - ); - const resetFilters = useInboxSignalsFilterStore( - (state) => state.resetFilters, - ); const queryClient = useQueryClient(); + const client = useOptionalAuthenticatedClient(); const isAuthenticated = useAuthStateValue( - (state) => state.status === "authenticated", + (s) => s.status === "authenticated", ); - const client = useOptionalAuthenticatedClient(); - const hasFetchedPending = useRef(false); + const pendingDrainedRef = useRef(false); - const handleOpenReport = useCallback( - async (reportId: string) => { - log.info(`Opening report from deep link: ${reportId}`); + const navigateToInbox = useNavigationStore((s) => s.navigateToInbox); + const setSelectedReportIds = useInboxReportSelectionStore( + (s) => s.setSelectedReportIds, + ); + const resetFilters = useInboxSignalsFilterStore((s) => s.resetFilters); + const openReport = useCallback( + async (reportId: string) => { if (!client) { log.warn("Ignoring inbox deep link — not authenticated"); return; } + log.info(`Opening report from deep link: ${reportId}`); + try { - const report = await queryClient.fetchQuery({ + const report = await queryClient.fetchQuery({ queryKey: reportKeys.detail(reportId), queryFn: () => client.getSignalReport(reportId), meta: AUTH_SCOPED_QUERY_META, @@ -68,9 +67,6 @@ export function useInboxDeepLink() { return; } - // Only mutate inbox UI state once we know the report resolves — so a - // bad or wrong-team link doesn't clobber the user's saved filters or - // yank them away from whatever view they were on. resetFilters(); navigateToInbox(); setSelectedReportIds([report.id]); @@ -80,38 +76,31 @@ export function useInboxDeepLink() { toast.error("Failed to open report"); } }, - [navigateToInbox, setSelectedReportIds, resetFilters, queryClient, client], + [client, navigateToInbox, queryClient, resetFilters, setSelectedReportIds], ); - // Cold start: drain pending deep link that arrived before renderer was ready. useEffect(() => { - if (!isAuthenticated || hasFetchedPending.current) return; + if (!isAuthenticated) { + pendingDrainedRef.current = false; + return; + } + if (!client || pendingDrainedRef.current) return; - const fetchPending = async () => { - hasFetchedPending.current = true; + pendingDrainedRef.current = true; + void (async () => { try { const pending = await trpcClient.deepLink.getPendingReportLink.query(); - if (pending) { - log.info( - `Found pending inbox deep link: reportId=${pending.reportId}`, - ); - handleOpenReport(pending.reportId); - } + if (pending) await openReport(pending.reportId); } catch (error) { log.error("Failed to check for pending inbox deep link:", error); } - }; - - fetchPending(); - }, [isAuthenticated, handleOpenReport]); + })(); + }, [isAuthenticated, client, openReport]); - // Warm start: receive deep link events while the renderer is running. useSubscription( trpcReact.deepLink.onOpenReport.subscriptionOptions(undefined, { onData: (data) => { - log.info(`Received inbox deep link event: reportId=${data.reportId}`); - if (!data?.reportId) return; - handleOpenReport(data.reportId); + if (data?.reportId) void openReport(data.reportId); }, }), );