From e300d4cbd521916cbbf8b347804d9e9f9067b049 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 21:28:17 +0500 Subject: [PATCH] perf: memoize MemberBadge, CurrentTaskIndicator, MemberPresenceDot, ReplyQuoteBlock, GlobalTaskList Prevent unnecessary re-renders on these frequently-rendered components that appear in MemberCard rows, activity feeds, and the sidebar task list. --- .../components/sidebar/GlobalTaskList.tsx | 1262 +++++++++-------- src/renderer/components/team/MemberBadge.tsx | 146 +- .../team/activity/ReplyQuoteBlock.tsx | 106 +- .../team/members/CurrentTaskIndicator.tsx | 76 +- .../team/members/MemberPresenceDot.tsx | 35 +- 5 files changed, 821 insertions(+), 804 deletions(-) diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 14dcc6c3..d4a48051 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -173,681 +173,689 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } -export const GlobalTaskList = ({ - hideHeader = false, - filters: externalFilters, - onFiltersChange: externalOnFiltersChange, - filtersPopoverOpen: externalFiltersPopoverOpen, - onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, -}: GlobalTaskListProps = {}): React.JSX.Element => { - const { - globalTasks, - globalTasksLoading, - globalTasksInitialized, - fetchAllTasks, - softDeleteTask, - projects, - viewMode, - repositoryGroups, - teams, - } = useStore( - useShallow((s) => ({ - globalTasks: s.globalTasks, - globalTasksLoading: s.globalTasksLoading, - globalTasksInitialized: s.globalTasksInitialized, - fetchAllTasks: s.fetchAllTasks, - softDeleteTask: s.softDeleteTask, - projects: s.projects, - viewMode: s.viewMode, - repositoryGroups: s.repositoryGroups, - teams: s.teams, - })) - ); +export const GlobalTaskList = memo( + ({ + hideHeader = false, + filters: externalFilters, + onFiltersChange: externalOnFiltersChange, + filtersPopoverOpen: externalFiltersPopoverOpen, + onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, + }: GlobalTaskListProps = {}): React.JSX.Element => { + const { + globalTasks, + globalTasksLoading, + globalTasksInitialized, + fetchAllTasks, + softDeleteTask, + projects, + viewMode, + repositoryGroups, + teams, + } = useStore( + useShallow((s) => ({ + globalTasks: s.globalTasks, + globalTasksLoading: s.globalTasksLoading, + globalTasksInitialized: s.globalTasksInitialized, + fetchAllTasks: s.fetchAllTasks, + softDeleteTask: s.softDeleteTask, + projects: s.projects, + viewMode: s.viewMode, + repositoryGroups: s.repositoryGroups, + teams: s.teams, + })) + ); - const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); - const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); - const filters = externalFilters ?? internalFilters; - const setFilters = externalOnFiltersChange ?? setInternalFilters; - const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; - const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; - const [searchQuery, setSearchQuery] = useState(''); - const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); - const [sortMode, setSortModeState] = useState(loadSortMode); - const [sortPopoverOpen, setSortPopoverOpen] = useState(false); - const [showArchived, setShowArchived] = useState(false); - const [renamingTaskKey, setRenamingTaskKey] = useState(null); - const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< - Record - >({}); - const searchInputRef = useRef(null); - const hasFetchedRef = useRef(false); - const readState = useReadStateSnapshot(); - const taskLocalState = useTaskLocalState(); + const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); + const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); + const filters = externalFilters ?? internalFilters; + const setFilters = externalOnFiltersChange ?? setInternalFilters; + const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; + const setFiltersPopoverOpen = + externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; + const [searchQuery, setSearchQuery] = useState(''); + const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); + const [sortMode, setSortModeState] = useState(loadSortMode); + const [sortPopoverOpen, setSortPopoverOpen] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); + const searchInputRef = useRef(null); + const hasFetchedRef = useRef(false); + const readState = useReadStateSnapshot(); + const taskLocalState = useTaskLocalState(); - // --- New-task animation tracking (same pattern as ChatHistory) --- - const knownTaskIdsRef = useRef>(new Set()); - const isInitialTaskLoadRef = useRef(true); + // --- New-task animation tracking (same pattern as ChatHistory) --- + const knownTaskIdsRef = useRef>(new Set()); + const isInitialTaskLoadRef = useRef(true); - const newTaskIds = useMemo(() => { - if (!globalTasksInitialized || globalTasks.length === 0) { - return new Set(); - } - - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (isInitialTaskLoadRef.current) { - isInitialTaskLoadRef.current = false; - for (const t of globalTasks) { - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); + const newTaskIds = useMemo(() => { + if (!globalTasksInitialized || globalTasks.length === 0) { + return new Set(); } - return new Set(); - } - const newIds = new Set(); - for (const t of globalTasks) { - const key = `${t.teamName}:${t.id}`; // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (!knownTaskIdsRef.current.has(key)) { - newIds.add(key); + if (isInitialTaskLoadRef.current) { + isInitialTaskLoadRef.current = false; + for (const t of globalTasks) { + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); + } + return new Set(); + } + + const newIds = new Set(); + for (const t of globalTasks) { + const key = `${t.teamName}:${t.id}`; // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(key); + if (!knownTaskIdsRef.current.has(key)) { + newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + knownTaskIdsRef.current.add(key); + } } - } - return newIds; - }, [globalTasks, globalTasksInitialized]); + return newIds; + }, [globalTasks, globalTasksInitialized]); - const isNewTask = useCallback( - (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), - [newTaskIds] - ); + const isNewTask = useCallback( + (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + [newTaskIds] + ); - const setGroupingMode = (mode: TaskGroupingMode): void => { - setGroupingModeState(mode); - saveGroupingMode(mode); - }; + const setGroupingMode = (mode: TaskGroupingMode): void => { + setGroupingModeState(mode); + saveGroupingMode(mode); + }; - const setSortMode = (mode: TaskSortMode): void => { - setSortModeState(mode); - saveSortMode(mode); - }; + const setSortMode = (mode: TaskSortMode): void => { + setSortModeState(mode); + saveSortMode(mode); + }; - const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { - taskLocalState.renameTask(teamName, taskId, newSubject); - setRenamingTaskKey(null); - }; + const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { + taskLocalState.renameTask(teamName, taskId, newSubject); + setRenamingTaskKey(null); + }; - const handleRenameCancel = (): void => { - setRenamingTaskKey(null); - }; + const handleRenameCancel = (): void => { + setRenamingTaskKey(null); + }; - const handleDeleteTask = async (teamName: string, taskId: string): Promise => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { - try { - await softDeleteTask(teamName, taskId); - await fetchAllTasks(); - } catch (err) { - void confirm({ - title: 'Failed to delete task', - message: err instanceof Error ? err.message : 'An unexpected error occurred', - confirmLabel: 'OK', - variant: 'danger', - }); + const handleDeleteTask = async (teamName: string, taskId: string): Promise => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + await fetchAllTasks(); + } catch (err) { + void confirm({ + title: 'Failed to delete task', + message: err instanceof Error ? err.message : 'An unexpected error occurred', + confirmLabel: 'OK', + variant: 'danger', + }); + } } - } - }; + }; - // Fetch tasks on mount — loading guard in the store action prevents - // duplicate IPC calls when the centralized init chain is already fetching. - useEffect(() => { - if (!hasFetchedRef.current && !globalTasksLoading) { - hasFetchedRef.current = true; - void fetchAllTasks(); - } - }, [fetchAllTasks, globalTasksLoading]); + // Fetch tasks on mount — loading guard in the store action prevents + // duplicate IPC calls when the centralized init chain is already fetching. + useEffect(() => { + if (!hasFetchedRef.current && !globalTasksLoading) { + hasFetchedRef.current = true; + void fetchAllTasks(); + } + }, [fetchAllTasks, globalTasksLoading]); - // Build project combobox options from available projects/repos - const projectFilterOptions = useMemo((): ComboboxOption[] => { - const items = - viewMode === 'grouped' - ? repositoryGroups - .filter((r) => r.totalSessions > 0) - .map((r) => ({ - value: r.worktrees[0]?.path ?? r.id, - label: r.name, - path: r.worktrees[0]?.path, - })) - : projects - .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) - .map((p) => ({ - value: p.path, - label: p.name, - path: p.path, - })); + // Build project combobox options from available projects/repos + const projectFilterOptions = useMemo((): ComboboxOption[] => { + const items = + viewMode === 'grouped' + ? repositoryGroups + .filter((r) => r.totalSessions > 0) + .map((r) => ({ + value: r.worktrees[0]?.path ?? r.id, + label: r.name, + path: r.worktrees[0]?.path, + })) + : projects + .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) + .map((p) => ({ + value: p.path, + label: p.name, + path: p.path, + })); - return items.map((item) => ({ - value: item.value, - label: item.label, - description: item.path, - })); - }, [viewMode, repositoryGroups, projects]); + return items.map((item) => ({ + value: item.value, + label: item.label, + description: item.path, + })); + }, [viewMode, repositoryGroups, projects]); - // Resolve project filter from filters state - const selectedProjectPath = filters.projectPath; - const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] - ); - const effectiveShowArchived = showArchived && hasArchivedTasks; + // Resolve project filter from filters state + const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; - const filtered = useMemo(() => { - let result = globalTasks; - result = applyProjectFilter(result, selectedProjectPath); - result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); - if (filters.teamName) { - result = result.filter((t) => t.teamName === filters.teamName); - } - if (filters.readFilter === 'unread') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 - ); - } else if (filters.readFilter === 'read') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 - ); - } - result = applySearch(result, searchQuery); - // Archive filtering - if (effectiveShowArchived) { - result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); - } else { - result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); - } - return result; - }, [ - globalTasks, - selectedProjectPath, - filters.statusIds, - filters.teamName, - filters.readFilter, - searchQuery, - readState, - effectiveShowArchived, - taskLocalState, - ]); + const filtered = useMemo(() => { + let result = globalTasks; + result = applyProjectFilter(result, selectedProjectPath); + result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); + if (filters.teamName) { + result = result.filter((t) => t.teamName === filters.teamName); + } + if (filters.readFilter === 'unread') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 + ); + } else if (filters.readFilter === 'read') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 + ); + } + result = applySearch(result, searchQuery); + // Archive filtering + if (effectiveShowArchived) { + result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); + } else { + result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); + } + return result; + }, [ + globalTasks, + selectedProjectPath, + filters.statusIds, + filters.teamName, + filters.readFilter, + searchQuery, + readState, + effectiveShowArchived, + taskLocalState, + ]); - // Split into pinned and normal (non-pinned) tasks - const pinnedTasks = useMemo( - () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); - const normalTasks = useMemo( - () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); + // Split into pinned and normal (non-pinned) tasks + const pinnedTasks = useMemo( + () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); + const normalTasks = useMemo( + () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); - const sortedFlat = useMemo( - () => applySortMode(normalTasks, sortMode, readState), - [normalTasks, sortMode, readState] - ); - const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); - const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); - const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); + const sortedFlat = useMemo( + () => applySortMode(normalTasks, sortMode, readState), + [normalTasks, sortMode, readState] + ); + const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); + const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); + const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); - // Collapsed group keys for each grouping mode - const projectGroupKeys = useMemo( - () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), - [projectGroups] - ); - const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); - const projectGroupVisibility = useMemo( - () => - projectGroups.map((group) => ({ - projectKey: group.projectKey, - taskCount: group.tasks.length, - })), - [projectGroups] - ); - const projectVisibleCountByKey = useMemo( - () => - syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), - [projectRequestedVisibleCountByKey, projectGroupVisibility] - ); + // Collapsed group keys for each grouping mode + const projectGroupKeys = useMemo( + () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), + [projectGroups] + ); + const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey( + projectRequestedVisibleCountByKey, + projectGroupVisibility + ), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); - const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); - const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); + const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); + const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); - const hasContent = - pinnedTasks.length > 0 || - (groupingMode === 'none' - ? sortedFlat.length > 0 - : groupingMode === 'time' - ? categories.length > 0 - : projectGroups.some((g) => g.tasks.length > 0)); + const hasContent = + pinnedTasks.length > 0 || + (groupingMode === 'none' + ? sortedFlat.length > 0 + : groupingMode === 'time' + ? categories.length > 0 + : projectGroups.some((g) => g.tasks.length > 0)); - const noProjectGroupColor = useMemo( - () => ({ - border: 'var(--color-border)', - glow: 'transparent', - icon: 'var(--color-text-muted)', - text: 'var(--color-text-secondary)', - }), - [] - ); + const noProjectGroupColor = useMemo( + () => ({ + border: 'var(--color-border)', + glow: 'transparent', + icon: 'var(--color-text-muted)', + text: 'var(--color-text-secondary)', + }), + [] + ); - return ( -
- {!hideHeader && ( + return ( +
+ {!hideHeader && ( +
+ Tasks +
+ )} + + {/* Search bar */}
- Tasks -
- )} - - {/* Search bar */} -
- - setSearchQuery(e.target.value)} - className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" - /> - {searchQuery && ( - - )} - - + + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( - - -
- {SORT_OPTIONS.map((opt) => ( - - ))} -
-
-
- ({ teamName: t.teamName, displayName: t.displayName }))} - projectOptions={projectFilterOptions} - filters={filters} - onFiltersChange={setFilters} - onApply={() => {}} - /> -
- - {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !effectiveShowArchived && ( -
-
- - Pinned -
- {sortTasksByFreshness(pinnedTasks).map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} -
- )} - - {/* Grouping mode — compact text toggle */} -
- Group by: -
- {(['none', 'project', 'time'] as const).map((mode) => { - const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; - return ( + )} + + - ); - })} + + +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+
+
+ ({ teamName: t.teamName, displayName: t.displayName }))} + projectOptions={projectFilterOptions} + filters={filters} + onFiltersChange={setFilters} + onApply={() => {}} + />
- {/* Archive toggle — only visible when archived tasks exist */} - {hasArchivedTasks && ( -
- - - - - - {effectiveShowArchived ? 'Hide archived' : 'Show archived'} - - -
- )} -
- {/* Content */} -
- {globalTasksLoading && !globalTasksInitialized && ( -
- {[1, 2, 3].map((i) => ( -
+ {/* Pinned tasks section */} + {pinnedTasks.length > 0 && !effectiveShowArchived && ( +
+
+ + Pinned +
+ {sortTasksByFreshness(pinnedTasks).map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + ))}
)} - {globalTasksInitialized && !hasContent && ( -
- - - {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} - + {/* Grouping mode — compact text toggle */} +
+ Group by: +
+ {(['none', 'project', 'time'] as const).map((mode) => { + const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; + return ( + + ); + })}
- )} - - {groupingMode === 'none' && - sortedFlat.map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} - - {groupingMode === 'project' && - projectGroups.map((group) => { - if (group.tasks.length === 0) return null; - const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; - const groupColor = isNoProjectGroup - ? noProjectGroupColor - : projectColor(group.projectLabel); - const visibleCount = getProjectGroupVisibleCount( - projectVisibleCountByKey[group.projectKey], - group.tasks.length - ); - const visibleTasks = group.tasks.slice(0, visibleCount); - const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); - const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); - let lastTeam: string | null = null; - return ( -
- - {!isGroupCollapsed && - visibleTasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) + + + + + {effectiveShowArchived ? 'Hide archived' : 'Show archived'} + + +
+ )} +
+ + {/* Content */} +
+ {globalTasksLoading && !globalTasksInitialized && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {globalTasksInitialized && !hasContent && ( +
+ + + {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} + +
+ )} + + {groupingMode === 'none' && + sortedFlat.map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} + + {groupingMode === 'project' && + projectGroups.map((group) => { + if (group.tasks.length === 0) return null; + const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup + ? noProjectGroupColor + : projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); + let lastTeam: string | null = null; + return ( +
+ + {!isGroupCollapsed && + visibleTasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( +
- ); - })} - {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( -
- {showMoreVisible && ( - - )} - {showLessVisible && ( - - )} -
- )} -
- ); - })} - - {groupingMode === 'time' && - categories.map((category) => { - const tasks = grouped[category]; - const isGroupCollapsed = timeCollapsed.isCollapsed(category); - let lastTeam: string | null = null; - - return ( -
- + )} + {showLessVisible && ( + + )} +
)} - {dateCategoryLabels[category] ?? category} - - {tasks.length} - - +
+ ); + })} - {!isGroupCollapsed && - tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; + {groupingMode === 'time' && + categories.map((category) => { + const tasks = grouped[category]; + const isGroupCollapsed = timeCollapsed.isCollapsed(category); + let lastTeam: string | null = null; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} -
- ); - })} + return ( +
+ + + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} +
+ ); + })} +
-
- ); -}; + ); + } +); diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index 7f29c0ff..15d8d479 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { getTeamColorSet, @@ -37,81 +37,83 @@ interface MemberBadgeProps { * When onClick is provided, both avatar and badge are clickable as one unit. * Wrapped in MemberHoverCard to show member info on hover. */ -export const MemberBadge = ({ - name, - color, - teamName, - size = 'sm', - hideAvatar, - onClick, - disableHoverCard, -}: MemberBadgeProps): React.JSX.Element => { - const colors = getTeamColorSet(color ?? ''); - const { isLight } = useTheme(); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const effectiveTeamName = teamName ?? selectedTeamName; - const teamMembers = useStore((s) => - effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; - const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; - const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; - const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5'; +export const MemberBadge = memo( + ({ + name, + color, + teamName, + size = 'sm', + hideAvatar, + onClick, + disableHoverCard, + }: MemberBadgeProps): React.JSX.Element => { + const colors = getTeamColorSet(color ?? ''); + const { isLight } = useTheme(); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const effectiveTeamName = teamName ?? selectedTeamName; + const teamMembers = useStore((s) => + effectiveTeamName ? selectResolvedMembersForTeamName(s, effectiveTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const avatarSize = size === 'md' ? 32 : size === 'sm' ? 24 : 18; + const avatarClass = size === 'md' ? 'size-6' : size === 'sm' ? 'size-5' : 'size-4'; + const textClass = size === 'md' ? 'text-xs' : size === 'sm' ? 'text-[10px]' : 'text-[9px]'; + const paddingClass = size === 'xs' ? 'px-1 py-0.5' : 'px-1.5 py-0.5'; - const badgeStyle = { - backgroundColor: getThemedBadge(colors, isLight), - color: getThemedText(colors, isLight), - border: `1px solid ${getThemedBorder(colors, isLight)}40`, - }; + const badgeStyle = { + backgroundColor: getThemedBadge(colors, isLight), + color: getThemedText(colors, isLight), + border: `1px solid ${getThemedBorder(colors, isLight)}40`, + }; - const avatar = ( - - ); + const avatar = ( + + ); - const badge = ( - - {displayMemberName(name)} - - ); + const badge = ( + + {displayMemberName(name)} + + ); - // Skip hover card for "user" and "system" pseudo-members - const skipHoverCard = disableHoverCard || name === 'user' || name === 'system'; + // Skip hover card for "user" and "system" pseudo-members + const skipHoverCard = disableHoverCard || name === 'user' || name === 'system'; - const content = onClick ? ( - - ) : ( - - {!hideAvatar && avatar} - {badge} - - ); + const content = onClick ? ( + + ) : ( + + {!hideAvatar && avatar} + {badge} + + ); - if (skipHoverCard) { - return content; + if (skipHoverCard) { + return content; + } + + return ( + + {content} + + ); } - - return ( - - {content} - - ); -}; +); diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 208291a2..417fe683 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -20,60 +20,62 @@ interface ReplyQuoteBlockProps { /** Threshold (characters) above which the "more/less" toggle is shown. */ const LONG_QUOTE_THRESHOLD = 200; -export const ReplyQuoteBlock = ({ - reply, - memberColor, - bodyMaxHeight = 'max-h-56', - replyTaskRefs, -}: ReplyQuoteBlockProps): React.JSX.Element => { - const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; - const [expanded, setExpanded] = useState(false); +export const ReplyQuoteBlock = memo( + ({ + reply, + memberColor, + bodyMaxHeight = 'max-h-56', + replyTaskRefs, + }: ReplyQuoteBlockProps): React.JSX.Element => { + const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; + const [expanded, setExpanded] = useState(false); - const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; + const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; - return ( -
- {/* Quote block — styled like SendMessageDialog */} -
- {/* Decorative quotation mark */} - - “ - + return ( +
+ {/* Quote block — styled like SendMessageDialog */} +
+ {/* Decorative quotation mark */} + + “ + - {/* "Replying to" + MemberBadge */} -
- Replying to - + {/* "Replying to" + MemberBadge */} +
+ Replying to + +
+ + {/* Quote text */} +
+ +
+ + {/* More/less toggle */} + {isLong ? ( + + ) : null}
- {/* Quote text */} -
- -
- - {/* More/less toggle */} - {isLong ? ( - - ) : null} + {/* Reply text */} +
- - {/* Reply text */} - -
- ); -}; + ); + } +); diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index 4781c199..4a8394f3 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -15,42 +17,44 @@ interface CurrentTaskIndicatorProps { * Inline indicator showing a spinning loader + "working on" + task label button. * Shared between MemberCard and MemberHoverCard. */ -export const CurrentTaskIndicator = ({ - task, - borderColor, - maxSubjectLength, - activityLabel = 'working on', - onOpenTask, -}: CurrentTaskIndicatorProps): React.JSX.Element => { - const subjectText = - typeof maxSubjectLength === 'number' && - maxSubjectLength > 0 && - task.subject.length > maxSubjectLength - ? `${task.subject.slice(0, maxSubjectLength)}…` - : task.subject; +export const CurrentTaskIndicator = memo( + ({ + task, + borderColor, + maxSubjectLength, + activityLabel = 'working on', + onOpenTask, + }: CurrentTaskIndicatorProps): React.JSX.Element => { + const subjectText = + typeof maxSubjectLength === 'number' && + maxSubjectLength > 0 && + task.subject.length > maxSubjectLength + ? `${task.subject.slice(0, maxSubjectLength)}…` + : task.subject; - return ( -
- - {activityLabel} - -
- ); -}; + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + e.stopPropagation(); + onOpenTask?.(); + } + }} + > + {formatTaskDisplayLabel(task)} {subjectText} + +
+ ); + } +); diff --git a/src/renderer/components/team/members/MemberPresenceDot.tsx b/src/renderer/components/team/members/MemberPresenceDot.tsx index aab1f38b..bb2f8a3f 100644 --- a/src/renderer/components/team/members/MemberPresenceDot.tsx +++ b/src/renderer/components/team/members/MemberPresenceDot.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle'; import { cn } from '@renderer/lib/utils'; @@ -8,21 +10,20 @@ interface MemberPresenceDotProps { label: string; } -export const MemberPresenceDot = ({ - className, - label, -}: MemberPresenceDotProps): React.JSX.Element => { - const shouldSyncPulse = className?.includes('animate-pulse') === true; - const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS); +export const MemberPresenceDot = memo( + ({ className, label }: MemberPresenceDotProps): React.JSX.Element => { + const shouldSyncPulse = className?.includes('animate-pulse') === true; + const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS); - return ( - - ); -}; + return ( + + ); + } +);