perf(desktop): make the Projects surface render-cheap (#6460)

Diagnostic profiling on a large community (101 issues, 258 PRs) showed
Projects tab switches taking 2.5–3.6s, dominated by single React commits
of 0.5–1.5s and per-render recomputation — fetch work was already off
the main thread; the cost was building the UI.

### Measured: tab click → painted, per tab

| tab | before | after |
|---|---|---:|
| projects | 3,608ms | 320–580ms |
| repositories | 3,126–3,534ms | 310–410ms |
| tasks | 395–1,101ms | ~115ms |
| reviews | 322–2,603ms | ~96ms |
| activity | 597–741ms | ~148ms |

Single-commit ceiling dropped from 1,541ms to ≤200ms (growth steps
25–40ms). Fixes in profiled-cost order:

- **Profile popover body mounts only while open.** `UserProfilePopover`
carried seven query subscriptions plus interaction hooks per instance
even when closed; grids mount hundreds (five per card in people stacks,
one per row author) — measured **~40ms per card**, the dominant share of
the 1.2s card-tab commits. The always-mounted shell is now just the
Radix root + trigger; trigger markup, hover timing, and keyboard
handling are unchanged, and hover/tooltip event continuity is preserved
because the trigger never remounts.
- **Incremental row mounting.** The first 12 cards / 30 rows render in
the first commit; the rest stream in 36–60-per-frame low-priority
transitions. Grouped lists trim across group boundaries via a pure,
tested slicer; the mounted count survives in-place refetches.
- **Activity feed**: was rebuilt unmemoized on every render,
markdown-flattening every issue/PR/comment body in the community just to
sort and keep 30 items (~360+ flattens per render on the measured
community). Now memoized, and bodies stay raw until after the sort+slice
— 30 flattens, once per data change.
- **Contribution graph** (always-visible rail, so every tab paid for
it): ~180 day cells each wrapped in a Radix tooltip with per-cell Intl
date formatting per render. Now memoized, cells precomputed once per
data change, native `title` tooltips. (The activity-bar segments keep
their styled Radix tooltips — pinned by an existing spec.)
- **Rows/cards memoized with identity-stable props**: per-row selection
arrays were rebuilt per row per render (O(n²) — 258 PRs × 258-item
arrays each render) and are now hoisted and shared; people arrays derive
inside the memoized cards; the rail's stat walk over every issue/PR is
memoized.
- **`content-visibility: auto`** on cards and rows so offscreen entries
skip layout and paint; **tab switches run in a React transition** so the
click stays responsive while the new tree mounts.

Remaining known cost (out of scope): cold-entry data readiness — the
work-item and activity queries ship thousands of events to compute
counts (2–4s on a large community; see the fan-lifecycle PR). The
structural fix is a relay-side aggregate; tracked as follow-up.

---------

Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Max Lampert
2026-08-22 07:42:55 -07:00
committed by GitHub
parent f99532585a
commit 040b203f73
12 changed files with 1067 additions and 480 deletions

View File

@@ -134,24 +134,138 @@ export function UserProfilePopover({
const hoverTimerRef = React.useRef<ReturnType<typeof setTimeout> | 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 (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<TriggerElement
aria-label={triggerAriaLabel}
role={canOpenProfilePanel ? "button" : undefined}
tabIndex={canOpenProfilePanel ? 0 : undefined}
onClick={handleTriggerClick}
onKeyDown={(e) => {
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}
</TriggerElement>
</PopoverAnchor>
{open ? (
<UserProfilePopoverBody
botIdenticonValue={botIdenticonValue}
canOpenProfilePanel={canOpenProfilePanel}
onBeforeAction={clearHoverTimer}
onContentMouseEnter={handleContentMouseEnter}
onMouseLeave={handleMouseLeave}
onTriggerClick={handleTriggerClick}
pubkey={pubkey}
role={role}
setOpen={setOpen}
/>
) : null}
</Popover>
);
}
/**
* 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 = (
<>
<ProfileAvatarWithStatus
@@ -349,220 +419,187 @@ export function UserProfilePopover({
);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<TriggerElement
aria-label={triggerAriaLabel}
role={canOpenProfilePanel ? "button" : undefined}
tabIndex={canOpenProfilePanel ? 0 : undefined}
onClick={handleTriggerClick}
onKeyDown={(e) => {
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}
</TriggerElement>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-80"
data-testid="user-profile-popover"
onMouseEnter={handleContentMouseEnter}
onMouseLeave={handleMouseLeave}
// This is a hover card: moving focus into its first button on open
// makes the profile header look keyboard-selected before the user has
// interacted with it. Keep focus on the trigger; Tab still enters the
// card and shows its normal focus treatment when needed.
onOpenAutoFocus={(event) => event.preventDefault()}
side="top"
sideOffset={8}
>
<div className="flex flex-col gap-3">
{canOpenProfilePanel ? (
<button
className="flex w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg text-left text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&_*]:cursor-pointer"
onClick={handleTriggerClick}
type="button"
>
{profileHeaderContent}
</button>
) : (
<div className="flex w-full min-w-0 items-center gap-3 text-left text-foreground">
{profileHeaderContent}
</div>
)}
<PopoverContent
align="start"
className="w-80"
data-testid="user-profile-popover"
onMouseEnter={onContentMouseEnter}
onMouseLeave={onMouseLeave}
// This is a hover card: moving focus into its first button on open
// makes the profile header look keyboard-selected before the user has
// interacted with it. Keep focus on the trigger; Tab still enters the
// card and shows its normal focus treatment when needed.
onOpenAutoFocus={(event) => event.preventDefault()}
side="top"
sideOffset={8}
>
<div className="flex flex-col gap-3">
{canOpenProfilePanel ? (
<button
className="flex w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg text-left text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&_*]:cursor-pointer"
onClick={onTriggerClick}
type="button"
>
{profileHeaderContent}
</button>
) : (
<div className="flex w-full min-w-0 items-center gap-3 text-left text-foreground">
{profileHeaderContent}
</div>
)}
{isBotProfile && (managedAgent || relayAgent) ? (
<div className="flex flex-wrap gap-1.5">
{managedAgent?.agentCommand ? (
<InfoBadge>{runtimeLabel(managedAgent.agentCommand)}</InfoBadge>
) : relayAgent?.agentType ? (
<InfoBadge>{runtimeLabel(relayAgent.agentType)}</InfoBadge>
) : null}
{managedAgent?.model ? (
<InfoBadge>
{resolveModelLabel(
managedAgent.model,
null,
managedAgent.provider,
)}
</InfoBadge>
) : null}
{managedAgent?.acpCommand ? (
<InfoBadge>ACP: {managedAgent.acpCommand}</InfoBadge>
) : null}
</div>
) : null}
{isBotProfile && (managedAgent || relayAgent) ? (
<div className="flex flex-wrap gap-1.5">
{managedAgent?.agentCommand ? (
<InfoBadge>{runtimeLabel(managedAgent.agentCommand)}</InfoBadge>
) : relayAgent?.agentType ? (
<InfoBadge>{runtimeLabel(relayAgent.agentType)}</InfoBadge>
) : null}
{managedAgent?.model ? (
<InfoBadge>
{resolveModelLabel(
managedAgent.model,
null,
managedAgent.provider,
)}
</InfoBadge>
) : null}
{managedAgent?.acpCommand ? (
<InfoBadge>ACP: {managedAgent.acpCommand}</InfoBadge>
) : null}
</div>
) : null}
{activeTurns.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{activeTurns.map(({ channelId, anchorAt }) => (
<PopoverWorkingBadge
key={channelId}
name={channelIdToName[channelId] ?? channelId}
anchorAt={anchorAt}
/>
))}
</div>
) : null}
{canViewActivity ? (
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={`user-profile-view-activity-${pubkey}`}
onClick={() => {
setOpen(false);
openAgentActivity(pubkey);
}}
type="button"
>
<Activity className="h-4 w-4 text-muted-foreground" />
View activity log
</button>
) : null}
{hasUserStatus || showAnyProfileActions ? (
<>
<div
aria-hidden="true"
className="my-1 border-t border-border/60"
{activeTurns.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{activeTurns.map(({ channelId, anchorAt }) => (
<PopoverWorkingBadge
key={channelId}
name={channelIdToName[channelId] ?? channelId}
anchorAt={anchorAt}
/>
{hasUserStatus ? (
<StatusLine>
{userStatus?.emoji ? (
<StatusEmoji
className="h-3.5 w-3.5 shrink-0"
value={userStatus.emoji}
/>
) : null}
{userStatusText ? (
<span className="truncate">{userStatusText}</span>
) : null}
</StatusLine>
) : null}
{showAnyProfileActions ? (
<div className="flex gap-2">
{showHumanProfileActions ? (
<Button
aria-label="Wave"
className="buzz-wave-hover-trigger shrink-0 px-3 transition-transform duration-100 ease-out motion-reduce:transition-none motion-safe:active:scale-[0.97]"
data-testid={`user-profile-popover-wave-${pubkey}`}
disabled={pendingAction !== null || isOpeningDm}
onClick={() => {
void handleWave();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "wave" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<span
aria-hidden="true"
className="buzz-wave-hand text-sm leading-none"
>
👋
</span>
)}
</Button>
) : null}
{showMessageAction ? (
<Button
className="min-w-0 flex-1"
data-testid={`user-profile-popover-message-${pubkey}`}
disabled={pendingAction !== null || isOpeningDm}
onClick={() => {
void handleMessage();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "message" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<MessageSquare />
)}
Message
</Button>
) : null}
{showHuddleAction ? (
<Button
className="min-w-0 flex-1"
data-testid={`user-profile-popover-huddle-${pubkey}`}
disabled={
pendingAction !== null ||
isOpeningDm ||
isStartingHuddle
}
onClick={() => {
void handleHuddle();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "huddle" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<Headphones />
)}
Huddle
</Button>
) : null}
</div>
) : null}
</>
) : null}
</div>
</PopoverContent>
</Popover>
))}
</div>
) : null}
{canViewActivity ? (
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={`user-profile-view-activity-${pubkey}`}
onClick={() => {
setOpen(false);
openAgentActivity(pubkey);
}}
type="button"
>
<Activity className="h-4 w-4 text-muted-foreground" />
View activity log
</button>
) : null}
{hasUserStatus || showAnyProfileActions ? (
<>
<div
aria-hidden="true"
className="my-1 border-t border-border/60"
/>
{hasUserStatus ? (
<StatusLine>
{userStatus?.emoji ? (
<StatusEmoji
className="h-3.5 w-3.5 shrink-0"
value={userStatus.emoji}
/>
) : null}
{userStatusText ? (
<span className="truncate">{userStatusText}</span>
) : null}
</StatusLine>
) : null}
{showAnyProfileActions ? (
<div className="flex gap-2">
{showHumanProfileActions ? (
<Button
aria-label="Wave"
className="buzz-wave-hover-trigger shrink-0 px-3 transition-transform duration-100 ease-out motion-reduce:transition-none motion-safe:active:scale-[0.97]"
data-testid={`user-profile-popover-wave-${pubkey}`}
disabled={pendingAction !== null || isOpeningDm}
onClick={() => {
void handleWave();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "wave" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<span
aria-hidden="true"
className="buzz-wave-hand text-sm leading-none"
>
👋
</span>
)}
</Button>
) : null}
{showMessageAction ? (
<Button
className="min-w-0 flex-1"
data-testid={`user-profile-popover-message-${pubkey}`}
disabled={pendingAction !== null || isOpeningDm}
onClick={() => {
void handleMessage();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "message" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<MessageSquare />
)}
Message
</Button>
) : null}
{showHuddleAction ? (
<Button
className="min-w-0 flex-1"
data-testid={`user-profile-popover-huddle-${pubkey}`}
disabled={
pendingAction !== null || isOpeningDm || isStartingHuddle
}
onClick={() => {
void handleHuddle();
}}
size="sm"
type="button"
variant="outline"
>
{pendingAction === "huddle" ? (
<Spinner
aria-hidden="true"
className="h-3.5 w-3.5 border-2"
/>
) : (
<Headphones />
)}
Huddle
</Button>
) : null}
</div>
) : null}
</>
) : null}
</div>
</PopoverContent>
);
}

View File

@@ -454,7 +454,9 @@ type ProjectItemProps = {
onOpenTerminal: (project: Project) => Promise<void> | 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 (
<Card
className="group relative flex min-h-40 flex-col overflow-hidden border-border/60 bg-transparent shadow-none transition-colors duration-150 hover:bg-muted/20"
className="group relative flex h-full min-h-40 flex-col overflow-hidden border-border/60 bg-transparent shadow-none transition-colors duration-150 hover:bg-muted/20"
data-projects-grid-card
data-testid={`project-card-${project.dtag}`}
>
@@ -541,9 +543,9 @@ export function ProjectGridCard({
</div>
</Card>
);
}
});
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({

View File

@@ -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);

View File

@@ -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 (
<Card
@@ -69,7 +81,7 @@ function IssueGridCard({
>
<button
className="absolute inset-0"
onClick={() => onOpen(project, issue)}
onClick={() => onOpen(project, repository, issue)}
type="button"
>
<span className="sr-only">View task {issue.title}</span>
@@ -97,7 +109,7 @@ function IssueGridCard({
</div>
</Card>
);
}
});
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<typeof issueSelectionItem>[];
@@ -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={
<ProjectAuthorIdentity
label={authorLabel}
@@ -157,7 +176,7 @@ function IssueListRow({
titleIcon={<ProjectEventTypeIcon className="h-3.5 w-3.5" kind="issue" />}
trailing={
<ProjectListRowMenu label={`More options for ${issue.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
<DropdownMenuItem onSelect={() => onOpen(project, repository, issue)}>
<Eye className="h-4 w-4" />
{nextStepLabel(issue.status)}
</DropdownMenuItem>
@@ -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 <BuzzLoadingState label="Loading tasks" />;
}
@@ -224,31 +277,28 @@ export function ProjectsIssuesList({
<div className="space-y-3">
{loadNotice}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{issues.map(({ project, issue, repository }) => (
<IssueGridCard
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
project={project}
/>
))}
{issues
.slice(0, mountedGridCount)
.map(({ project, issue, repository }) => (
<IssueGridCard
issue={issue}
key={`${repository.id}:${issue.id}`}
onOpen={onOpen}
project={project}
repository={repository}
/>
))}
</div>
</div>
);
}
const groups = groupProjectWorkItemsByProject(issues);
return (
<div className="space-y-3">
{loadNotice}
<div data-testid="projects-list-container">
{groups.map((group) => {
const groupSelectionItems = group.rows.map((row) =>
issueSelectionItem(row.project, row.repository, row.issue),
);
const groupSelectionItems = group.selectionItems;
return (
<ProjectSelectableGroup
count={group.rows.length}
@@ -264,12 +314,13 @@ export function ProjectsIssuesList({
>
<ul>
{group.rows.map(({ project, issue, repository }) => (
<li key={`${repository.id}:${issue.id}`}>
<li
className="[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
key={`${repository.id}:${issue.id}`}
>
<IssueListRow
issue={issue}
onOpen={(selectedProject, selectedIssue) =>
onOpen(selectedProject, repository, selectedIssue)
}
onOpen={onOpen}
profiles={profiles}
project={project}
rangeItems={groupSelectionItems}

View File

@@ -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 <EmptyFilteredState />;
}
@@ -74,25 +125,31 @@ export function ProjectsOverviewProjectItems({
filter !== "all" && "xl:grid-cols-3",
)}
>
{visibleProjects.map((project) => {
{mountedProjects.map((project) => {
const summary = summaries?.[project.id];
return (
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
<div
className={
"[contain-intrinsic-size:auto_11rem] [content-visibility:auto]"
}
key={project.id}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
>
<ProjectGridCard
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={peopleByProject.get(project.id) ?? EMPTY_PEOPLE}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
summary={summary}
/>
</div>
);
})}
</div>
@@ -102,33 +159,30 @@ export function ProjectsOverviewProjectItems({
<div data-testid="projects-list-container">
{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 (
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
<div
className={
"[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
}
key={project.id}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={projectPeople(project, summary)}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
selectionRangeItems={selectionRangeItems}
summary={summary}
/>
>
<ProjectListRow
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
deleteDisabled={deleteDisabled}
hasLocal={hasLocalCheckout(project, localRepoNames)}
onDelete={onDelete}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
people={peopleByProject.get(project.id) ?? EMPTY_PEOPLE}
profiles={profiles}
project={project}
repositoryUnavailableReason={repositoryUnavailableReasonFor(
project,
)}
selectionRangeItems={selectionRangeItems}
summary={summary}
/>
</div>
);
})}
</div>
@@ -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 <EmptyFilteredState />;
}
if (viewMode === "grid") {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleRepositories.map(({ project, repository }) => (
<RepositoryGridCard
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
{mountedRepositories.map(({ project, repository }) => (
<div
className={
"[contain-intrinsic-size:auto_11rem] [content-visibility:auto]"
}
key={repository.repoAddress}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={summaries?.[repository.repoAddress]}
/>
>
<RepositoryGridCard
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
summary={summaries?.[repository.repoAddress]}
/>
</div>
))}
</div>
);
@@ -176,26 +263,23 @@ export function ProjectsOverviewRepositoryItems({
return (
<div data-testid="projects-list-container">
{visibleRepositories.map(({ project, repository }) => (
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
<div
className={
"[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
}
key={repository.repoAddress}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
selectionRangeItems={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,
}),
)}
summary={summaries?.[repository.repoAddress]}
/>
>
<RepositoryListRow
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
onOpen={onOpen}
onOpenTerminal={onOpenTerminal}
profiles={profiles}
project={project}
repository={repository}
selectionRangeItems={selectionRangeItems}
summary={summaries?.[repository.repoAddress]}
/>
</div>
))}
</div>
);

View File

@@ -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

View File

@@ -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 (
<Card
@@ -68,7 +80,7 @@ function PullRequestGridCard({
>
<button
className="absolute inset-0"
onClick={() => onOpen(project, pullRequest)}
onClick={() => onOpen(project, repository, pullRequest)}
type="button"
>
<span className="sr-only">View review {pullRequest.title}</span>
@@ -96,7 +108,7 @@ function PullRequestGridCard({
</div>
</Card>
);
}
});
function reviewSelectionItem(
project: Project,
@@ -112,7 +124,10 @@ function reviewSelectionItem(
});
}
function PullRequestListRow({
// 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 PullRequestListRow = React.memo(function PullRequestListRow({
project,
profiles,
pullRequest,
@@ -125,7 +140,11 @@ function PullRequestListRow({
pullRequest: ProjectPullRequest;
rangeItems: ReturnType<typeof reviewSelectionItem>[];
repository: Repository;
onOpen: (project: Project, pullRequest: ProjectPullRequest) => void;
onOpen: (
project: Project,
repository: Repository,
pullRequest: ProjectPullRequest,
) => void;
}) {
const authorLabel = resolveUserLabel({
profiles,
@@ -139,7 +158,7 @@ function PullRequestListRow({
dateSeconds={pullRequest.updatedAt}
dateTestId="projects-row-date"
icon={null}
onClick={() => onOpen(project, pullRequest)}
onClick={() => onOpen(project, repository, pullRequest)}
peopleSlot={
<ProjectAuthorIdentity
label={authorLabel}
@@ -161,7 +180,9 @@ function PullRequestListRow({
}
trailing={
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>
<DropdownMenuItem
onSelect={() => onOpen(project, repository, pullRequest)}
>
<GitPullRequest className="h-4 w-4" />
{nextStepLabel(pullRequest.status)}
</DropdownMenuItem>
@@ -174,7 +195,7 @@ function PullRequestListRow({
}
/>
);
}
});
export function ProjectsPullRequestsList({
embedded,
@@ -188,6 +209,40 @@ export function ProjectsPullRequestsList({
pullRequests,
viewMode,
}: ProjectsPullRequestsListProps) {
// 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(pullRequests).map((group) => ({
...group,
selectionItems: group.rows.map((row) =>
reviewSelectionItem(row.project, row.repository, row.pullRequest),
),
})),
[pullRequests],
);
// 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(
pullRequests.length,
30,
60,
viewMode === "grid",
);
const groups = React.useMemo(
() => sliceGroupedRows(allGroups, mountedRowCount),
[allGroups, mountedRowCount],
);
if (isLoading) {
return <BuzzLoadingState label="Loading reviews" />;
}
@@ -227,31 +282,28 @@ export function ProjectsPullRequestsList({
<div className="space-y-3">
{loadNotice}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{pullRequests.map(({ project, pullRequest, repository }) => (
<PullRequestGridCard
key={`${repository.id}:${pullRequest.id}`}
onOpen={(selectedProject, selectedPullRequest) =>
onOpen(selectedProject, repository, selectedPullRequest)
}
project={project}
pullRequest={pullRequest}
/>
))}
{pullRequests
.slice(0, mountedGridCount)
.map(({ project, pullRequest, repository }) => (
<PullRequestGridCard
key={`${repository.id}:${pullRequest.id}`}
onOpen={onOpen}
project={project}
pullRequest={pullRequest}
repository={repository}
/>
))}
</div>
</div>
);
}
const groups = groupProjectWorkItemsByProject(pullRequests);
return (
<div className="space-y-3">
{loadNotice}
<div data-testid="projects-list-container">
{groups.map((group) => {
const groupSelectionItems = group.rows.map((row) =>
reviewSelectionItem(row.project, row.repository, row.pullRequest),
);
const groupSelectionItems = group.selectionItems;
return (
<ProjectSelectableGroup
count={group.rows.length}
@@ -267,11 +319,12 @@ export function ProjectsPullRequestsList({
>
<ul>
{group.rows.map(({ project, pullRequest, repository }) => (
<li key={`${repository.id}:${pullRequest.id}`}>
<li
className="[contain-intrinsic-size:auto_3.5rem] [content-visibility:auto]"
key={`${repository.id}:${pullRequest.id}`}
>
<PullRequestListRow
onOpen={(selectedProject, selectedPullRequest) =>
onOpen(selectedProject, repository, selectedPullRequest)
}
onOpen={onOpen}
profiles={profiles}
project={project}
pullRequest={pullRequest}

View File

@@ -17,7 +17,6 @@ import {
} from "@/features/projects/hooks";
import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks";
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
import type { ProjectSelectionItem } from "@/features/projects/lib/projectSelection";
@@ -112,6 +111,12 @@ import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { Button } from "@/shared/ui/button";
import { useOptionalSidebar } from "@/shared/ui/sidebar";
import { useProjectsOverviewAgentContext } from "./useProjectsOverviewAgentContext";
import {
EMPTY_ITEMS,
useContextWorkItems,
useDeleteProjectHandler,
useOpenProjectTerminalHandler,
} from "./projectsViewWorkItems";
const MANY_PROJECTS_THRESHOLD = 12;
const PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX = 1024;
@@ -481,16 +486,21 @@ export function ProjectsView() {
});
const handleFilterChange = React.useCallback(
(nextFilter: ProjectsFilter) => {
if (
nextFilter === "projects" &&
(repositoryScope === "buzz" || repositoryScope === "linked")
) {
setRepositoryScope("all");
writeStoredRepositoryScope("all");
}
setSelectionAgentContext(null);
setFilter(nextFilter);
writeStoredFilter(nextFilter);
// Tab content swaps mount hundreds of rows/cards at once; a transition
// lets React keep the click responsive and paint the previous tab until
// the new tree is ready instead of blocking the main thread.
React.startTransition(() => {
if (
nextFilter === "projects" &&
(repositoryScope === "buzz" || repositoryScope === "linked")
) {
setRepositoryScope("all");
writeStoredRepositoryScope("all");
}
setSelectionAgentContext(null);
setFilter(nextFilter);
});
},
[repositoryScope, setSelectionAgentContext],
);
@@ -543,20 +553,9 @@ export function ProjectsView() {
);
const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir);
const handleOpenTerminal = React.useCallback(
(project: Project) => {
const repository = selectProjectRepository(project, null);
if (!repository) return Promise.resolve();
return openTerminal(repository, {
// Check the selected repository only — not all members — so the
// terminal affordance reflects the repository the button will open.
hasLocalCheckout: hasLocalRepositoryCheckout(
repository,
localRepoNames,
),
});
},
[localRepoNames, openTerminal],
const handleOpenTerminal = useOpenProjectTerminalHandler(
openTerminal,
localRepoNames,
);
const handleOpenRepositoryTerminal = React.useCallback(
(repository: Repository) =>
@@ -569,18 +568,12 @@ export function ProjectsView() {
[localRepoNames, openTerminal],
);
const handleDeleteProject = React.useCallback(
async (project: Project) => {
try {
await deleteProjectMutation.mutateAsync(project);
toast.success("Project deleted");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete project",
);
}
},
[deleteProjectMutation],
const handleDeleteProject = useDeleteProjectHandler(
deleteProjectMutation.mutateAsync,
);
const { contextIssues, contextPullRequests } = useContextWorkItems(
projectsWorkItemsQuery.data,
);
if (projectsQuery.isLoading) {
@@ -672,14 +665,16 @@ export function ProjectsView() {
isLoading={
repoSnapshotsQuery.isLoading || projectsWorkItemsQuery.isLoading
}
issues={projectsWorkItemsQuery.data?.issues.items ?? []}
issues={projectsWorkItemsQuery.data?.issues.items ?? EMPTY_ITEMS}
onOpenCommit={handleOpenCommit}
onOpenIssue={handleOpenIssue}
onOpenProject={handleOpenProject}
onOpenPullRequest={handleOpenPullRequest}
profiles={profiles}
projects={projects}
pullRequests={projectsWorkItemsQuery.data?.pullRequests.items ?? []}
pullRequests={
projectsWorkItemsQuery.data?.pullRequests.items ?? EMPTY_ITEMS
}
snapshots={repoSnapshotsQuery.data?.snapshots}
/>
</>
@@ -687,8 +682,7 @@ export function ProjectsView() {
const contextPanelProps = {
filter,
issues:
projectsWorkItemsQuery.data?.issues.items.map(({ issue }) => issue) ?? [],
issues: contextIssues,
onChatWithAgent: (items: ProjectSelectionItem[]) =>
setSelectionAgentContext(buildProjectSelectionAgentContext(items)),
onCreateIssue: () => setCreateIssueOpen(true),
@@ -696,10 +690,7 @@ export function ProjectsView() {
onCreatePullRequest: () => setCreatePullRequestOpen(true),
profiles,
projects,
pullRequests:
projectsWorkItemsQuery.data?.pullRequests.items.map(
({ pullRequest }) => pullRequest,
) ?? [],
pullRequests: contextPullRequests,
summaries: activitySummariesQuery.data,
};
const contextOpen = isNarrowProjectsLayout

View File

@@ -1,3 +1,5 @@
import * as React from "react";
import { FolderGit2, Globe, SquareTerminal } from "lucide-react";
import type {
@@ -191,7 +193,11 @@ function RepositoryActionsMenu({
);
}
export function RepositoryGridCard(props: RepositoryItemProps) {
// Memoized: unbounded grids/lists; identity-stable caller props keep
// re-renders scoped to changed cards.
export const RepositoryGridCard = React.memo(function RepositoryGridCard(
props: RepositoryItemProps,
) {
const {
hasLocal,
onOpen,
@@ -203,7 +209,7 @@ export function RepositoryGridCard(props: RepositoryItemProps) {
} = props;
return (
<Card
className="group relative flex min-h-40 flex-col overflow-hidden border-border/60 bg-transparent shadow-none transition-colors duration-150 hover:bg-muted/20"
className="group relative flex h-full min-h-40 flex-col overflow-hidden border-border/60 bg-transparent shadow-none transition-colors duration-150 hover:bg-muted/20"
data-testid={`repository-card-${repository.dtag}`}
>
<RepositoryOpenButton
@@ -252,9 +258,11 @@ export function RepositoryGridCard(props: RepositoryItemProps) {
</div>
</Card>
);
}
});
export function RepositoryListRow(props: RepositoryItemProps) {
export const RepositoryListRow = React.memo(function RepositoryListRow(
props: RepositoryItemProps,
) {
const {
hasLocal,
onOpen,
@@ -306,4 +314,4 @@ export function RepositoryListRow(props: RepositoryItemProps) {
}
/>
);
}
});

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { toast } from "sonner";
import type { ProjectsWorkItemsResult } from "@/features/projects/projectWorkItems";
import type { Project } from "@/features/projects/hooks";
import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos";
import { selectProjectRepository } from "@/features/projects/projectModels";
import type { useOpenProjectTerminal } from "@/features/projects/ui/useOpenProjectTerminal";
// Split from ProjectsView.tsx to keep that file under the per-file line cap.
/**
* Stable empty fallback: a fresh `[]` per render would defeat the memoized
* activity feed while work items load.
*/
export const EMPTY_ITEMS: never[] = [];
/**
* Memoized flat issue/PR arrays for the overview context panel. Fresh
* `.map()` arrays per render would change the panel's memo deps every
* render, re-walking every issue and pull request in the community on
* unrelated Projects-view state changes.
*/
export function useContextWorkItems(
workItemsData: ProjectsWorkItemsResult<Project> | undefined,
) {
const contextIssues = React.useMemo(
() => workItemsData?.issues.items.map(({ issue }) => issue) ?? [],
[workItemsData],
);
const contextPullRequests = React.useMemo(
() =>
workItemsData?.pullRequests.items.map(({ pullRequest }) => pullRequest) ??
[],
[workItemsData],
);
return { contextIssues, contextPullRequests };
}
/**
* Delete-project handler with toast feedback. Depends on the stable
* mutateAsync, not the per-render mutation object, so memoized card/row
* trees keep their identity across renders.
*/
export function useDeleteProjectHandler(
mutateAsync: (project: Project) => Promise<unknown>,
) {
return React.useCallback(
async (project: Project) => {
try {
await mutateAsync(project);
toast.success("Project deleted");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete project",
);
}
},
[mutateAsync],
);
}
/** Opens the project's selected repository in a terminal (see ProjectsView). */
export function useOpenProjectTerminalHandler(
openTerminal: ReturnType<typeof useOpenProjectTerminal>,
localRepoNames: Set<string>,
) {
return React.useCallback(
(project: Project) => {
const repository = selectProjectRepository(project, null);
if (!repository) return Promise.resolve();
return openTerminal(repository, {
// Check the selected repository only — not all members — so the
// terminal affordance reflects the repository the button will open.
hasLocalCheckout: hasLocalRepositoryCheckout(
repository,
localRepoNames,
),
});
},
[localRepoNames, openTerminal],
);
}

View File

@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import test from "node:test";
import { countGroupedRows, sliceGroupedRows } from "./useIncrementalMount.ts";
function group(id, rowCount) {
return { id, rows: Array.from({ length: rowCount }, (_, i) => `${id}-${i}`) };
}
test("sliceGroupedRows trims across group boundaries in order", () => {
const groups = [group("a", 3), group("b", 4), group("c", 2)];
assert.deepEqual(
sliceGroupedRows(groups, 5).map((g) => [g.id, g.rows.length]),
[
["a", 3],
["b", 2],
],
);
});
test("sliceGroupedRows keeps whole groups untouched when under budget", () => {
const groups = [group("a", 3), group("b", 4)];
const sliced = sliceGroupedRows(groups, 10);
assert.equal(sliced.length, 2);
// Fully-included groups keep their identity (no needless clones).
assert.equal(sliced[0], groups[0]);
assert.equal(sliced[1], groups[1]);
});
test("sliceGroupedRows with zero budget is empty; counts add up", () => {
const groups = [group("a", 3), group("b", 4)];
assert.deepEqual(sliceGroupedRows(groups, 0), []);
assert.equal(countGroupedRows(groups), 7);
});
// --- Hook behavior: growth, layout gating, shrink reset -------------------
async function withHookHarness(run) {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<!doctype html><html><body></body></html>");
const originals = {
window: globalThis.window,
document: globalThis.document,
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
act: globalThis.IS_REACT_ACT_ENVIRONMENT,
};
const frames = [];
dom.window.requestAnimationFrame = (cb) => frames.push(cb) && frames.length;
dom.window.cancelAnimationFrame = (id) => {
frames[id - 1] = null;
};
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
try {
const React = (await import("react")).default;
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { useIncrementalMount } = await import("./useIncrementalMount.ts");
const seen = [];
function Probe({ total, enabled }) {
seen.push(useIncrementalMount(total, 2, 3, enabled));
return null;
}
const root = createRoot(
dom.window.document.body.appendChild(
dom.window.document.createElement("div"),
),
);
const render = async (total, enabled = true) => {
await act(async () => {
root.render(React.createElement(Probe, { enabled, total }));
});
};
const flushFrames = async () => {
// Growth chains one rAF per step; run until quiescent.
for (let i = 0; i < 20 && frames.some(Boolean); i += 1) {
const pending = frames.splice(0, frames.length).filter(Boolean);
await act(async () => {
for (const cb of pending) cb();
});
}
};
await run({ render, flushFrames, seen, root, act });
await act(async () => root.unmount());
} finally {
globalThis.window = originals.window;
globalThis.document = originals.document;
if (originals.navigator) {
Object.defineProperty(globalThis, "navigator", originals.navigator);
}
globalThis.IS_REACT_ACT_ENVIRONMENT = originals.act;
}
}
test("hook grows stepwise to total and persists across data updates", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
await render(8);
assert.equal(seen.at(-1), 2); // initial window paints first
await flushFrames();
assert.equal(seen.at(-1), 8); // grown 2→5→8
await render(8); // in-place update: stays fully mounted
assert.equal(seen.at(-1), 8);
});
});
test("hook holds at initial while disabled and grows on activation", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
await render(9, false);
await flushFrames();
assert.equal(seen.at(-1), 2, "disabled layout must not grow");
await render(9, true);
await flushFrames();
assert.equal(seen.at(-1), 9);
// Deactivating resets to the initial window for the next activation.
await render(9, false);
await flushFrames();
assert.equal(seen.at(-1), 2);
});
});
test("hook snaps down on total shrink and re-grows stepwise on restore", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
await render(9);
await flushFrames();
assert.equal(seen.at(-1), 9);
await render(4); // scope narrowed
await flushFrames();
assert.equal(seen.at(-1), 4);
await render(9); // scope restored: no one-shot bulk mount
assert.equal(seen.at(-1), 4);
await flushFrames();
assert.equal(seen.at(-1), 9);
});
});
test("list dwell then grid switch: first enabled commit stays at the initial window", async () => {
await withHookHarness(async ({ render, flushFrames, seen }) => {
// Dwell in list layout (counter disabled) long enough that an ungated
// counter would have grown to the full collection.
await render(20, false);
await flushFrames();
assert.equal(seen.at(-1), 2, "dormant counter must hold at initial");
// Switch to grid: the FIRST grid commit must mount only the initial
// window — never the whole collection in one commit.
await render(20, true);
assert.equal(seen.at(-1), 2, "first grid commit stays at initial");
await flushFrames();
assert.equal(seen.at(-1), 20, "then grows stepwise to the total");
});
});

View File

@@ -0,0 +1,86 @@
import * as React from "react";
/**
* Mounts a long list progressively: the first `initial` items render in the
* first commit (fast click→paint), then the count grows by `step` in chained
* low-priority transitions until everything is mounted. This bounds the cost
* of any single React commit — a one-shot mount of hundreds of heavy rows
* blocked the main thread for 0.5–1.5s on the Projects tabs.
*
* The count persists across in-place data updates (a refetch re-rendering an
* already-grown list stays fully mounted) and resets naturally when the
* consuming component remounts (tab switches swap subtrees).
*
* Two guards keep the single-commit bound honest without a remount:
* - `enabled: false` (the inactive layout of a list/grid pair) holds the
* count at `initial`, so switching layouts mounts the other collection
* incrementally instead of all at once from its already-grown counter.
* - A `total` shrink (scope narrowed) snaps the count down — everything
* visible stays mounted — so restoring the wider scope re-grows stepwise
* instead of bulk-mounting the whole collection in one commit.
*/
export function useIncrementalMount(
total: number,
initial = 30,
step = 60,
enabled = true,
): number {
const [count, setCount] = React.useState(initial);
React.useEffect(() => {
if (!enabled) {
if (count !== initial) setCount(initial);
return;
}
if (total < count) {
setCount(Math.max(initial, total));
return;
}
if (count >= total) return;
let cancelled = false;
// rAF so a frame paints between growth steps; the transition keeps each
// growth commit interruptible by user input.
const frame = window.requestAnimationFrame(() => {
if (cancelled) return;
React.startTransition(() => {
setCount((current) => Math.min(current + step, total));
});
});
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
};
}, [count, enabled, initial, step, total]);
return Math.min(count, total);
}
/**
* Trims grouped rows to a total row budget, preserving group order and
* dropping empty groups. Pure core for incrementally-mounted grouped lists.
*/
export function sliceGroupedRows<G extends { rows: readonly unknown[] }>(
groups: readonly G[],
limit: number,
): G[] {
const result: G[] = [];
let remaining = limit;
for (const group of groups) {
if (remaining <= 0) break;
if (group.rows.length <= remaining) {
result.push(group);
remaining -= group.rows.length;
} else {
result.push({ ...group, rows: group.rows.slice(0, remaining) });
remaining = 0;
}
}
return result;
}
/** Total row count across groups. */
export function countGroupedRows(
groups: readonly { rows: readonly unknown[] }[],
): number {
return groups.reduce((sum, group) => sum + group.rows.length, 0);
}