perf: trim team page render overhead
This commit is contained in:
parent
ef129f8931
commit
f05aefe097
7 changed files with 132 additions and 148 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue