diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 683ca3341..fe3c24aa8 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,4 +1,6 @@ import type { + AvailableSuggestedReviewer, + AvailableSuggestedReviewersResponse, SandboxEnvironment, SandboxEnvironmentInput, SignalReport, @@ -161,6 +163,50 @@ function parseSignalReportArtefactsPayload( }; } +function normalizeAvailableSuggestedReviewer( + uuid: string, + value: unknown, +): AvailableSuggestedReviewer | null { + if (!isObjectRecord(value)) { + return null; + } + + const normalizedUuid = optionalString(uuid); + if (!normalizedUuid) { + return null; + } + + return { + uuid: normalizedUuid, + name: optionalString(value.name) ?? "", + email: optionalString(value.email) ?? "", + }; +} + +function parseAvailableSuggestedReviewersPayload( + value: unknown, +): AvailableSuggestedReviewersResponse { + if (!isObjectRecord(value)) { + return { + results: [], + count: 0, + }; + } + + const results = Object.entries(value) + .map(([uuid, reviewer]) => + normalizeAvailableSuggestedReviewer(uuid, reviewer), + ) + .filter( + (reviewer): reviewer is AvailableSuggestedReviewer => reviewer !== null, + ); + + return { + results, + count: results.length, + }; +} + export class PostHogAPIClient { private api: ReturnType; private _teamId: number | null = null; @@ -958,6 +1004,9 @@ export class PostHogAPIClient { if (params?.source_product) { url.searchParams.set("source_product", params.source_product); } + if (params?.suggested_reviewers) { + url.searchParams.set("suggested_reviewers", params.suggested_reviewers); + } const response = await this.api.fetcher.fetch({ method: "get", @@ -976,6 +1025,34 @@ export class PostHogAPIClient { }; } + async getAvailableSuggestedReviewers( + query?: string, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/available_reviewers/`, + ); + const path = `/api/projects/${teamId}/signal_reports/available_reviewers/`; + + if (query?.trim()) { + url.searchParams.set("query", query.trim()); + } + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch available suggested reviewers: ${response.statusText}`, + ); + } + + return parseAvailableSuggestedReviewersPayload(await response.json()); + } + async getSignalReportSignals( reportId: string, ): Promise { diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index f53798fb0..82c4bcd51 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -6,7 +6,10 @@ import { } from "@features/inbox/components/InboxEmptyStates"; import { InboxLiveRail } from "@features/inbox/components/InboxLiveRail"; import { InboxSourcesDialog } from "@features/inbox/components/InboxSourcesDialog"; -import { useInboxReportsInfinite } from "@features/inbox/hooks/useInboxReports"; +import { + useInboxAvailableSuggestedReviewers, + useInboxReportsInfinite, +} from "@features/inbox/hooks/useInboxReports"; import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; @@ -15,6 +18,7 @@ import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesD import { buildSignalReportListOrdering, buildStatusFilterParam, + buildSuggestedReviewerFilterParam, filterReportsBySearch, } from "@features/inbox/utils/filterReports"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; @@ -38,6 +42,9 @@ export function InboxSignalsTab() { const sourceProductFilter = useInboxSignalsFilterStore( (s) => s.sourceProductFilter, ); + const suggestedReviewerFilter = useInboxSignalsFilterStore( + (s) => s.suggestedReviewerFilter, + ); // ── Signal source configs ─────────────────────────────────────────────── const { data: signalSourceConfigs } = useSignalSourceConfigs(); @@ -64,6 +71,10 @@ export function InboxSignalsTab() { const inboxPollingActive = windowFocused && isInboxView; // ── Data fetching ─────────────────────────────────────────────────────── + useInboxAvailableSuggestedReviewers({ + enabled: isInboxView, + }); + const inboxQueryParams = useMemo( (): SignalReportsQueryParams => ({ status: buildStatusFilterParam(statusFilter), @@ -72,8 +83,18 @@ export function InboxSignalsTab() { sourceProductFilter.length > 0 ? sourceProductFilter.join(",") : undefined, + suggested_reviewers: + suggestedReviewerFilter.length > 0 + ? buildSuggestedReviewerFilterParam(suggestedReviewerFilter) + : undefined, }), - [statusFilter, sortField, sortDirection, sourceProductFilter], + [ + statusFilter, + sortField, + sortDirection, + sourceProductFilter, + suggestedReviewerFilter, + ], ); const { @@ -194,7 +215,9 @@ export function InboxSignalsTab() { // ── Layout mode (computed early — needed by focus effect below) ──────── const hasReports = allReports.length > 0; const hasActiveFilters = - sourceProductFilter.length > 0 || statusFilter.length < 5; + sourceProductFilter.length > 0 || + suggestedReviewerFilter.length > 0 || + statusFilter.length < 5; const shouldShowTwoPane = hasReports || !!searchQuery.trim() || hasActiveFilters; @@ -324,11 +347,7 @@ export function InboxSignalsTab() { className="outline-none" onMouseDownCapture={(e) => { const target = e.target as HTMLElement; - if ( - target.closest( - "[data-report-id], button, input, select, textarea, [role='checkbox']", - ) - ) { + if (target.closest("[data-report-id]")) { focusListPane(); } }} @@ -336,9 +355,7 @@ export function InboxSignalsTab() { const target = e.target as HTMLElement; if ( target !== leftPaneRef.current && - target.closest( - "[data-report-id], button, input, select, textarea, [role='checkbox']", - ) + target.closest("[data-report-id]") ) { focusListPane(); } diff --git a/apps/code/src/renderer/features/inbox/components/list/FilterSortMenu.tsx b/apps/code/src/renderer/features/inbox/components/list/FilterSortMenu.tsx new file mode 100644 index 000000000..d4cbedeb3 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/list/FilterSortMenu.tsx @@ -0,0 +1,267 @@ +import { + type SourceProduct, + useInboxSignalsFilterStore, +} from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + inboxStatusAccentCss, + inboxStatusLabel, +} from "@features/inbox/utils/inboxSort"; +import { + BrainIcon, + BugIcon, + CalendarPlus, + Check, + Clock, + FunnelSimple as FunnelSimpleIcon, + GithubLogoIcon, + KanbanIcon, + ListNumbers, + TicketIcon, + TrendUp, + VideoIcon, +} from "@phosphor-icons/react"; +import { Box, Flex, Popover, Text } from "@radix-ui/themes"; +import type { + SignalReportOrderingField, + SignalReportStatus, +} from "@shared/types"; +import type React from "react"; +import type { KeyboardEvent } from "react"; + +type SortOption = { + label: string; + field: Extract< + SignalReportOrderingField, + "priority" | "created_at" | "total_weight" + >; + direction: "asc" | "desc"; + icon: React.ReactNode; +}; + +const SORT_OPTIONS: SortOption[] = [ + { + label: "Priority", + field: "priority", + direction: "asc", + icon: , + }, + { + label: "Strongest signal", + field: "total_weight", + direction: "desc", + icon: , + }, + { + label: "Newest first", + field: "created_at", + direction: "desc", + icon: , + }, + { + label: "Oldest first", + field: "created_at", + direction: "asc", + icon: , + }, +]; + +const FILTERABLE_STATUSES: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", +]; + +const SOURCE_PRODUCT_OPTIONS: { + value: SourceProduct; + label: string; + icon: React.ReactNode; +}[] = [ + { + value: "session_replay", + label: "Session replay", + icon: , + }, + { + value: "error_tracking", + label: "Error tracking", + icon: , + }, + { + value: "llm_analytics", + label: "LLM analytics", + icon: , + }, + { value: "github", label: "GitHub", icon: }, + { value: "linear", label: "Linear", icon: }, + { value: "zendesk", label: "Zendesk", icon: }, +]; + +const ITEM_CLASS_NAME = + "flex w-full items-center justify-between rounded-sm px-1 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3 focus-visible:bg-gray-3 focus-visible:outline-none"; + +export function FilterSortMenu() { + const sortField = useInboxSignalsFilterStore((s) => s.sortField); + const sortDirection = useInboxSignalsFilterStore((s) => s.sortDirection); + const setSort = useInboxSignalsFilterStore((s) => s.setSort); + const statusFilter = useInboxSignalsFilterStore((s) => s.statusFilter); + const toggleStatus = useInboxSignalsFilterStore((s) => s.toggleStatus); + const sourceProductFilter = useInboxSignalsFilterStore( + (s) => s.sourceProductFilter, + ); + const toggleSourceProduct = useInboxSignalsFilterStore( + (s) => s.toggleSourceProduct, + ); + + const handleContentKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + + e.preventDefault(); + e.stopPropagation(); + + const container = e.currentTarget; + const buttons = Array.from( + container.querySelectorAll("button"), + ); + + if (buttons.length === 0) { + return; + } + + const currentIndex = buttons.indexOf( + document.activeElement as HTMLButtonElement, + ); + const nextIndex = + e.key === "ArrowDown" + ? (currentIndex + 1) % buttons.length + : (currentIndex - 1 + buttons.length) % buttons.length; + + buttons[nextIndex]?.focus(); + }; + + return ( + + + + + + + + + + Sort by + + + {SORT_OPTIONS.map((option) => { + const isActive = + sortField === option.field && + sortDirection === option.direction; + + return ( + + ); + })} + + + + + + Status + + + {FILTERABLE_STATUSES.map((status) => { + const isActive = statusFilter.includes(status); + const accent = inboxStatusAccentCss(status); + + return ( + + ); + })} + + + + + + Source + + + {SOURCE_PRODUCT_OPTIONS.map((option) => { + const isActive = sourceProductFilter.includes(option.value); + + return ( + + ); + })} + + + + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx index b448c9a6e..825d75d1b 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -1,31 +1,12 @@ import { useInboxBulkActions } from "@features/inbox/hooks/useInboxBulkActions"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; -import { - type SourceProduct, - useInboxSignalsFilterStore, -} from "@features/inbox/stores/inboxSignalsFilterStore"; -import { - inboxStatusAccentCss, - inboxStatusLabel, -} from "@features/inbox/utils/inboxSort"; +import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { ArrowClockwiseIcon, - BrainIcon, - BugIcon, - CalendarPlus, - Check, - Clock, EyeSlashIcon, - FunnelSimple as FunnelSimpleIcon, - GithubLogoIcon, - KanbanIcon, - ListNumbers, MagnifyingGlass, PauseIcon, - TicketIcon, TrashIcon, - TrendUp, - VideoIcon, } from "@phosphor-icons/react"; import { AlertDialog, @@ -33,19 +14,16 @@ import { Button, Checkbox, Flex, - Popover, Spinner, Text, TextField, Tooltip, } from "@radix-ui/themes"; import { IS_DEV } from "@shared/constants/environment"; -import type { - SignalReport, - SignalReportOrderingField, - SignalReportStatus, -} from "@shared/types"; -import { type KeyboardEvent, useState } from "react"; +import type { SignalReport } from "@shared/types"; +import { useState } from "react"; +import { FilterSortMenu } from "./FilterSortMenu"; +import { SuggestedReviewerFilterMenu } from "./SuggestedReviewerFilterMenu"; interface SignalsToolbarProps { totalCount: number; @@ -59,51 +37,6 @@ interface SignalsToolbarProps { reports?: SignalReport[]; } -type SortOption = { - label: string; - field: Extract< - SignalReportOrderingField, - "priority" | "created_at" | "total_weight" - >; - direction: "asc" | "desc"; - icon: React.ReactNode; -}; - -const sortOptions: SortOption[] = [ - { - label: "Priority", - field: "priority", - direction: "asc", - icon: , - }, - { - label: "Strongest signal", - field: "total_weight", - direction: "desc", - icon: , - }, - { - label: "Newest first", - field: "created_at", - direction: "desc", - icon: , - }, - { - label: "Oldest first", - field: "created_at", - direction: "asc", - icon: , - }, -]; - -const FILTERABLE_STATUSES: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "candidate", - "potential", -]; - export function SignalsToolbar({ totalCount, filteredCount, @@ -223,11 +156,10 @@ export function SignalsToolbar({ ) : null} - {!hideFilters && } - - + + + {!hideFilters && ( + + + + + )} {selectedCount > 0 && ( @@ -413,185 +351,3 @@ export function SignalsToolbar({ ); } - -const SOURCE_PRODUCT_OPTIONS: { - value: SourceProduct; - label: string; - icon: React.ReactNode; -}[] = [ - { - value: "session_replay", - label: "Session replay", - icon: , - }, - { - value: "error_tracking", - label: "Error tracking", - icon: , - }, - { - value: "llm_analytics", - label: "LLM analytics", - icon: , - }, - { value: "github", label: "GitHub", icon: }, - { value: "linear", label: "Linear", icon: }, - { value: "zendesk", label: "Zendesk", icon: }, -]; - -function FilterSortMenu() { - const sortField = useInboxSignalsFilterStore((s) => s.sortField); - const sortDirection = useInboxSignalsFilterStore((s) => s.sortDirection); - const setSort = useInboxSignalsFilterStore((s) => s.setSort); - const statusFilter = useInboxSignalsFilterStore((s) => s.statusFilter); - const toggleStatus = useInboxSignalsFilterStore((s) => s.toggleStatus); - const sourceProductFilter = useInboxSignalsFilterStore( - (s) => s.sourceProductFilter, - ); - const toggleSourceProduct = useInboxSignalsFilterStore( - (s) => s.toggleSourceProduct, - ); - - const itemClassName = - "flex w-full items-center justify-between rounded-sm px-1 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3 focus-visible:bg-gray-3 focus-visible:outline-none"; - - const handleContentKeyDown = (e: KeyboardEvent) => { - if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; - e.preventDefault(); - e.stopPropagation(); - const container = e.currentTarget; - const buttons = Array.from( - container.querySelectorAll("button"), - ); - if (buttons.length === 0) return; - const currentIndex = buttons.indexOf( - document.activeElement as HTMLButtonElement, - ); - const next = - e.key === "ArrowDown" - ? (currentIndex + 1) % buttons.length - : (currentIndex - 1 + buttons.length) % buttons.length; - buttons[next]?.focus(); - }; - - return ( - - - - - - - - - Sort by - - - {sortOptions.map((option) => { - const isActive = - sortField === option.field && - sortDirection === option.direction; - return ( - - ); - })} - - - - - - Status - - - {FILTERABLE_STATUSES.map((status) => { - const isActive = statusFilter.includes(status); - const accent = inboxStatusAccentCss(status); - return ( - - ); - })} - - - - - - Source - - - {SOURCE_PRODUCT_OPTIONS.map((option) => { - const isActive = sourceProductFilter.includes(option.value); - return ( - - ); - })} - - - - - - ); -} diff --git a/apps/code/src/renderer/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx b/apps/code/src/renderer/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx new file mode 100644 index 000000000..fc2b4e3f6 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx @@ -0,0 +1,187 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useCurrentUser } from "@features/auth/hooks/authQueries"; +import { useInboxAvailableSuggestedReviewers } from "@features/inbox/hooks/useInboxReports"; +import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + buildSuggestedReviewerFilterOptions, + getSuggestedReviewerDisplayName, +} from "@features/inbox/utils/suggestedReviewerFilters"; +import { Check, MagnifyingGlass, UsersThree } from "@phosphor-icons/react"; +import { Box, Flex, Popover, Separator, Spinner, Text } from "@radix-ui/themes"; +import { useDeferredValue, useMemo, useState } from "react"; + +export function SuggestedReviewerFilterMenu() { + const client = useOptionalAuthenticatedClient(); + const [open, setOpen] = useState(false); + const [reviewerQuery, setReviewerQuery] = useState(""); + const deferredReviewerQuery = useDeferredValue(reviewerQuery); + const { data: currentUser } = useCurrentUser({ + client, + enabled: !!client, + }); + const { data: availableReviewers, isFetching } = + useInboxAvailableSuggestedReviewers({ + enabled: !!client, + query: deferredReviewerQuery, + }); + const suggestedReviewerFilter = useInboxSignalsFilterStore( + (s) => s.suggestedReviewerFilter, + ); + const toggleSuggestedReviewer = useInboxSignalsFilterStore( + (s) => s.toggleSuggestedReviewer, + ); + const setSuggestedReviewerFilter = useInboxSignalsFilterStore( + (s) => s.setSuggestedReviewerFilter, + ); + + const visibleReviewerOptions = useMemo(() => { + const reviewers = availableReviewers?.results ?? []; + return buildSuggestedReviewerFilterOptions(reviewers, currentUser); + }, [availableReviewers?.results, currentUser]); + + const selectedCount = suggestedReviewerFilter.length; + const hasSelectedReviewers = selectedCount > 0; + + return ( + { + setOpen(nextOpen); + if (!nextOpen) { + setReviewerQuery(""); + } + }} + > + + + + + + + + Suggested reviewer + + {hasSelectedReviewers ? ( + + ) : null} + + + + + setReviewerQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-gray-12 outline-none placeholder:text-gray-9" + /> + + + + {isFetching && visibleReviewerOptions.length === 0 ? ( + + + + ) : visibleReviewerOptions.length === 0 ? ( + + No users found. + + ) : ( + + {visibleReviewerOptions.map((reviewer, index) => { + const isSelected = suggestedReviewerFilter.includes( + reviewer.uuid, + ); + const displayName = getSuggestedReviewerDisplayName(reviewer); + + return ( + + + + {reviewer.showSeparatorBelow && + index < visibleReviewerOptions.length - 1 ? ( + + ) : null} + + ); + })} + + )} + + + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts index af31a6bd0..f218afb12 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts @@ -1,12 +1,18 @@ +import { + getAuthIdentity, + useAuthStateValue, +} from "@features/auth/hooks/authQueries"; +import { useInboxAvailableSuggestedReviewersStore } from "@features/inbox/stores/inboxAvailableSuggestedReviewersStore"; import { useAuthenticatedInfiniteQuery } from "@hooks/useAuthenticatedInfiniteQuery"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { + AvailableSuggestedReviewersResponse, SignalReportArtefactsResponse, SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, } from "@shared/types"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; const REPORTS_PAGE_SIZE = 100; @@ -20,6 +26,12 @@ const reportKeys = { [...reportKeys.all, reportId, "artefacts"] as const, signals: (reportId: string) => [...reportKeys.all, reportId, "signals"] as const, + availableSuggestedReviewers: (authIdentity: string | null) => + [ + ...reportKeys.all, + authIdentity ?? "anonymous", + "available-reviewers", + ] as const, }; export function useInboxReports( @@ -82,6 +94,61 @@ export function useInboxReportsInfinite( return { ...query, allReports, totalCount }; } +export function useInboxAvailableSuggestedReviewers(options?: { + enabled?: boolean; + staleTime?: number; + query?: string; +}) { + const authState = useAuthStateValue((state) => state); + const authIdentity = getAuthIdentity(authState); + const reviewerQuery = options?.query?.trim() ?? ""; + const shouldUseCachedBaseList = reviewerQuery.length === 0; + const cachedEntry = useInboxAvailableSuggestedReviewersStore((state) => + shouldUseCachedBaseList + ? state.getReviewersForAuthIdentity(authIdentity) + : null, + ); + const setReviewersForAuthIdentity = useInboxAvailableSuggestedReviewersStore( + (state) => state.setReviewersForAuthIdentity, + ); + + const query = useAuthenticatedQuery( + reportKeys.availableSuggestedReviewers( + authIdentity ? `${authIdentity}:${reviewerQuery}` : null, + ), + (client) => client.getAvailableSuggestedReviewers(reviewerQuery), + { + enabled: !!authIdentity && (options?.enabled ?? true), + staleTime: options?.staleTime ?? 5 * 60 * 1000, + refetchOnMount: "always", + refetchInterval: 60_000, + refetchIntervalInBackground: true, + placeholderData: + shouldUseCachedBaseList && cachedEntry + ? { + results: cachedEntry.reviewers, + count: cachedEntry.reviewers.length, + } + : undefined, + }, + ); + + useEffect(() => { + if (!authIdentity || !query.data || !shouldUseCachedBaseList) { + return; + } + + setReviewersForAuthIdentity(authIdentity, query.data.results); + }, [ + authIdentity, + query.data, + setReviewersForAuthIdentity, + shouldUseCachedBaseList, + ]); + + return query; +} + export function useInboxReportArtefacts( reportId: string, options?: { enabled?: boolean }, diff --git a/apps/code/src/renderer/features/inbox/stores/inboxAvailableSuggestedReviewersStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxAvailableSuggestedReviewersStore.ts new file mode 100644 index 000000000..89129fc0a --- /dev/null +++ b/apps/code/src/renderer/features/inbox/stores/inboxAvailableSuggestedReviewersStore.ts @@ -0,0 +1,67 @@ +import type { AvailableSuggestedReviewer } from "@shared/types"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface AvailableSuggestedReviewersCacheEntry { + reviewers: AvailableSuggestedReviewer[]; + fetchedAt: number; +} + +interface InboxAvailableSuggestedReviewersStoreState { + byAuthIdentity: Record; +} + +interface InboxAvailableSuggestedReviewersStoreActions { + setReviewersForAuthIdentity: ( + authIdentity: string, + reviewers: AvailableSuggestedReviewer[], + ) => void; + clearReviewersForAuthIdentity: (authIdentity: string) => void; + getReviewersForAuthIdentity: ( + authIdentity: string | null | undefined, + ) => AvailableSuggestedReviewersCacheEntry | null; +} + +type InboxAvailableSuggestedReviewersStore = + InboxAvailableSuggestedReviewersStoreState & + InboxAvailableSuggestedReviewersStoreActions; + +export const useInboxAvailableSuggestedReviewersStore = + create()( + persist( + (set, get) => ({ + byAuthIdentity: {}, + + setReviewersForAuthIdentity: (authIdentity, reviewers) => + set((state) => ({ + byAuthIdentity: { + ...state.byAuthIdentity, + [authIdentity]: { + reviewers, + fetchedAt: Date.now(), + }, + }, + })), + + clearReviewersForAuthIdentity: (authIdentity) => + set((state) => { + const next = { ...state.byAuthIdentity }; + delete next[authIdentity]; + return { byAuthIdentity: next }; + }), + + getReviewersForAuthIdentity: (authIdentity) => { + if (!authIdentity) { + return null; + } + return get().byAuthIdentity[authIdentity] ?? null; + }, + }), + { + name: "inbox-available-suggested-reviewers-storage", + partialize: (state) => ({ + byAuthIdentity: state.byAuthIdentity, + }), + }, + ), + ); 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 4ae6b5846..38683f452 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts @@ -8,6 +8,15 @@ describe("inboxSignalsFilterStore", () => { sortField: "total_weight", sortDirection: "desc", searchQuery: "", + statusFilter: [ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", + ], + sourceProductFilter: [], + suggestedReviewerFilter: [], }); }); @@ -16,6 +25,15 @@ describe("inboxSignalsFilterStore", () => { expect(state.sortField).toBe("total_weight"); expect(state.sortDirection).toBe("desc"); expect(state.searchQuery).toBe(""); + expect(state.statusFilter).toEqual([ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", + ]); + expect(state.sourceProductFilter).toEqual([]); + expect(state.suggestedReviewerFilter).toEqual([]); }); it("setSort updates field and direction", () => { @@ -48,4 +66,41 @@ describe("inboxSignalsFilterStore", () => { const persisted = JSON.parse(raw as string); expect(persisted.state.searchQuery).toBeUndefined(); }); + + it("toggleSuggestedReviewer adds and removes reviewer ids", () => { + useInboxSignalsFilterStore.getState().toggleSuggestedReviewer("reviewer-1"); + expect( + useInboxSignalsFilterStore.getState().suggestedReviewerFilter, + ).toEqual(["reviewer-1"]); + + useInboxSignalsFilterStore.getState().toggleSuggestedReviewer("reviewer-1"); + expect( + useInboxSignalsFilterStore.getState().suggestedReviewerFilter, + ).toEqual([]); + }); + + it("setSuggestedReviewerFilter de-duplicates reviewer ids", () => { + useInboxSignalsFilterStore + .getState() + .setSuggestedReviewerFilter(["reviewer-1", "reviewer-2", "reviewer-1"]); + + expect( + useInboxSignalsFilterStore.getState().suggestedReviewerFilter, + ).toEqual(["reviewer-1", "reviewer-2"]); + }); + + it("persists suggestedReviewerFilter", () => { + useInboxSignalsFilterStore + .getState() + .setSuggestedReviewerFilter(["reviewer-1", "reviewer-2"]); + + const raw = localStorage.getItem("inbox-signals-filter-storage"); + expect(raw).toBeTruthy(); + const persisted = JSON.parse(raw as string); + + expect(persisted.state.suggestedReviewerFilter).toEqual([ + "reviewer-1", + "reviewer-2", + ]); + }); }); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index 8298420f8..865b4b765 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -35,6 +35,8 @@ interface InboxSignalsFilterState { statusFilter: SignalReportStatus[]; /** Empty array means "all sources" (no filter). */ sourceProductFilter: SourceProduct[]; + /** Empty array means "all suggested reviewers" (no filter). Stored as PostHog user UUID strings. */ + suggestedReviewerFilter: string[]; } interface InboxSignalsFilterActions { @@ -43,6 +45,8 @@ interface InboxSignalsFilterActions { setStatusFilter: (statuses: SignalReportStatus[]) => void; toggleStatus: (status: SignalReportStatus) => void; toggleSourceProduct: (source: SourceProduct) => void; + toggleSuggestedReviewer: (reviewerUuid: string) => void; + setSuggestedReviewerFilter: (reviewerUuids: string[]) => void; } type InboxSignalsFilterStore = InboxSignalsFilterState & @@ -56,6 +60,7 @@ export const useInboxSignalsFilterStore = create()( searchQuery: "", statusFilter: DEFAULT_STATUS_FILTER, sourceProductFilter: [], + suggestedReviewerFilter: [], setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), setSearchQuery: (searchQuery) => set({ searchQuery }), setStatusFilter: (statusFilter) => set({ statusFilter }), @@ -75,6 +80,18 @@ export const useInboxSignalsFilterStore = create()( : [...current, source]; return { sourceProductFilter: next }; }), + toggleSuggestedReviewer: (reviewerUuid) => + set((state) => { + const current = state.suggestedReviewerFilter; + const next = current.includes(reviewerUuid) + ? current.filter((uuid) => uuid !== reviewerUuid) + : [...current, reviewerUuid]; + return { suggestedReviewerFilter: next }; + }), + setSuggestedReviewerFilter: (reviewerUuids) => + set({ + suggestedReviewerFilter: Array.from(new Set(reviewerUuids)), + }), }), { name: "inbox-signals-filter-storage", @@ -83,6 +100,7 @@ export const useInboxSignalsFilterStore = create()( sortDirection: state.sortDirection, statusFilter: state.statusFilter, sourceProductFilter: state.sourceProductFilter, + suggestedReviewerFilter: state.suggestedReviewerFilter, }), }, ), diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts index ec388667f..0bb61db6f 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts +++ b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts @@ -2,6 +2,7 @@ import type { SignalReport } from "@shared/types"; import { describe, expect, it } from "vitest"; import { buildSignalReportListOrdering, + buildSuggestedReviewerFilterParam, filterReportsBySearch, } from "./filterReports"; @@ -115,3 +116,41 @@ describe("buildSignalReportListOrdering", () => { ); }); }); + +describe("buildSuggestedReviewerFilterParam", () => { + it("returns undefined for an empty array", () => { + expect(buildSuggestedReviewerFilterParam([])).toBeUndefined(); + }); + + it("trims reviewer ids and joins them with commas", () => { + expect( + buildSuggestedReviewerFilterParam([ + " reviewer-1 ", + "reviewer-2", + " reviewer-3", + ]), + ).toBe("reviewer-1,reviewer-2,reviewer-3"); + }); + + it("deduplicates reviewer ids after trimming", () => { + expect( + buildSuggestedReviewerFilterParam([ + " reviewer-1 ", + "reviewer-2", + "reviewer-1", + " reviewer-2 ", + ]), + ).toBe("reviewer-1,reviewer-2"); + }); + + it("drops blank reviewer ids", () => { + expect( + buildSuggestedReviewerFilterParam([ + "reviewer-1", + " ", + "reviewer-2", + "", + ]), + ).toBe("reviewer-1,reviewer-2"); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.ts index 43331f2a8..300d79f31 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.ts +++ b/apps/code/src/renderer/features/inbox/utils/filterReports.ts @@ -4,6 +4,10 @@ import type { SignalReportStatus, } from "@shared/types"; +function normalizeReviewerId(value: string): string { + return value.trim(); +} + export function filterReportsBySearch( reports: SignalReport[], query: string, @@ -39,3 +43,15 @@ export function buildSignalReportListOrdering( const fieldKey = direction === "desc" ? `-${field}` : field; return `status,-is_suggested_reviewer,${fieldKey}`; } + +export function buildSuggestedReviewerFilterParam( + reviewerIds: string[], +): string | undefined { + const normalizedIds = reviewerIds.map(normalizeReviewerId).filter(Boolean); + + if (normalizedIds.length === 0) { + return undefined; + } + + return Array.from(new Set(normalizedIds)).join(","); +} diff --git a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts b/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts new file mode 100644 index 000000000..a820c8953 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts @@ -0,0 +1,181 @@ +import type { AvailableSuggestedReviewer } from "@shared/types"; +import { describe, expect, it } from "vitest"; +import { + buildSuggestedReviewerFilterOptions, + getSuggestedReviewerDisplayName, +} from "./suggestedReviewerFilters"; + +function makeReviewer( + overrides: Partial = {}, +): AvailableSuggestedReviewer { + return { + uuid: "reviewer-1", + name: "Alice Jones", + email: "alice@example.com", + ...overrides, + }; +} + +describe("getSuggestedReviewerDisplayName", () => { + it("returns name when present", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ name: "Alice Jones" }), + isMe: false, + }), + ).toBe("Alice Jones"); + }); + + it("falls back to email when name is missing", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ + name: "", + email: "fallback@example.com", + }), + isMe: false, + }), + ).toBe("fallback@example.com"); + }); + + it("falls back to Unknown user when name and email are missing", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ + name: "", + email: "", + }), + isMe: false, + }), + ).toBe("Unknown user"); + }); + + it("appends Me for the pinned current user", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ name: "Boss Person" }), + isMe: true, + }), + ).toBe("Boss Person (Me)"); + }); +}); + +describe("buildSuggestedReviewerFilterOptions", () => { + it("pins the current user to the top and marks them as me", () => { + const me = { + uuid: "me-id", + first_name: "Boss", + last_name: "Person", + email: "boss@example.com", + }; + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ + uuid: "other-id", + name: "Alice Jones", + }), + ], + me, + ); + + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ + uuid: "me-id", + name: "Boss Person", + isMe: true, + showSeparatorBelow: true, + }); + expect(getSuggestedReviewerDisplayName(options[0])).toBe( + "Boss Person (Me)", + ); + expect(options[1]).toMatchObject({ + uuid: "other-id", + name: "Alice Jones", + isMe: false, + showSeparatorBelow: false, + }); + }); + + it("deduplicates the current user if already present in backend results", () => { + const me = { + uuid: "me-id", + first_name: "Boss", + last_name: "Person", + email: "boss@example.com", + }; + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ + uuid: "me-id", + name: "Old Name", + email: "old@example.com", + }), + makeReviewer({ + uuid: "other-id", + name: "Alice Jones", + }), + ], + me, + ); + + expect(options.map((option) => option.uuid)).toEqual(["me-id", "other-id"]); + expect(options[0]).toMatchObject({ + uuid: "me-id", + name: "Boss Person", + email: "boss@example.com", + isMe: true, + }); + }); + + it("sorts backend reviewers alphabetically by name", () => { + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ uuid: "c", name: "Charlie Zebra" }), + makeReviewer({ uuid: "a", name: "Alice Jones" }), + makeReviewer({ uuid: "b", name: "Bob Smith" }), + ], + null, + ); + + expect(options.map((option) => option.uuid)).toEqual(["a", "b", "c"]); + expect(options.map((option) => option.name)).toEqual([ + "Alice Jones", + "Bob Smith", + "Charlie Zebra", + ]); + }); + + it("uses email and uuid as stable alphabetical tie-breakers", () => { + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ uuid: "b", name: "", email: "b@example.com" }), + makeReviewer({ uuid: "a", name: "", email: "a@example.com" }), + makeReviewer({ uuid: "c", name: "", email: "a@example.com" }), + ], + null, + ); + + expect(options.map((option) => option.uuid)).toEqual(["a", "c", "b"]); + }); + + it("returns backend reviewers unchanged when there is no current user", () => { + const reviewers = [ + makeReviewer({ uuid: "b", name: "Bob Smith" }), + makeReviewer({ uuid: "a", name: "Alice Jones" }), + ]; + + const options = buildSuggestedReviewerFilterOptions(reviewers, null); + + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ + uuid: "a", + name: "Alice Jones", + isMe: false, + }); + expect(options[1]).toMatchObject({ + uuid: "b", + name: "Bob Smith", + isMe: false, + }); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts b/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts new file mode 100644 index 000000000..d654626e7 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts @@ -0,0 +1,98 @@ +import type { AvailableSuggestedReviewer } from "@shared/types"; + +export interface CurrentSuggestedReviewerUser { + uuid: string; + email?: string | null; + first_name?: string | null; + last_name?: string | null; +} + +export interface SuggestedReviewerFilterOption { + uuid: string; + name: string; + email: string; + isMe: boolean; + showSeparatorBelow: boolean; +} + +function normalizeString(value: string | null | undefined): string { + return typeof value === "string" ? value.trim() : ""; +} + +function buildCurrentUserName( + currentUser?: CurrentSuggestedReviewerUser | null, +): string { + const firstName = normalizeString(currentUser?.first_name); + const lastName = normalizeString(currentUser?.last_name); + return [firstName, lastName].filter(Boolean).join(" "); +} + +function sortReviewerOptionsByName( + reviewers: SuggestedReviewerFilterOption[], +): SuggestedReviewerFilterOption[] { + return [...reviewers].sort((a, b) => { + const aName = normalizeString(a.name).toLowerCase(); + const bName = normalizeString(b.name).toLowerCase(); + const aEmail = normalizeString(a.email).toLowerCase(); + const bEmail = normalizeString(b.email).toLowerCase(); + + return ( + aName.localeCompare(bName) || + aEmail.localeCompare(bEmail) || + a.uuid.localeCompare(b.uuid) + ); + }); +} + +export function getSuggestedReviewerDisplayName( + reviewer: Pick, +): string { + const baseLabel = + normalizeString(reviewer.name) || + normalizeString(reviewer.email) || + "Unknown user"; + + return reviewer.isMe ? `${baseLabel} (Me)` : baseLabel; +} + +export function buildSuggestedReviewerFilterOptions( + reviewers: AvailableSuggestedReviewer[], + currentUser?: CurrentSuggestedReviewerUser | null, +): SuggestedReviewerFilterOption[] { + const byUuid = new Map(); + + for (const reviewer of reviewers) { + const uuid = normalizeString(reviewer.uuid); + if (!uuid || byUuid.has(uuid)) { + continue; + } + + byUuid.set(uuid, { + uuid, + name: normalizeString(reviewer.name), + email: normalizeString(reviewer.email), + isMe: false, + showSeparatorBelow: false, + }); + } + + const currentUserUuid = normalizeString(currentUser?.uuid); + if (currentUserUuid) { + const existing = byUuid.get(currentUserUuid); + byUuid.set(currentUserUuid, { + uuid: currentUserUuid, + name: buildCurrentUserName(currentUser) || existing?.name || "", + email: normalizeString(currentUser?.email) || existing?.email || "", + isMe: true, + showSeparatorBelow: true, + }); + } + + const options = Array.from(byUuid.values()); + const meOption = options.find((option) => option.isMe) ?? null; + const otherOptions = sortReviewerOptionsByName( + options.filter((option) => !option.isMe), + ); + + return meOption ? [meOption, ...otherOptions] : otherOptions; +} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 6ae90abb6..5e7a83c44 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -236,6 +236,13 @@ export interface SuggestedReviewerUser { uuid: string; email: string; first_name: string; + last_name: string; +} + +export interface AvailableSuggestedReviewer { + uuid: string; + name: string; + email: string; } export interface SuggestedReviewer { @@ -275,6 +282,11 @@ export interface SignalReportsResponse { count: number; } +export interface AvailableSuggestedReviewersResponse { + results: AvailableSuggestedReviewer[]; + count: number; +} + export interface SignalReportSignalsResponse { report: SignalReport | null; signals: Signal[]; @@ -309,4 +321,6 @@ export interface SignalReportsQueryParams { ordering?: string; /** Comma-separated source products — only returns reports with signals from these sources. */ source_product?: string; + /** Comma-separated PostHog user UUIDs — only returns reports with these suggested reviewers. */ + suggested_reviewers?: string; }