Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/web-ui/src/app/scenes/nav-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ import type { SceneTabId } from '../components/SceneBar/types';

type LazyNavComponent = ReturnType<typeof lazy<ComponentType>>;

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');

Expand Down
15 changes: 11 additions & 4 deletions src/web-ui/src/app/scenes/settings/SettingsNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import React, {
startTransition,
useCallback,
useEffect,
useMemo,
Expand Down Expand Up @@ -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);
},
Expand All @@ -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);
},
Expand Down
17 changes: 17 additions & 0 deletions src/web-ui/src/app/scenes/settings/SettingsScene.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,28 @@ 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'
) {
useSettingsStore.setState({ activeTab: tab });
await act(async () => {
root.render(<SettingsScene />);
});
await waitForPanelContent(`${tab}-config`);
}

it('renders the lazy MCP tools config tab', async () => {
Expand Down Expand Up @@ -115,7 +130,9 @@ describe('SettingsScene lazy tab routing', () => {
await act(async () => {
root.render(<SettingsScene />);
});
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();
Expand Down
31 changes: 30 additions & 1 deletion src/web-ui/src/app/scenes/settings/SettingsScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import React, {
Suspense,
useEffect,
useState,
} from 'react';
import { useSettingsStore } from './settingsStore';
import type { ConfigTab } from './settingsConfig';
Expand All @@ -30,6 +31,8 @@ import {
SessionPersonalizationConfig,
VoiceInputConfig,
WorktreesConfig,
isSettingsTabContentReady,
preloadSettingsTabContent,
} from './settingsContentRegistry';
import './SettingsScene.scss';

Expand Down Expand Up @@ -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 (
<div className="bitfun-settings-scene" data-testid="settings-scene" data-settings-tab={resolvedTab}>
Expand Down
26 changes: 25 additions & 1 deletion src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -64,6 +65,29 @@ const SETTINGS_CONTENT_LOADERS: Partial<Record<ConfigTab, () => Promise<unknown>
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<ConfigTab>();

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<void> {
await SETTINGS_CONTENT_LOADERS[tab]?.();
if (readyTabs.has(tab)) return;

await Promise.all([
SETTINGS_CONTENT_LOADERS[tab]?.(),
preloadSettingsTabI18n(tab),
]);

readyTabs.add(tab);
}
42 changes: 42 additions & 0 deletions src/web-ui/src/app/scenes/settings/settingsTabI18n.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
60 changes: 60 additions & 0 deletions src/web-ui/src/app/scenes/settings/settingsTabI18n.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigTab, readonly I18nNamespace[]> = {
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<void> {
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<void> {
return preloadNamespaces(SETTINGS_SHELL_NAMESPACES);
}

export function preloadSettingsTabI18n(tab: ConfigTab): Promise<void> {
return preloadNamespaces(SETTINGS_TAB_I18N_NAMESPACES[tab] ?? []);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
@keyframes bitfun-config-page-loading-in {
from { opacity: 0; }
to { opacity: 1; }
}

.bitfun-config-page-loading {
min-height: 200px;
display: flex;
align-items: center;
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConfigPageLoadingProps> = ({
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 (
<div className={`bitfun-config-page-loading ${className}`}>
{text}
</div>
);
};

Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down