@@ -566,3 +576,19 @@ function ChannelSetupSurface({
);
}
+
+function ChannelPluginLoading({ compact = false }: { compact?: boolean }) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("settings.status.loading")}
+
+ );
+}
diff --git a/webui/src/components/thread/activity/ThinkingReasoningShell.tsx b/webui/src/components/thread/activity/ThinkingReasoningShell.tsx
index c647e8738..d49f4922b 100644
--- a/webui/src/components/thread/activity/ThinkingReasoningShell.tsx
+++ b/webui/src/components/thread/activity/ThinkingReasoningShell.tsx
@@ -69,6 +69,8 @@ export function ThinkingReasoningShell({
{children}
diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts
index e82b26da1..71ec185a5 100644
--- a/webui/src/i18n/index.ts
+++ b/webui/src/i18n/index.ts
@@ -1,4 +1,4 @@
-import i18n from "i18next";
+import i18n, { type InitOptions } from "i18next";
import { initReactI18next } from "react-i18next";
import {
@@ -14,66 +14,122 @@ import {
normalizeLocale,
persistLocale,
resolveInitialLocale,
+ supportedLocales,
type SupportedLocale,
} from "./config";
-import enCommon from "./locales/en/common.json";
-import zhCNCommon from "./locales/zh-CN/common.json";
-import zhTWCommon from "./locales/zh-TW/common.json";
-import frCommon from "./locales/fr/common.json";
-import jaCommon from "./locales/ja/common.json";
-import koCommon from "./locales/ko/common.json";
-import esCommon from "./locales/es/common.json";
-import ptBRCommon from "./locales/pt-BR/common.json";
-import viCommon from "./locales/vi/common.json";
-import idCommon from "./locales/id/common.json";
+type CommonMessages = typeof import("./locales/en/common.json");
+type CommonMessagesModule = { default: CommonMessages };
+type LocaleResource = { common: CommonMessages } & Record
;
-export const resources = {
- en: { common: enCommon, ...channelLocaleResources("en") },
- "zh-CN": { common: zhCNCommon, ...channelLocaleResources("zh-CN") },
- "zh-TW": { common: zhTWCommon, ...channelLocaleResources("zh-TW") },
- fr: { common: frCommon, ...channelLocaleResources("fr") },
- ja: { common: jaCommon, ...channelLocaleResources("ja") },
- ko: { common: koCommon, ...channelLocaleResources("ko") },
- es: { common: esCommon, ...channelLocaleResources("es") },
- "pt-BR": { common: ptBRCommon, ...channelLocaleResources("pt-BR") },
- vi: { common: viCommon, ...channelLocaleResources("vi") },
- id: { common: idCommon, ...channelLocaleResources("id") },
-} as const;
+const commonModules = import.meta.glob(
+ "./locales/*/common.json",
+);
+const commonLoaders = new Map Promise>();
+const resourcePromises = new Map>();
+const supportedLocaleCodes = new Set(supportedLocales.map(({ code }) => code));
+
+for (const [modulePath, loader] of Object.entries(commonModules)) {
+ const match = modulePath.match(/locales\/([^/]+)\/common\.json$/);
+ if (!match || !supportedLocaleCodes.has(match[1])) continue;
+ commonLoaders.set(match[1] as SupportedLocale, loader);
+}
+
+// Tests and validation tooling inspect this registry after explicitly loading
+// every locale. Production startup populates only the current and fallback
+// entries, keeping all other translation JSON out of the initial graph.
+export const resources = {} as Record;
+
+let initialization: Promise | undefined;
+let localeListenerBound = false;
export function currentLocale(): SupportedLocale {
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
}
+export async function loadLocaleResources(
+ locale: SupportedLocale,
+): Promise {
+ const existing = resourcePromises.get(locale);
+ if (existing) return existing;
+
+ const loader = commonLoaders.get(locale);
+ if (!loader) throw new Error(`No common locale loader registered for '${locale}'`);
+
+ const pending = Promise.all([loader(), channelLocaleResources(locale)])
+ .then(([common, channels]) => {
+ const resource: LocaleResource = { common: common.default, ...channels };
+ resources[locale] = resource;
+ if (i18n.isInitialized) {
+ for (const [namespace, messages] of Object.entries(resource)) {
+ i18n.addResourceBundle(locale, namespace, messages, true, true);
+ }
+ }
+ return resource;
+ })
+ .catch((error) => {
+ resourcePromises.delete(locale);
+ throw error;
+ });
+ resourcePromises.set(locale, pending);
+ return pending;
+}
+
+export async function loadAllLocaleResources(): Promise {
+ await Promise.all(supportedLocales.map(({ code }) => loadLocaleResources(code)));
+}
+
+export async function initializeI18n(): Promise {
+ if (i18n.isInitialized) return i18n;
+ if (initialization) return initialization;
+
+ initialization = (async () => {
+ const initialLocale = resolveInitialLocale();
+ const startupLocales = initialLocale === fallbackLocale
+ ? [fallbackLocale]
+ : [fallbackLocale, initialLocale];
+ await Promise.all(startupLocales.map(loadLocaleResources));
+ await i18n
+ .use(initReactI18next)
+ .init({
+ resources: Object.fromEntries(
+ startupLocales.map((locale) => [locale, resources[locale]]),
+ ) as InitOptions["resources"],
+ lng: initialLocale,
+ fallbackLng: fallbackLocale,
+ defaultNS: "common",
+ ns: ["common", ...channelLocaleNamespaces()],
+ interpolation: {
+ escapeValue: false,
+ },
+ returnNull: false,
+ supportedLngs: supportedLocales.map(({ code }) => code),
+ });
+
+ syncLocaleSideEffects(currentLocale());
+ if (!localeListenerBound) {
+ i18n.on("languageChanged", syncLocaleSideEffects);
+ localeListenerBound = true;
+ }
+ return i18n;
+ })().catch((error) => {
+ initialization = undefined;
+ throw error;
+ });
+ return initialization;
+}
+
export async function setAppLanguage(locale: SupportedLocale): Promise {
+ await initializeI18n();
+ await loadLocaleResources(locale);
await i18n.changeLanguage(locale);
}
-if (!i18n.isInitialized) {
- void i18n
- .use(initReactI18next)
- .init({
- resources,
- lng: resolveInitialLocale(),
- fallbackLng: fallbackLocale,
- defaultNS: "common",
- ns: ["common", ...channelLocaleNamespaces()],
- interpolation: {
- escapeValue: false,
- },
- returnNull: false,
- supportedLngs: Object.keys(resources),
- });
-}
-
-const syncLocaleSideEffects = (language: string) => {
+function syncLocaleSideEffects(language: string) {
const locale = normalizeLocale(language);
applyDocumentLocale(locale);
persistLocale(locale);
-};
-
-syncLocaleSideEffects(currentLocale());
-i18n.on("languageChanged", syncLocaleSideEffects);
+}
export { LOCALE_STORAGE_KEY };
export default i18n;
diff --git a/webui/src/main.tsx b/webui/src/main.tsx
index a8343c699..06eca3dec 100644
--- a/webui/src/main.tsx
+++ b/webui/src/main.tsx
@@ -2,7 +2,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
-import "./i18n";
+import { initializeI18n } from "./i18n";
import { initializeLoopbackRuntimeHost } from "./lib/runtime";
// `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost).
@@ -26,8 +26,13 @@ if (!root) throw new Error("root element missing");
initializeLoopbackRuntimeHost();
-/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
-ReactDOM.createRoot(root).render();
+async function renderWebui(container: HTMLElement) {
+ await initializeI18n();
+ /* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
+ ReactDOM.createRoot(container).render();
+}
+
+void renderWebui(root);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
diff --git a/webui/src/tests/channel-locale-registry.test.ts b/webui/src/tests/channel-locale-registry.test.ts
index e66ea57a3..8249d5f68 100644
--- a/webui/src/tests/channel-locale-registry.test.ts
+++ b/webui/src/tests/channel-locale-registry.test.ts
@@ -105,8 +105,11 @@ describe("channel locale registry", () => {
const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8");
expect(localeRegistry).toContain("webui/locales/*.json");
+ expect(localeRegistry).not.toContain("eager: true");
expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i);
expect(i18nEntry).toContain("channel-plugins/locale-registry");
+ expect(i18nEntry).toContain("import.meta.glob");
+ expect(i18nEntry).not.toMatch(/import\s+\w+Common\s+from/);
expect(i18nEntry).not.toContain("channel-plugins/registry");
});
});
diff --git a/webui/src/tests/channel-ui-registry.test.ts b/webui/src/tests/channel-ui-registry.test.ts
index 1691af56f..82e894a5f 100644
--- a/webui/src/tests/channel-ui-registry.test.ts
+++ b/webui/src/tests/channel-ui-registry.test.ts
@@ -12,8 +12,8 @@ import {
describe("channel UI contributions", () => {
it("selects channel-owned UI only through the backend manifest entry", () => {
- expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function");
- expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function");
+ expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeDefined();
+ expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeDefined();
expect(channelUiContribution("feishu", undefined)).toBeUndefined();
expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined();
expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined();
@@ -56,7 +56,8 @@ describe("channel UI contributions", () => {
"utf8",
);
- expect(source).toContain("../../../nanobot/channels/*/webui/**/*.{ts,tsx}");
+ expect(source).toContain("../../../nanobot/channels/*/webui/index.{ts,tsx}");
+ expect(source).not.toContain("webui/**/*.{ts,tsx}");
expect(source).not.toContain('"./*/index.tsx"');
});
diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx
index c67e35b9f..9d0e4369b 100644
--- a/webui/src/tests/chat-list.test.tsx
+++ b/webui/src/tests/chat-list.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
+import { readDraggedSession, SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial): ChatSummary {
@@ -24,7 +25,7 @@ describe("ChatList", () => {
vi.unstubAllGlobals();
});
- it("keeps tabs and panes outside every drag-and-drop protocol", () => {
+ it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render(
{
"websocket:root": {
tabKey: "websocket:root",
title: "Root topic",
- activePaneKey: "websocket:child",
+ activePaneKey: "websocket:root",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
@@ -50,8 +51,22 @@ describe("ChatList", () => {
expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("draggable", "false");
- expect(screen.getByRole("button", { name: "Research pane" }))
- .toHaveAttribute("draggable", "false");
+ const pane = screen.getByRole("button", { name: "Research pane" });
+ expect(pane).toHaveAttribute("draggable", "true");
+ const dataTransfer = {
+ effectAllowed: "none",
+ setData: vi.fn(),
+ getData: vi.fn(() => ""),
+ types: [],
+ } as unknown as DataTransfer;
+ fireEvent.dragStart(pane, { dataTransfer });
+ expect(dataTransfer.setData).toHaveBeenCalledWith(
+ SESSION_DRAG_TYPE,
+ "websocket:child",
+ );
+ expect(readDraggedSession(dataTransfer)).toBe("websocket:child");
+ fireEvent.dragEnd(pane, { dataTransfer });
+ expect(readDraggedSession(dataTransfer)).toBeNull();
expect(document.querySelector("[data-pane-drag-overlay]")).not.toBeInTheDocument();
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
});
diff --git a/webui/src/tests/index-html.test.ts b/webui/src/tests/index-html.test.ts
new file mode 100644
index 000000000..1ac5584a5
--- /dev/null
+++ b/webui/src/tests/index-html.test.ts
@@ -0,0 +1,15 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+describe("index.html", () => {
+ it("keeps browser zoom available", () => {
+ const html = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
+ const viewport = html.match(/ {
expect(globalThis.crypto.randomUUID()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
- expect(createRoot).toHaveBeenCalledWith(document.getElementById("root"));
+ await waitFor(() => {
+ expect(createRoot).toHaveBeenCalledWith(document.getElementById("root"));
+ });
});
});
diff --git a/webui/src/tests/settings-channels.test.tsx b/webui/src/tests/settings-channels.test.tsx
index b34a60256..2c0c9845d 100644
--- a/webui/src/tests/settings-channels.test.tsx
+++ b/webui/src/tests/settings-channels.test.tsx
@@ -270,7 +270,11 @@ describe("Settings channels", () => {
expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument();
expect(screen.queryByText("nanobot channels login feishu")).not.toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "nanobot" }));
+ fireEvent.click(await screen.findByRole(
+ "button",
+ { name: "nanobot" },
+ { timeout: 3_000 },
+ ));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() =>
@@ -324,7 +328,11 @@ describe("Settings channels", () => {
renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "nanobot" }));
+ fireEvent.click(await screen.findByRole(
+ "button",
+ { name: "nanobot" },
+ { timeout: 3_000 },
+ ));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() =>
@@ -1158,9 +1166,13 @@ describe("Settings channels", () => {
for (const [, displayName, guideLabel] of channels) {
fireEvent.click(await screen.findByRole("button", { name: `View ${displayName} settings` }));
if (displayName === "Feishu") {
- fireEvent.click(screen.getByRole("button", { name: "nanobot" }));
+ fireEvent.click(await screen.findByRole(
+ "button",
+ { name: "nanobot" },
+ { timeout: 3_000 },
+ ));
}
- const guide = screen.getByRole("link", { name: guideLabel });
+ const guide = await screen.findByRole("link", { name: guideLabel });
expect(guide).toHaveAttribute("href", expect.stringMatching(/^https:\/\//));
expect(guide.querySelector("span[aria-hidden] img, span[aria-hidden] svg")).not.toBeNull();
}
diff --git a/webui/src/tests/setup.ts b/webui/src/tests/setup.ts
index f5a4c24c4..adba7fb80 100644
--- a/webui/src/tests/setup.ts
+++ b/webui/src/tests/setup.ts
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
-import { beforeEach } from "vitest";
+import { beforeAll, beforeEach } from "vitest";
-import i18n from "@/i18n";
+import i18n, { initializeI18n, loadAllLocaleResources } from "@/i18n";
function createTestStorage(): Storage {
const store = new Map();
@@ -53,6 +53,11 @@ if (!("randomUUID" in globalThis.crypto)) {
});
}
+beforeAll(async () => {
+ await initializeI18n();
+ await loadAllLocaleResources();
+});
+
beforeEach(async () => {
await i18n.changeLanguage("en");
document.documentElement.lang = "en";
diff --git a/webui/src/tests/sw.test.ts b/webui/src/tests/sw.test.ts
index 3577a47ca..ec3b07b4e 100644
--- a/webui/src/tests/sw.test.ts
+++ b/webui/src/tests/sw.test.ts
@@ -126,6 +126,7 @@ describe("service worker", () => {
expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
+ expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true);
});
it("removes stale cache names and prunes unreferenced static entries on activate", async () => {
@@ -135,8 +136,16 @@ describe("service worker", () => {
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js", "/assets/index-v2.css"]));
await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2"));
await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css"));
+ await sw.store.put(`${ORIGIN}/assets/lazy-v2.js`, new Response("lazy v2"));
await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1"));
await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css"));
+ await sw.store.put(`${ORIGIN}/asset-manifest.json`, new Response(JSON.stringify({
+ "src/main.tsx": {
+ file: "assets/index-v2.js",
+ css: ["assets/index-v2.css"],
+ },
+ "src/lazy.tsx": { file: "assets/lazy-v2.js", isDynamicEntry: true },
+ })));
await sw.fire("activate");
@@ -144,8 +153,10 @@ describe("service worker", () => {
expect(sw.claimMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
+ expect(sw.store.entries.has(`${ORIGIN}/asset-manifest.json`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).toBe(true);
+ expect(sw.store.entries.has(`${ORIGIN}/assets/lazy-v2.js`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false);
});
@@ -235,6 +246,31 @@ describe("service worker", () => {
expect(sw.store.entries.has(iconUrl)).toBe(true);
});
+ it("clones network responses before yielding their body to the browser", async () => {
+ const sw = loadSw();
+ const request = new Request(`${ORIGIN}/brand/nanobot_icon_192.png`);
+ let browserOwnsBody = false;
+ let clonedBeforeBrowser = false;
+ const response = {
+ ok: true,
+ clone: vi.fn(() => {
+ clonedBeforeBrowser = !browserOwnsBody;
+ return new Response("cached png bytes");
+ }),
+ } as unknown as Response;
+ sw.fetchMock.mockResolvedValue(response);
+ const respondWith = vi.fn((pending: Promise) => {
+ void pending.then(() => {
+ browserOwnsBody = true;
+ });
+ });
+
+ await sw.fire("fetch", { request, respondWith });
+
+ expect(clonedBeforeBrowser).toBe(true);
+ expect(response.clone).toHaveBeenCalledTimes(1);
+ });
+
it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => {
const sw = loadSw();
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"]));
@@ -255,7 +291,15 @@ describe("service worker", () => {
// Online: network response wins, refreshes the cached shell, and prunes
// entries the new index.html no longer references.
const freshShell = indexHtml(["/assets/index-v3.js"]);
- sw.fetchMock.mockResolvedValue(freshShell);
+ sw.fetchMock.mockImplementation((input: Request | string) => {
+ const url = new URL(typeof input === "string" ? input : input.url, ORIGIN);
+ if (url.pathname === "/asset-manifest.json") {
+ return Promise.resolve(new Response(JSON.stringify({
+ "src/main.tsx": { file: "assets/index-v3.js" },
+ })));
+ }
+ return Promise.resolve(freshShell.clone());
+ });
const onlineEvent = {
request: new Request(`${ORIGIN}/`),
respondWith: vi.fn(),
diff --git a/webui/src/tests/thinking-reasoning-shell.test.tsx b/webui/src/tests/thinking-reasoning-shell.test.tsx
new file mode 100644
index 000000000..2fe497810
--- /dev/null
+++ b/webui/src/tests/thinking-reasoning-shell.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ThinkingReasoningShell } from "@/components/thread/activity/ThinkingReasoningShell";
+
+function renderShell(expanded: boolean) {
+ return render(
+ undefined}
+ contentRef={() => undefined}
+ fadeTop={false}
+ fadeBottom={false}
+ onToggle={vi.fn()}
+ onScroll={vi.fn()}
+ >
+
+ ,
+ );
+}
+
+describe("ThinkingReasoningShell", () => {
+ it("makes collapsed descendants inert as well as visually hidden", () => {
+ const { rerender } = renderShell(false);
+ const disclosure = screen.getByRole("button", { name: "Thought" });
+ const collapsible = disclosure.nextElementSibling;
+
+ expect(collapsible).toHaveAttribute("inert");
+ expect(collapsible).toHaveAttribute("aria-hidden", "true");
+
+ rerender(
+ undefined}
+ contentRef={() => undefined}
+ fadeTop={false}
+ fadeBottom={false}
+ onToggle={vi.fn()}
+ onScroll={vi.fn()}
+ >
+
+ ,
+ );
+
+ expect(disclosure.nextElementSibling).not.toHaveAttribute("inert");
+ expect(disclosure.nextElementSibling).toHaveAttribute("aria-hidden", "false");
+ });
+});
diff --git a/webui/vite.config.ts b/webui/vite.config.ts
index 9525c3020..605c0a83b 100644
--- a/webui/vite.config.ts
+++ b/webui/vite.config.ts
@@ -130,6 +130,7 @@ export default defineConfig(({ mode }) => {
build: {
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
emptyOutDir: true,
+ manifest: "asset-manifest.json",
sourcemap: false,
rollupOptions: {
output: {