diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 257256c5f63..f82bac1c336 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -134,24 +134,138 @@ export function UserProfilePopover({ const hoverTimerRef = React.useRef | null>( null, ); - const profileQuery = useUserProfileQuery(open ? pubkey : undefined); - const usersBatchQuery = useUsersBatchQuery(open ? [pubkey] : [], { - enabled: open, - }); - const relayAgentsQuery = useRelayAgentsQuery({ - enabled: open, - }); - const managedAgentsQuery = useManagedAgentsQuery({ - enabled: open, - }); - const presenceQuery = usePresenceQuery(open ? [pubkey] : [], { - enabled: open, - }); - const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []); - - const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const { openProfilePanel } = useProfilePanel(); const canOpenProfilePanel = enableProfilePanel && Boolean(openProfilePanel); + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const handleTriggerMouseEnter = React.useCallback(() => { + if (!enableHoverPopover) { + return; + } + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer, enableHoverPopover]); + + const handleMouseLeave = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const handleContentMouseEnter = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + const handleTriggerClick = React.useCallback( + (event: React.MouseEvent) => { + clearHoverTimer(); + if (canOpenProfilePanel && openProfilePanel) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + openProfilePanel(pubkey); + } + }, + [canOpenProfilePanel, clearHoverTimer, openProfilePanel, pubkey], + ); + + React.useEffect(() => { + return clearHoverTimer; + }, [clearHoverTimer]); + + const TriggerElement = triggerElement; + return ( + + + { + if ( + (e.key === "Enter" || e.key === " ") && + canOpenProfilePanel && + openProfilePanel + ) { + e.preventDefault(); + e.stopPropagation(); + clearHoverTimer(); + setOpen(false); + openProfilePanel(pubkey); + } + }} + onMouseEnter={handleTriggerMouseEnter} + onMouseLeave={handleMouseLeave} + className={cn( + "inline-flex", + canOpenProfilePanel && "cursor-pointer [&_*]:cursor-pointer", + )} + > + {children} + + + {open ? ( + + ) : null} + + ); +} + +/** + * Everything behind the popover surface: seven query subscriptions, agent + * classification, and the interaction actions. Mounted only while the + * popover is open β€” the trigger shell above stays cheap enough for grids + * that render hundreds of instances (~40ms per card when this was eager). + */ +function UserProfilePopoverBody({ + botIdenticonValue, + canOpenProfilePanel, + onBeforeAction, + onContentMouseEnter, + onMouseLeave, + onTriggerClick, + pubkey, + role, + setOpen, +}: { + botIdenticonValue?: string; + canOpenProfilePanel: boolean; + onBeforeAction: () => void; + onContentMouseEnter: () => void; + onMouseLeave: () => void; + onTriggerClick: (event: React.MouseEvent) => void; + pubkey: string; + role?: string; + setOpen: (open: boolean) => void; +}) { + const profileQuery = useUserProfileQuery(pubkey); + const usersBatchQuery = useUsersBatchQuery([pubkey]); + const relayAgentsQuery = useRelayAgentsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const presenceQuery = usePresenceQuery([pubkey]); + const userStatusQuery = useUserStatusQuery([pubkey]); + + const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, @@ -160,7 +274,7 @@ export function UserProfilePopover({ const ownerPubkey = profile?.ownerPubkey ?? null; const ownerProfileQuery = useUsersBatchQuery( ownerPubkey ? [ownerPubkey] : [], - { enabled: open && Boolean(ownerPubkey) }, + { enabled: Boolean(ownerPubkey) }, ); const normalizedPubkey = normalizePubkey(pubkey); const isAgentByOaOwner = Boolean( @@ -173,7 +287,6 @@ export function UserProfilePopover({ isAgentByProfileOwner || isAgentByOaOwner; const isAgentClassificationPending = - open && role !== "bot" && (profileQuery.isPending || relayAgentsQuery.isPending || @@ -234,48 +347,10 @@ export function UserProfilePopover({ return map; }, [channelsQuery.data]); - const clearHoverTimer = React.useCallback(() => { - if (hoverTimerRef.current !== null) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } - }, []); - - const handleTriggerMouseEnter = React.useCallback(() => { - if (!enableHoverPopover) { - return; - } - clearHoverTimer(); - hoverTimerRef.current = setTimeout(() => { - setOpen(true); - }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); - }, [clearHoverTimer, enableHoverPopover]); - - const handleMouseLeave = React.useCallback(() => { - clearHoverTimer(); - hoverTimerRef.current = setTimeout(() => { - setOpen(false); - }, HOVER_CLOSE_DELAY_MS); - }, [clearHoverTimer]); - - const handleContentMouseEnter = React.useCallback(() => { - clearHoverTimer(); - }, [clearHoverTimer]); - - const handleTriggerClick = React.useCallback( - (event: React.MouseEvent) => { - clearHoverTimer(); - if (canOpenProfilePanel && openProfilePanel) { - event.preventDefault(); - event.stopPropagation(); - setOpen(false); - openProfilePanel(pubkey); - } - }, - [canOpenProfilePanel, clearHoverTimer, openProfilePanel, pubkey], + const closeProfileActions = React.useCallback( + () => setOpen(false), + [setOpen], ); - - const closeProfileActions = React.useCallback(() => setOpen(false), []); const { handleHuddle, handleMessage, @@ -290,19 +365,14 @@ export function UserProfilePopover({ wave: showHumanProfileActions, }, effectivePubkey: pubkey, - enabled: open, + enabled: true, isBot: isBotProfile, isSelf, - onBeforeAction: clearHoverTimer, + onBeforeAction: onBeforeAction, onClose: closeProfileActions, viewerIsOwner, }); - React.useEffect(() => { - return clearHoverTimer; - }, [clearHoverTimer]); - - const TriggerElement = triggerElement; const profileHeaderContent = ( <> - - { - if ( - (e.key === "Enter" || e.key === " ") && - canOpenProfilePanel && - openProfilePanel - ) { - e.preventDefault(); - e.stopPropagation(); - clearHoverTimer(); - setOpen(false); - openProfilePanel(pubkey); - } - }} - onMouseEnter={handleTriggerMouseEnter} - onMouseLeave={handleMouseLeave} - className={cn( - "inline-flex", - canOpenProfilePanel && "cursor-pointer [&_*]:cursor-pointer", - )} - > - {children} - - - event.preventDefault()} - side="top" - sideOffset={8} - > -
- {canOpenProfilePanel ? ( - - ) : ( -
- {profileHeaderContent} -
- )} + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ {canOpenProfilePanel ? ( + + ) : ( +
+ {profileHeaderContent} +
+ )} - {isBotProfile && (managedAgent || relayAgent) ? ( -
- {managedAgent?.agentCommand ? ( - {runtimeLabel(managedAgent.agentCommand)} - ) : relayAgent?.agentType ? ( - {runtimeLabel(relayAgent.agentType)} - ) : null} - {managedAgent?.model ? ( - - {resolveModelLabel( - managedAgent.model, - null, - managedAgent.provider, - )} - - ) : null} - {managedAgent?.acpCommand ? ( - ACP: {managedAgent.acpCommand} - ) : null} -
- ) : null} + {isBotProfile && (managedAgent || relayAgent) ? ( +
+ {managedAgent?.agentCommand ? ( + {runtimeLabel(managedAgent.agentCommand)} + ) : relayAgent?.agentType ? ( + {runtimeLabel(relayAgent.agentType)} + ) : null} + {managedAgent?.model ? ( + + {resolveModelLabel( + managedAgent.model, + null, + managedAgent.provider, + )} + + ) : null} + {managedAgent?.acpCommand ? ( + ACP: {managedAgent.acpCommand} + ) : null} +
+ ) : null} - {activeTurns.length > 0 ? ( -
- {activeTurns.map(({ channelId, anchorAt }) => ( - - ))} -
- ) : null} + {activeTurns.length > 0 ? ( +
+ {activeTurns.map(({ channelId, anchorAt }) => ( + + ))} +
+ ) : null} - {canViewActivity ? ( - - ) : null} + {canViewActivity ? ( + + ) : null} - {hasUserStatus || showAnyProfileActions ? ( - <> - - - + {hasUserStatus || showAnyProfileActions ? ( + <> + + ); } diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 68e067e0d1b..e9672d643bd 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -454,7 +454,9 @@ type ProjectItemProps = { onOpenTerminal: (project: Project) => Promise | void; }; -export function ProjectGridCard({ +// Memoized: these cards render in unbounded grids/lists; identity-stable +// props from the caller keep re-renders scoped to genuinely changed cards. +export const ProjectGridCard = React.memo(function ProjectGridCard({ project, people, profiles, @@ -469,7 +471,7 @@ export function ProjectGridCard({ }: ProjectItemProps) { return ( @@ -541,9 +543,9 @@ export function ProjectGridCard({
); -} +}); -export function ProjectListRow({ +export const ProjectListRow = React.memo(function ProjectListRow({ project, people, profiles, @@ -617,7 +619,7 @@ export function ProjectListRow({ } /> ); -} +}); /** Compact, borderless repository row for the overview side rail. */ export function ProjectRailRow({ diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index cae4fdfe079..a7befcb560a 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -1,3 +1,7 @@ +import * as React from "react"; + +import { useNow } from "@/shared/lib/useNow"; + import { resolveUserLabel, type UserProfileLookup, @@ -181,7 +185,7 @@ function buildActivityItems({ actorName: null, action: "created the repository", title: project.name, - body: contentPreview(project.description), + body: project.description, detail: null, target: { type: "project", project }, }); @@ -228,7 +232,7 @@ function buildActivityItems({ actorName: null, action: "opened a review in", title: pullRequest.title, - body: contentPreview(pullRequest.content), + body: pullRequest.content, detail: pullRequest.status, target, }); @@ -241,7 +245,7 @@ function buildActivityItems({ actorName: null, action: "updated a review in", title: pullRequest.title, - body: contentPreview(update.content), + body: update.content, detail: update.commit?.slice(0, 7) ?? null, target, }); @@ -275,7 +279,7 @@ function buildActivityItems({ ? "requested review in" : "commented on a review in", title: pullRequest.title, - body: contentPreview(comment.content), + body: comment.content, detail: kind === "approval" ? "Approved" @@ -297,7 +301,7 @@ function buildActivityItems({ actorName: null, action: "created a task in", title: issue.title, - body: contentPreview(issue.content), + body: issue.content, detail: issue.status, target, }); @@ -310,16 +314,26 @@ function buildActivityItems({ actorName: null, action: "commented on a task in", title: issue.title, - body: contentPreview(comment.content), + body: comment.content, detail: null, target, }); } } - return items - .sort((left, right) => right.createdAt - left.createdAt) - .slice(0, ACTIVITY_LIMIT); + return ( + items + .sort((left, right) => right.createdAt - left.createdAt) + .slice(0, ACTIVITY_LIMIT) + // Bodies are carried raw until here so the markdown flattening runs for + // the rendered window only β€” every issue/PR/comment in the community + // used to be flattened just to be sorted and discarded. + .map((item) => + item.body === null + ? item + : { ...item, body: contentPreview(item.body) }, + ) + ); } export function buildProjectsActivityAgentContextItems( @@ -358,8 +372,8 @@ function startOfWeek(timestamp: number) { return Math.floor(date.getTime() / 1_000); } -function groupActivityItems(items: ProjectActivityItem[]) { - const thisWeek = startOfWeek(Math.floor(Date.now() / 1_000)); +function groupActivityItems(items: ProjectActivityItem[], nowMs: number) { + const thisWeek = startOfWeek(Math.floor(nowMs / 1_000)); const lastWeek = thisWeek - WEEK_SECONDS; const groups: ProjectActivityGroup[] = [ { key: "this-week", label: "This week", items: [] }, @@ -596,8 +610,21 @@ function ActivityCard({ /** Mixed GitHub-style workspace activity shown beneath the overview callouts. */ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) { - const items = buildActivityItems(props); - const groups = groupActivityItems(items); + const { issues, projects, pullRequests, snapshots } = props; + // Memoized: this feed re-renders with every parent state change (profiles + // landing, selection, hover), and an unmemoized rebuild re-flattened and + // re-sorted the whole community's activity each time. + const items = React.useMemo( + () => buildActivityItems({ issues, projects, pullRequests, snapshots }), + [issues, projects, pullRequests, snapshots], + ); + // Week buckets are clock-derived; ticked so the memo cannot freeze "This + // week" across a week boundary. Coarse cadence β€” the boundary moves weekly. + const now = useNow(600_000); + const groups = React.useMemo( + () => groupActivityItems(items, now), + [items, now], + ); const rangeItems = groups.flatMap((group) => group.items.flatMap((item) => { const selectionItem = activitySelectionItem(item); diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index 9778d5b2b3d..c7c55367be5 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -1,4 +1,5 @@ import { Eye, FolderKanban } from "lucide-react"; +import * as React from "react"; import type { Project, @@ -13,6 +14,11 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { + countGroupedRows, + sliceGroupedRows, + useIncrementalMount, +} from "@/shared/hooks/useIncrementalMount"; import { cn } from "@/shared/lib/cn"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { Card } from "@/shared/ui/card"; @@ -53,14 +59,20 @@ function nextStepLabel(status: ProjectIssue["status"]) { return "Open task"; } -function IssueGridCard({ +const IssueGridCard = React.memo(function IssueGridCard({ issue, onOpen, project, + repository, }: { issue: ProjectIssue; - onOpen: (project: Project, issue: ProjectIssue) => void; + onOpen: ( + project: Project, + repository: Repository, + issue: ProjectIssue, + ) => void; project: Project; + repository: Repository; }) { return (
); -} +}); function issueSelectionItem( project: Project, @@ -113,7 +125,10 @@ function issueSelectionItem( }); } -function IssueListRow({ +// Memoized: these rows render in unbounded lists, and any Projects-view +// state change used to re-render every row. Props are kept identity-stable +// by the list (memoized groups/selection arrays, stable onOpen). +const IssueListRow = React.memo(function IssueListRow({ issue, onOpen, profiles, @@ -122,7 +137,11 @@ function IssueListRow({ repository, }: { issue: ProjectIssue; - onOpen: (project: Project, issue: ProjectIssue) => void; + onOpen: ( + project: Project, + repository: Repository, + issue: ProjectIssue, + ) => void; profiles?: UserProfileLookup; project: Project; rangeItems: ReturnType[]; @@ -137,7 +156,7 @@ function IssueListRow({ dateSeconds={issue.updatedAt} dateTestId="projects-row-date" icon={null} - onClick={() => onOpen(project, issue)} + onClick={() => onOpen(project, repository, issue)} peopleSlot={ } trailing={ - onOpen(project, issue)}> + onOpen(project, repository, issue)}> {nextStepLabel(issue.status)} @@ -170,7 +189,7 @@ function IssueListRow({ } /> ); -} +}); export function ProjectsIssuesList({ embedded, @@ -185,6 +204,40 @@ export function ProjectsIssuesList({ profiles, viewMode, }: ProjectsIssuesListProps) { + // Grouping and per-group selection arrays are identity-stable across + // re-renders so the memoized rows only re-render when their data changes. + const allGroups = React.useMemo( + () => + groupProjectWorkItemsByProject(issues).map((group) => ({ + ...group, + selectionItems: group.rows.map((row) => + issueSelectionItem(row.project, row.repository, row.issue), + ), + })), + [issues], + ); + // Mount rows progressively: a one-shot mount of hundreds of rows blocked + // the main thread for over a second on tab entry. + // Each layout's counter grows only while that layout is active: otherwise + // a layout switch would find the other counter already grown and mount the + // whole collection in one commit. + const mountedRowCount = useIncrementalMount( + countGroupedRows(allGroups), + 30, + 60, + viewMode !== "grid", + ); + const mountedGridCount = useIncrementalMount( + issues.length, + 30, + 60, + viewMode === "grid", + ); + const groups = React.useMemo( + () => sliceGroupedRows(allGroups, mountedRowCount), + [allGroups, mountedRowCount], + ); + if (isLoading) { return ; } @@ -224,31 +277,28 @@ export function ProjectsIssuesList({
{loadNotice}
- {issues.map(({ project, issue, repository }) => ( - - onOpen(selectedProject, repository, selectedIssue) - } - project={project} - /> - ))} + {issues + .slice(0, mountedGridCount) + .map(({ project, issue, repository }) => ( + + ))}
); } - const groups = groupProjectWorkItemsByProject(issues); - return (
{loadNotice}
{groups.map((group) => { - const groupSelectionItems = group.rows.map((row) => - issueSelectionItem(row.project, row.repository, row.issue), - ); + const groupSelectionItems = group.selectionItems; return (
    {group.rows.map(({ project, issue, repository }) => ( -
  • +
  • - onOpen(selectedProject, repository, selectedIssue) - } + onOpen={onOpen} profiles={profiles} project={project} rangeItems={groupSelectionItems} diff --git a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx index df1ceb25595..3c23c7452b0 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; + import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Project, @@ -32,8 +34,12 @@ import { RepositoryGridCard, RepositoryListRow, } from "@/features/projects/ui/RepositoryCards"; +import { useIncrementalMount } from "@/shared/hooks/useIncrementalMount"; import { cn } from "@/shared/lib/cn"; +// Stable fallback so a cache miss cannot hand a memoized card a fresh array. +const EMPTY_PEOPLE: string[] = []; + export function ProjectsOverviewProjectItems({ currentPubkey, deleteDisabled, @@ -63,6 +69,51 @@ export function ProjectsOverviewProjectItems({ viewMode: ProjectsViewMode; visibleProjects: Project[]; }) { + // One selection array shared by every row (was rebuilt per row per render β€” + // O(nΒ²) object churn that also defeated row memoization). + const selectionRangeItems = React.useMemo( + () => + visibleProjects.map((item) => + selectionItemFromProject({ + channelId: item.projectChannelId, + id: item.id, + owner: item.owner, + shareLink: projectShareLink(item), + title: item.name, + }), + ), + [visibleProjects], + ); + // Identity-stable people arrays: an inline projectPeople() call would hand + // every memoized card a fresh array each render, defeating React.memo. + const peopleByProject = React.useMemo( + () => + new Map( + visibleProjects.map((project) => [ + project.id, + projectPeople(project, summaries?.[project.id]), + ]), + ), + [summaries, visibleProjects], + ); + // Mount cards progressively; a one-shot mount of every card blocked the + // main thread on tab entry. + // Cards cost ~5ms each to mount (activity bar, people stack, menus); the + // viewport fits under a dozen, so a small first window keeps the tab-entry + // commit short and the rest streams in within a few frames. + // Grid cards are the expensive layout; while list view is active the + // counter stays dormant at its initial window so a later listβ†’grid switch + // still mounts incrementally instead of in one full-collection commit. + const mountedCount = useIncrementalMount( + visibleProjects.length, + 12, + 36, + viewMode === "grid", + ); + const mountedProjects = React.useMemo( + () => visibleProjects.slice(0, mountedCount), + [mountedCount, visibleProjects], + ); if (visibleProjects.length === 0) { return ; } @@ -74,61 +125,64 @@ export function ProjectsOverviewProjectItems({ filter !== "all" && "xl:grid-cols-3", )} > - {visibleProjects.map((project) => { + {mountedProjects.map((project) => { const summary = summaries?.[project.id]; return ( - + +
+ ); + })} +
+ ); + } + return ( +
+ {visibleProjects.map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ - ); - })} -
- ); - } - return ( -
- {visibleProjects.map((project) => { - const summary = summaries?.[project.id]; - const selectionRangeItems = visibleProjects.map((item) => - selectionItemFromProject({ - channelId: item.projectChannelId, - id: item.id, - owner: item.owner, - shareLink: projectShareLink(item), - title: item.name, - }), - ); - return ( - +
); })}
@@ -152,23 +206,56 @@ export function ProjectsOverviewRepositoryItems({ viewMode: ProjectsViewMode; visibleRepositories: Array<{ project: Project; repository: Repository }>; }) { + // Shared, identity-stable selection array (see ProjectsOverviewProjectItems). + const selectionRangeItems = React.useMemo( + () => + visibleRepositories.map((row) => + selectionItemFromRepository({ + channelId: row.repository.channelId ?? row.project.projectChannelId, + id: row.repository.id, + owner: row.repository.owner, + shareLink: repositoryShareLink(row.repository), + title: row.repository.name, + }), + ), + [visibleRepositories], + ); + // Mount cards progressively (see ProjectsOverviewProjectItems). + // Small first window: see ProjectsOverviewProjectItems. + // Dormant outside grid layout; see ProjectsOverviewProjectItems. + const mountedCount = useIncrementalMount( + visibleRepositories.length, + 12, + 36, + viewMode === "grid", + ); + const mountedRepositories = React.useMemo( + () => visibleRepositories.slice(0, mountedCount), + [mountedCount, visibleRepositories], + ); if (visibleRepositories.length === 0) { return ; } if (viewMode === "grid") { return (
- {visibleRepositories.map(({ project, repository }) => ( - ( +
+ > + +
))}
); @@ -176,26 +263,23 @@ export function ProjectsOverviewRepositoryItems({ return (
{visibleRepositories.map(({ project, repository }) => ( - - selectionItemFromRepository({ - channelId: - row.repository.channelId ?? row.project.projectChannelId, - id: row.repository.id, - owner: row.repository.owner, - shareLink: repositoryShareLink(row.repository), - title: row.repository.name, - }), - )} - summary={summaries?.[repository.repoAddress]} - /> + > + +
))} ); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index f15532cf9fe..f774c192d51 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -8,7 +8,7 @@ import { Hash, Plus, } from "lucide-react"; -import type * as React from "react"; +import * as React from "react"; import type { Project, @@ -184,16 +184,24 @@ export function ProjectsOverviewContextPanel({ summaries, }: ProjectsOverviewContextPanelProps) { const selection = useProjectSelection(); - const selectionPresentation = projectSelectionPresentation( - selection?.items ?? [], + const selectionItems = selection?.items; + const selectionPresentation = React.useMemo( + () => projectSelectionPresentation(selectionItems ?? []), + [selectionItems], + ); + // Memoized: the rail re-renders with every Projects-view state change, and + // the context stats walk every issue and pull request in the community. + const context = React.useMemo( + () => + projectsOverviewContext({ + filter, + issues, + projects, + pullRequests, + summaries, + }), + [filter, issues, projects, pullRequests, summaries], ); - const context = projectsOverviewContext({ - filter, - issues, - projects, - pullRequests, - summaries, - }); const actionHandler = context.action?.kind === "issue" ? onCreateIssue diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index 02f9d1059e1..f4315576f57 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -1,4 +1,5 @@ import { FolderKanban, GitPullRequest } from "lucide-react"; +import * as React from "react"; import type { Project, @@ -9,6 +10,11 @@ import type { import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks"; import { selectionItemFromReview } from "@/features/projects/lib/projectSelection"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; +import { + countGroupedRows, + sliceGroupedRows, + useIncrementalMount, +} from "@/shared/hooks/useIncrementalMount"; import { cn } from "@/shared/lib/cn"; import { resolveUserLabel, @@ -52,14 +58,20 @@ function nextStepLabel(status: ProjectPullRequest["status"]) { return "Open review"; } -function PullRequestGridCard({ +const PullRequestGridCard = React.memo(function PullRequestGridCard({ project, pullRequest, onOpen, + repository, }: { project: Project; pullRequest: ProjectPullRequest; - onOpen: (project: Project, pullRequest: ProjectPullRequest) => void; + onOpen: ( + project: Project, + repository: Repository, + pullRequest: ProjectPullRequest, + ) => void; + repository: Repository; }) { return (