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.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); + }); +}); 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(); + }, + ), }); 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 d53ed8779..6e90a3672 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1201,6 +1201,30 @@ 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}`); + + 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; + } + } + async getSignalReports( params?: SignalReportsQueryParams, ): Promise { diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx index f49e76d66..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"; @@ -41,6 +42,7 @@ export function MainLayout() { useIntegrations(); useTaskDeepLink(); + useInboxDeepLink(); useEffect(() => { if (tasks) { diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index b948c39fa..5624a31cc 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -5,6 +5,7 @@ import { WelcomePane, } from "@features/inbox/components/InboxEmptyStates"; import { InboxSourcesDialog } from "@features/inbox/components/InboxSourcesDialog"; +import { useInboxDeepLinkListSync } from "@features/inbox/hooks/useInboxDeepLinkListSync"; import { useInboxAvailableSuggestedReviewers, useInboxReportsInfinite, @@ -154,26 +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; - // Prune selection when visible reports change (e.g. filter/search) - useEffect(() => { - pruneSelection(reports.map((report) => report.id)); - }, [reports, pruneSelection]); - - // 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]); - // 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/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 ──────────────────────────────── */} diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts new file mode 100644 index 000000000..77e7b4eec --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts @@ -0,0 +1,107 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +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"; +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"); + +/** + * 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: + * 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 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(); + const queryClient = useQueryClient(); + const client = useOptionalAuthenticatedClient(); + const isAuthenticated = useAuthStateValue( + (s) => s.status === "authenticated", + ); + const pendingDrainedRef = useRef(false); + + 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({ + queryKey: reportKeys.detail(reportId), + queryFn: () => client.getSignalReport(reportId), + meta: AUTH_SCOPED_QUERY_META, + }); + + if (!report) { + log.warn(`Report not found or not accessible: ${reportId}`); + toast.error("Report not found in the current team"); + return; + } + + 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"); + } + }, + [client, navigateToInbox, queryClient, resetFilters, setSelectedReportIds], + ); + + useEffect(() => { + if (!isAuthenticated) { + pendingDrainedRef.current = false; + return; + } + if (!client || pendingDrainedRef.current) return; + + pendingDrainedRef.current = true; + void (async () => { + try { + const pending = await trpcClient.deepLink.getPendingReportLink.query(); + if (pending) await openReport(pending.reportId); + } catch (error) { + log.error("Failed to check for pending inbox deep link:", error); + } + })(); + }, [isAuthenticated, client, openReport]); + + useSubscription( + trpcReact.deepLink.onOpenReport.subscriptionOptions(undefined, { + onData: (data) => { + if (data?.reportId) void openReport(data.reportId); + }, + }), + ); +} 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 }; +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts index 312e386d9..8ba010385 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, @@ -17,12 +18,14 @@ 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, 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,27 @@ export function useInboxSignalProcessingState(options?: { ); } +export function useInboxReportById( + reportId: string | null, + 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), + refetchInterval: options?.refetchInterval, + refetchIntervalInBackground: options?.refetchIntervalInBackground, + staleTime: options?.staleTime, + }, + ); +} + export function useInboxReportArtefacts( reportId: string, options?: { enabled?: boolean }, 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",