perf: trim team page render overhead

This commit is contained in:
777genius 2026-05-29 15:15:01 +03:00
parent ef129f8931
commit f05aefe097
7 changed files with 132 additions and 148 deletions

View file

@ -1,7 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useAppTranslation } from '@features/localization/renderer'; import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
@ -178,7 +177,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
return ( return (
<button <button
type="button" type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`} className={`sidebar-task-item flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
onClick={() => { onClick={() => {
if (!isRenaming) { if (!isRenaming) {
@ -225,37 +224,29 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
/> />
</div> </div>
) : ( ) : (
<Tooltip> <span
<TooltipTrigger asChild> className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
title={displaySubject}
>
<StatusIcon className={cn('mr-1.5 inline-block align-[-1px]', statusIconClassName)} />
{unreadCount > 0 &&
(unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
) : (
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
))}
{displaySubject}
{isTeamTaskNeedsFixActionable(task) && (
<span <span
className="line-clamp-2 text-[13px] font-medium leading-tight" className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
style={{ color: 'var(--color-text-muted)' }}
> >
<StatusIcon {tCommon('tasks.reviewState.needsFix')}
className={cn('mr-1.5 inline-block align-[-1px]', statusIconClassName)}
/>
{unreadCount > 0 &&
(unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
) : (
<span className="mr-1 inline-flex size-3.5 items-center justify-center rounded-full bg-blue-500 align-middle text-[8px] font-bold leading-none text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
))}
{displaySubject}
{isTeamTaskNeedsFixActionable(task) && (
<span
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
>
{tCommon('tasks.reviewState.needsFix')}
</span>
)}
</span> </span>
</TooltipTrigger> )}
<TooltipContent side="right" sideOffset={6}> </span>
{displaySubject}
</TooltipContent>
</Tooltip>
)} )}
</div> </div>

View file

@ -356,6 +356,12 @@ const ActiveTeamCard = ({
const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch'; const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch';
const launchLabel = const launchLabel =
launchMode === 'relaunch' ? t('list.actions.relaunchTeam') : t('list.actions.launchTeam'); launchMode === 'relaunch' ? t('list.actions.relaunchTeam') : t('list.actions.launchTeam');
const launchTitle =
launchingTeamName === team.teamName ? t('list.actions.launching') : launchLabel;
const stopTitle =
stoppingTeamName === team.teamName ? t('list.actions.stopping') : t('list.actions.stopTeam');
const copyTitle = t('list.actions.copyTeam');
const deleteTitle = t('list.actions.deleteTeam');
return ( return (
<div <div
@ -400,78 +406,51 @@ const ActiveTeamCard = ({
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
{canLaunch ? ( {canLaunch ? (
<Tooltip> <button
<TooltipTrigger asChild> type="button"
<button className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
type="button" onClick={(event) =>
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100" onLaunchTeam(team.teamName, team.projectPath ?? undefined, launchMode, event)
onClick={(event) => }
onLaunchTeam( disabled={launchingTeamName === team.teamName}
team.teamName, aria-label={launchTitle}
team.projectPath ?? undefined, title={launchTitle}
launchMode, >
event <Play size={14} fill="currentColor" />
) </button>
}
disabled={launchingTeamName === team.teamName}
aria-label={launchLabel}
>
<Play size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{launchingTeamName === team.teamName
? t('list.actions.launching')
: launchLabel}
</TooltipContent>
</Tooltip>
) : null} ) : null}
{status === 'active' || status === 'idle' ? ( {status === 'active' || status === 'idle' ? (
<Tooltip> <button
<TooltipTrigger asChild> type="button"
<button className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
type="button" onClick={(event) => onStopTeam(team.teamName, event)}
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100" disabled={stoppingTeamName === team.teamName}
onClick={(event) => onStopTeam(team.teamName, event)} aria-label={stopTitle}
disabled={stoppingTeamName === team.teamName} title={stopTitle}
aria-label={t('list.actions.stopTeam')} >
> <Square size={14} fill="currentColor" />
<Square size={14} fill="currentColor" /> </button>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName
? t('list.actions.stopping')
: t('list.actions.stopTeam')}
</TooltipContent>
</Tooltip>
) : null} ) : null}
{!team.pendingCreate ? ( {!team.pendingCreate ? (
<Tooltip> <button
<TooltipTrigger asChild> type="button"
<button className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
type="button" onClick={(event) => onCopyTeam(team.teamName, event)}
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100" aria-label={copyTitle}
onClick={(event) => onCopyTeam(team.teamName, event)} title={copyTitle}
> >
<Copy size={14} /> <Copy size={14} />
</button> </button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('list.actions.copyTeam')}</TooltipContent>
</Tooltip>
) : null} ) : null}
<Tooltip> <button
<TooltipTrigger asChild> type="button"
<button className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
type="button" onClick={(event) => onDeleteTeam(team.teamName, !!team.pendingCreate, event)}
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100" aria-label={deleteTitle}
onClick={(event) => onDeleteTeam(team.teamName, !!team.pendingCreate, event)} title={deleteTitle}
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('list.actions.deleteTeam')}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>

View file

@ -10,12 +10,7 @@ import { AttachmentDisplay } from '@renderer/components/team/attachments/Attachm
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { import {
CARD_BG, CARD_BG,
CARD_BG_ZEBRA, CARD_BG_ZEBRA,
@ -1310,7 +1305,7 @@ export const ActivityItem = memo(
return ( return (
<article <article
className="group overflow-hidden rounded-md" className="activity-timeline-card group overflow-hidden rounded-md"
style={{ style={{
marginLeft: isSlashCommandResult ? 26 : isUserSent ? 15 : undefined, marginLeft: isSlashCommandResult ? 26 : isUserSent ? 15 : undefined,
backgroundColor: backgroundColor:
@ -1424,27 +1419,14 @@ export const ActivityItem = memo(
)} )}
</div> </div>
</div> </div>
<TooltipProvider delayDuration={1000}> <div title={compactPreviewTooltipText}>
<Tooltip> <CompactMarkdownPreview
<TooltipTrigger asChild> content={compactPreviewMarkdown}
<div> className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
<CompactMarkdownPreview teamColorByName={teamColorByName}
content={compactPreviewMarkdown} onTeamClick={onTeamClick}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4" />
teamColorByName={teamColorByName} </div>
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
) : !isExpanded ? ( ) : !isExpanded ? (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@ -1506,27 +1488,14 @@ export const ActivityItem = memo(
)} )}
</div> </div>
</div> </div>
<TooltipProvider delayDuration={1000}> <div title={compactPreviewTooltipText}>
<Tooltip> <CompactMarkdownPreview
<TooltipTrigger asChild> content={compactPreviewMarkdown}
<div> className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
<CompactMarkdownPreview teamColorByName={teamColorByName}
content={compactPreviewMarkdown} onTeamClick={onTeamClick}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4" />
teamColorByName={teamColorByName} </div>
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
) : ( ) : (
<> <>

View file

@ -83,6 +83,8 @@ export interface TimelineViewport {
scrollMargin?: number; scrollMargin?: number;
/** Enable virtualization (wired in a follow-up; ignored for now). */ /** Enable virtualization (wired in a follow-up; ignored for now). */
virtualizationEnabled?: boolean; virtualizationEnabled?: boolean;
/** Optional row-count gate for compact hosts that need virtualization earlier. */
virtualizationRowThreshold?: number;
} }
interface ActivityTimelineProps { interface ActivityTimelineProps {
@ -692,7 +694,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
const shouldVirtualize = const shouldVirtualize =
viewport?.virtualizationEnabled === true && viewport?.virtualizationEnabled === true &&
viewport.scrollElementRef != null && viewport.scrollElementRef != null &&
renderRows.length >= VIRTUALIZATION_ROW_THRESHOLD; renderRows.length >= (viewport.virtualizationRowThreshold ?? VIRTUALIZATION_ROW_THRESHOLD);
// DOM-measured distance from the scroll container's scroll origin to the // DOM-measured distance from the scroll container's scroll origin to the
// timeline root. We avoid re-measuring on every scroll: the offset only // timeline root. We avoid re-measuring on every scroll: the offset only

View file

@ -28,6 +28,29 @@ export const AnimatedHeightReveal = ({
style, style,
containerRef, containerRef,
children, children,
}: AnimatedHeightRevealProps): JSX.Element => {
if (!animate && !className && !style && !containerRef) {
return <>{children}</>;
}
return (
<AnimatedHeightRevealInner
animate={animate}
className={className}
style={style}
containerRef={containerRef}
>
{children}
</AnimatedHeightRevealInner>
);
};
const AnimatedHeightRevealInner = ({
animate,
className,
style,
containerRef,
children,
}: AnimatedHeightRevealProps): JSX.Element => { }: AnimatedHeightRevealProps): JSX.Element => {
const [shouldAnimateOnMount] = useState(() => Boolean(animate)); const [shouldAnimateOnMount] = useState(() => Boolean(animate));
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);

View file

@ -363,8 +363,9 @@ export const MessagesPanel = memo(function MessagesPanel({
// path for short lists and only switches to the windowed path once // path for short lists and only switches to the windowed path once
// the row count crosses its internal threshold. // the row count crosses its internal threshold.
virtualizationEnabled: true, virtualizationEnabled: true,
virtualizationRowThreshold: position === 'sidebar' ? 24 : undefined,
}; };
}, [activeScrollContainerRef]); }, [activeScrollContainerRef, position]);
const handleExpandContent = useCallback(() => { const handleExpandContent = useCallback(() => {
// no-op: user is reading expanded content, not composing // no-op: user is reading expanded content, not composing
}, []); }, []);

View file

@ -1470,6 +1470,25 @@ a[href],
--row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 99%, var(--color-text) 1%); --row-zebra-bg: color-mix(in srgb, var(--color-surface-raised) 99%, var(--color-text) 1%);
--row-zebra-hover-bg: color-mix(in srgb, var(--row-zebra-bg) 97%, var(--color-text) 3%); --row-zebra-hover-bg: color-mix(in srgb, var(--row-zebra-bg) 97%, var(--color-text) 3%);
background-color: var(--row-card-bg); background-color: var(--row-card-bg);
content-visibility: auto;
}
.team-row-zebra-card {
contain-intrinsic-size: auto 260px;
}
.project-row-zebra-card {
contain-intrinsic-size: auto 190px;
}
.activity-timeline-card {
content-visibility: auto;
contain-intrinsic-size: auto 96px;
}
.sidebar-task-item {
content-visibility: auto;
contain-intrinsic-size: auto 52px;
} }
.team-row-zebra-card:hover, .team-row-zebra-card:hover,