diff --git a/src/web-ui/src/app/scenes/nav-registry.ts b/src/web-ui/src/app/scenes/nav-registry.ts index 0ac6975b03..0a4f9645e6 100644 --- a/src/web-ui/src/app/scenes/nav-registry.ts +++ b/src/web-ui/src/app/scenes/nav-registry.ts @@ -14,7 +14,18 @@ import type { SceneTabId } from '../components/SceneBar/types'; type LazyNavComponent = ReturnType>; -const loadSettingsNav = () => import('./settings/SettingsNav'); +/** + * The settings nav renders every label from the lazy `settings` namespace, so the + * chunk and that namespace are loaded together — mounting before it resolves + * paints raw i18n keys for a frame. + */ +const loadSettingsNav = async () => { + const [navModule] = await Promise.all([ + import('./settings/SettingsNav'), + import('./settings/settingsTabI18n').then((m) => m.preloadSettingsShellI18n()), + ]); + return navModule; +}; const loadFileViewerNav = () => import('./file-viewer/FileViewerNav'); const loadShellNav = () => import('./shell/ShellNav'); diff --git a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx index 2ed2835a2c..bfad63176f 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsNav.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsNav.tsx @@ -12,6 +12,7 @@ */ import React, { + startTransition, useCallback, useEffect, useMemo, @@ -185,8 +186,13 @@ function useSettingsNav() { const requestId = ++activationRequestRef.current; const commit = () => { if (activationRequestRef.current !== requestId) return; - setActiveTab(tab); - clearSearch(); + // A cached lazy panel still suspends for one promise microtask on its + // first mount; inside a transition React keeps the painted panel until + // the new one is ready instead of committing the skeleton fallback. + startTransition(() => { + setActiveTab(tab); + clearSearch(); + }); }; void preloadSettingsTabContent(tab).then(commit, commit); }, @@ -197,9 +203,10 @@ function useSettingsNav() { (tab: ConfigTab) => { const requestId = ++activationRequestRef.current; const commit = () => { - if (activationRequestRef.current === requestId) { + if (activationRequestRef.current !== requestId) return; + startTransition(() => { setActiveTab(tab); - } + }); }; void preloadSettingsTabContent(tab).then(commit, commit); }, diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx index 1ea8581fe5..89df4790e6 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx @@ -78,6 +78,20 @@ describe('SettingsScene lazy tab routing', () => { vi.useRealTimers(); }); + /** + * The scene holds its very first paint until the active tab's lazy chunk and + * i18n namespaces are in memory, so a cold entry cannot flash a skeleton and a + * frame of raw i18n keys. Both land off a macrotask, past what act() flushes. + */ + async function waitForPanelContent(testId: string) { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (container.querySelector(`[data-testid="${testId}"]`)) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + } + async function renderActiveTab( tab: 'mcp-tools' | 'acp-agents' | 'external-sources' | 'voice-input' ) { @@ -85,6 +99,7 @@ describe('SettingsScene lazy tab routing', () => { await act(async () => { root.render(); }); + await waitForPanelContent(`${tab}-config`); } it('renders the lazy MCP tools config tab', async () => { @@ -115,7 +130,9 @@ describe('SettingsScene lazy tab routing', () => { await act(async () => { root.render(); }); + await waitForPanelContent('basics-config'); + /** Only the cold first paint waits for resources; later switches are synchronous. */ await act(async () => { useSettingsStore.setState({ activeTab: 'appearance' }); await Promise.resolve(); diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 2593d1953b..04c37abf6f 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -9,6 +9,7 @@ import React, { Suspense, useEffect, + useState, } from 'react'; import { useSettingsStore } from './settingsStore'; import type { ConfigTab } from './settingsConfig'; @@ -30,6 +31,8 @@ import { SessionPersonalizationConfig, VoiceInputConfig, WorktreesConfig, + isSettingsTabContentReady, + preloadSettingsTabContent, } from './settingsContentRegistry'; import './SettingsScene.scss'; @@ -81,7 +84,33 @@ const SettingsScene: React.FC = () => { } }, [activeTab, setActiveTab]); - const Content = resolveSettingsContent(resolvedTab); + /** + * Cold entries into the scene (first open after launch, deep links) mount a + * panel whose chunk and i18n namespaces are still in flight, which paints the + * skeleton and then a frame of raw i18n keys. Hold the first paint until those + * resources land — an empty content area for a few ms reads as instant, a + * three-stage flash does not. SettingsNav preloads before it flips the active + * tab, so tab switches are never gated here. + */ + const [firstPaintReady, setFirstPaintReady] = useState(() => + isSettingsTabContentReady(resolvedTab) + ); + + useEffect(() => { + if (firstPaintReady) return; + + let cancelled = false; + const commit = () => { + if (!cancelled) setFirstPaintReady(true); + }; + void preloadSettingsTabContent(resolvedTab).then(commit, commit); + + return () => { + cancelled = true; + }; + }, [firstPaintReady, resolvedTab]); + + const Content = firstPaintReady ? resolveSettingsContent(resolvedTab) : null; return (
diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index dc000f35cb..3882c074e0 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -1,5 +1,6 @@ import { lazy } from 'react'; import type { ConfigTab } from './settingsConfig'; +import { preloadSettingsTabI18n } from './settingsTabI18n'; const loadAIModelConfig = () => import('../../../infrastructure/config/components/AIModelConfig'); const loadMcpToolsConfig = () => import('../../../infrastructure/config/components/McpToolsConfig'); @@ -64,6 +65,29 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise keyboard: loadKeyboardShortcutsTab, }; +/** + * Tabs whose chunk *and* i18n namespaces are already in memory. Rendering such a + * tab is a plain synchronous mount: no Suspense fallback, no frame of raw i18n keys. + */ +const readyTabs = new Set(); + +export function isSettingsTabContentReady(tab: ConfigTab): boolean { + return readyTabs.has(tab); +} + +/** + * Warm everything a tab needs to paint in a single frame: its lazy panel chunk + * (with the CSS Vite ships alongside it) and its i18n namespaces. Callers await + * this before switching tabs so the panel appears fully rendered instead of + * stepping through skeleton → untranslated keys → content. + */ export async function preloadSettingsTabContent(tab: ConfigTab): Promise { - await SETTINGS_CONTENT_LOADERS[tab]?.(); + if (readyTabs.has(tab)) return; + + await Promise.all([ + SETTINGS_CONTENT_LOADERS[tab]?.(), + preloadSettingsTabI18n(tab), + ]); + + readyTabs.add(tab); } diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts new file mode 100644 index 0000000000..04b0e4ff88 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { i18nService } from '@/infrastructure/i18n/core/I18nService'; +import { SETTINGS_TAB_I18N_NAMESPACES, preloadSettingsShellI18n } from './settingsTabI18n'; +import { isSettingsTabContentReady, preloadSettingsTabContent } from './settingsContentRegistry'; +import { SETTINGS_CATEGORIES } from './settingsConfig'; + +/** + * A namespace that has not resolved yet makes `t(key)` echo the key back, which is + * the raw-key frame a panel paints when it mounts ahead of its translations. + */ +function resolveKey(namespace: string, key: string): string { + return String(i18nService.getI18nInstance().getFixedT(null, namespace)(key)); +} + +describe('settings tab i18n preloading', () => { + it('covers every nav tab', () => { + for (const category of SETTINGS_CATEGORIES) { + for (const tab of category.tabs) { + expect(SETTINGS_TAB_I18N_NAMESPACES[tab.id]?.length ?? 0).toBeGreaterThan(0); + } + } + }); + + it('resolves a tab namespace before the panel can render', async () => { + expect(resolveKey('settings/basics', 'title')).toBe('title'); + + await preloadSettingsTabContent('basics'); + + expect(isSettingsTabContentReady('basics')).toBe(true); + expect(i18nService.getI18nInstance().hasLoadedNamespace('settings/basics')).toBe(true); + expect(resolveKey('settings/basics', 'title')).not.toBe('title'); + }); + + it('resolves the shell namespace behind the nav labels', async () => { + await preloadSettingsShellI18n(); + + expect(resolveKey('settings', 'configCenter.tabs.basics')).not.toBe( + 'configCenter.tabs.basics' + ); + }); +}); diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts new file mode 100644 index 0000000000..59cf070804 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts @@ -0,0 +1,60 @@ +/** + * settingsTabI18n — i18n namespaces every settings tab renders from. + * + * Only WEB_UI_BOOTSTRAP_NAMESPACES ship with the initial bundle; everything else + * resolves through the lazy namespace backend. A panel mounted before its + * namespace lands paints raw i18n keys for a frame (react.useSuspense is off, so + * `t()` returns the key) and then swaps to real copy — the visible "flash" on the + * first open of a tab. Loading these alongside the tab's lazy chunk makes the + * first open look identical to every later one. + * + * Keep in sync with the `useTranslation(...)` / `useI18n(...)` namespaces used by + * each panel and the components it renders. + */ + +import { i18nService } from '@/infrastructure/i18n/core/I18nService'; +import type { I18nNamespace } from '@/infrastructure/i18n/types'; +import type { ConfigTab } from './settingsConfig'; + +/** Namespace behind the settings shell itself: category labels, tab labels, keyboard tab copy. */ +export const SETTINGS_SHELL_NAMESPACES: readonly I18nNamespace[] = ['settings']; + +export const SETTINGS_TAB_I18N_NAMESPACES: Record = { + basics: ['settings/basics'], + appearance: ['settings/appearance', 'settings/basics'], + models: ['settings/ai-model', 'settings/default-model', 'components'], + 'archived-sessions': ['common'], + worktrees: ['worktrees'], + // Both session panels live in the same module and share its sub-sections. + 'session-personalization': ['settings/session-config', 'settings/agentic-tools', 'settings/debug'], + 'session-permissions': ['settings/session-config', 'settings/agentic-tools', 'settings/debug'], + 'quick-actions': ['settings/quick-actions'], + 'voice-input': ['settings/voice-input'], + review: ['settings/review'], + memories: ['settings/memories'], + 'mcp-tools': ['settings/mcp-tools', 'settings/mcp', 'shared'], + 'external-sources': ['settings/external-sources', 'shared'], + hooks: ['settings/hooks'], + 'acp-agents': ['settings/acp-agents'], + editor: ['settings/editor'], + keyboard: ['settings'], +}; + +async function preloadNamespaces(namespaces: readonly I18nNamespace[]): Promise { + await Promise.all( + namespaces.map((namespace) => + i18nService.loadNamespace(namespace).catch(() => { + // A namespace that fails to resolve must not block tab activation; + // i18next keeps falling back the same way it does today. + }) + ) + ); +} + +export function preloadSettingsShellI18n(): Promise { + return preloadNamespaces(SETTINGS_SHELL_NAMESPACES); +} + +export function preloadSettingsTabI18n(tab: ConfigTab): Promise { + return preloadNamespaces(SETTINGS_TAB_I18N_NAMESPACES[tab] ?? []); +} diff --git a/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss b/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss index 3ebdb166ff..44757c87ab 100644 --- a/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss +++ b/src/web-ui/src/component-library/components/ConfigPage/ConfigPage.scss @@ -1,3 +1,8 @@ +@keyframes bitfun-config-page-loading-in { + from { opacity: 0; } + to { opacity: 1; } +} + .bitfun-config-page-loading { min-height: 200px; display: flex; @@ -5,6 +10,15 @@ justify-content: center; color: var(--color-text-muted); font-size: var(--font-size-sm); + /* Mounts only after a grace period (see ConfigPageLoading), so ease it in + rather than popping a block of text into an otherwise settled page. */ + animation: bitfun-config-page-loading-in 140ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-config-page-loading { + animation: none; + } } .bitfun-config-page-message { diff --git a/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx b/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx index 063cb1b5b5..ef13f498c3 100644 --- a/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx +++ b/src/web-ui/src/component-library/components/ConfigPage/ConfigPageLoading.tsx @@ -1,19 +1,47 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import './ConfigPage.scss'; +/** + * Grace period before a section admits it is loading. + * + * Config sections read their state through ConfigManager, which serves warm paths + * from cache and cold ones over IPC. Painting the placeholder unconditionally means + * a page full of 200px "loading" blocks appears and collapses within one or two + * frames on the first open of a tab. Staying invisible for a beat lets fast reads + * land silently; only genuinely slow ones surface a placeholder. + */ +const LOADING_VISIBLE_DELAY_MS = 200; + export interface ConfigPageLoadingProps { text: string; className?: string; + /** Override the grace period; 0 paints immediately. */ + delayMs?: number; } export const ConfigPageLoading: React.FC = ({ text, className = '', + delayMs = LOADING_VISIBLE_DELAY_MS, }) => { + const [visible, setVisible] = useState(delayMs <= 0); + + useEffect(() => { + if (delayMs <= 0) { + setVisible(true); + return; + } + + setVisible(false); + const timer = window.setTimeout(() => setVisible(true), delayMs); + return () => window.clearTimeout(timer); + }, [delayMs]); + + if (!visible) return null; + return (
{text}
); }; - diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 3b3d7fb889..3246872038 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -301,6 +301,12 @@ describe('ExternalSourcesConfig', () => { '.bitfun-config-page-layout.bitfun-external-sources-config', ); expect(page?.querySelector('.bitfun-config-page-header__title')?.textContent).toBe('title'); + + /** + * ConfigPageLoading waits out a grace period before admitting it is loading, so + * reads served from the ConfigManager cache never flash a placeholder. + */ + await act(async () => vi.advanceTimersByTimeAsync(260)); expect(page?.querySelector( '.bitfun-config-page-content .bitfun-config-page-loading', )?.textContent).toBe('loading');