mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
83
desktop/src/features/projects/ui/projectsViewWorkItems.ts
Normal file
83
desktop/src/features/projects/ui/projectsViewWorkItems.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
157
desktop/src/shared/hooks/useIncrementalMount.test.mjs
Normal file
157
desktop/src/shared/hooks/useIncrementalMount.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
86
desktop/src/shared/hooks/useIncrementalMount.ts
Normal file
86
desktop/src/shared/hooks/useIncrementalMount.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user