From feeb9bb6ec2470e43f86eb0147a49001e83dc956 Mon Sep 17 00:00:00 2001 From: Jimmy Stridh Date: Fri, 29 May 2026 15:42:04 +0200 Subject: [PATCH 1/2] perf(tasks): speed up task open paths --- packages/apps/app/e2e/perf/scenarios.spec.ts | 98 ++++++++++++++++++- packages/apps/app/src/renderer/src/App.tsx | 73 ++++++++++---- .../src/components/sidebar/AppSidebar.tsx | 3 + .../src/components/sidebar/views/TreeView.tsx | 9 +- .../src/components/sidebar/views/types.ts | 2 + .../projects/src/client/ProjectSelect.tsx | 15 ++- .../settings/src/client/useTabStore.ts | 10 +- .../task/src/client/CreateTaskDialog.tsx | 20 ++-- .../task/src/client/TaskDetailPage.tsx | 11 ++- .../task/src/client/taskDetailCache.test.ts | 1 + .../task/src/client/taskDetailCache.ts | 80 +++++++++++---- .../domains/tasks/src/client/KanbanBoard.tsx | 54 ++++++---- .../domains/tasks/src/client/KanbanCard.tsx | 13 +-- .../tasks/src/client/KanbanListView.tsx | 13 +-- .../tasks/src/client/useKanbanSelection.ts | 25 ++--- .../terminal/src/client/PtyContext.tsx | 43 ++++++-- packages/domains/terminal/src/client/index.ts | 8 +- .../suspense/src/suspense-cache.test.tsx | 23 +++++ .../shared/suspense/src/suspense-cache.ts | 18 ++++ 19 files changed, 391 insertions(+), 128 deletions(-) diff --git a/packages/apps/app/e2e/perf/scenarios.spec.ts b/packages/apps/app/e2e/perf/scenarios.spec.ts index b75e313b..5375c8a4 100644 --- a/packages/apps/app/e2e/perf/scenarios.spec.ts +++ b/packages/apps/app/e2e/perf/scenarios.spec.ts @@ -32,6 +32,7 @@ void __dirname const ITERATIONS = 5 const RUN_DIR = path.join(defaultResultsDir(), `run-${Date.now()}`) const PROJ_ABBREV = 'PE' +const PROJECT_NAME = 'PerfRun' let projectId: string let firstTaskId: string @@ -46,7 +47,7 @@ test.describe await resetApp(mainWindow) const s = seed(mainWindow) const p = await s.createProject({ - name: 'PerfRun', + name: PROJECT_NAME, color: '#8b5cf6', path: TEST_PROJECT_PATH }) @@ -168,6 +169,101 @@ test.describe expect(result.runs.length).toBe(ITERATIONS) }) + test('open-closed-task-from-sidebar', async ({ mainWindow }) => { + const taskId = secondTaskId + + const definition: ScenarioDefinition = { + name: 'open-closed-task-from-sidebar', + description: 'Click a closed task row in the sidebar tree, wait for task detail mount.', + iterations: ITERATIONS, + beforeEach: async (page) => { + await page.evaluate( + ({ anchorId, targetId, pid }) => { + const storeApi = (window as any).__slayzone_tabStore + const store = storeApi.getState() + storeApi.setState({ + sidebarView: 'projects', + selectedProjectId: pid, + treeStatusFilter: ['in_progress'], + treePriorityFilter: [1, 2, 3, 4, 5], + treeShowSubtasks: true, + treeShowAllSubtasks: false, + treeShowOnlyActive: false, + treeShowTemporary: true, + treeShowAllOpen: true, + treeCrossOutDone: false, + treeShowStatus: false, + treeShowPriority: true, + treeShowWorktree: true, + treeGroupBy: 'status', + treeOrderBy: 'manual', + treeOrderDir: 'asc' + }) + store.openTaskInBackground(anchorId) + store.closeTabByTaskId(targetId) + store.setActiveTabIndex(0) + storeApi.setState({ sidebarView: 'tree' }) + }, + { anchorId: firstTaskId, targetId: taskId, pid: projectId } + ) + const expand = page.getByRole('button', { name: `Expand ${PROJECT_NAME}` }).first() + if (await expand.isVisible({ timeout: 500 }).catch(() => false)) { + await expand.click({ force: true }).catch(() => {}) + } + await page.evaluate((id) => { + const store = (window as any).__slayzone_tabStore.getState() + performance.clearMarks(`sz:taskDetail:${id}:mount`) + store.closeTabByTaskId(id) + store.setActiveTabIndex(0) + }, taskId) + const row = page.locator( + `[data-sidebar-tree-item="task"][data-task-id="${taskId}"]` + ) + await expect(row).toBeVisible({ timeout: 5000 }) + await page.waitForTimeout(150) + }, + run: async (page) => { + await page.evaluate(async (id) => { + const mark = `sz:taskDetail:${id}:mount` + performance.clearMarks(mark) + const row = document.querySelector( + `[data-sidebar-tree-item="task"][data-task-id="${id}"]` + ) as HTMLButtonElement | null + if (!row) throw new Error(`task row missing: ${id}`) + row.click() + await new Promise((resolve, reject) => { + const deadline = performance.now() + 5000 + const tick = () => { + if (performance.getEntriesByName(mark, 'mark').length > 0) { + resolve() + return + } + if (performance.now() > deadline) { + reject(new Error(`task detail mount timeout: ${id}`)) + return + } + window.setTimeout(tick, 5) + } + tick() + }) + }, taskId) + }, + afterEach: async (page) => { + await page.evaluate((id) => { + const store = (window as any).__slayzone_tabStore.getState() + store.closeTabByTaskId(id) + store.setActiveTabIndex(0) + }, taskId) + await page.waitForTimeout(100) + } + } + const result = await profileScenario(mainWindow, definition, { runDir: RUN_DIR }) + console.log( + `[perf] ${result.name} p50=${result.summary.wallP50}ms p95=${result.summary.wallP95}ms` + ) + expect(result.runs.length).toBe(ITERATIONS) + }) + test('create-task', async ({ mainWindow }) => { // Make sure dialog is closed before starting await mainWindow.evaluate(() => diff --git a/packages/apps/app/src/renderer/src/App.tsx b/packages/apps/app/src/renderer/src/App.tsx index a8b02539..9ccbceb5 100644 --- a/packages/apps/app/src/renderer/src/App.tsx +++ b/packages/apps/app/src/renderer/src/App.tsx @@ -151,6 +151,10 @@ import { BoostPill } from '@/components/usage/BoostPill' import { useUsage } from '@/components/usage/useUsage' import { useOnboardingChecklist } from '@/hooks/useOnboardingChecklist' import { TaskShell } from '@slayzone/task/client/TaskShell' +import { + buildTaskDetailDataFromSnapshot, + taskDetailCache +} from '@slayzone/task/client/taskDetailCache' // Extracted hooks (self-contained, clean interfaces) import { useHomePanel, HOME_PANEL_SIZE_KEY } from '@/hooks/useHomePanel' import { useTerminalStateTracking } from '@/hooks/useTerminalStateTracking' @@ -159,11 +163,11 @@ import { useTabColors } from '@/hooks/useTabColors' import { useVisibleTabs } from '@/hooks/useVisibleTabs' import { useDiagnosticsSync } from '@/hooks/useDiagnosticsSync' // Lazy-loaded: heavy components not needed for first paint -const TaskDetailDataLoader = lazy(() => +const loadTaskDetailDataLoader = () => import('@slayzone/task/client/TaskDetailDataLoader').then((m) => ({ default: m.TaskDetailDataLoader })) -) +const TaskDetailDataLoader = lazy(loadTaskDetailDataLoader) const FileEditorView = lazy(() => import('@slayzone/file-editor/client/FileEditorView').then((m) => ({ default: m.FileEditorView })) ) @@ -651,9 +655,34 @@ function App(): React.JSX.Element { return m }, [allIdleTasks]) + const tasksMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]) + const projectsMap = useMemo(() => new Map(projects.map((p) => [p.id, p])), [projects]) const selectedProject = useMemo( - () => projects.find((p) => p.id === selectedProjectId) ?? null, - [projects, selectedProjectId] + () => projectsMap.get(selectedProjectId) ?? null, + [projectsMap, selectedProjectId] + ) + const prefetchTaskDetail = useCallback( + (taskId: string) => { + const task = tasksMap.get(taskId) + if (task) { + taskDetailCache.prime( + 'taskDetail', + [taskId], + buildTaskDetailDataFromSnapshot({ + task, + tasks, + projects, + tags, + taskTagIds: taskTags.get(taskId) ?? [], + projectPathMissing: task.project_id === selectedProjectId ? projectPathMissing : false + }) + ) + } else { + taskDetailCache.prefetch('taskDetail', taskId) + } + void loadTaskDetailDataLoader() + }, + [tasksMap, tasks, projects, tags, taskTags, selectedProjectId, projectPathMissing] ) // Project lock guard — single chokepoint for task-open paths. Resolves the task's @@ -672,8 +701,8 @@ function App(): React.JSX.Element { const taskProject = projectOverride ?? (() => { - const task = tasks.find((t) => t.id === taskId) - return task ? projects.find((p) => p.id === task.project_id) : undefined + const task = tasksMap.get(taskId) + return task ? projectsMap.get(task.project_id) : undefined })() if (taskProject && isProjectLocked(taskProject)) { toast(PROJECT_LOCKED_TOAST) @@ -684,9 +713,10 @@ function App(): React.JSX.Element { return } if (taskProject) recordTaskOpen(taskProject.id) + prefetchTaskDetail(taskId) fn(taskId) }, - [tasks, projects] + [tasksMap, projectsMap, prefetchTaskDetail] ) const openTask = useCallback( @@ -726,9 +756,6 @@ function App(): React.JSX.Element { setSelectedProjectId(projects[0].id) }, [projects, selectedProjectId, setSelectedProjectId]) - // Task lookup map (used for tab props and active-tab project switching) - const tasksMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]) - // Visible tabs (project-scoped filtering — purely visual, full tabs stay mounted) const { visibleTabs, visibleActiveIndex, toFullIndex, toVisibleIndex } = useVisibleTabs( tabs, @@ -870,14 +897,18 @@ function App(): React.JSX.Element { }, [selectedProjectId, projects, validateProjectPath]) // Computed values - const projectTasks = selectedProjectId - ? tasks.filter((t) => t.project_id === selectedProjectId) - : [] - const projectTags = selectedProjectId - ? tags.filter((t) => t.project_id === selectedProjectId) - : tags - const displayTasks = applyFilters(projectTasks, filter, taskTags, selectedProject?.columns_config) - const projectsMap = new Map(projects.map((p) => [p.id, p])) + const projectTasks = useMemo( + () => (selectedProjectId ? tasks.filter((t) => t.project_id === selectedProjectId) : []), + [tasks, selectedProjectId] + ) + const projectTags = useMemo( + () => (selectedProjectId ? tags.filter((t) => t.project_id === selectedProjectId) : tags), + [tags, selectedProjectId] + ) + const displayTasks = useMemo( + () => applyFilters(projectTasks, filter, taskTags, selectedProject?.columns_config), + [projectTasks, filter, taskTags, selectedProject?.columns_config] + ) const createTaskDialogDraft = useMemo( () => ({ projectId: selectedProjectId || projects[0]?.id, ...createTaskDraft }), [selectedProjectId, projects, createTaskDraft] @@ -2201,6 +2232,7 @@ function App(): React.JSX.Element { useTabStore.getState().setActiveView('usage-analytics') }} onTaskClick={openTask} + onTaskPrefetch={prefetchTaskDetail} onCloseTab={closeTabByTaskId} onOpenTaskInBackground={(id) => useTabStore.getState().openTaskInBackground(id)} onCreateTemporaryTask={(projectId) => { @@ -2257,9 +2289,7 @@ function App(): React.JSX.Element { } isPinned={!!task.pinned} onTogglePin={() => setTaskPinned(task.id, !task.pinned)} - canMarkUnread={ - terminalStates.get(task.id) === 'idle' && !task.needs_attention - } + canMarkUnread={terminalStates.get(task.id) === 'idle' && !task.needs_attention} > {child} @@ -2997,6 +3027,7 @@ function App(): React.JSX.Element { onCreatedAndOpen={handleTaskCreatedAndOpen} draft={createTaskDialogDraft} tags={projectTags} + projects={projects} onTagCreated={(tag: Tag) => setTags((prev) => [...prev, tag])} /> diff --git a/packages/apps/app/src/renderer/src/components/sidebar/AppSidebar.tsx b/packages/apps/app/src/renderer/src/components/sidebar/AppSidebar.tsx index d7179f64..756b826c 100644 --- a/packages/apps/app/src/renderer/src/components/sidebar/AppSidebar.tsx +++ b/packages/apps/app/src/renderer/src/components/sidebar/AppSidebar.tsx @@ -29,6 +29,7 @@ interface AppSidebarProps { onUsageAnalytics: () => void onLeaderboard: () => void onTaskClick?: (taskId: string) => void + onTaskPrefetch?: (taskId: string) => void onCloseTab?: (taskId: string) => void onOpenTaskInBackground?: (taskId: string) => void onCreateTemporaryTask?: (projectId: string) => void @@ -132,6 +133,7 @@ export function AppSidebar({ onUsageAnalytics, onLeaderboard, onTaskClick, + onTaskPrefetch, onCloseTab, onOpenTaskInBackground, onCreateTemporaryTask, @@ -228,6 +230,7 @@ export function AppSidebar({ onSelectProject, onProjectSettings, onTaskClick, + onTaskPrefetch, onCloseTab, onOpenTaskInBackground, onCreateTemporaryTask, diff --git a/packages/apps/app/src/renderer/src/components/sidebar/views/TreeView.tsx b/packages/apps/app/src/renderer/src/components/sidebar/views/TreeView.tsx index 5e518968..1fb6580a 100644 --- a/packages/apps/app/src/renderer/src/components/sidebar/views/TreeView.tsx +++ b/packages/apps/app/src/renderer/src/components/sidebar/views/TreeView.tsx @@ -177,6 +177,7 @@ interface TaskBranchCtx { treeGroupBy: 'none' | 'status' | 'priority' treeGroupPinned: boolean onTaskClick?: (taskId: string) => void + onTaskPrefetch?: (taskId: string) => void onRowSelectClick: (event: ReactMouseEvent, taskId: string) => void onCloseTab?: (taskId: string) => void onOpenTaskInBackground?: (taskId: string) => void @@ -327,6 +328,8 @@ function TaskRowView({ data-task-id={task.id} data-active={isActive ? 'true' : undefined} data-selected={isSelected ? 'true' : undefined} + onFocus={() => ctx.onTaskPrefetch?.(task.id)} + onPointerEnter={() => ctx.onTaskPrefetch?.(task.id)} onClick={(e) => ctx.onRowSelectClick(e, task.id)} onAuxClick={(e) => { if (e.button !== 1) return @@ -336,6 +339,7 @@ function TaskRowView({ else ctx.onOpenTaskInBackground?.(task.id) }} onMouseDown={(e) => { + if (e.button === 0) ctx.onTaskPrefetch?.(task.id) if (e.button === 1) e.preventDefault() }} style={{ ...style, paddingLeft: tgPaddingLeft(depth), minHeight: TG_ROW_HEIGHT }} @@ -853,6 +857,7 @@ export function TreeView({ onSelectProject, onProjectSettings, onTaskClick, + onTaskPrefetch, onCloseTab, onOpenTaskInBackground, onCreateTemporaryTask, @@ -1394,11 +1399,12 @@ export function TreeView({ } // Plain click — clear selection, set anchor, open task. + onTaskPrefetch?.(taskId) setSelectedTaskIds(new Set([taskId])) setSelectionAnchorId(taskId) onTaskClick?.(taskId) }, - [selectionAnchorId, tasksById, getSiblings, onTaskClick] + [selectionAnchorId, tasksById, getSiblings, onTaskPrefetch, onTaskClick] ) // Render-order list of moved task ids — the dragged set, in tree visual @@ -1918,6 +1924,7 @@ export function TreeView({ treeGroupBy, treeGroupPinned, onTaskClick, + onTaskPrefetch, onRowSelectClick: handleRowSelectClick, onCloseTab, onOpenTaskInBackground, diff --git a/packages/apps/app/src/renderer/src/components/sidebar/views/types.ts b/packages/apps/app/src/renderer/src/components/sidebar/views/types.ts index 6d7b3cf6..06153dee 100644 --- a/packages/apps/app/src/renderer/src/components/sidebar/views/types.ts +++ b/packages/apps/app/src/renderer/src/components/sidebar/views/types.ts @@ -11,6 +11,8 @@ export interface SidebarViewContext { onSelectProject: (id: string) => void onProjectSettings: (project: Project) => void onTaskClick?: (taskId: string) => void + /** Warm task detail data/module before the user commits to opening the task. */ + onTaskPrefetch?: (taskId: string) => void /** Close a task tab by id (handles temporary task DB cleanup). */ onCloseTab?: (taskId: string) => void /** Open a task as a background tab without changing focus. */ diff --git a/packages/domains/projects/src/client/ProjectSelect.tsx b/packages/domains/projects/src/client/ProjectSelect.tsx index 302b255c..e40d4520 100644 --- a/packages/domains/projects/src/client/ProjectSelect.tsx +++ b/packages/domains/projects/src/client/ProjectSelect.tsx @@ -6,18 +6,23 @@ interface ProjectSelectProps { value: string | undefined onChange: (value: string) => void disabled?: boolean + projects?: Project[] } export function ProjectSelect({ value, onChange, - disabled + disabled, + projects }: ProjectSelectProps): React.JSX.Element { - const [projects, setProjects] = useState([]) + const [loadedProjects, setLoadedProjects] = useState([]) useEffect(() => { - window.api.db.getProjects().then(setProjects) - }, []) + if (projects) return + window.api.db.getProjects().then(setLoadedProjects) + }, [projects]) + + const options = projects ?? loadedProjects return (