From 686dd0603ecb2e7bd065910ff0f88b4689bd8ad5 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 12 Aug 2026 16:22:34 +0800 Subject: [PATCH] fix(webui): restore session drag and review findings --- nanobot/channels/feishu/webui/index.tsx | 8 +- nanobot/channels/weixin/webui/WeixinPanel.tsx | 26 +--- nanobot/channels/weixin/webui/index.tsx | 15 +- nanobot/channels/weixin/webui/presentation.ts | 21 +++ webui/index.html | 2 +- webui/public/sw.js | 77 +++++++-- webui/src/channel-plugins/locale-registry.ts | 55 +++++-- webui/src/channel-plugins/registry.ts | 2 +- webui/src/components/ChatList.tsx | 25 ++- webui/src/components/CliAppMentionText.tsx | 31 ---- .../settings/channels/ChannelSetupPanel.tsx | 60 +++++-- .../activity/ThinkingReasoningShell.tsx | 3 +- webui/src/i18n/index.ts | 146 ++++++++++++------ webui/src/main.tsx | 11 +- .../src/tests/channel-locale-registry.test.ts | 3 + webui/src/tests/channel-ui-registry.test.ts | 7 +- webui/src/tests/chat-list.test.tsx | 23 ++- webui/src/tests/index-html.test.ts | 15 ++ webui/src/tests/main-randomuuid.test.tsx | 5 +- webui/src/tests/settings-channels.test.tsx | 20 ++- webui/src/tests/setup.ts | 9 +- webui/src/tests/sw.test.ts | 46 +++++- .../tests/thinking-reasoning-shell.test.tsx | 52 +++++++ webui/vite.config.ts | 1 + 24 files changed, 494 insertions(+), 169 deletions(-) create mode 100644 nanobot/channels/weixin/webui/presentation.ts create mode 100644 webui/src/tests/index-html.test.ts create mode 100644 webui/src/tests/thinking-reasoning-shell.test.tsx diff --git a/nanobot/channels/feishu/webui/index.tsx b/nanobot/channels/feishu/webui/index.tsx index c149e7b2f..5e32a56b5 100644 --- a/nanobot/channels/feishu/webui/index.tsx +++ b/nanobot/channels/feishu/webui/index.tsx @@ -1,7 +1,13 @@ +import { lazy } from "react"; + import type { ChannelUiContribution } from "@/channel-plugins/types"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; -import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel"; +const FeishuAssistantsPanel = lazy(() => + import("./FeishuAssistantsPanel").then(({ FeishuAssistantsPanel: component }) => ({ + default: component, + })), +); export default { Panel: FeishuAssistantsPanel, diff --git a/nanobot/channels/weixin/webui/WeixinPanel.tsx b/nanobot/channels/weixin/webui/WeixinPanel.tsx index 8bf3e2ecc..adfda64f9 100644 --- a/nanobot/channels/weixin/webui/WeixinPanel.tsx +++ b/nanobot/channels/weixin/webui/WeixinPanel.tsx @@ -33,28 +33,10 @@ import { WEIXIN_AUTH_EXPIRED_MESSAGE, WeixinConnectFlow, } from "./WeixinConnectFlow"; - -export const WEIXIN_PRIMARY_FIELD_KEYS = [ - "channels.weixin.sendProgress", - "channels.weixin.sendToolHints", - "channels.weixin.streaming", -] as const; - -export const WEIXIN_ADVANCED_FIELD_KEYS = [ - "channels.weixin.allowFrom", - "channels.weixin.token", - "channels.weixin.replyProgressMessages", - "channels.weixin.replyProgressMaxMessages", - "channels.weixin.contextMessageBudget", - "channels.weixin.blockStreaming", - "channels.weixin.blockStreamingMinChars", - "channels.weixin.blockStreamingMaxMessages", - "channels.weixin.baseUrl", - "channels.weixin.cdnBaseUrl", - "channels.weixin.routeTag", - "channels.weixin.stateDir", - "channels.weixin.pollTimeout", -] as const; +import { + WEIXIN_ADVANCED_FIELD_KEYS, + WEIXIN_PRIMARY_FIELD_KEYS, +} from "./presentation"; export function WeixinPanel({ token, diff --git a/nanobot/channels/weixin/webui/index.tsx b/nanobot/channels/weixin/webui/index.tsx index 85176c23d..5fb35ca77 100644 --- a/nanobot/channels/weixin/webui/index.tsx +++ b/nanobot/channels/weixin/webui/index.tsx @@ -1,12 +1,21 @@ +import { lazy } from "react"; + import type { ChannelUiContribution } from "@/channel-plugins/types"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; -import { WeixinConnectFlow } from "./WeixinConnectFlow"; import { WEIXIN_ADVANCED_FIELD_KEYS, WEIXIN_PRIMARY_FIELD_KEYS, - WeixinPanel, -} from "./WeixinPanel"; +} from "./presentation"; + +const WeixinPanel = lazy(() => + import("./WeixinPanel").then(({ WeixinPanel: component }) => ({ default: component })), +); +const WeixinConnectFlow = lazy(() => + import("./WeixinConnectFlow").then(({ WeixinConnectFlow: component }) => ({ + default: component, + })), +); export default { Panel: WeixinPanel, diff --git a/nanobot/channels/weixin/webui/presentation.ts b/nanobot/channels/weixin/webui/presentation.ts new file mode 100644 index 000000000..ad04026b1 --- /dev/null +++ b/nanobot/channels/weixin/webui/presentation.ts @@ -0,0 +1,21 @@ +export const WEIXIN_PRIMARY_FIELD_KEYS = [ + "channels.weixin.sendProgress", + "channels.weixin.sendToolHints", + "channels.weixin.streaming", +] as const; + +export const WEIXIN_ADVANCED_FIELD_KEYS = [ + "channels.weixin.allowFrom", + "channels.weixin.token", + "channels.weixin.replyProgressMessages", + "channels.weixin.replyProgressMaxMessages", + "channels.weixin.contextMessageBudget", + "channels.weixin.blockStreaming", + "channels.weixin.blockStreamingMinChars", + "channels.weixin.blockStreamingMaxMessages", + "channels.weixin.baseUrl", + "channels.weixin.cdnBaseUrl", + "channels.weixin.routeTag", + "channels.weixin.stateDir", + "channels.weixin.pollTimeout", +] as const; diff --git a/webui/index.html b/webui/index.html index 8832b888b..bb4659303 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,7 +4,7 @@ { event.waitUntil( @@ -20,6 +21,42 @@ function referencedAssetPaths(html) { return refs; } +// Vite's build manifest contains every emitted entry, static dependency, and +// lazy chunk. The HTML alone only references the entry chunk, so pruning from +// its tags can delete a current build's not-yet-requested dynamic imports. +async function manifestedAssetPaths(cache) { + const response = await cache.match(ASSET_MANIFEST_PATH); + if (!response) return new Set(); + try { + const manifest = await response.json(); + const refs = new Set(); + for (const entry of Object.values(manifest)) { + if (!entry || typeof entry !== "object") continue; + for (const file of [entry.file, ...(entry.css ?? []), ...(entry.assets ?? [])]) { + if (typeof file !== "string") continue; + const url = new URL(file, self.location.origin); + if (url.origin === self.location.origin) refs.add(url.pathname + url.search); + } + } + return refs; + } catch { + return new Set(); + } +} + +async function refreshAssetManifest(cache) { + const response = await fetch(ASSET_MANIFEST_PATH, { cache: "no-store" }); + if (!response.ok) return false; + try { + const manifest = await response.clone().json(); + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return false; + } catch { + return false; + } + await cache.put(ASSET_MANIFEST_PATH, response); + return true; +} + // Drop cached entries that the current index.html no longer references. // CACHE_NAME is stable across deployments, so without this, hashed assets from // previous builds would pile up in the same cache forever. The cached @@ -31,11 +68,16 @@ async function pruneStaleEntries() { const cachedIndex = await cache.match("/"); if (!cachedIndex) return; const refs = referencedAssetPaths(await cachedIndex.text()); + for (const path of await manifestedAssetPaths(cache)) refs.add(path); const keys = await cache.keys(); await Promise.all( keys.map(async (request) => { const url = new URL(request.url); - if (url.pathname === "/" || url.pathname === "/manifest.json") return; + if ( + url.pathname === "/" + || url.pathname === "/manifest.json" + || url.pathname === ASSET_MANIFEST_PATH + ) return; if (refs.has(url.pathname + url.search)) return; await cache.delete(request); }) @@ -108,19 +150,28 @@ self.addEventListener("fetch", (event) => { } // Everything else: network-first (index.html, manifest, brand assets, etc.) - event.respondWith( - fetch(request) - .then((response) => { - if (response.ok) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((c) => c.put(request, clone)); - // The shell just changed; prune entries the new index.html no longer - // references so hashed assets from old builds do not accumulate even - // when sw.js itself is unchanged between deployments. - if (path === "/") pruneStaleEntries(); + const networkResponse = fetch(request); + event.waitUntil( + networkResponse + .then(async (response) => { + if (!response.ok) return; + // Clone before the first await. The original response is also handed + // to respondWith(), which may lock its body as soon as this callback + // yields to the event loop. + const cachedResponse = response.clone(); + const cache = await caches.open(CACHE_NAME); + await cache.put(request, cachedResponse); + // Refresh the complete build graph before pruning. A deployment can + // change index.html without changing sw.js, so this cannot rely only + // on the manifest cached when the worker was installed. + if (path === "/") { + if (await refreshAssetManifest(cache)) await pruneStaleEntries(); } - return response; }) + .catch(() => undefined) + ); + event.respondWith( + networkResponse .catch(() => { // Offline: serve the app shell for navigations (deep links resolve // client-side), the last cached copy for everything else. diff --git a/webui/src/channel-plugins/locale-registry.ts b/webui/src/channel-plugins/locale-registry.ts index 7073ac358..da1941fa4 100644 --- a/webui/src/channel-plugins/locale-registry.ts +++ b/webui/src/channel-plugins/locale-registry.ts @@ -9,17 +9,20 @@ type ChannelMessagesModule = { default?: ChannelMessages; }; +type ChannelMessagesLoader = () => Promise; + const modules = import.meta.glob( "../../../nanobot/channels/*/webui/locales/*.json", - { eager: true }, ); +const loadersByChannel = new Map< + string, + Map +>(); const translationsByChannel = new Map>(); const supportedLocaleCodes = new Set(supportedLocales.map(({ code }) => code)); -for (const [modulePath, module] of Object.entries(modules)) { - const messages = module.default; - if (!messages) continue; +for (const [modulePath, loader] of Object.entries(modules)) { const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/); if (!match) { throw new Error(`Cannot derive channel locale identity from '${modulePath}'`); @@ -28,25 +31,27 @@ for (const [modulePath, module] of Object.entries(modules)) { if (!supportedLocaleCodes.has(locale)) { throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`); } - const translations = translationsByChannel.get(channel) ?? new Map(); - if (translations.has(locale as SupportedLocale)) { + const loaders = loadersByChannel.get(channel) ?? new Map(); + if (loaders.has(locale as SupportedLocale)) { throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`); } - translations.set(locale as SupportedLocale, messages); - translationsByChannel.set(channel, translations); + loaders.set(locale as SupportedLocale, loader); + loadersByChannel.set(channel, loaders); } export function channelLocaleNamespaces(): string[] { - return [...translationsByChannel.keys()].map(channelNamespace); + return [...loadersByChannel.keys()].map(channelNamespace); } -export function channelLocaleResources(locale: SupportedLocale): Record { - return Object.fromEntries( - [...translationsByChannel.keys()].map((channel) => [ +export async function channelLocaleResources( + locale: SupportedLocale, +): Promise> { + return Object.fromEntries(await Promise.all( + [...loadersByChannel.keys()].map(async (channel) => [ channelNamespace(channel), - channelLocaleMessages(channel, locale) ?? {}, + await loadChannelLocale(channel, locale), ]), - ); + )); } export function channelLocaleMessages( @@ -63,3 +68,25 @@ export function registeredChannelLocales(): ReadonlyMap< > { return translationsByChannel; } + +async function loadChannelLocale( + channel: string, + locale: SupportedLocale, +): Promise { + const translations = translationsByChannel.get(channel) ?? new Map(); + const loaded = translations.get(locale); + if (loaded) return loaded; + + const loaders = loadersByChannel.get(channel); + const loader = loaders?.get(locale) ?? loaders?.get("en"); + if (!loader) { + throw new Error(`Channel '${channel}' has no locale loader for '${locale}' or 'en'`); + } + const messages = (await loader()).default; + if (!messages) { + throw new Error(`Channel '${channel}' locale '${locale}' has no default export`); + } + translations.set(locale, messages); + translationsByChannel.set(channel, translations); + return messages; +} diff --git a/webui/src/channel-plugins/registry.ts b/webui/src/channel-plugins/registry.ts index edd84f661..22be0b97e 100644 --- a/webui/src/channel-plugins/registry.ts +++ b/webui/src/channel-plugins/registry.ts @@ -8,7 +8,7 @@ type ChannelUiContributionModule = { }; const modules = import.meta.glob( - "../../../nanobot/channels/*/webui/**/*.{ts,tsx}", + "../../../nanobot/channels/*/webui/index.{ts,tsx}", { eager: true, }, diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index e4a7d198e..d4d040907 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -55,6 +55,7 @@ import { type ChatGroupLabels, } from "@/lib/chat-groups"; import { deriveTemporaryChatTitle } from "@/lib/temporary-chat"; +import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag"; import { cn } from "@/lib/utils"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; @@ -616,6 +617,7 @@ export const ChatList = memo(function ChatList({ : updated.has(s.chatId) && !topicActive ? "updated" : null; + const canDragSession = !topicActive && !deleteSelectionMode; return (
  • { + if (!canDragSession) { + event.preventDefault(); + return; + } + writeDraggedSession(event.dataTransfer, s.key); + }} + onDragEnd={clearDraggedSession} aria-current={topicActive ? "page" : undefined} aria-pressed={deleteSelectionMode ? tabSelected : undefined} title={tooltipTitle} className={cn( "flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left", + canDragSession && "cursor-grab active:cursor-grabbing", deleteSelectionMode && "cursor-default", compact ? "py-1" : "py-1.5", projectMode && "pl-7", @@ -1050,6 +1061,7 @@ function ActivePaneRows({ const selected = selectedDeleteKeys.has(pane.key); const isPinned = pinned.has(pane.key); const isArchived = archived.has(pane.key); + const canDragSession = !active && !deleteSelectionMode; return (
  • { + if (!canDragSession) { + event.preventDefault(); + return; + } + writeDraggedSession(event.dataTransfer, pane.key); + }} + onDragEnd={clearDraggedSession} aria-current={active ? "true" : undefined} aria-pressed={deleteSelectionMode ? selected : undefined} title={pane.title} className={cn( "flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left font-medium leading-5", + canDragSession && "cursor-grab active:cursor-grabbing", compact ? "py-1" : "py-1.5", deleteSelectionMode && "cursor-default", )} diff --git a/webui/src/components/CliAppMentionText.tsx b/webui/src/components/CliAppMentionText.tsx index 03fa6984e..3c12c9c90 100644 --- a/webui/src/components/CliAppMentionText.tsx +++ b/webui/src/components/CliAppMentionText.tsx @@ -104,37 +104,6 @@ export function splitCapabilityMentionSegments( return segments.length ? segments : [{ kind: "text", text: value }]; } -export function CliAppMentionText({ - text, - cliApps, - mcpPresets = [], - sessionMentions = [], -}: { - text: string; - cliApps: CliAppInfo[]; - mcpPresets?: McpPresetInfo[]; - sessionMentions?: SessionMention[]; -}) { - const segments = splitCapabilityMentionSegments(text, cliApps, mcpPresets, sessionMentions); - if (!segments.some((segment) => segment.kind !== "text")) return <>{text}; - return ( - <> - {segments.map((segment, index) => { - if (segment.kind === "text") { - return {segment.text}; - } - return ( - - ); - })} - - ); -} - export function CapabilityMentionToken({ segment, variant, diff --git a/webui/src/components/settings/channels/ChannelSetupPanel.tsx b/webui/src/components/settings/channels/ChannelSetupPanel.tsx index 8db52eefa..2f54be2a6 100644 --- a/webui/src/components/settings/channels/ChannelSetupPanel.tsx +++ b/webui/src/components/settings/channels/ChannelSetupPanel.tsx @@ -1,4 +1,10 @@ -import { useEffect, useMemo, useState, type ComponentType } from "react"; +import { + Suspense, + useEffect, + useMemo, + useState, + type ComponentType, +} from "react"; import { Check, ChevronDown, @@ -135,15 +141,17 @@ export function ChannelSetupPanel({ const PluginPanel = uiContribution?.Panel; if (PluginPanel) { return ( - + }> + + ); } if (feature.instances !== undefined) { @@ -432,13 +440,15 @@ function ChannelSetupSurface({ {mode === "connect" && ConnectFlow ? ( - + }> + + ) : mode === "connect" ? ( <>
    @@ -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: {