diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f4c63779..919bbcd1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,10 +1,12 @@ import { + forwardRef, lazy, memo, Suspense, useCallback, useEffect, useId, + useImperativeHandle, useMemo, useRef, useState, @@ -117,14 +119,15 @@ const TeamGraphOverlay = lazy(() => default: m.TeamGraphOverlay, })) ); -let taskDetailDialogImportPromise: Promise<{ - default: typeof import('./dialogs/TaskDetailDialog').TaskDetailDialog; -}> | null = null; -function loadTaskDetailDialog(): Promise<{ - default: typeof import('./dialogs/TaskDetailDialog').TaskDetailDialog; -}> { +type TaskDetailDialogComponent = typeof import('./dialogs/TaskDetailDialog').TaskDetailDialog; +let loadedTaskDetailDialogComponent: TaskDetailDialogComponent | null = null; +let taskDetailDialogImportPromise: Promise<{ default: TaskDetailDialogComponent }> | null = null; +function loadTaskDetailDialog(): Promise<{ default: TaskDetailDialogComponent }> { taskDetailDialogImportPromise ??= import('./dialogs/TaskDetailDialog') - .then((m) => ({ default: m.TaskDetailDialog })) + .then((m) => { + loadedTaskDetailDialogComponent = m.TaskDetailDialog; + return { default: m.TaskDetailDialog }; + }) .catch((error) => { taskDetailDialogImportPromise = null; throw error; @@ -134,7 +137,7 @@ function loadTaskDetailDialog(): Promise<{ function preloadTaskDetailDialog(): void { void loadTaskDetailDialog().catch(() => undefined); } -const TaskDetailDialog = lazy(loadTaskDetailDialog); +const LazyTaskDetailDialog = lazy(loadTaskDetailDialog); const SendMessageDialog = lazy(() => import('./dialogs/SendMessageDialog').then((m) => ({ default: m.SendMessageDialog })) ); @@ -174,6 +177,7 @@ import type { SessionInjection } from './session-injection-types'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { + KanbanTaskState, MemberSpawnStatusEntry, ResolvedTeamMember, TaskRef, @@ -186,6 +190,92 @@ import type { } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; +interface TaskDetailDialogHostHandle { + openTask: (task: TeamTaskWithKanban) => void; + close: () => void; +} + +interface TaskDetailDialogHostProps { + teamName: string; + kanbanTaskStateByTaskId: Record; + taskMap: Map; + members: ResolvedTeamMember[]; + onOwnerChange: (taskId: string, owner: string | null) => void; + onViewChanges: (taskId: string, filePath?: string) => void; + onOpenInEditor: (filePath: string) => void; + onDeleteTask: (taskId: string) => void; +} + +const TaskDetailDialogHost = memo( + forwardRef(function TaskDetailDialogHost( + { + teamName, + kanbanTaskStateByTaskId, + taskMap, + members, + onOwnerChange, + onViewChanges, + onOpenInEditor, + onDeleteTask, + }, + ref + ) { + const [selectedTask, setSelectedTask] = useState(null); + + useImperativeHandle( + ref, + () => ({ + openTask: setSelectedTask, + close: () => setSelectedTask(null), + }), + [] + ); + + const handleScrollToTask = useCallback((taskId: string) => { + setSelectedTask(null); + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener('animationend', () => el.classList.remove('kanban-card-focus-pulse'), { + once: true, + }); + } + }, []); + + if (selectedTask === null) { + return null; + } + + const DialogComponent = loadedTaskDetailDialogComponent ?? LazyTaskDetailDialog; + const dialog = ( + setSelectedTask(null)} + onScrollToTask={handleScrollToTask} + onOwnerChange={onOwnerChange} + onViewChanges={onViewChanges} + onOpenInEditor={onOpenInEditor} + onDeleteTask={onDeleteTask} + /> + ); + + if (loadedTaskDetailDialogComponent) { + return dialog; + } + + return {dialog}; + }) +); +TaskDetailDialogHost.displayName = 'TaskDetailDialogHost'; + interface TeamDetailViewProps { teamName: string; isActive?: boolean; @@ -1398,7 +1488,6 @@ export const TeamDetailView = memo(function TeamDetailView({ const { t } = useAppTranslation('team'); const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [selectedMemberView, setSelectedMemberView] = useState<{ initialTab?: MemberDetailTab; @@ -1429,6 +1518,8 @@ export const TeamDetailView = memo(function TeamDetailView({ const [editorOpen, setEditorOpen] = useState(false); const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); + const taskDetailDialogRef = useRef(null); + const taskDetailDialogPreloadScheduledRef = useRef(false); const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( null ); @@ -2020,19 +2111,19 @@ export const TeamDetailView = memo(function TeamDetailView({ if (!kanbanSearchQuery) return filteredTasks; return filterKanbanTasks(filteredTasks, kanbanSearchQuery); }, [filteredTasks, kanbanSearchQuery]); - const loadedTeamName = data?.teamName; - const taskCount = data?.tasks.length ?? 0; useEffect(() => { - if (taskCount === 0) { + if (taskDetailDialogPreloadScheduledRef.current) { return; } - return scheduleStartupIdleTask(preloadTaskDetailDialog, { - minDelayMs: 1000, - maxDelayMs: 5000, + taskDetailDialogPreloadScheduledRef.current = true; + // Start this with the team page, before slow task data can delay the first task click. + scheduleStartupIdleTask(preloadTaskDetailDialog, { + minDelayMs: 250, + maxDelayMs: 2500, }); - }, [loadedTeamName, taskCount]); + }, []); const resolvedActiveTeammateCount = useMemo( () => activeMembers.filter((m) => !isLeadMember(m)).length, @@ -2227,6 +2318,10 @@ export const TeamDetailView = memo(function TeamDetailView({ setSelectedMemberView(null); }, []); + const openTaskDetailDialog = useCallback((task: TeamTaskWithKanban) => { + taskDetailDialogRef.current?.openTask(task); + }, []); + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { setSendDialogRecipient(member.name); setSendDialogDefaultText(undefined); @@ -2242,12 +2337,15 @@ export const TeamDetailView = memo(function TeamDetailView({ [openCreateTaskDialog] ); - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); + const handleOpenTaskById = useCallback( + (taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + openTaskDetailDialog(task); + } + }, + [openTaskDetailDialog] + ); const handleOpenMessagePanelTask = useCallback( (task: TeamTaskWithKanban) => { @@ -2260,11 +2358,29 @@ export const TeamDetailView = memo(function TeamDetailView({ (taskId: string) => { const task = taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); + if (task) openTaskDetailDialog(task); }, - [taskMap, data?.tasks] + [data?.tasks, openTaskDetailDialog, taskMap] ); + const handleTaskOwnerChange = useCallback( + (taskId: string, owner: string | null) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store + } + })(); + }, + [teamName, updateTaskOwner] + ); + + const handleOpenTaskFileInEditor = useCallback((filePath: string) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }, []); + const handleEditorAction = useCallback( (action: EditorSelectionAction) => { const chip = createChipFromSelection(action, []) ?? undefined; @@ -3181,7 +3297,7 @@ export const TeamDetailView = memo(function TeamDetailView({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} + onTaskClick={openTaskDetailDialog} onViewChanges={handleViewChanges} onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately) @@ -3196,7 +3312,7 @@ export const TeamDetailView = memo(function TeamDetailView({ teamName={teamName} tasks={data.tasks} memberColorMap={resolvedMemberColorMap} - onOpenTask={(task) => setSelectedTask(task)} + onOpenTask={openTaskDetailDialog} onViewChanges={handleViewChangesForFile} /> @@ -3298,7 +3414,7 @@ export const TeamDetailView = memo(function TeamDetailView({ onRestartMember={handleRestartMember} onTaskClick={(task) => { closeSelectedMemberDialog(); - setSelectedTask(task); + openTaskDetailDialog(task); }} onUpdateRole={async (memberName, role) => { setUpdatingRoleLoading(true); @@ -3540,51 +3656,17 @@ export const TeamDetailView = memo(function TeamDetailView({ )} - {selectedTask !== null && ( - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - )} +