diff --git a/src/renderer/components/layout/PaneContent.test.tsx b/src/renderer/components/layout/PaneContent.test.tsx new file mode 100644 index 00000000..57ce64a1 --- /dev/null +++ b/src/renderer/components/layout/PaneContent.test.tsx @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Pane } from '@renderer/types/panes'; +import type { Tab } from '@renderer/types/tabs'; + +vi.mock('../dashboard/DashboardView', () => ({ + DashboardView: () => React.createElement('div', { 'data-view': 'dashboard' }, 'Dashboard view'), +})); + +vi.mock('../extensions/ExtensionStoreView', () => ({ + ExtensionStoreView: () => + React.createElement('div', { 'data-view': 'extensions' }, 'Extension store view'), +})); + +/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */ + +import { PaneContent } from './PaneContent'; + +const flushReact = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +}; + +const roots: Root[] = []; + +const dashboardTab: Tab = { + id: 'tab-dashboard', + type: 'dashboard', + label: 'Dashboard', + createdAt: 1, +}; + +const extensionTab: Tab = { + id: 'tab-extensions', + type: 'extensions', + label: 'Extensions', + createdAt: 2, +}; + +const createPane = (tabs: Tab[], activeTabId: string | null): Pane => ({ + id: 'pane-main', + tabs, + activeTabId, + selectedTabIds: [], + widthFraction: 1, +}); + +const createHarness = (): { host: HTMLDivElement; root: Root } => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + roots.push(root); + return { host, root }; +}; + +const renderPane = async (root: Root, pane: Pane): Promise => { + await act(async () => { + root.render(); + await flushReact(); + }); +}; + +const waitForText = async (host: HTMLElement, text: string): Promise => { + for (let attempt = 0; attempt < 10; attempt += 1) { + if (host.textContent?.includes(text)) { + return; + } + + await act(async () => { + await flushReact(); + }); + } + + expect(host.textContent).toContain(text); +}; + +describe('PaneContent', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + }); + + afterEach(async () => { + await act(async () => { + for (const root of roots.splice(0)) { + root.unmount(); + } + await flushReact(); + }); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('renders the default dashboard without suspending when no tabs are open', async () => { + const { host, root } = createHarness(); + + await renderPane(root, createPane([], null)); + + expect(host.textContent).toContain('Dashboard view'); + expect(host.querySelector('[role="status"]')).toBeNull(); + }); + + it('does not mount inactive lazy tab content during initial pane render', async () => { + const { host, root } = createHarness(); + + await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id)); + + expect(host.textContent).toContain('Dashboard view'); + expect(host.textContent).not.toContain('Extension store view'); + expect(host.querySelector('[role="status"]')).toBeNull(); + }); + + it('loads a lazy tab on first activation and keeps it mounted after switching away', async () => { + const { host, root } = createHarness(); + + await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id)); + expect(host.textContent).not.toContain('Extension store view'); + + await renderPane(root, createPane([dashboardTab, extensionTab], extensionTab.id)); + await waitForText(host, 'Extension store view'); + + const extensionView = host.querySelector('[data-view="extensions"]'); + expect(extensionView).not.toBeNull(); + + await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id)); + + expect(host.querySelector('[data-view="extensions"]')).toBe(extensionView); + expect(extensionView?.closest('.absolute')?.style.display).toBe('none'); + }); +}); diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index b4d96187..d7d71e43 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -3,27 +3,135 @@ * Uses CSS display-toggle to keep all tabs mounted (preserving state). */ -import { TeamGraphTab } from '@features/agent-graph/renderer'; +import { lazy, Suspense, useEffect, useState } from 'react'; + import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; -import { ExtensionStoreView } from '../extensions/ExtensionStoreView'; -import { NotificationsView } from '../notifications/NotificationsView'; -import { SessionReportTab } from '../report/SessionReportTab'; -import { SchedulesView } from '../schedules/SchedulesView'; -import { SettingsView } from '../settings/SettingsView'; -import { TeamDetailView } from '../team/TeamDetailView'; -import { TeamListView } from '../team/TeamListView'; - -import { SessionTabContent } from './SessionTabContent'; import type { Pane } from '@renderer/types/panes'; +import type { Tab } from '@renderer/types/tabs'; + +const ExtensionStoreView = lazy(() => + import('../extensions/ExtensionStoreView').then((module) => ({ + default: module.ExtensionStoreView, + })) +); +const NotificationsView = lazy(() => + import('../notifications/NotificationsView').then((module) => ({ + default: module.NotificationsView, + })) +); +const SessionReportTab = lazy(() => + import('../report/SessionReportTab').then((module) => ({ + default: module.SessionReportTab, + })) +); +const SchedulesView = lazy(() => + import('../schedules/SchedulesView').then((module) => ({ + default: module.SchedulesView, + })) +); +const SettingsView = lazy(() => + import('../settings/SettingsView').then((module) => ({ + default: module.SettingsView, + })) +); +const TeamDetailView = lazy(() => + import('../team/TeamDetailView').then((module) => ({ + default: module.TeamDetailView, + })) +); +const TeamListView = lazy(() => + import('../team/TeamListView').then((module) => ({ + default: module.TeamListView, + })) +); +const SessionTabContent = lazy(() => + import('./SessionTabContent').then((module) => ({ + default: module.SessionTabContent, + })) +); +const TeamGraphTab = lazy(() => + import('@features/agent-graph/renderer').then((module) => ({ + default: module.TeamGraphTab, + })) +); interface PaneContentProps { pane: Pane; isPaneFocused: boolean; } +interface PaneTabSlotProps { + tab: Tab; + isActive: boolean; + isPaneFocused: boolean; +} + +const PaneLazyFallback = (): React.JSX.Element => ( +
+
+
+); + +const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React.JSX.Element => { + const [hasActivated, setHasActivated] = useState(isActive); + + useEffect(() => { + if (isActive) { + setHasActivated(true); + } + }, [isActive]); + + return ( +
+ {hasActivated && ( + }> + {tab.type === 'dashboard' && } + {tab.type === 'notifications' && } + {tab.type === 'settings' && } + {tab.type === 'teams' && } + {tab.type === 'team' && ( + + + + )} + {tab.type === 'session' && ( + + + + )} + {tab.type === 'report' && } + {tab.type === 'extensions' && ( + + + + )} + {tab.type === 'schedules' && } + {tab.type === 'graph' && ( + + + + )} + + )} +
+ ); +}; + export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => { const activeTabId = pane.activeTabId; @@ -41,46 +149,7 @@ export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JS {pane.tabs.map((tab) => { const isActive = tab.id === activeTabId; return ( -
- {tab.type === 'dashboard' && } - {tab.type === 'notifications' && } - {tab.type === 'settings' && } - {tab.type === 'teams' && } - {tab.type === 'team' && ( - - - - )} - {tab.type === 'session' && ( - - - - )} - {tab.type === 'report' && } - {tab.type === 'extensions' && ( - - - - )} - {tab.type === 'schedules' && } - {tab.type === 'graph' && ( - - - - )} -
+ ); })}
diff --git a/src/renderer/components/layout/Sidebar.test.tsx b/src/renderer/components/layout/Sidebar.test.tsx new file mode 100644 index 00000000..43a34441 --- /dev/null +++ b/src/renderer/components/layout/Sidebar.test.tsx @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const storeMock = vi.hoisted(() => ({ + state: { + sidebarCollapsed: false, + toggleSidebar: vi.fn(), + }, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof storeMock.state) => T): T => selector(storeMock.state), +})); + +vi.mock('../sidebar/GlobalTaskList', () => ({ + GlobalTaskList: () => React.createElement('div', { 'data-testid': 'tasks-panel' }, 'Tasks panel'), +})); + +vi.mock('../sidebar/DateGroupedSessions', () => ({ + DateGroupedSessions: () => + React.createElement('div', { 'data-testid': 'sessions-panel' }, 'Sessions panel'), +})); + +/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */ + +import { Sidebar } from './Sidebar'; + +const roots: Root[] = []; + +const flushReact = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); +}; + +const createHarness = (): { host: HTMLDivElement; root: Root } => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + roots.push(root); + return { host, root }; +}; + +const renderSidebar = async (root: Root): Promise => { + await act(async () => { + root.render(); + await flushReact(); + }); +}; + +function findButtonByText(host: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(host.querySelectorAll('button')).find( + (candidate) => candidate.textContent?.trim() === text + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Button not found: ${text}`); + } + return button; +} + +describe('Sidebar', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeMock.state.sidebarCollapsed = false; + storeMock.state.toggleSidebar.mockClear(); + }); + + afterEach(async () => { + await act(async () => { + for (const root of roots.splice(0)) { + root.unmount(); + } + await flushReact(); + }); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('does not mount the sessions panel before the sessions tab is opened', async () => { + const { host, root } = createHarness(); + + await renderSidebar(root); + + expect(host.querySelector('[data-testid="tasks-panel"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="sessions-panel"]')).toBeNull(); + }); + + it('mounts the sessions panel on first activation and keeps it mounted when hidden', async () => { + const { host, root } = createHarness(); + await renderSidebar(root); + + await act(async () => { + findButtonByText(host, 'Sessions').click(); + await flushReact(); + }); + + const sessionsPanel = host.querySelector('[data-testid="sessions-panel"]'); + expect(sessionsPanel).not.toBeNull(); + expect(sessionsPanel?.closest('[role="tabpanel"]')?.hidden).toBe(false); + + await act(async () => { + findButtonByText(host, 'Tasks').click(); + await flushReact(); + }); + + expect(host.querySelector('[data-testid="sessions-panel"]')).toBe(sessionsPanel); + expect(sessionsPanel?.closest('[role="tabpanel"]')?.hidden).toBe(true); + }); +}); diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index b6f07b7a..8568a1b5 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -37,6 +37,7 @@ export const Sidebar = (): React.JSX.Element => { const [width, setWidth] = useState(DEFAULT_WIDTH); const [isResizing, setIsResizing] = useState(false); const [sidebarTab, setSidebarTab] = useState('tasks'); + const [hasOpenedSessionsTab, setHasOpenedSessionsTab] = useState(false); const [taskFilters, setTaskFilters] = useState(defaultTaskFiltersState); const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false); const [isCollapseHovered, setIsCollapseHovered] = useState(false); @@ -77,6 +78,12 @@ export const Sidebar = (): React.JSX.Element => { }; }, [isResizing, handleMouseMove, handleMouseUp]); + useEffect(() => { + if (sidebarTab === 'sessions') { + setHasOpenedSessionsTab(true); + } + }, [sidebarTab]); + const handleResizeStart = (e: React.MouseEvent): void => { e.preventDefault(); setIsResizing(true); @@ -195,7 +202,7 @@ export const Sidebar = (): React.JSX.Element => { hidden={sidebarTab !== 'sessions'} className="min-w-0 flex-1 overflow-hidden" > - + {hasOpenedSessionsTab && } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index d0d655d1..3d651676 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -215,11 +215,10 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { useStore.getState().unsubscribeProvisioningProgress(); }); - // Initial data fetches. Config loads first (needed for theme), then the rest - // run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16 - // prevents thread pool saturation even with concurrent I/O on Windows. - // Components also fire these from useEffect — loading guards in each action - // prevent duplicate IPC calls (whichever caller starts first wins). + // Initial data fetches. Config loads first (needed for theme), then the + // immediately visible data follows. Repository grouping is owned by the + // Sessions sidebar tab when it first mounts; scanning it here made startup + // pay for a hidden panel. void (async () => { // Config: fast (in-memory read) — needed for theme before first paint. await useStore.getState().fetchConfig(); @@ -262,10 +261,8 @@ export function initializeNotificationListeners(): () => void { runtimeStatusTimer = null; }, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS); - // Remaining fetches have no data dependency on each other — run in parallel - // to avoid blocking teams/notifications behind a slow repository scan. + // Remaining visible startup fetches have no data dependency on each other. await Promise.all([ - useStore.getState().fetchRepositoryGroups(), useStore.getState().fetchAllTasks(), useStore.getState().fetchTeams(), useStore.getState().fetchNotifications(), diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 162d2270..4ce938e3 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -82,6 +82,8 @@ vi.mock('@renderer/api', () => ({ }, })); +import { api } from '@renderer/api'; + import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice'; import { @@ -90,7 +92,6 @@ import { summarizeTeamRefreshFanout, type TeamRefreshFanoutSnapshot, } from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; -import { api } from '@renderer/api'; describe('team change throttling', () => { let cleanup: (() => void) | null = null; @@ -190,6 +191,17 @@ describe('team change throttling', () => { expect(fetchTeamsSpy).toHaveBeenCalledTimes(1); }); + it('does not scan repository groups during centralized startup initialization', async () => { + const getRepositoryGroupsSpy = vi.mocked(api.getRepositoryGroups); + getRepositoryGroupsSpy.mockClear(); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + + expect(getRepositoryGroupsSpy).not.toHaveBeenCalled(); + }); + it('allows next refresh after throttle window passes', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');