From 7aeccd35558db4a4635a417ade1d8e5660fe0232 Mon Sep 17 00:00:00 2001 From: ibrahimmaq14 Date: Thu, 22 Oct 2026 17:53:47 +0530 Subject: [PATCH 1/2] perf(desktop): load heavy libraries and panels on demand Startup JavaScript (entry plus the bootstrap chunks) drops from 2,307.8 KB to 1,055.2 KB (630.1 KB to 300.7 KB gzip), measured from `bun run build`. - Mermaid loads with a dynamic import the first time a diagram renders. - KaTeX and its stylesheet load the first time a message contains math; until then the TeX source is shown in place. - The diff body (react-diff-viewer) loads on first use; the header renders at once and a placeholder reserves the diff's height. - The Doctor panel loads on demand on the error screens, guarded so a failed chunk load cannot break the screen that is reporting an error. - The Settings pages load when the Settings window first opens. The modal shell moves to pages/SettingsModal.tsx and stays eager so it still remembers the opener before taking focus. - The workspace panel loads the first time it is opened. - Fuse loads when session search opens. Closes #152 --- desktop/src/components/ErrorBoundary.test.tsx | 4 +- desktop/src/components/ErrorBoundary.tsx | 4 +- .../src/components/chat/DiffViewer.test.tsx | 26 ++++ desktop/src/components/chat/DiffViewer.tsx | 137 +++--------------- .../src/components/chat/DiffViewerBody.tsx | 129 +++++++++++++++++ .../components/chat/MermaidRenderer.test.tsx | 38 +++-- .../src/components/chat/MermaidRenderer.tsx | 38 +++-- .../doctor/LazyDoctorPanel.test.tsx | 44 ++++++ .../src/components/doctor/LazyDoctorPanel.tsx | 35 +++++ .../src/components/layout/AppShell.test.tsx | 2 +- desktop/src/components/layout/AppShell.tsx | 2 +- .../components/layout/StartupErrorView.tsx | 4 +- .../MarkdownRenderer.lazyMath.test.tsx | 71 +++++++++ .../markdown/MarkdownRenderer.test.tsx | 8 +- .../components/markdown/MarkdownRenderer.tsx | 28 +++- desktop/src/hooks/useSessionSearch.ts | 34 ++++- desktop/src/lib/katexLoader.ts | 33 +++++ desktop/src/pages/ActiveSession.test.tsx | 4 +- desktop/src/pages/ActiveSession.tsx | 29 +++- desktop/src/pages/Settings.tsx | 84 +---------- desktop/src/pages/SettingsModal.test.tsx | 18 ++- desktop/src/pages/SettingsModal.tsx | 105 ++++++++++++++ 22 files changed, 623 insertions(+), 254 deletions(-) create mode 100644 desktop/src/components/chat/DiffViewer.test.tsx create mode 100644 desktop/src/components/chat/DiffViewerBody.tsx create mode 100644 desktop/src/components/doctor/LazyDoctorPanel.test.tsx create mode 100644 desktop/src/components/doctor/LazyDoctorPanel.tsx create mode 100644 desktop/src/components/markdown/MarkdownRenderer.lazyMath.test.tsx create mode 100644 desktop/src/lib/katexLoader.ts create mode 100644 desktop/src/pages/SettingsModal.tsx diff --git a/desktop/src/components/ErrorBoundary.test.tsx b/desktop/src/components/ErrorBoundary.test.tsx index 5e4dee7f..672e73bf 100644 --- a/desktop/src/components/ErrorBoundary.test.tsx +++ b/desktop/src/components/ErrorBoundary.test.tsx @@ -33,7 +33,7 @@ describe('ErrorBoundary', () => { consoleErrorSpy.mockRestore() }) - it('shows retry and compact Doctor fallback when a child crashes', () => { + it('shows retry and compact Doctor fallback when a child crashes', async () => { render( @@ -43,7 +43,7 @@ describe('ErrorBoundary', () => { expect(screen.getByText('Something went wrong.')).toBeInTheDocument() expect(screen.getByText('The error was recorded in Diagnostics.')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() - expect(screen.getByTestId('doctor-panel')).toHaveTextContent('compact doctor') + expect(await screen.findByTestId('doctor-panel')).toHaveTextContent('compact doctor') expect(reportReactError).toHaveBeenCalled() }) }) diff --git a/desktop/src/components/ErrorBoundary.tsx b/desktop/src/components/ErrorBoundary.tsx index fadb7edc..68934a0b 100644 --- a/desktop/src/components/ErrorBoundary.tsx +++ b/desktop/src/components/ErrorBoundary.tsx @@ -2,7 +2,7 @@ import React from 'react' import { t } from '../i18n' import { reportReactError } from '../lib/diagnosticsCapture' import { Button } from './shared/Button' -import { DoctorPanel } from './doctor/DoctorPanel' +import { LazyDoctorPanel } from './doctor/LazyDoctorPanel' type Props = { children: React.ReactNode @@ -46,7 +46,7 @@ function ErrorBoundaryFallback() {
- +
diff --git a/desktop/src/components/chat/DiffViewer.test.tsx b/desktop/src/components/chat/DiffViewer.test.tsx new file mode 100644 index 00000000..1aa78eb2 --- /dev/null +++ b/desktop/src/components/chat/DiffViewer.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import '@testing-library/jest-dom' +import { DiffViewer } from './DiffViewer' + +describe('DiffViewer', () => { + it('shows the file header at once and renders the diff body after it loads on first use', async () => { + const { container } = render( + , + ) + + expect(screen.getByText('src/greeting.ts')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + expect(screen.getByText('-1')).toBeInTheDocument() + // The placeholder reserves roughly the diff's height so the card does not jump. + expect(screen.getByRole('status', { name: 'Loading diff' })).toHaveStyle({ height: '60px' }) + + const body = container.querySelector('.max-h-\\[400px\\]') as HTMLElement + await within(body).findByText('world', { exact: false }) + expect(screen.queryByRole('status', { name: 'Loading diff' })).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/chat/DiffViewer.tsx b/desktop/src/components/chat/DiffViewer.tsx index eba7d263..1113cf07 100644 --- a/desktop/src/components/chat/DiffViewer.tsx +++ b/desktop/src/components/chat/DiffViewer.tsx @@ -1,122 +1,25 @@ -import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued' -import { Highlight, type PrismTheme } from 'prism-react-renderer' +import { lazy, Suspense } from 'react' import { CopyButton } from '../shared/CopyButton' +const DiffViewerBody = lazy(() => import('./DiffViewerBody')) + +// Matches the rendered diff rows (12px font, 1.45 line height, 1px padding) so the card +// does not jump when the diff body finishes loading. +const DIFF_ROW_HEIGHT_PX = 20 +const DIFF_MAX_HEIGHT_PX = 400 + type Props = { filePath: string oldString: string newString: string } -function inferLanguage(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() - const langMap: Record = { - ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', - py: 'python', rs: 'rust', go: 'go', rb: 'ruby', - json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', - md: 'markdown', css: 'css', html: 'markup', xml: 'markup', - sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', - } - return langMap[ext ?? ''] || 'text' -} - -const warmSyntaxTheme: PrismTheme = { - plain: { - color: 'var(--color-code-fg)', - backgroundColor: 'transparent', - }, - styles: [ - { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } }, - { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, - { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, - { types: ['function'], style: { color: 'var(--color-code-function)' } }, - { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, - { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, - { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, - { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, - { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, - { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, - { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, - { types: ['regex'], style: { color: 'var(--color-primary-container)' } }, - { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, - { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, - ], -} - -function highlightSyntax(str: string, language: string) { - return ( - - {({ tokens, getTokenProps }) => ( - <> - {tokens.map((line, i) => ( - - {line.map((token, key) => ( - - ))} - - ))} - - )} - - ) -} - -const diffStyles = { - variables: { - light: { - diffViewerBackground: 'var(--color-code-bg)', - diffViewerColor: 'var(--color-code-fg)', - addedBackground: 'var(--color-diff-added-bg)', - addedColor: 'var(--color-code-fg)', - removedBackground: 'var(--color-diff-removed-bg)', - removedColor: 'var(--color-code-fg)', - wordAddedBackground: 'var(--color-diff-added-word)', - wordRemovedBackground: 'var(--color-diff-removed-word)', - addedGutterBackground: 'var(--color-diff-added-gutter)', - removedGutterBackground: 'var(--color-diff-removed-gutter)', - gutterBackground: 'var(--color-surface-container-low)', - gutterBackgroundDark: 'var(--color-surface-container)', - highlightBackground: 'var(--color-diff-highlight-bg)', - highlightGutterBackground: 'var(--color-diff-highlight-gutter)', - codeFoldGutterBackground: 'var(--color-surface-container-high)', - codeFoldBackground: 'var(--color-surface-container-highest)', - emptyLineBackground: 'var(--color-surface-container-low)', - gutterColor: 'var(--color-text-tertiary)', - addedGutterColor: 'var(--color-diff-added-text)', - removedGutterColor: 'var(--color-diff-removed-text)', - codeFoldContentColor: 'var(--color-text-tertiary)', - diffViewerTitleBackground: 'var(--color-diff-title-bg)', - diffViewerTitleColor: 'var(--color-diff-title-color)', - diffViewerTitleBorderColor: 'var(--color-diff-title-border)', - }, - }, - diffContainer: { - borderRadius: '0', - fontSize: '12px', - lineHeight: '1.45', - fontFamily: 'var(--font-mono)', - }, - line: { - padding: '1px 0', - }, - gutter: { - padding: '1px 8px', - minWidth: '40px', - fontSize: '11px', - }, - wordDiff: { - padding: '1px 2px', - borderRadius: '2px', - }, -} - export function DiffViewer({ filePath, oldString, newString }: Props) { - const language = inferLanguage(filePath) - const oldLines = oldString.split('\n') const newLines = newString.split('\n') const additions = newLines.filter((l, i) => l !== (oldLines[i] ?? null)).length const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length + const placeholderHeight = Math.min(DIFF_MAX_HEIGHT_PX, (oldLines.length + additions) * DIFF_ROW_HEIGHT_PX) return (
@@ -138,16 +41,18 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
- highlightSyntax(str, language)} - hideLineNumbers={false} - styles={diffStyles} - useDarkTheme={document.documentElement.getAttribute('data-theme') === 'dark'} - /> + + } + > + +
) diff --git a/desktop/src/components/chat/DiffViewerBody.tsx b/desktop/src/components/chat/DiffViewerBody.tsx new file mode 100644 index 00000000..f5d13930 --- /dev/null +++ b/desktop/src/components/chat/DiffViewerBody.tsx @@ -0,0 +1,129 @@ +import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued' +import { Highlight, type PrismTheme } from 'prism-react-renderer' + +type Props = { + filePath: string + oldString: string + newString: string +} + +function inferLanguage(filePath: string): string { + const ext = filePath.split('.').pop()?.toLowerCase() + const langMap: Record = { + ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', + py: 'python', rs: 'rust', go: 'go', rb: 'ruby', + json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', + md: 'markdown', css: 'css', html: 'markup', xml: 'markup', + sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', + } + return langMap[ext ?? ''] || 'text' +} + +const warmSyntaxTheme: PrismTheme = { + plain: { + color: 'var(--color-code-fg)', + backgroundColor: 'transparent', + }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } }, + { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, + { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['function'], style: { color: 'var(--color-code-function)' } }, + { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, + { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, + { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, + { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, + { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, + { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, + { types: ['regex'], style: { color: 'var(--color-primary-container)' } }, + { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, + { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, + ], +} + +function highlightSyntax(str: string, language: string) { + return ( + + {({ tokens, getTokenProps }) => ( + <> + {tokens.map((line, i) => ( + + {line.map((token, key) => ( + + ))} + + ))} + + )} + + ) +} + +const diffStyles = { + variables: { + light: { + diffViewerBackground: 'var(--color-code-bg)', + diffViewerColor: 'var(--color-code-fg)', + addedBackground: 'var(--color-diff-added-bg)', + addedColor: 'var(--color-code-fg)', + removedBackground: 'var(--color-diff-removed-bg)', + removedColor: 'var(--color-code-fg)', + wordAddedBackground: 'var(--color-diff-added-word)', + wordRemovedBackground: 'var(--color-diff-removed-word)', + addedGutterBackground: 'var(--color-diff-added-gutter)', + removedGutterBackground: 'var(--color-diff-removed-gutter)', + gutterBackground: 'var(--color-surface-container-low)', + gutterBackgroundDark: 'var(--color-surface-container)', + highlightBackground: 'var(--color-diff-highlight-bg)', + highlightGutterBackground: 'var(--color-diff-highlight-gutter)', + codeFoldGutterBackground: 'var(--color-surface-container-high)', + codeFoldBackground: 'var(--color-surface-container-highest)', + emptyLineBackground: 'var(--color-surface-container-low)', + gutterColor: 'var(--color-text-tertiary)', + addedGutterColor: 'var(--color-diff-added-text)', + removedGutterColor: 'var(--color-diff-removed-text)', + codeFoldContentColor: 'var(--color-text-tertiary)', + diffViewerTitleBackground: 'var(--color-diff-title-bg)', + diffViewerTitleColor: 'var(--color-diff-title-color)', + diffViewerTitleBorderColor: 'var(--color-diff-title-border)', + }, + }, + diffContainer: { + borderRadius: '0', + fontSize: '12px', + lineHeight: '1.45', + fontFamily: 'var(--font-mono)', + }, + line: { + padding: '1px 0', + }, + gutter: { + padding: '1px 8px', + minWidth: '40px', + fontSize: '11px', + }, + wordDiff: { + padding: '1px 2px', + borderRadius: '2px', + }, +} + +// Loaded on demand by DiffViewer: react-diff-viewer and its styling runtime are only +// needed once a tool call or permission prompt actually shows a diff. +export default function DiffViewerBody({ filePath, oldString, newString }: Props) { + const language = inferLanguage(filePath) + + return ( + highlightSyntax(str, language)} + hideLineNumbers={false} + styles={diffStyles} + useDarkTheme={document.documentElement.getAttribute('data-theme') === 'dark'} + /> + ) +} diff --git a/desktop/src/components/chat/MermaidRenderer.test.tsx b/desktop/src/components/chat/MermaidRenderer.test.tsx index 3775e44a..6fce18dc 100644 --- a/desktop/src/components/chat/MermaidRenderer.test.tsx +++ b/desktop/src/components/chat/MermaidRenderer.test.tsx @@ -2,20 +2,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' -const { initializeMock, renderMock } = vi.hoisted(() => ({ +const { initializeMock, renderMock, mermaidModule } = vi.hoisted(() => ({ initializeMock: vi.fn(), renderMock: vi.fn(), + mermaidModule: { loads: 0 }, })) -vi.mock('mermaid', () => ({ - default: { - initialize: initializeMock, - render: renderMock, - }, -})) +vi.mock('mermaid', () => { + mermaidModule.loads += 1 + return { + default: { + initialize: initializeMock, + render: renderMock, + }, + } +}) import { MermaidRenderer } from './MermaidRenderer' +const mermaidLoadsAtImport = mermaidModule.loads + describe('MermaidRenderer', () => { beforeEach(() => { initializeMock.mockReset() @@ -25,14 +31,26 @@ describe('MermaidRenderer', () => { }) }) - it('opens preview with zoom controls and updates the zoom label', async () => { + it('loads mermaid only once a diagram renders, showing progress until then', async () => { + expect(mermaidLoadsAtImport).toBe(0) + render(B'} />) - const previewButton = await screen.findByRole('button', { name: /preview/i }) - expect(previewButton).toBeInTheDocument() + expect(screen.getByText('Rendering diagram...')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: /preview/i })).toBeInTheDocument() + expect(mermaidModule.loads).toBe(1) expect(initializeMock).toHaveBeenCalledWith(expect.objectContaining({ + securityLevel: 'strict', suppressErrorRendering: true, })) + expect(renderMock).toHaveBeenCalledWith(expect.stringMatching(/^mermaid-\d+$/), 'graph TB\nA-->B') + }) + + it('opens preview with zoom controls and updates the zoom label', async () => { + render(B'} />) + + const previewButton = await screen.findByRole('button', { name: /preview/i }) + expect(previewButton).toBeInTheDocument() fireEvent.click(previewButton) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index 769f9ab3..0a98cbff 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react' import DOMPurify from 'dompurify' -import mermaid from 'mermaid' +import type { Mermaid } from 'mermaid' import { Modal } from '../shared/Modal' import { CopyButton } from '../shared/CopyButton' @@ -8,7 +8,6 @@ type Props = { code: string } -let mermaidInitialized = false const MIN_PREVIEW_ZOOM = 0.5 const MAX_PREVIEW_ZOOM = 3 const PREVIEW_ZOOM_STEP = 0.25 @@ -26,16 +25,28 @@ type DragState = { scrollTop: number } -function initMermaid() { - if (mermaidInitialized) return - mermaid.initialize({ - startOnLoad: false, - theme: 'default', - securityLevel: 'strict', - suppressErrorRendering: true, - fontFamily: 'var(--font-sans)', - }) - mermaidInitialized = true +let mermaidPromise: Promise | null = null + +// Mermaid is large, so it is only fetched once a message actually contains a diagram. +function loadMermaid(): Promise { + mermaidPromise ??= import('mermaid').then( + ({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'strict', + suppressErrorRendering: true, + fontFamily: 'var(--font-sans)', + }) + return mermaid + }, + (err: unknown) => { + // Let a later diagram retry instead of caching a failed chunk load forever. + mermaidPromise = null + throw err + }, + ) + return mermaidPromise } let mermaidIdCounter = 0 @@ -106,11 +117,10 @@ export function MermaidRenderer({ code }: Props) { useEffect(() => { let cancelled = false - initMermaid() const id = `mermaid-${++mermaidIdCounter}` - mermaid.render(id, code).then( + loadMermaid().then((mermaid) => mermaid.render(id, code)).then( ({ svg: renderedSvg }) => { if (!cancelled) { setSvg(renderedSvg) diff --git a/desktop/src/components/doctor/LazyDoctorPanel.test.tsx b/desktop/src/components/doctor/LazyDoctorPanel.test.tsx new file mode 100644 index 00000000..e85c579a --- /dev/null +++ b/desktop/src/components/doctor/LazyDoctorPanel.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +describe('LazyDoctorPanel', () => { + afterEach(() => { + vi.doUnmock('./DoctorPanel') + vi.resetModules() + vi.restoreAllMocks() + }) + + it('renders the Doctor panel once its chunk has loaded', async () => { + vi.doMock('./DoctorPanel', () => ({ + DoctorPanel: ({ compact }: { compact?: boolean }) => ( +
{compact ? 'compact doctor' : 'doctor'}
+ ), + })) + const { LazyDoctorPanel } = await import('./LazyDoctorPanel') + + render() + + expect(await screen.findByTestId('doctor-panel')).toHaveTextContent('compact doctor') + }) + + it('renders nothing, without breaking the surrounding error screen, when the chunk fails to load', async () => { + vi.doMock('./DoctorPanel', () => { + throw new Error('chunk failed to load') + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { LazyDoctorPanel } = await import('./LazyDoctorPanel') + + const { container } = render( +
+

Something went wrong.

+ +
, + ) + + // React reports the error it caught at the panel's own boundary. + await waitFor(() => expect(consoleError).toHaveBeenCalled()) + expect(screen.getByText('Something went wrong.')).toBeInTheDocument() + expect(container.querySelector('main')?.children).toHaveLength(1) + }) +}) diff --git a/desktop/src/components/doctor/LazyDoctorPanel.tsx b/desktop/src/components/doctor/LazyDoctorPanel.tsx new file mode 100644 index 00000000..827f8344 --- /dev/null +++ b/desktop/src/components/doctor/LazyDoctorPanel.tsx @@ -0,0 +1,35 @@ +import { Component, lazy, Suspense, type ReactNode } from 'react' + +const DoctorPanel = lazy(() => import('./DoctorPanel').then((m) => ({ default: m.DoctorPanel }))) + +type DoctorPanelProps = { + compact?: boolean +} + +type GuardProps = { children: ReactNode } +type GuardState = { failed: boolean } + +// The Doctor panel is shown on error screens. If its chunk cannot load there, render nothing +// rather than letting the failure escape the screen that is already reporting an error. +class DoctorPanelLoadGuard extends Component { + state: GuardState = { failed: false } + + static getDerivedStateFromError(): GuardState { + return { failed: true } + } + + render() { + return this.state.failed ? null : this.props.children + } +} + +/** DoctorPanel loaded on first render, for screens on the startup path. */ +export function LazyDoctorPanel(props: DoctorPanelProps) { + return ( + + + + + + ) +} diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index 73583515..2ce38fc9 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -102,7 +102,7 @@ vi.mock('./ContentRouter', () => ({ ContentRouter: () =>
content loaded
, })) -vi.mock('../../pages/Settings', () => ({ +vi.mock('../../pages/SettingsModal', () => ({ SettingsModal: () =>
settings modal mounted
, })) diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 6e7656ce..f0f14569 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -16,7 +16,7 @@ import { import { WindowControls } from './WindowControls' import { TitlebarSidebarToggle } from './TitlebarSidebarToggle' import { isMacOverlayTitlebar } from '../../lib/windowChrome' -import { SettingsModal } from '../../pages/Settings' +import { SettingsModal } from '../../pages/SettingsModal' import { StartupErrorView } from './StartupErrorView' import { useTabStore } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx index 134cf4ee..40bc6a06 100644 --- a/desktop/src/components/layout/StartupErrorView.tsx +++ b/desktop/src/components/layout/StartupErrorView.tsx @@ -2,7 +2,7 @@ import { Copy, RefreshCw } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from '../../i18n' import { Button } from '../shared/Button' -import { DoctorPanel } from '../doctor/DoctorPanel' +import { LazyDoctorPanel } from '../doctor/LazyDoctorPanel' import { copyTextToClipboard } from '../chat/clipboard' const LOG_MARKER = '\n\nRecent server logs:\n' @@ -97,7 +97,7 @@ export function StartupErrorView({ error }: StartupErrorViewProps) { - + diff --git a/desktop/src/components/markdown/MarkdownRenderer.lazyMath.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.lazyMath.test.tsx new file mode 100644 index 00000000..0bbd5dd2 --- /dev/null +++ b/desktop/src/components/markdown/MarkdownRenderer.lazyMath.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import katex from 'katex' + +const katexLoader = vi.hoisted(() => { + let resolveLoad: (() => void) | null = null + const state = { + loaded: null as typeof katex | null, + loadKatex: null as unknown as ReturnType, + finishLoad: () => resolveLoad?.(), + } + state.loadKatex = vi.fn(() => new Promise((resolve) => { + resolveLoad = () => { + state.loaded = katex + resolve() + } + })) + return state +}) + +vi.mock('../../lib/katexLoader', () => ({ + getLoadedKatex: () => katexLoader.loaded, + loadKatex: katexLoader.loadKatex, +})) + +import { MarkdownRenderer, __markdownParseCacheInternals } from './MarkdownRenderer' + +describe('MarkdownRenderer on-demand KaTeX', () => { + beforeEach(() => { + katexLoader.loaded = null + katexLoader.loadKatex.mockClear() + __markdownParseCacheInternals.reset() + }) + + afterEach(() => { + katexLoader.finishLoad() + }) + + it('does not load KaTeX for a reply without math', () => { + const { container } = render() + + expect(container).toHaveTextContent('Plain markdown, priced at $5.') + expect(katexLoader.loadKatex).not.toHaveBeenCalled() + }) + + it('shows the TeX source in place while KaTeX loads, then renders the formula', async () => { + const { container } = render( + , + ) + + expect(katexLoader.loadKatex).toHaveBeenCalledTimes(1) + expect(container.querySelector('.katex')).not.toBeInTheDocument() + expect(container.querySelector('.md-math-inline')).toHaveTextContent('E = mc^2') + expect(container.querySelector('.md-math-display')).toHaveTextContent('\\frac{1}{3}') + + katexLoader.finishLoad() + + await waitFor(() => expect(container.querySelectorAll('.katex')).toHaveLength(2)) + expect(container.querySelector('.md-math-inline .katex')).toBeInTheDocument() + expect(container.querySelector('.md-math-display .katex')).toBeInTheDocument() + expect(container.textContent).not.toContain('\\frac{1}{3}') + }) + + it('shows TeX that looks like markup as plain text while KaTeX loads', () => { + const { container } = render(c$'} />) + + expect(container.querySelector('.md-math-inline')).toHaveTextContent('ac') + expect(container.querySelector('.md-math-inline b')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index f0fe5571..ed16c776 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, it, expect, vi } from 'vitest' +import { beforeAll, beforeEach, describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' @@ -17,6 +17,7 @@ vi.mock('../chat/MermaidRenderer', () => ({ })) import { MarkdownRenderer, __markdownParseCacheInternals } from './MarkdownRenderer' +import { loadKatex } from '../../lib/katexLoader' function visibleMathText(container: HTMLElement): string { const clone = container.cloneNode(true) as HTMLElement @@ -25,6 +26,11 @@ function visibleMathText(container: HTMLElement): string { } describe('MarkdownRenderer', () => { + // These tests assert KaTeX output; loading KaTeX on demand is covered in MarkdownRenderer.lazyMath.test.tsx. + beforeAll(async () => { + await loadKatex() + }) + it('applies document prose classes and custom width classes', () => { const { container } = render( /g, '>') +} + function renderMath(block: MathBlock): string { const cacheKey = `${block.displayMode ? 'block' : 'inline'}\0${block.tex}` const cached = mathRenderCache.get(cacheKey) if (cached) return cached + // Until KaTeX has loaded, show the TeX source in place; the renderer re-runs once it arrives. + const katex = getLoadedKatex() + if (!katex) return escapeHtmlText(block.tex) + try { const rendered = katex.renderToString(block.tex, { displayMode: block.displayMode, @@ -460,6 +467,19 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian () => getProseClasses(variant, className), [variant, className], ) + const [katexReady, setKatexReady] = useState(() => getLoadedKatex() !== null) + const needsKatex = mathBlocks.length > 0 && !katexReady + + useEffect(() => { + if (!needsKatex) return + let cancelled = false + loadKatex().then( + () => { if (!cancelled) setKatexReady(true) }, + // The TeX source stays visible; the next message with math retries the load. + () => {}, + ) + return () => { cancelled = true } + }, [needsKatex]) const parts = useMemo(() => { if (codeBlocks.length === 0) { @@ -487,7 +507,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian } return result - }, [html, codeBlocks, mathBlocks]) + }, [html, codeBlocks, mathBlocks, katexReady]) // katexReady re-runs the math enhancement once KaTeX loads const handleClick = useCallback(async (event: ReactMouseEvent) => { const target = event.target as HTMLElement | null diff --git a/desktop/src/hooks/useSessionSearch.ts b/desktop/src/hooks/useSessionSearch.ts index 0677abbf..ae649c91 100644 --- a/desktop/src/hooks/useSessionSearch.ts +++ b/desktop/src/hooks/useSessionSearch.ts @@ -1,13 +1,30 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import Fuse from 'fuse.js' +import type Fuse from 'fuse.js' import { sessionsApi } from '../api/sessions' import type { SessionListItem } from '../types/session' const PAGE_SIZE = 100 const RESULT_LIMIT = 10 +type FuseConstructor = typeof Fuse + +let fusePromise: Promise | null = null + +// Fuse is only needed once the session search opens, so keep it out of the startup bundle. +function loadFuse(): Promise { + fusePromise ??= import('fuse.js').then( + ({ default: FuseClass }) => FuseClass, + (err: unknown) => { + fusePromise = null + throw err + }, + ) + return fusePromise +} + export function useSessionSearch(query: string) { const [sessions, setSessions] = useState([]) + const [FuseClass, setFuseClass] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [attempt, setAttempt] = useState(0) @@ -20,6 +37,9 @@ export function useSessionSearch(query: string) { setSessions([]) async function load() { + const fuseLoad = loadFuse() + // Observed below; this only stops an early failure from being reported as unhandled. + fuseLoad.catch(() => {}) try { const byId = new Map() let offset = 0 @@ -41,6 +61,9 @@ export function useSessionSearch(query: string) { offset += PAGE_SIZE } while (offset < total) + const LoadedFuse = await fuseLoad + if (cancelled) return + setFuseClass(() => LoadedFuse) setSessions([...byId.values()].sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt))) } catch (err) { if (!cancelled) setError(err instanceof Error ? err : new Error(typeof err === 'string' ? err : '')) @@ -53,18 +76,17 @@ export function useSessionSearch(query: string) { return () => { cancelled = true } }, [attempt]) - const index = useMemo(() => new Fuse(sessions, { + const index = useMemo(() => FuseClass && new FuseClass(sessions, { keys: [{ name: 'title', weight: 2 }, { name: 'lastAssistantMessage', weight: 1 }], threshold: 0.35, ignoreLocation: true, ignoreFieldNorm: true, - }), [sessions]) + }), [FuseClass, sessions]) const results = useMemo(() => { const trimmed = query.trim() - return trimmed - ? index.search(trimmed, { limit: RESULT_LIMIT }).map(({ item }) => item) - : sessions.slice(0, RESULT_LIMIT) + if (!trimmed) return sessions.slice(0, RESULT_LIMIT) + return index ? index.search(trimmed, { limit: RESULT_LIMIT }).map(({ item }) => item) : [] }, [index, query, sessions]) return { results, isLoading, error, retry } diff --git a/desktop/src/lib/katexLoader.ts b/desktop/src/lib/katexLoader.ts new file mode 100644 index 00000000..712ee856 --- /dev/null +++ b/desktop/src/lib/katexLoader.ts @@ -0,0 +1,33 @@ +import type Katex from 'katex' + +type KatexModule = typeof Katex + +let loadedKatex: KatexModule | null = null +let katexPromise: Promise | null = null + +/** KaTeX once it has been loaded, so cached renders can stay synchronous. */ +export function getLoadedKatex(): KatexModule | null { + return loadedKatex +} + +/** + * Loads KaTeX and its stylesheet on first use. Most replies contain no math, so + * neither belongs in the startup bundle. + */ +export function loadKatex(): Promise { + katexPromise ??= Promise.all([ + import('katex'), + import('katex/dist/katex.min.css'), + ]).then( + ([{ default: katex }]) => { + loadedKatex = katex + return katex + }, + (err: unknown) => { + // Let a later formula retry instead of caching a failed chunk load forever. + katexPromise = null + throw err + }, + ) + return katexPromise +} diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 48264081..d7de38d7 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -551,7 +551,7 @@ describe('ActiveSession task polling', () => { useCLITaskStore.setState(originalCliTaskState) }) - it('renders the workspace panel to the right of chat and supports resizing', () => { + it('renders the workspace panel to the right of chat and supports resizing', async () => { const sessionId = 'workspace-session' useSessionStore.setState({ @@ -597,6 +597,8 @@ describe('ActiveSession task polling', () => { useWorkspacePanelStore.getState().openPanel(sessionId) render() + // The workspace panel is loaded on first open. + await screen.findByTestId('workspace-panel') const contentRow = screen.getByTestId('active-session-content-row') const chatColumn = screen.getByTestId('active-session-chat-column') diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 3d084bbc..e95b8468 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from 'react' import { PanelRight, SquareTerminal, Target } from 'lucide-react' import { SCHEDULED_TAB_ID, @@ -20,7 +20,6 @@ import { useTranslation } from '../i18n' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' import { SessionTaskBar } from '../components/chat/SessionTaskBar' -import { WorkspacePanel } from '../components/workspace/WorkspacePanel' import { OpenProjectMenu } from '../components/layout/OpenProjectMenu' import { SessionHeaderMenu } from '../components/layout/SessionHeaderMenu' import { TeamStatusBar } from '../components/teams/TeamStatusBar' @@ -28,11 +27,15 @@ import type { SessionListItem } from '../types/session' import type { ActiveGoalState } from '../types/chat' import { useMobileViewport } from '../hooks/useMobileViewport' import { useWindowDrag } from '../hooks/useWindowDrag' +import { Spinner } from '../components/shared/Spinner' import { isMacOverlayTitlebar } from '../lib/windowChrome' import { isTauriRuntime } from '../lib/desktopRuntime' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 +// The workspace panel (file tree, diffs, terminal) loads the first time it is opened. +const WorkspacePanel = lazy(() => import('../components/workspace/WorkspacePanel').then((m) => ({ default: m.WorkspacePanel }))) + const SIDE_PANEL_CLASS = [ 'relative flex shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]', 'max-w-[calc(100%-320px)]', @@ -483,11 +486,23 @@ export function ActiveSession() { style={panelExpanded ? undefined : { width: panelWidth }} > {!panelExpanded && } - + + + + } + > + + ) : null} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index aebdb0fd..98a8a3a5 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react' -import { createPortal } from 'react-dom' +import { useState, useEffect, useMemo } from 'react' import { Bot, Brain, @@ -18,7 +17,6 @@ import { Smartphone, TerminalIcon, Wrench, - X, Zap, type LucideIcon, } from 'lucide-react' @@ -44,7 +42,6 @@ import { SkillSettings } from '../components/settings/SkillSettings' import { AboutSettings } from '../components/settings/AboutSettings' import { SearchField } from '../components/shared/SearchField' import { moveRovingFocus } from '../lib/rovingFocus' -import { useEscapeLayer } from '../components/shared/Modal' type SettingsNavGroup = { titleKey: TranslationKey | null @@ -83,85 +80,6 @@ const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ { titleKey: null, items: [{ tab: 'about', Icon: Info }] }, ] -const MODAL_EXIT_MS = 180 - -/** Settings window over the current page, driven by uiStore.settingsOpen / settingsTab. */ -export function SettingsModal() { - const settingsOpen = useUIStore((s) => s.settingsOpen) - const closeSettings = useUIStore((s) => s.closeSettings) - const [mounted, setMounted] = useState(settingsOpen) - const [visible, setVisible] = useState(false) - const panelRef = useRef(null) - const returnFocusRef = useRef(null) - const t = useTranslation() - - useEscapeLayer(settingsOpen, closeSettings) - - // Remember the opener synchronously, before the page behind the window turns inert. - useEffect(() => useUIStore.subscribe((state, prev) => { - if (!state.settingsOpen || prev.settingsOpen) return - const active = document.activeElement - returnFocusRef.current = active instanceof HTMLElement && active !== document.body ? active : null - }), []) - - useEffect(() => { - if (settingsOpen) { - setMounted(true) - const frame = window.requestAnimationFrame(() => setVisible(true)) - return () => window.cancelAnimationFrame(frame) - } - setVisible(false) - const opener = returnFocusRef.current - returnFocusRef.current = null - // The opener can unmount while the window is open (e.g. a menu item); fall back to the composer. - if (opener?.isConnected) opener.focus() - else if (opener) document.querySelector('textarea:not([disabled])')?.focus() - const timer = window.setTimeout(() => setMounted(false), MODAL_EXIT_MS) - return () => window.clearTimeout(timer) - }, [settingsOpen]) - - useEffect(() => { - if (settingsOpen && mounted) panelRef.current?.focus() - }, [mounted, settingsOpen]) - - if (!mounted) return null - - return createPortal( -
- , - document.body, - ) -} - export function Settings() { const activeTab = useUIStore((s) => s.settingsTab) const setActiveTab = useUIStore((s) => s.setSettingsTab) diff --git a/desktop/src/pages/SettingsModal.test.tsx b/desktop/src/pages/SettingsModal.test.tsx index fc42067c..b160f6be 100644 --- a/desktop/src/pages/SettingsModal.test.tsx +++ b/desktop/src/pages/SettingsModal.test.tsx @@ -18,11 +18,18 @@ vi.mock('./MemorySettings', () => ({ MemorySettings: () =>
memory page
({ PluginList: () => null })) vi.mock('../components/plugins/PluginDetail', () => ({ PluginDetail: () => null })) -import { SettingsModal } from './Settings' +import { SettingsModal } from './SettingsModal' import { Dropdown } from '../components/shared/Dropdown' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' +// The settings pages load lazily when the window opens; let that import settle and render. +async function flushSettingsPages() { + await act(async () => { + await import('./Settings') + }) +} + function renderWithOpener() { return render( <> @@ -44,13 +51,14 @@ describe('SettingsModal', () => { vi.useRealTimers() }) - it('opens at the requested category and closes on Escape, returning focus to the opener', () => { + it('opens at the requested category and closes on Escape, returning focus to the opener', async () => { renderWithOpener() const opener = screen.getByRole('button', { name: 'open settings' }) opener.focus() fireEvent.click(opener) act(() => { vi.runOnlyPendingTimers() }) + await flushSettingsPages() expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByText('memory page')).toBeInTheDocument() @@ -109,9 +117,10 @@ describe('SettingsModal', () => { expect(useUIStore.getState().settingsOpen).toBe(false) }) - it('filters categories by the nav search and clears it when reopened', () => { + it('filters categories by the nav search and clears it when reopened', async () => { useUIStore.getState().openSettings() renderWithOpener() + await flushSettingsPages() fireEvent.change(screen.getByRole('searchbox', { name: 'Search settings' }), { target: { value: 'mem' } }) @@ -128,9 +137,10 @@ describe('SettingsModal', () => { expect(screen.getByRole('navigation')).toHaveTextContent('Providers') }) - it('switches category from the nav', () => { + it('switches category from the nav', async () => { useUIStore.getState().openSettings() renderWithOpener() + await flushSettingsPages() fireEvent.click(screen.getByRole('button', { name: 'About' })) diff --git a/desktop/src/pages/SettingsModal.tsx b/desktop/src/pages/SettingsModal.tsx new file mode 100644 index 00000000..60a3484f --- /dev/null +++ b/desktop/src/pages/SettingsModal.tsx @@ -0,0 +1,105 @@ +import { lazy, Suspense, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import { useTranslation } from '../i18n' +import { useUIStore } from '../stores/uiStore' +import { useEscapeLayer } from '../components/shared/Modal' +import { Spinner } from '../components/shared/Spinner' + +// The settings pages load when the window first opens, so they add nothing to app startup. +// This shell stays eager so it can remember the opener before the window takes focus. +const Settings = lazy(() => import('./Settings').then((m) => ({ default: m.Settings }))) + +const MODAL_EXIT_MS = 180 + +/** Settings window over the current page, driven by uiStore.settingsOpen / settingsTab. */ +export function SettingsModal() { + const settingsOpen = useUIStore((s) => s.settingsOpen) + const closeSettings = useUIStore((s) => s.closeSettings) + const [mounted, setMounted] = useState(settingsOpen) + const [visible, setVisible] = useState(false) + const panelRef = useRef(null) + const returnFocusRef = useRef(null) + const t = useTranslation() + + useEscapeLayer(settingsOpen, closeSettings) + + // Remember the opener synchronously, before the page behind the window turns inert. + useEffect(() => useUIStore.subscribe((state, prev) => { + if (!state.settingsOpen || prev.settingsOpen) return + const active = document.activeElement + returnFocusRef.current = active instanceof HTMLElement && active !== document.body ? active : null + }), []) + + useEffect(() => { + if (settingsOpen) { + setMounted(true) + const frame = window.requestAnimationFrame(() => setVisible(true)) + return () => window.cancelAnimationFrame(frame) + } + setVisible(false) + const opener = returnFocusRef.current + returnFocusRef.current = null + // The opener can unmount while the window is open (e.g. a menu item); fall back to the composer. + if (opener?.isConnected) opener.focus() + else if (opener) document.querySelector('textarea:not([disabled])')?.focus() + const timer = window.setTimeout(() => setMounted(false), MODAL_EXIT_MS) + return () => window.clearTimeout(timer) + }, [settingsOpen]) + + useEffect(() => { + if (settingsOpen && mounted) panelRef.current?.focus() + }, [mounted, settingsOpen]) + + if (!mounted) return null + + return createPortal( +
+ , + document.body, + ) +} + +function SettingsLoading() { + const t = useTranslation() + return ( +
+ +
+ ) +} From 1d10198d234fc7480f29bd8b3e1f0b98e97bd299 Mon Sep 17 00:00:00 2001 From: ibrahimmaq14 Date: Thu, 22 Oct 2026 19:32:34 +0530 Subject: [PATCH 2/2] chore(desktop): drop comments from the lazy-loading change --- desktop/src/components/chat/DiffViewer.test.tsx | 1 - desktop/src/components/chat/DiffViewer.tsx | 2 -- desktop/src/components/chat/DiffViewerBody.tsx | 2 -- desktop/src/components/chat/MermaidRenderer.tsx | 2 -- desktop/src/components/doctor/LazyDoctorPanel.test.tsx | 1 - desktop/src/components/doctor/LazyDoctorPanel.tsx | 3 --- desktop/src/components/markdown/MarkdownRenderer.test.tsx | 1 - desktop/src/components/markdown/MarkdownRenderer.tsx | 4 +--- desktop/src/hooks/useSessionSearch.ts | 2 -- desktop/src/lib/katexLoader.ts | 6 ------ desktop/src/pages/ActiveSession.test.tsx | 1 - desktop/src/pages/ActiveSession.tsx | 5 ++--- desktop/src/pages/SettingsModal.test.tsx | 1 - desktop/src/pages/SettingsModal.tsx | 2 -- 14 files changed, 3 insertions(+), 30 deletions(-) diff --git a/desktop/src/components/chat/DiffViewer.test.tsx b/desktop/src/components/chat/DiffViewer.test.tsx index 1aa78eb2..fb09b65d 100644 --- a/desktop/src/components/chat/DiffViewer.test.tsx +++ b/desktop/src/components/chat/DiffViewer.test.tsx @@ -16,7 +16,6 @@ describe('DiffViewer', () => { expect(screen.getByText('src/greeting.ts')).toBeInTheDocument() expect(screen.getByText('+1')).toBeInTheDocument() expect(screen.getByText('-1')).toBeInTheDocument() - // The placeholder reserves roughly the diff's height so the card does not jump. expect(screen.getByRole('status', { name: 'Loading diff' })).toHaveStyle({ height: '60px' }) const body = container.querySelector('.max-h-\\[400px\\]') as HTMLElement diff --git a/desktop/src/components/chat/DiffViewer.tsx b/desktop/src/components/chat/DiffViewer.tsx index 1113cf07..4705bd4f 100644 --- a/desktop/src/components/chat/DiffViewer.tsx +++ b/desktop/src/components/chat/DiffViewer.tsx @@ -3,8 +3,6 @@ import { CopyButton } from '../shared/CopyButton' const DiffViewerBody = lazy(() => import('./DiffViewerBody')) -// Matches the rendered diff rows (12px font, 1.45 line height, 1px padding) so the card -// does not jump when the diff body finishes loading. const DIFF_ROW_HEIGHT_PX = 20 const DIFF_MAX_HEIGHT_PX = 400 diff --git a/desktop/src/components/chat/DiffViewerBody.tsx b/desktop/src/components/chat/DiffViewerBody.tsx index f5d13930..2c7d8f11 100644 --- a/desktop/src/components/chat/DiffViewerBody.tsx +++ b/desktop/src/components/chat/DiffViewerBody.tsx @@ -109,8 +109,6 @@ const diffStyles = { }, } -// Loaded on demand by DiffViewer: react-diff-viewer and its styling runtime are only -// needed once a tool call or permission prompt actually shows a diff. export default function DiffViewerBody({ filePath, oldString, newString }: Props) { const language = inferLanguage(filePath) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index 0a98cbff..6fe5701d 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -27,7 +27,6 @@ type DragState = { let mermaidPromise: Promise | null = null -// Mermaid is large, so it is only fetched once a message actually contains a diagram. function loadMermaid(): Promise { mermaidPromise ??= import('mermaid').then( ({ default: mermaid }) => { @@ -41,7 +40,6 @@ function loadMermaid(): Promise { return mermaid }, (err: unknown) => { - // Let a later diagram retry instead of caching a failed chunk load forever. mermaidPromise = null throw err }, diff --git a/desktop/src/components/doctor/LazyDoctorPanel.test.tsx b/desktop/src/components/doctor/LazyDoctorPanel.test.tsx index e85c579a..f603ef2d 100644 --- a/desktop/src/components/doctor/LazyDoctorPanel.test.tsx +++ b/desktop/src/components/doctor/LazyDoctorPanel.test.tsx @@ -36,7 +36,6 @@ describe('LazyDoctorPanel', () => { , ) - // React reports the error it caught at the panel's own boundary. await waitFor(() => expect(consoleError).toHaveBeenCalled()) expect(screen.getByText('Something went wrong.')).toBeInTheDocument() expect(container.querySelector('main')?.children).toHaveLength(1) diff --git a/desktop/src/components/doctor/LazyDoctorPanel.tsx b/desktop/src/components/doctor/LazyDoctorPanel.tsx index 827f8344..c380bb2c 100644 --- a/desktop/src/components/doctor/LazyDoctorPanel.tsx +++ b/desktop/src/components/doctor/LazyDoctorPanel.tsx @@ -9,8 +9,6 @@ type DoctorPanelProps = { type GuardProps = { children: ReactNode } type GuardState = { failed: boolean } -// The Doctor panel is shown on error screens. If its chunk cannot load there, render nothing -// rather than letting the failure escape the screen that is already reporting an error. class DoctorPanelLoadGuard extends Component { state: GuardState = { failed: false } @@ -23,7 +21,6 @@ class DoctorPanelLoadGuard extends Component { } } -/** DoctorPanel loaded on first render, for screens on the startup path. */ export function LazyDoctorPanel(props: DoctorPanelProps) { return ( diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index ed16c776..d9df4cef 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -26,7 +26,6 @@ function visibleMathText(container: HTMLElement): string { } describe('MarkdownRenderer', () => { - // These tests assert KaTeX output; loading KaTeX on demand is covered in MarkdownRenderer.lazyMath.test.tsx. beforeAll(async () => { await loadKatex() }) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index ac8d6f9f..0f7e4fb0 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -236,7 +236,6 @@ function renderMath(block: MathBlock): string { const cached = mathRenderCache.get(cacheKey) if (cached) return cached - // Until KaTeX has loaded, show the TeX source in place; the renderer re-runs once it arrives. const katex = getLoadedKatex() if (!katex) return escapeHtmlText(block.tex) @@ -475,7 +474,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian let cancelled = false loadKatex().then( () => { if (!cancelled) setKatexReady(true) }, - // The TeX source stays visible; the next message with math retries the load. () => {}, ) return () => { cancelled = true } @@ -507,7 +505,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian } return result - }, [html, codeBlocks, mathBlocks, katexReady]) // katexReady re-runs the math enhancement once KaTeX loads + }, [html, codeBlocks, mathBlocks, katexReady]) const handleClick = useCallback(async (event: ReactMouseEvent) => { const target = event.target as HTMLElement | null diff --git a/desktop/src/hooks/useSessionSearch.ts b/desktop/src/hooks/useSessionSearch.ts index ae649c91..78cdd7a4 100644 --- a/desktop/src/hooks/useSessionSearch.ts +++ b/desktop/src/hooks/useSessionSearch.ts @@ -10,7 +10,6 @@ type FuseConstructor = typeof Fuse let fusePromise: Promise | null = null -// Fuse is only needed once the session search opens, so keep it out of the startup bundle. function loadFuse(): Promise { fusePromise ??= import('fuse.js').then( ({ default: FuseClass }) => FuseClass, @@ -38,7 +37,6 @@ export function useSessionSearch(query: string) { async function load() { const fuseLoad = loadFuse() - // Observed below; this only stops an early failure from being reported as unhandled. fuseLoad.catch(() => {}) try { const byId = new Map() diff --git a/desktop/src/lib/katexLoader.ts b/desktop/src/lib/katexLoader.ts index 712ee856..208c3594 100644 --- a/desktop/src/lib/katexLoader.ts +++ b/desktop/src/lib/katexLoader.ts @@ -5,15 +5,10 @@ type KatexModule = typeof Katex let loadedKatex: KatexModule | null = null let katexPromise: Promise | null = null -/** KaTeX once it has been loaded, so cached renders can stay synchronous. */ export function getLoadedKatex(): KatexModule | null { return loadedKatex } -/** - * Loads KaTeX and its stylesheet on first use. Most replies contain no math, so - * neither belongs in the startup bundle. - */ export function loadKatex(): Promise { katexPromise ??= Promise.all([ import('katex'), @@ -24,7 +19,6 @@ export function loadKatex(): Promise { return katex }, (err: unknown) => { - // Let a later formula retry instead of caching a failed chunk load forever. katexPromise = null throw err }, diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index d7de38d7..7db6f21c 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -597,7 +597,6 @@ describe('ActiveSession task polling', () => { useWorkspacePanelStore.getState().openPanel(sessionId) render() - // The workspace panel is loaded on first open. await screen.findByTestId('workspace-panel') const contentRow = screen.getByTestId('active-session-content-row') diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index e95b8468..aa8aac5c 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -31,11 +31,10 @@ import { Spinner } from '../components/shared/Spinner' import { isMacOverlayTitlebar } from '../lib/windowChrome' import { isTauriRuntime } from '../lib/desktopRuntime' -const TASK_POLL_INTERVAL_MS = 1000 -const WORKSPACE_RESIZE_STEP = 32 -// The workspace panel (file tree, diffs, terminal) loads the first time it is opened. const WorkspacePanel = lazy(() => import('../components/workspace/WorkspacePanel').then((m) => ({ default: m.WorkspacePanel }))) +const TASK_POLL_INTERVAL_MS = 1000 +const WORKSPACE_RESIZE_STEP = 32 const SIDE_PANEL_CLASS = [ 'relative flex shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]', 'max-w-[calc(100%-320px)]', diff --git a/desktop/src/pages/SettingsModal.test.tsx b/desktop/src/pages/SettingsModal.test.tsx index b160f6be..c956d146 100644 --- a/desktop/src/pages/SettingsModal.test.tsx +++ b/desktop/src/pages/SettingsModal.test.tsx @@ -23,7 +23,6 @@ import { Dropdown } from '../components/shared/Dropdown' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' -// The settings pages load lazily when the window opens; let that import settle and render. async function flushSettingsPages() { await act(async () => { await import('./Settings') diff --git a/desktop/src/pages/SettingsModal.tsx b/desktop/src/pages/SettingsModal.tsx index 60a3484f..6e3672f8 100644 --- a/desktop/src/pages/SettingsModal.tsx +++ b/desktop/src/pages/SettingsModal.tsx @@ -6,8 +6,6 @@ import { useUIStore } from '../stores/uiStore' import { useEscapeLayer } from '../components/shared/Modal' import { Spinner } from '../components/shared/Spinner' -// The settings pages load when the window first opens, so they add nothing to app startup. -// This shell stays eager so it can remember the opener before the window takes focus. const Settings = lazy(() => import('./Settings').then((m) => ({ default: m.Settings }))) const MODAL_EXIT_MS = 180