diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 688aa958..5469b20f 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; @@ -69,218 +69,220 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = ({ - task, - hideTeamName, - showTeamName, - renamingKey, - onRenameComplete, - onRenameCancel, - getDisplaySubject, -}: 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); - const { isLight } = useTheme(); +export const SidebarTaskItem = memo( + ({ + task, + hideTeamName, + showTeamName, + renamingKey, + onRenameComplete, + onRenameCancel, + getDisplaySubject, + }: 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); + const { isLight } = useTheme(); - const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; - const [editValue, setEditValue] = useState(displaySubject); - const inputRef = useRef(null); - // Focus input when rename starts - useEffect(() => { - if (!isRenaming) return; - const raf = requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => cancelAnimationFrame(raf); - }, [isRenaming]); + const isRenaming = renamingKey === `${task.teamName}:${task.id}`; + const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const [editValue, setEditValue] = useState(displaySubject); + const inputRef = useRef(null); + // Focus input when rename starts + useEffect(() => { + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [isRenaming]); - // Reset edit value when renaming starts - useEffect(() => { - if (isRenaming) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change - setEditValue(displaySubject); - } - }, [isRenaming, displaySubject]); + // Reset edit value when renaming starts + useEffect(() => { + if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setEditValue(displaySubject); + } + }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); - const cfg = - reviewColumn === 'approved' - ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) - : reviewColumn === 'review' - ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) - : (statusConfig[task.status] ?? statusConfig.pending); - const StatusIcon = cfg.icon; - const updatedLabel = formatUpdatedLabel(task); - const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); + const reviewColumn = getTaskKanbanColumn(task); + const cfg = + reviewColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) + : reviewColumn === 'review' + ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); + const StatusIcon = cfg.icon; + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); - const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); - return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + const ownerColorSet = useMemo(() => { + if (!teamMembers || !task.owner) return null; + const colorMap = buildMemberColorMap(teamMembers); + const colorName = colorMap.get(task.owner); + return colorName ? getTeamColorSet(colorName) : null; + }, [teamMembers, task.owner]); - const ownerTextColor = useMemo(() => { - if (!ownerColorSet) return undefined; - return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; - }, [ownerColorSet, isLight]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); - const projectLabel = useMemo(() => { - if (!task.projectPath?.trim()) return null; - return projectLabelFromPath(task.projectPath); - }, [task.projectPath]); + const projectLabel = useMemo(() => { + if (!task.projectPath?.trim()) return null; + return projectLabelFromPath(task.projectPath); + }, [task.projectPath]); - const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel, isLight) : null), - [projectLabel, isLight] - ); + const projectColorSet = useMemo( + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] + ); - const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), - [showTeamName, task.teamDisplayName, isLight] - ); + const teamColor = useMemo( + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] + ); - const showTeamRow = showTeamName && !hideTeamName; - const unreadBackgroundClass = - unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; + const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; - return ( - - ); -}; + + ); + } +); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 62d5759e..056f0014 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -311,445 +311,454 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = ({ - tasks, - teamName, - kanbanState, - filter, - sort, - sessions, - leadSessionId, - members, - onFilterChange, - onSortChange, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onColumnOrderChange, - toolbarLeft, - onAddTask, - onDeleteTask, - deletedTaskCount, - onOpenTrash, -}: KanbanBoardProps): React.JSX.Element => { - const boardRef = useRef(null); - const scrollRestoreTimeoutsRef = useRef([]); - const [viewMode, setViewMode] = useState('grid'); - const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); - const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); - const hasReviewers = kanbanState.reviewers.length > 0; - const enableTaskSorting = - viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; +export const KanbanBoard = memo( + ({ + tasks, + teamName, + kanbanState, + filter, + sort, + sessions, + leadSessionId, + members, + onFilterChange, + onSortChange, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onColumnOrderChange, + toolbarLeft, + onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, + }: KanbanBoardProps): React.JSX.Element => { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); + const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const stableTaskMapRef = useRef<{ - signatures: string[]; - map: Map; - } | null>(null); - const taskMap = useMemo(() => { - const signatures = tasks.map( - (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` - ); - const previous = stableTaskMapRef.current; - if ( - previous?.signatures.length === signatures.length && - previous.signatures.every((signature, index) => signature === signatures[index]) - ) { - return previous.map; - } - - const next = new Map(tasks.map((task) => [task.id, task])); - stableTaskMapRef.current = { signatures, map: next }; - return next; - }, [tasks]); - const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); - const grouped = useMemo(() => { - const result = new Map( - COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) - ); - for (const task of tasks) { - const column = getTaskColumn(task, kanbanState); - if (!column) { - continue; - } - result.get(column)?.push(task); - } - return result; - }, [tasks, kanbanState]); - - const groupedOrdered = useMemo(() => { - const result = new Map(); - for (const column of COLUMNS) { - const columnTasks = grouped.get(column.id) ?? []; - const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); - } - return result; - }, [grouped, kanbanState.columnOrder, sort.field]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }) - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!onColumnOrderChange || !over || active.id === over.id) { - return; - } - const activeData = active.data.current; - if (activeData?.type !== 'kanban-task') { - return; - } - const columnId = activeData.columnId as KanbanColumnId; - const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; - const oldIndex = orderedIds.indexOf(active.id as string); - const newIndex = orderedIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const newOrder = arrayMove(orderedIds, oldIndex, newIndex); - onColumnOrderChange(columnId, newOrder); - }, - [onColumnOrderChange, groupedOrdered] - ); - - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { - const addHandler = - onAddTask && columnId === 'todo' - ? () => onAddTask(false) - : onAddTask && columnId === 'in_progress' - ? () => onAddTask(true) - : undefined; - - const addButton = addHandler ? ( - - ) : null; - - if (columnTasks.length === 0) { - return ( - addButton ?? ( -
- No tasks -
- ) + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); - } - if (enableTaskSorting) { - const itemIds = columnTasks.map((t) => t.id); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } + + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); + const grouped = useMemo(() => { + const result = new Map( + COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) + ); + for (const task of tasks) { + const column = getTaskColumn(task, kanbanState); + if (!column) { + continue; + } + result.get(column)?.push(task); + } + return result; + }, [tasks, kanbanState]); + + const groupedOrdered = useMemo(() => { + const result = new Map(); + for (const column of COLUMNS) { + const columnTasks = grouped.get(column.id) ?? []; + const order = kanbanState.columnOrder?.[column.id]; + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); + } + return result; + }, [grouped, kanbanState.columnOrder, sort.field]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!onColumnOrderChange || !over || active.id === over.id) { + return; + } + const activeData = active.data.current; + if (activeData?.type !== 'kanban-task') { + return; + } + const columnId = activeData.columnId as KanbanColumnId; + const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; + const oldIndex = orderedIds.indexOf(active.id as string); + const newIndex = orderedIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + const newOrder = arrayMove(orderedIds, oldIndex, newIndex); + onColumnOrderChange(columnId, newOrder); + }, + [onColumnOrderChange, groupedOrdered] + ); + + const renderCards = ( + columnId: KanbanColumnId, + columnTasks: TeamTask[], + compact?: boolean + ): React.JSX.Element => { + const addHandler = + onAddTask && columnId === 'todo' + ? () => onAddTask(false) + : onAddTask && columnId === 'in_progress' + ? () => onAddTask(true) + : undefined; + + const addButton = addHandler ? ( + + ) : null; + + if (columnTasks.length === 0) { + return ( + addButton ?? ( +
+ No tasks +
+ ) + ); + } + if (enableTaskSorting) { + const itemIds = columnTasks.map((t) => t.id); + return ( + <> + + {columnTasks.map((task) => ( + + ))} + + {addButton} + + ); + } return ( <> - - {columnTasks.map((task) => ( - - ))} - + {columnTasks.map((task) => ( + + ))} {addButton} ); - } - return ( - <> - {columnTasks.map((task) => ( - - ))} - {addButton} - + }; + + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] ); - }; + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; - const visibleColumns = useMemo( - () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), - [filter.columns] - ); - const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' + ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) + : columnModeSearchWidth; - const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); - const { widths: columnWidths, getHandleProps } = useResizableColumns({ - storageKey: teamName, - columnIds: resizableColumnIds, - }); - const columnModeSearchWidth = - primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; - const toolbarLeftWidth = - viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; - - const clearScheduledScrollRestore = useCallback(() => { - for (const timeoutId of scrollRestoreTimeoutsRef.current) { - window.clearTimeout(timeoutId); - } - scrollRestoreTimeoutsRef.current = []; - }, []); - - useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); - - const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { - let current = startNode?.parentElement ?? null; - while (current) { - const { overflowY } = window.getComputedStyle(current); - if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { - return current; + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); } - current = current.parentElement; - } - return null; - }, []); + scrollRestoreTimeoutsRef.current = []; + }, []); - const scheduleScrollRestore = useCallback( - (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { - const container = findScrollContainer(boardRef.current); - if (!container) { - return; + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; + } + current = current.parentElement; } + return null; + }, []); - const savedScrollTop = container.scrollTop; - clearScheduledScrollRestore(); + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; + } - const restore = (): void => { - container.scrollTop = savedScrollTop; - }; + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); - const delays = - nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; - scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); - }, - [clearScheduledScrollRestore, findScrollContainer] - ); + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; - const switchViewMode = useCallback( - (nextViewMode: KanbanViewMode) => { - const nextSkeletonDelayMs = - nextViewMode === 'grid' && viewMode === 'columns' - ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH - : SKELETON_HIDE_DELAY_MS; + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); - setGridSkeletonDelayMs(nextSkeletonDelayMs); - scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); - setViewMode(nextViewMode); - }, - [scheduleScrollRestore, viewMode] - ); + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; - const boardContent = ( -
-
- {toolbarLeft != null && ( -
- {toolbarLeft} -
- )} -
-
- -
- -
- {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( - - - - - Trash - - ) : null} -
- - - - - Grid view - - - - - - Columns view - + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); + + const boardContent = ( +
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft} +
+ )} +
+
+ +
+ +
+ {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null} +
+ + + + + Grid view + + + + + + Columns view + +
-
- {viewMode === 'grid' ? ( - column.id)} - 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), - })), - }; - })} - /> - ) : ( -
-
- {visibleColumns.map((column, index) => { + {viewMode === 'grid' ? ( + column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} + columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
-
- ) : null} -
- ); + + 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.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
-
- )} -
- ); - - if (enableTaskSorting) { - return ( - - {boardContent} - + )} +
); - } - return boardContent; -}; + if (enableTaskSorting) { + return ( + + {boardContent} + + ); + } + + return boardContent; + } +); diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index 6b5f868e..9b30de1a 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -1,5 +1,5 @@ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; @@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({ ); }; -export const KanbanGridLayout = ({ - columns, - allColumnIds, - primaryColumnId, - onPrimaryColumnWidthChange, - skeletonDelayMs = SKELETON_HIDE_DELAY_MS, -}: KanbanGridLayoutProps): React.JSX.Element => { - const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); - const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ - scopeKey: GRID_SCOPE_KEY, - allItemIds: allColumnIds, - visibleItemIds: visibleColumnIds, - cols: GRID_COLS, - repository: browserGridLayoutRepository, - buildDefaultItems, - }); - const [showResolvedLayout, setShowResolvedLayout] = useState(false); +export const KanbanGridLayout = memo( + ({ + columns, + allColumnIds, + primaryColumnId, + onPrimaryColumnWidthChange, + skeletonDelayMs = SKELETON_HIDE_DELAY_MS, + }: KanbanGridLayoutProps): React.JSX.Element => { + const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); + const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ + scopeKey: GRID_SCOPE_KEY, + allItemIds: allColumnIds, + visibleItemIds: visibleColumnIds, + cols: GRID_COLS, + repository: browserGridLayoutRepository, + buildDefaultItems, + }); + const [showResolvedLayout, setShowResolvedLayout] = useState(false); - useEffect(() => { - if (showResolvedLayout) return; + useEffect(() => { + if (showResolvedLayout) return; - const timeoutId = window.setTimeout(() => { - setShowResolvedLayout(true); - }, skeletonDelayMs); + const timeoutId = window.setTimeout(() => { + setShowResolvedLayout(true); + }, skeletonDelayMs); - return () => { - window.clearTimeout(timeoutId); - }; - }, [showResolvedLayout, skeletonDelayMs]); + return () => { + window.clearTimeout(timeoutId); + }; + }, [showResolvedLayout, skeletonDelayMs]); - const applyReactGridLayout = useCallback( - (layout: Layout, options?: { persist?: boolean }) => { - if (options?.persist) { - applyVisibleItems(fromReactGridLayout(layout), options); - } - }, - [applyVisibleItems] - ); - const showSkeletonOverlay = !showResolvedLayout || !isLoaded; + const applyReactGridLayout = useCallback( + (layout: Layout, options?: { persist?: boolean }) => { + if (options?.persist) { + applyVisibleItems(fromReactGridLayout(layout), options); + } + }, + [applyVisibleItems] + ); + const showSkeletonOverlay = !showResolvedLayout || !isLoaded; - const gridKey = visibleItems.map((item) => item.id).join('|'); + const gridKey = visibleItems.map((item) => item.id).join('|'); - return ( -
- - {showSkeletonOverlay ? ( - + - ) : null} -
- ); -}; + {showSkeletonOverlay ? ( + + ) : null} +
+ ); + } +); export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 6c9453e3..dbfe9c34 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; @@ -91,622 +91,632 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = ({ - member, - memberColor, - runtimeSummary, - runtimeEntry, - runtimeRunId, - taskCounts, - isTeamAlive, - isTeamProvisioning, - leadActivity, - currentTask, - reviewTask, - isAwaitingReply, - isRemoved, - spawnStatus, - spawnEntry, - spawnError, - spawnLivenessSource, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - onOpenTask, - onOpenReviewTask, - onClick, - onSendMessage, - onAssignTask, - onRestartMember, - onSkipMemberForLaunch, -}: MemberCardProps): React.JSX.Element => { - // NOTE: lead context display disabled — usage formula is inaccurate - // const teamName = useStore((s) => s.selectedTeamName); - // const leadContext = useStore((s) => - // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - // ); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const [retryingLaunch, setRetryingLaunch] = useState(false); - const [retryLaunchError, setRetryLaunchError] = useState(null); - const [skippingLaunch, setSkippingLaunch] = useState(false); - const [skipLaunchError, setSkipLaunchError] = useState(null); - const teamMembers = useStore((s) => - selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const launchPresentation = buildMemberLaunchPresentation({ +export const MemberCard = memo( + ({ member, - spawnStatus, - spawnLaunchState, - spawnLivenessSource, - spawnRuntimeAlive, + memberColor, + runtimeSummary, runtimeEntry, - runtimeAdvisory: member.runtimeAdvisory, - isLaunchSettling, + runtimeRunId, + taskCounts, isTeamAlive, isTeamProvisioning, leadActivity, - }); - const dotClass = launchPresentation.dotClass; - const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; - const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; - const presenceLabel = launchPresentation.presenceLabel; - const spawnCardClass = launchPresentation.cardClass; - const launchVisualState = launchPresentation.launchVisualState; - const launchStatusLabel = launchPresentation.launchStatusLabel; - const displayPresenceLabel = - launchVisualState === 'queued' || - launchVisualState === 'runtime_pending' || - launchVisualState === 'permission_pending' || - launchVisualState === 'shell_only' || - launchVisualState === 'runtime_candidate' || - launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; - const colors = getTeamColorSet(memberColor); - const { isLight } = useTheme(); - const pending = taskCounts?.pending ?? 0; - const inProgress = taskCounts?.inProgress ?? 0; - const completed = taskCounts?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const { summary: runtimeSummaryText, memory: memoryLabel } = - splitRuntimeSummaryMemory(runtimeSummary); - const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); - const isLead = isLeadMember(member); - const workspacePath = member.cwd?.trim(); - const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; - const workspaceTooltipLines = [ - 'Worktree isolation is enabled.', - workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', - member.gitBranch ? `Branch: ${member.gitBranch}` : null, - ].filter((line): line is string => Boolean(line)); - const activityTask = currentTask ?? reviewTask ?? null; - const activityTitle = currentTask - ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` - : reviewTask - ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` - : undefined; - const showStartingSkeleton = - !isRemoved && - presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; - const showLaunchBadge = - !isRemoved && - !runtimeAdvisoryLabel && - (presenceLabel === 'starting' || - presenceLabel === 'connecting' || + currentTask, + reviewTask, + isAwaitingReply, + isRemoved, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + onOpenTask, + onOpenReviewTask, + onClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, + }: MemberCardProps): React.JSX.Element => { + // NOTE: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); + const teamMembers = useStore((s) => + selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const launchPresentation = buildMemberLaunchPresentation({ + member, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, + runtimeEntry, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning, + leadActivity, + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; + const launchVisualState = launchPresentation.launchVisualState; + const launchStatusLabel = launchPresentation.launchStatusLabel; + const displayPresenceLabel = launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime'); - const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; - const launchDiagnosticsPayload = useMemo( - () => - buildMemberLaunchDiagnosticsPayload({ - teamName: selectedTeamName, - runId: runtimeRunId, - memberName: member.name, - spawnStatus, - launchState: spawnLaunchState, - livenessSource: spawnLivenessSource, - spawnEntry, + launchVisualState === 'stale_runtime' + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; + const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); + const pending = taskCounts?.pending ?? 0; + const inProgress = taskCounts?.inProgress ?? 0; + const completed = taskCounts?.completed ?? 0; + const totalTasks = pending + inProgress + completed; + const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); + const { summary: runtimeSummaryText, memory: memoryLabel } = + splitRuntimeSummaryMemory(runtimeSummary); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); + const isLead = isLeadMember(member); + const workspacePath = member.cwd?.trim(); + const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; + const workspaceTooltipLines = [ + 'Worktree isolation is enabled.', + workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', + member.gitBranch ? `Branch: ${member.gitBranch}` : null, + ].filter((line): line is string => Boolean(line)); + const activityTask = currentTask ?? reviewTask ?? null; + const activityTitle = currentTask + ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` + : reviewTask + ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` + : undefined; + const showStartingSkeleton = + !isRemoved && + presenceLabel === 'starting' && + spawnLaunchState !== 'failed_to_start' && + !activityTask && + !runtimeSummary; + const showLaunchBadge = + !isRemoved && + !runtimeAdvisoryLabel && + (presenceLabel === 'starting' || + presenceLabel === 'connecting' || + launchVisualState === 'queued' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime'); + const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; + const launchDiagnosticsPayload = useMemo( + () => + buildMemberLaunchDiagnosticsPayload({ + teamName: selectedTeamName, + runId: runtimeRunId, + memberName: member.name, + spawnStatus, + launchState: spawnLaunchState, + livenessSource: spawnLivenessSource, + spawnEntry, + runtimeEntry, + }), + [ + member.name, runtimeEntry, - }), - [ - member.name, - runtimeEntry, - runtimeRunId, - selectedTeamName, + runtimeRunId, + selectedTeamName, + spawnEntry, + spawnLaunchState, + spawnLivenessSource, + spawnStatus, + ] + ); + const showCopyDiagnostics = + !isRemoved && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const hasRestartMemberControl = + !isRemoved && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, spawnEntry, - spawnLaunchState, - spawnLivenessSource, - spawnStatus, - ] - ); - const showCopyDiagnostics = - !isRemoved && - hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && - hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; - const isSkippedLaunch = - spawnStatus === 'skipped' || - spawnLaunchState === 'skipped_for_launch' || - spawnEntry?.skippedForLaunch === true; - const showFailedLaunchBadge = !isRemoved && isFailedLaunch; - const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; - const hasLiveLaunchControls = - isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const hasRestartMemberControl = - !isRemoved && - !isLeadMember(member) && - Boolean(onRestartMember) && - hasLiveLaunchControls && - runtimeEntry?.restartable !== false; - const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ - member, - spawnEntry, - runtimeEntry, - }); - const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; - const canSkipFailedLaunch = - showFailedLaunchBadge && - !isLeadMember(member) && - Boolean(onSkipMemberForLaunch) && - hasLiveLaunchControls; - const showRuntimeAdvisoryBadge = - !isRemoved && - Boolean(runtimeAdvisoryLabel) && - !showLaunchBadge && - !isFailedLaunch && - !isSkippedLaunch && - (Boolean(activityTask) || !isAwaitingReply); - const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; - const restartActionBusyLabel = canRelaunchOpenCode - ? 'Relaunching OpenCode teammate' - : 'Retrying teammate'; - const restartActionErrorFallback = canRelaunchOpenCode - ? 'Failed to relaunch OpenCode teammate' - : 'Failed to retry teammate'; - const handleRestartMember = async (event: React.MouseEvent): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onRestartMember || retryingLaunch) { - return; - } - setRetryLaunchError(null); - setRetryingLaunch(true); - try { - await onRestartMember(member.name); - } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); - } finally { - setRetryingLaunch(false); - } - }; - const handleSkipFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onSkipMemberForLaunch || skippingLaunch) { - return; - } - setSkipLaunchError(null); - setSkippingLaunch(true); - try { - await onSkipMemberForLaunch(member.name); - } catch (error) { - setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); - } finally { - setSkippingLaunch(false); - } - }; + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; + const showRuntimeAdvisoryBadge = + !isRemoved && + Boolean(runtimeAdvisoryLabel) && + !showLaunchBadge && + !isFailedLaunch && + !isSkippedLaunch && + (Boolean(activityTask) || !isAwaitingReply); + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; - return ( -
+ return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} + className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`} > -
-
-
-
- {member.name} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} + > +
+
+
+
+ {member.name} +
+
- -
-
-
- - {displayMemberName(member.name)} - - {member.gitBranch && !showWorkspaceBadge ? ( - - - {member.gitBranch} +
+
+ + {displayMemberName(member.name)} + {member.gitBranch && !showWorkspaceBadge ? ( + + + {member.gitBranch} + + ) : null} + {showWorkspaceBadge ? ( + + + + worktree + + + +
+ {workspaceTooltipLines.map((line) => ( +

+ {line} +

+ ))} +
+
+
+ ) : null} + {currentTask ? ( + + ) : null} + {reviewTask ? ( + + ) : null} + {!activityTask && isAwaitingReply ? ( + <> + {runtimeAdvisoryTone === 'error' ? ( + + ) : ( + + )} + + {runtimeAdvisoryLabel ?? 'awaiting reply'} + + + ) : null} +
+ {showStartingSkeleton ? ( + - {showStartingSkeleton ? ( -