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 { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
@ -178,7 +177,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
return (
<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)' }}
onClick={() => {
if (!isRenaming) {
@ -225,37 +224,29 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
/>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
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
className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
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}`}
>
<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
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>
)}
{tCommon('tasks.reviewState.needsFix')}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{displaySubject}
</TooltipContent>
</Tooltip>
)}
</span>
)}
</div>

View file

@ -356,6 +356,12 @@ const ActiveTeamCard = ({
const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch';
const launchLabel =
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 (
<div
@ -400,78 +406,51 @@ const ActiveTeamCard = ({
</div>
<div className="flex shrink-0 gap-1">
{canLaunch ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="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"
onClick={(event) =>
onLaunchTeam(
team.teamName,
team.projectPath ?? undefined,
launchMode,
event
)
}
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>
<button
type="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"
onClick={(event) =>
onLaunchTeam(team.teamName, team.projectPath ?? undefined, launchMode, event)
}
disabled={launchingTeamName === team.teamName}
aria-label={launchTitle}
title={launchTitle}
>
<Play size={14} fill="currentColor" />
</button>
) : null}
{status === 'active' || status === 'idle' ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="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"
onClick={(event) => onStopTeam(team.teamName, event)}
disabled={stoppingTeamName === team.teamName}
aria-label={t('list.actions.stopTeam')}
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName
? t('list.actions.stopping')
: t('list.actions.stopTeam')}
</TooltipContent>
</Tooltip>
<button
type="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"
onClick={(event) => onStopTeam(team.teamName, event)}
disabled={stoppingTeamName === team.teamName}
aria-label={stopTitle}
title={stopTitle}
>
<Square size={14} fill="currentColor" />
</button>
) : null}
{!team.pendingCreate ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="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"
onClick={(event) => onCopyTeam(team.teamName, event)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('list.actions.copyTeam')}</TooltipContent>
</Tooltip>
<button
type="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"
onClick={(event) => onCopyTeam(team.teamName, event)}
aria-label={copyTitle}
title={copyTitle}
>
<Copy size={14} />
</button>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="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"
onClick={(event) => onDeleteTeam(team.teamName, !!team.pendingCreate, event)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('list.actions.deleteTeam')}</TooltipContent>
</Tooltip>
<button
type="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"
onClick={(event) => onDeleteTeam(team.teamName, !!team.pendingCreate, event)}
aria-label={deleteTitle}
title={deleteTitle}
>
<Trash2 size={14} />
</button>
</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 { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
CARD_BG,
CARD_BG_ZEBRA,
@ -1310,7 +1305,7 @@ export const ActivityItem = memo(
return (
<article
className="group overflow-hidden rounded-md"
className="activity-timeline-card group overflow-hidden rounded-md"
style={{
marginLeft: isSlashCommandResult ? 26 : isUserSent ? 15 : undefined,
backgroundColor:
@ -1424,27 +1419,14 @@ export const ActivityItem = memo(
)}
</div>
</div>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div title={compactPreviewTooltipText}>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</div>
) : !isExpanded ? (
<div className="min-w-0 flex-1">
@ -1506,27 +1488,14 @@ export const ActivityItem = memo(
)}
</div>
</div>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div title={compactPreviewTooltipText}>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</div>
) : (
<>

View file

@ -83,6 +83,8 @@ export interface TimelineViewport {
scrollMargin?: number;
/** Enable virtualization (wired in a follow-up; ignored for now). */
virtualizationEnabled?: boolean;
/** Optional row-count gate for compact hosts that need virtualization earlier. */
virtualizationRowThreshold?: number;
}
interface ActivityTimelineProps {
@ -692,7 +694,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
const shouldVirtualize =
viewport?.virtualizationEnabled === true &&
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
// timeline root. We avoid re-measuring on every scroll: the offset only

View file

@ -28,6 +28,29 @@ export const AnimatedHeightReveal = ({
style,
containerRef,
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 => {
const [shouldAnimateOnMount] = useState(() => Boolean(animate));
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
// the row count crosses its internal threshold.
virtualizationEnabled: true,
virtualizationRowThreshold: position === 'sidebar' ? 24 : undefined,
};
}, [activeScrollContainerRef]);
}, [activeScrollContainerRef, position]);
const handleExpandContent = useCallback(() => {
// 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-hover-bg: color-mix(in srgb, var(--row-zebra-bg) 97%, var(--color-text) 3%);
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,