fix(perf): resolve all PR #93 review blockers

- Fix react/display-name in DisplayItemList, MarkdownViewer, SessionItem,
  SidebarTaskItem, TeamDetailView, TeamListView, KanbanBoard, GlobalTaskList,
  MemberCard, TaskRow — all anonymous arrows inside memo() replaced with
  named function form
- Fix simple-import-sort violations in TeamDetailView, TeamListView,
  SchedulesView, ScheduleSection — static imports moved before lazy() consts
- Gate all lazy dialogs in TeamDetailView by their open flag so dynamic
  import() only fires when the dialog is actually opened:
  launchDialogOpen, createTaskDialog.open, sendDialogOpen,
  selectedTask !== null, reviewDialogState.open
This commit is contained in:
Mike 2026-05-03 08:57:59 +05:00
parent 49006ee589
commit 053caed8b6
12 changed files with 4537 additions and 4501 deletions

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { memo, useCallback, useState } from 'react';
import {
CODE_BG,
@ -65,9 +65,6 @@ function buildItemMetaTooltip(
return parts.length > 0 ? parts.join(' • ') : undefined;
}
/**
* Truncates text to a maximum length and adds ellipsis if needed.
*/
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
@ -75,25 +72,63 @@ function truncateText(text: string, maxLength: number): string {
return text.substring(0, maxLength) + '...';
}
/**
* Renders a flat list of AIGroupDisplayItem[] into the appropriate components.
*
* This component maps each display item to its corresponding component based on type:
* - thinking -> ThinkingItem
* - output -> TextItem
* - tool -> LinkedToolItem
* - subagent -> SubagentItem
* - slash -> SlashItem
*
* The list is completely flat with no nested toggles or hierarchies.
*/
export const DisplayItemList = React.memo(
({
items,
function getItemKey(item: AIGroupDisplayItem, index: number): string {
switch (item.type) {
case 'thinking':
return `thinking-${index}`;
case 'output':
return `output-${index}`;
case 'tool':
return `tool-${item.tool.id}-${index}`;
case 'subagent':
return `subagent-${item.subagent.id}-${index}`;
case 'slash':
return `slash-${item.slash.name}-${index}`;
case 'teammate_message':
return `teammate-${item.teammateMessage.id}-${index}`;
case 'subagent_input':
return `input-${index}`;
case 'compact_boundary':
return `compact-${index}`;
default:
return `unknown-${index}`;
}
}
// =============================================================================
// Per-item row — memoized to prevent re-renders from parent state changes
// =============================================================================
interface DisplayItemRowProps {
item: AIGroupDisplayItem;
index: number;
itemKey: string;
isExpanded: boolean;
isDimmed: boolean;
hasReplyLink: boolean;
onItemClick: (key: string) => void;
onReplyHover: (toolId: string | null) => void;
aiGroupId: string;
searchQueryOverride?: string;
highlightToolUseId?: string;
highlightColor?: TriggerColor;
notificationColorMap?: Map<string, TriggerColor>;
registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void;
previewMaxLength?: number;
timestampFormat?: string;
showItemMetaTooltip?: boolean;
}
const DisplayItemRow = memo(function DisplayItemRow({
item,
index: _index,
itemKey,
isExpanded,
isDimmed,
hasReplyLink,
onItemClick,
expandedItemIds,
onReplyHover,
aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId,
highlightColor,
@ -102,47 +137,13 @@ export const DisplayItemList = React.memo(
previewMaxLength,
timestampFormat,
showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element => {
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
}: DisplayItemRowProps): React.JSX.Element | null {
const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]);
const handleReplyHover = useCallback((toolId: string | null) => {
setReplyLinkToolId(toolId);
}, []);
/** Check if an item is part of the currently highlighted reply link */
const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => {
if (!replyLinkToolId) return false;
if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true;
if (
item.type === 'teammate_message' &&
item.teammateMessage.replyToToolId === replyLinkToolId
)
return true;
return false;
};
if (!items || items.length === 0) {
return (
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
No items to display
</div>
);
}
return (
<div
className={
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{items.map((item, index) => {
let itemKey = '';
let element: React.ReactNode = null;
switch (item.type) {
case 'thinking': {
itemKey = `thinking-${index}`;
const thinkingStep = {
id: itemKey,
type: 'thinking' as const,
@ -157,8 +158,8 @@ export const DisplayItemList = React.memo(
<ThinkingItem
step={thinkingStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
@ -174,7 +175,6 @@ export const DisplayItemList = React.memo(
}
case 'output': {
itemKey = `output-${index}`;
const textStep = {
id: itemKey,
type: 'output' as const,
@ -189,8 +189,8 @@ export const DisplayItemList = React.memo(
<TextItem
step={textStep}
preview={truncateText(item.content, previewMaxLength ?? 150)}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.timestamp}
timestampFormat={timestampFormat}
titleText={
@ -206,37 +206,29 @@ export const DisplayItemList = React.memo(
}
case 'tool': {
itemKey = `tool-${item.tool.id}-${index}`;
element = (
<LinkedToolItem
linkedTool={item.tool}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.tool.startTime}
timestampFormat={timestampFormat}
titleText={
showItemMetaTooltip
? buildItemMetaTooltip(
item.tool.startTime,
getToolContextTokens(item.tool),
'tokens'
)
? buildItemMetaTooltip(item.tool.startTime, getToolContextTokens(item.tool), 'tokens')
: undefined
}
searchQueryOverride={searchQueryOverride}
isHighlighted={highlightToolUseId === item.tool.id}
highlightColor={highlightColor}
notificationDotColor={notificationColorMap?.get(item.tool.id)}
registerRef={
registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined
}
registerRef={registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined}
/>
);
break;
}
case 'subagent': {
itemKey = `subagent-${item.subagent.id}-${index}`;
const subagentStep = {
id: itemKey,
type: 'subagent' as const,
@ -254,8 +246,8 @@ export const DisplayItemList = React.memo(
<SubagentItem
step={subagentStep}
subagent={item.subagent}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onClick={handleClick}
isExpanded={isExpanded}
aiGroupId={aiGroupId}
highlightToolUseId={highlightToolUseId}
highlightColor={highlightColor}
@ -267,12 +259,11 @@ export const DisplayItemList = React.memo(
}
case 'slash': {
itemKey = `slash-${item.slash.name}-${index}`;
element = (
<SlashItem
slash={item.slash}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onClick={handleClick}
isExpanded={isExpanded}
timestamp={item.slash.timestamp}
timestampFormat={timestampFormat}
titleText={
@ -290,20 +281,18 @@ export const DisplayItemList = React.memo(
}
case 'teammate_message': {
itemKey = `teammate-${item.teammateMessage.id}-${index}`;
element = (
<TeammateMessageItem
teammateMessage={item.teammateMessage}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onReplyHover={handleReplyHover}
onClick={handleClick}
isExpanded={isExpanded}
onReplyHover={onReplyHover}
/>
);
break;
}
case 'subagent_input': {
itemKey = `input-${index}`;
const inputContent = item.content;
const inputTokenCount = item.tokenCount;
element = (
@ -319,8 +308,8 @@ export const DisplayItemList = React.memo(
? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens')
: undefined
}
onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)}
onClick={handleClick}
isExpanded={isExpanded}
>
<MarkdownViewer
content={inputContent}
@ -334,34 +323,26 @@ export const DisplayItemList = React.memo(
}
case 'compact_boundary': {
itemKey = `compact-${index}`;
const compactContent = item.content;
const compactExpanded = expandedItemIds.has(itemKey);
element = (
<div>
<button
onClick={() => onItemClick(itemKey)}
onClick={handleClick}
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
style={{
backgroundColor: TOOL_CALL_BG,
border: `1px solid ${TOOL_CALL_BORDER}`,
}}
aria-expanded={compactExpanded}
>
<div
className="flex shrink-0 items-center gap-1.5"
style={{ color: TOOL_CALL_TEXT }}
aria-expanded={isExpanded}
>
<div className="flex shrink-0 items-center gap-1.5" style={{ color: TOOL_CALL_TEXT }}>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
<Layers size={14} />
</div>
<span
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
Compacted
</span>
{item.tokenDelta && (
@ -386,14 +367,11 @@ export const DisplayItemList = React.memo(
>
Phase {item.phaseNumber}
</span>
<span
className="ml-auto shrink-0 text-[11px]"
style={{ color: COLOR_TEXT_MUTED }}
>
<span className="ml-auto shrink-0 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
{format(new Date(item.timestamp), 'h:mm:ss a')}
</span>
</button>
{compactExpanded && compactContent && (
{isExpanded && compactContent && (
<div
className="mt-1 overflow-hidden rounded-lg"
style={{
@ -418,22 +396,102 @@ export const DisplayItemList = React.memo(
return null;
}
// Apply reply-link spotlight: dim items not in the highlighted pair
const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item);
return (
<div
key={itemKey}
style={
replyLinkToolId !== null
? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' }
: undefined
hasReplyLink ? { opacity: isDimmed ? 0.2 : 1, transition: 'opacity 150ms ease' } : undefined
}
>
{element}
</div>
);
})}
});
// =============================================================================
// Main component
// =============================================================================
/**
* Renders a flat list of AIGroupDisplayItem[] into the appropriate components.
*
* This component maps each display item to its corresponding component based on type:
* - thinking -> ThinkingItem
* - output -> TextItem
* - tool -> LinkedToolItem
* - subagent -> SubagentItem
* - slash -> SlashItem
*
* The list is completely flat with no nested toggles or hierarchies.
*/
export const DisplayItemList = React.memo(function DisplayItemList({
items,
onItemClick,
expandedItemIds,
aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId,
highlightColor,
notificationColorMap,
registerToolRef,
previewMaxLength,
timestampFormat,
showItemMetaTooltip = false,
}: Readonly<DisplayItemListProps>): React.JSX.Element {
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
const handleReplyHover = useCallback((toolId: string | null) => {
setReplyLinkToolId(toolId);
}, []);
if (!items || items.length === 0) {
return (
<div className="px-3 py-2 text-sm italic text-claude-dark-text-secondary">
No items to display
</div>
);
}
);
return (
<div
className={
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{items.map((item, index) => {
const itemKey = getItemKey(item, index);
const isExpanded = expandedItemIds.has(itemKey);
const isInReplyLink =
replyLinkToolId !== null &&
((item.type === 'tool' && item.tool.id === replyLinkToolId) ||
(item.type === 'teammate_message' &&
item.teammateMessage.replyToToolId === replyLinkToolId));
const isDimmed = replyLinkToolId !== null && !isInReplyLink;
return (
<DisplayItemRow
key={itemKey}
item={item}
index={index}
itemKey={itemKey}
isExpanded={isExpanded}
isDimmed={isDimmed}
hasReplyLink={replyLinkToolId !== null}
onItemClick={onItemClick}
onReplyHover={handleReplyHover}
aiGroupId={aiGroupId}
searchQueryOverride={searchQueryOverride}
highlightToolUseId={highlightToolUseId}
highlightColor={highlightColor}
notificationColorMap={notificationColorMap}
registerToolRef={registerToolRef}
previewMaxLength={previewMaxLength}
timestampFormat={timestampFormat}
showItemMetaTooltip={showItemMetaTooltip}
/>
);
})}
</div>
);
});

View file

@ -946,8 +946,7 @@ export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = Rea
}
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
({
export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(function MarkdownViewer({
content,
maxHeight = 'max-h-96',
className = '',
@ -959,7 +958,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
baseDir,
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) => {
}) {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
@ -1063,8 +1062,8 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview
to keep the UI responsive.
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
keep the UI responsive.
</div>
)}
@ -1196,5 +1195,4 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = React.memo(
</div>
</div>
);
}
);
});

View file

@ -25,12 +25,12 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
const LaunchTeamDialog = lazy(() =>
import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';

View file

@ -173,14 +173,13 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
}
export const GlobalTaskList = memo(
({
export const GlobalTaskList = memo(function GlobalTaskList({
hideHeader = false,
filters: externalFilters,
onFiltersChange: externalOnFiltersChange,
filtersPopoverOpen: externalFiltersPopoverOpen,
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
}: GlobalTaskListProps = {}): React.JSX.Element => {
}: GlobalTaskListProps = {}): React.JSX.Element {
const {
globalTasks,
globalTasksLoading,
@ -210,8 +209,7 @@ export const GlobalTaskList = memo(
const filters = externalFilters ?? internalFilters;
const setFilters = externalOnFiltersChange ?? setInternalFilters;
const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen;
const setFiltersPopoverOpen =
externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
const [searchQuery, setSearchQuery] = useState('');
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
const [sortMode, setSortModeState] = useState<TaskSortMode>(loadSortMode);
@ -273,16 +271,20 @@ export const GlobalTaskList = memo(
saveSortMode(mode);
};
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
const handleRenameComplete = useCallback(
(teamName: string, taskId: string, newSubject: string): void => {
taskLocalState.renameTask(teamName, taskId, newSubject);
setRenamingTaskKey(null);
};
},
[taskLocalState]
);
const handleRenameCancel = (): void => {
const handleRenameCancel = useCallback((): void => {
setRenamingTaskKey(null);
};
}, []);
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => {
const handleDeleteTask = useCallback(
async (teamName: string, taskId: string): Promise<void> => {
const confirmed = await confirm({
title: 'Delete task',
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
@ -303,7 +305,9 @@ export const GlobalTaskList = memo(
});
}
}
};
},
[fetchAllTasks, softDeleteTask]
);
// Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching.
@ -418,10 +422,7 @@ export const GlobalTaskList = memo(
);
const projectVisibleCountByKey = useMemo(
() =>
syncProjectGroupVisibleCountByKey(
projectRequestedVisibleCountByKey,
projectGroupVisibility
),
syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility),
[projectRequestedVisibleCountByKey, projectGroupVisibility]
);
@ -578,9 +579,7 @@ export const GlobalTaskList = memo(
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-1.5 py-0.5 transition-colors',
groupingMode === mode
? 'text-text'
: 'text-text-muted hover:text-text-secondary'
groupingMode === mode ? 'text-text' : 'text-text-muted hover:text-text-secondary'
)}
>
{label}
@ -857,5 +856,4 @@ export const GlobalTaskList = memo(
</div>
</div>
);
}
);
});

View file

@ -155,15 +155,14 @@ const SessionRuntimeBadge = ({
);
};
export const SessionItem = memo(
({
export const SessionItem = memo(function SessionItem({
session,
isActive,
isPinned,
isHidden,
multiSelectActive,
isSelected,
}: Readonly<SessionItemProps>): React.JSX.Element => {
}: Readonly<SessionItemProps>): React.JSX.Element {
const {
openTab,
activeProjectId,
@ -346,9 +345,7 @@ export const SessionItem = memo(
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">
{formatShortTime(new Date(session.createdAt))}
</span>
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
{session.model && (
<>
<span style={{ opacity: 0.5 }}>·</span>
@ -393,5 +390,4 @@ export const SessionItem = memo(
)}
</>
);
}
);
});

View file

@ -69,8 +69,7 @@ interface SidebarTaskItemProps {
getDisplaySubject?: (task: GlobalTask) => string | undefined;
}
export const SidebarTaskItem = memo(
({
export const SidebarTaskItem = memo(function SidebarTaskItem({
task,
hideTeamName,
showTeamName,
@ -78,7 +77,7 @@ export const SidebarTaskItem = memo(
onRenameComplete,
onRenameCancel,
getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element => {
}: SidebarTaskItemProps): React.JSX.Element {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
@ -284,5 +283,4 @@ export const SidebarTaskItem = memo(
)}
</button>
);
}
);
});

View file

@ -79,7 +79,6 @@ import { useShallow } from 'zustand/react/shallow';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import { ReviewDialog } from './dialogs/ReviewDialog';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { KanbanBoard } from './kanban/KanbanBoard';
@ -90,6 +89,7 @@ import { MemberDetailDialog } from './members/MemberDetailDialog';
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { ComponentProps, CSSProperties } from 'react';
@ -958,8 +958,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
);
});
export const TeamDetailView = memo(
({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => {
export const TeamDetailView = memo(function TeamDetailView({
teamName,
isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element {
const { isLight } = useTheme();
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
@ -968,8 +970,8 @@ export const TeamDetailView = memo(
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
} | null>(null);
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(
() => getTeamPendingRepliesState(teamName)
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() =>
getTeamPendingRepliesState(teamName)
);
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false,
@ -1201,9 +1203,7 @@ export const TeamDetailView = memo(
const [stoppingTeam, setStoppingTeam] = useState(false);
const [trashOpen, setTrashOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(
undefined
);
const [sendDialogDefaultText, setSendDialogDefaultText] = useState<string | undefined>(undefined);
const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState<InlineChip | undefined>(
undefined
);
@ -1364,8 +1364,7 @@ export const TeamDetailView = memo(
maxWidth: 600,
side: 'left',
});
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } =
useResizablePanel({
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({
height: sidebarLogsHeight,
onHeightChange: setSidebarLogsHeight,
minHeight: 120,
@ -1715,17 +1714,11 @@ export const TeamDetailView = memo(
}, [activeMembers, data?.config.members, launchParams?.providerId]);
const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
const taskMap = useMemo(
() => new Map((data?.tasks ?? []).map((t) => [t.id, t])),
[data?.tasks]
);
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const taskMapRef = useRef(taskMap);
taskMapRef.current = taskMap;
const memberTaskCounts = useMemo(
() => buildTaskCountsByOwner(data?.tasks ?? []),
[data?.tasks]
);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
const openCreateTaskDialog = useCallback(
(subject = '', description = '', owner = '', startImmediately?: boolean): void => {
@ -2029,13 +2022,7 @@ export const TeamDetailView = memo(
startImmediately,
});
if (
prompt &&
owner &&
data?.isAlive &&
!isTeamProvisioning &&
startImmediately !== false
) {
if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) {
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
try {
await api.teams.processSend(teamName, msg);
@ -2187,6 +2174,7 @@ export const TeamDetailView = memo(
</div>
</div>
</div>
{launchDialogOpen && (
<Suspense fallback={null}>
<LaunchTeamDialog
mode={launchDialogState.mode}
@ -2201,6 +2189,7 @@ export const TeamDetailView = memo(
onRelaunch={handleRelaunchDialogSubmit}
/>
</Suspense>
)}
</>
);
}
@ -2377,10 +2366,7 @@ export const TeamDetailView = memo(
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5">
{data.config.projectPath && (
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]">
<FolderOpen
size={11}
className="shrink-0 text-[var(--color-text-muted)]"
/>
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-60 truncate font-mono">
@ -2415,10 +2401,7 @@ export const TeamDetailView = memo(
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
title={leadBranch}
>
<GitBranch
size={11}
className="shrink-0 text-[var(--color-text-muted)]"
/>
<GitBranch size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="max-w-32 truncate">{leadBranch}</span>
</span>
)}
@ -2447,9 +2430,7 @@ export const TeamDetailView = memo(
</div>
{(() => {
const currentPath = data.config.projectPath;
const history = data.config.projectPathHistory?.filter(
(p) => p !== currentPath
);
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
if (!history || history.length === 0) return null;
return (
<div
@ -2542,9 +2523,7 @@ export const TeamDetailView = memo(
sessionsError={sessionsError}
leadSessionId={data.config.leadSessionId}
selectedSessionId={kanbanFilter.sessionId}
onSelectSession={(id) =>
setKanbanFilter((prev) => ({ ...prev, sessionId: id }))
}
onSelectSession={(id) => setKanbanFilter((prev) => ({ ...prev, sessionId: id }))}
projectPath={data.config.projectPath}
/>
</CollapsibleTeamSection>
@ -2865,6 +2844,7 @@ export const TeamDetailView = memo(
}}
/>
{createTaskDialog.open && (
<Suspense fallback={null}>
<CreateTaskDialog
open={createTaskDialog.open}
@ -2882,6 +2862,7 @@ export const TeamDetailView = memo(
submitting={creatingTask}
/>
</Suspense>
)}
<EditTeamDialog
open={editDialogOpen}
@ -2948,11 +2929,7 @@ export const TeamDetailView = memo(
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => setRemoveMemberConfirm(null)}
>
<Button variant="ghost" size="sm" onClick={() => setRemoveMemberConfirm(null)}>
Cancel
</Button>
<Button
@ -2976,8 +2953,8 @@ export const TeamDetailView = memo(
<DialogHeader>
<DialogTitle>Delete team</DialogTitle>
<DialogDescription>
Delete team &ldquo;{data.config.name}&rdquo;? This action is irreversible.
All team data and tasks will be deleted.
Delete team &ldquo;{data.config.name}&rdquo;? This action is irreversible. All
team data and tasks will be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -2991,6 +2968,7 @@ export const TeamDetailView = memo(
</DialogContent>
</Dialog>
{launchDialogOpen && (
<Suspense fallback={null}>
<LaunchTeamDialog
mode={launchDialogState.mode}
@ -3006,7 +2984,9 @@ export const TeamDetailView = memo(
onRelaunch={handleRelaunchDialogSubmit}
/>
</Suspense>
)}
{sendDialogOpen && (
<Suspense fallback={null}>
<SendMessageDialog
open={sendDialogOpen}
@ -3064,7 +3044,9 @@ export const TeamDetailView = memo(
}}
/>
</Suspense>
)}
{selectedTask !== null && (
<Suspense fallback={null}>
<TaskDetailDialog
open={selectedTask !== null}
@ -3108,6 +3090,7 @@ export const TeamDetailView = memo(
onDeleteTask={handleDeleteTask}
/>
</Suspense>
)}
<TrashDialog
open={trashOpen}
@ -3124,6 +3107,7 @@ export const TeamDetailView = memo(
}}
/>
{reviewDialogState.open && (
<Suspense fallback={null}>
<ChangeReviewDialog
open={reviewDialogState.open}
@ -3146,6 +3130,7 @@ export const TeamDetailView = memo(
onEditorAction={handleEditorAction}
/>
</Suspense>
)}
</div>
<div
ref={setMessagesPanelMountPoint}
@ -3213,5 +3198,4 @@ export const TeamDetailView = memo(
{renderBody()}
</>
);
}
);
});

View file

@ -45,7 +45,6 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import {
@ -54,13 +53,7 @@ import {
teamMatchesProjectSelection,
} from './teamProjectSelection';
const CreateTeamDialog = lazy(() =>
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
);
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type { TeamStatus } from '@renderer/utils/teamListStatus';
import type {
@ -72,6 +65,13 @@ import type {
TeamSummaryMember,
} from '@shared/types';
const CreateTeamDialog = lazy(() =>
import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog }))
);
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
const existing = new Set(existingNames);
@ -238,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
}
};
export const TeamListView = memo((): React.JSX.Element => {
export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const { isLight } = useTheme();
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);

View file

@ -311,8 +311,7 @@ const SortableKanbanTaskCard = ({
);
};
export const KanbanBoard = memo(
({
export const KanbanBoard = memo(function KanbanBoard({
tasks,
teamName,
kanbanState,
@ -339,7 +338,7 @@ export const KanbanBoard = memo(
onDeleteTask,
deletedTaskCount,
onOpenTrash,
}: KanbanBoardProps): React.JSX.Element => {
}: KanbanBoardProps): React.JSX.Element {
const boardRef = useRef<HTMLDivElement>(null);
const scrollRestoreTimeoutsRef = useRef<number[]>([]);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -423,11 +422,8 @@ export const KanbanBoard = memo(
[onColumnOrderChange, groupedOrdered]
);
const renderCards = (
columnId: KanbanColumnId,
columnTasks: TeamTask[],
compact?: boolean
): React.JSX.Element => {
const renderCards = useCallback(
(columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
@ -517,7 +513,28 @@ export const KanbanBoard = memo(
{addButton}
</>
);
};
},
[
enableTaskSorting,
hasReviewers,
kanbanState,
memberColorMap,
onAddTask,
onApprove,
onCancelTask,
onCompleteTask,
onDeleteTask,
onMoveBackToDone,
onRequestChanges,
onRequestReview,
onScrollToTask,
onStartTask,
onTaskClick,
onViewChanges,
taskMap,
teamName,
]
);
const visibleColumns = useMemo(
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
@ -533,9 +550,7 @@ export const KanbanBoard = memo(
const columnModeSearchWidth =
primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256;
const toolbarLeftWidth =
viewMode === 'grid'
? (gridPrimaryColumnWidth ?? columnModeSearchWidth)
: columnModeSearchWidth;
viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth;
const clearScheduledScrollRestore = useCallback(() => {
for (const timeoutId of scrollRestoreTimeoutsRef.current) {
@ -594,6 +609,29 @@ export const KanbanBoard = memo(
[scheduleScrollRestore, viewMode]
);
const gridColumns = useMemo(
() =>
visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({
key: task.id,
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
})),
};
}),
[visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers]
);
const boardContent = (
<div ref={boardRef} className="min-w-0 max-w-full overflow-x-hidden">
<div
@ -685,30 +723,7 @@ export const KanbanBoard = memo(
primaryColumnId={primaryVisibleColumnId}
onPrimaryColumnWidthChange={setGridPrimaryColumnWidth}
skeletonDelayMs={gridSkeletonDelayMs}
columns={visibleColumns.map((column) => {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
title: column.title,
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({
key: task.id,
height: estimateGridSkeletonCardHeight(
task,
column.id,
kanbanState,
hasReviewers
),
})),
};
})}
columns={gridColumns}
/>
) : (
<div className="w-full min-w-0 max-w-full overflow-x-auto overflow-y-hidden px-1 pb-6 pr-4 pt-2">
@ -760,5 +775,4 @@ export const KanbanBoard = memo(
}
return boardContent;
}
);
});

View file

@ -91,8 +91,7 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
};
}
export const MemberCard = memo(
({
export const MemberCard = memo(function MemberCard({
member,
memberColor,
runtimeSummary,
@ -120,7 +119,7 @@ export const MemberCard = memo(
onAssignTask,
onRestartMember,
onSkipMemberForLaunch,
}: MemberCardProps): React.JSX.Element => {
}: MemberCardProps): React.JSX.Element {
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
// const leadContext = useStore((s) =>
@ -278,9 +277,7 @@ export const MemberCard = memo(
const restartActionErrorFallback = canRelaunchOpenCode
? 'Failed to relaunch OpenCode teammate'
: 'Failed to retry teammate';
const handleRestartMember = async (
event: React.MouseEvent<HTMLButtonElement>
): Promise<void> => {
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
if (!onRestartMember || retryingLaunch) {
@ -473,9 +470,7 @@ export const MemberCard = memo(
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
@ -544,9 +539,7 @@ export const MemberCard = memo(
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch || skippingLaunch}
onClick={handleRestartMember}
@ -588,9 +581,7 @@ export const MemberCard = memo(
<TooltipTrigger asChild>
<button
type="button"
aria-label={
retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel
}
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
@ -718,5 +709,4 @@ export const MemberCard = memo(
</div>
</div>
);
}
);
});

View file

@ -19,13 +19,13 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { ScheduleEmptyState } from './ScheduleEmptyState';
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
const LaunchTeamDialog = lazy(() =>
import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
import type { Schedule, ScheduleRun } from '@shared/types';

View file

@ -14,7 +14,7 @@ interface TaskRowProps {
task: TeamTaskWithKanban;
}
export const TaskRow = memo(({ task }: TaskRowProps): React.JSX.Element => {
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const kanbanColumn = getTaskKanbanColumn(task);