fix(webui): restore session drag and review findings

This commit is contained in:
chengyongru
2026-08-12 17:26:13 +08:00
committed by chengyongru
parent 4b5319b760
commit 686dd0603e
24 changed files with 494 additions and 169 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
import { lazy } from "react";
import type { ChannelUiContribution } from "@/channel-plugins/types"; import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { FeishuAssistantsPanel } from "./FeishuAssistantsPanel"; const FeishuAssistantsPanel = lazy(() =>
import("./FeishuAssistantsPanel").then(({ FeishuAssistantsPanel: component }) => ({
default: component,
})),
);
export default { export default {
Panel: FeishuAssistantsPanel, Panel: FeishuAssistantsPanel,
+4 -22
View File
@@ -33,28 +33,10 @@ import {
WEIXIN_AUTH_EXPIRED_MESSAGE, WEIXIN_AUTH_EXPIRED_MESSAGE,
WeixinConnectFlow, WeixinConnectFlow,
} from "./WeixinConnectFlow"; } from "./WeixinConnectFlow";
import {
export const WEIXIN_PRIMARY_FIELD_KEYS = [ WEIXIN_ADVANCED_FIELD_KEYS,
"channels.weixin.sendProgress", WEIXIN_PRIMARY_FIELD_KEYS,
"channels.weixin.sendToolHints", } from "./presentation";
"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;
export function WeixinPanel({ export function WeixinPanel({
token, token,
+12 -3
View File
@@ -1,12 +1,21 @@
import { lazy } from "react";
import type { ChannelUiContribution } from "@/channel-plugins/types"; import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
import { WeixinConnectFlow } from "./WeixinConnectFlow";
import { import {
WEIXIN_ADVANCED_FIELD_KEYS, WEIXIN_ADVANCED_FIELD_KEYS,
WEIXIN_PRIMARY_FIELD_KEYS, WEIXIN_PRIMARY_FIELD_KEYS,
WeixinPanel, } from "./presentation";
} from "./WeixinPanel";
const WeixinPanel = lazy(() =>
import("./WeixinPanel").then(({ WeixinPanel: component }) => ({ default: component })),
);
const WeixinConnectFlow = lazy(() =>
import("./WeixinConnectFlow").then(({ WeixinConnectFlow: component }) => ({
default: component,
})),
);
export default { export default {
Panel: WeixinPanel, Panel: WeixinPanel,
@@ -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;
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1, user-scalable=no" content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/> />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta <meta
+64 -13
View File
@@ -1,5 +1,6 @@
const CACHE_NAME = "nanobot-static-v1"; const CACHE_NAME = "nanobot-static-v1";
const PRECACHE = ["/", "/manifest.json"]; const ASSET_MANIFEST_PATH = "/asset-manifest.json";
const PRECACHE = ["/", "/manifest.json", ASSET_MANIFEST_PATH];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
@@ -20,6 +21,42 @@ function referencedAssetPaths(html) {
return refs; 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. // Drop cached entries that the current index.html no longer references.
// CACHE_NAME is stable across deployments, so without this, hashed assets from // CACHE_NAME is stable across deployments, so without this, hashed assets from
// previous builds would pile up in the same cache forever. The cached // previous builds would pile up in the same cache forever. The cached
@@ -31,11 +68,16 @@ async function pruneStaleEntries() {
const cachedIndex = await cache.match("/"); const cachedIndex = await cache.match("/");
if (!cachedIndex) return; if (!cachedIndex) return;
const refs = referencedAssetPaths(await cachedIndex.text()); const refs = referencedAssetPaths(await cachedIndex.text());
for (const path of await manifestedAssetPaths(cache)) refs.add(path);
const keys = await cache.keys(); const keys = await cache.keys();
await Promise.all( await Promise.all(
keys.map(async (request) => { keys.map(async (request) => {
const url = new URL(request.url); 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; if (refs.has(url.pathname + url.search)) return;
await cache.delete(request); await cache.delete(request);
}) })
@@ -108,19 +150,28 @@ self.addEventListener("fetch", (event) => {
} }
// Everything else: network-first (index.html, manifest, brand assets, etc.) // Everything else: network-first (index.html, manifest, brand assets, etc.)
event.respondWith( const networkResponse = fetch(request);
fetch(request) event.waitUntil(
.then((response) => { networkResponse
if (response.ok) { .then(async (response) => {
const clone = response.clone(); if (!response.ok) return;
caches.open(CACHE_NAME).then((c) => c.put(request, clone)); // Clone before the first await. The original response is also handed
// The shell just changed; prune entries the new index.html no longer // to respondWith(), which may lock its body as soon as this callback
// references so hashed assets from old builds do not accumulate even // yields to the event loop.
// when sw.js itself is unchanged between deployments. const cachedResponse = response.clone();
if (path === "/") pruneStaleEntries(); 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(() => { .catch(() => {
// Offline: serve the app shell for navigations (deep links resolve // Offline: serve the app shell for navigations (deep links resolve
// client-side), the last cached copy for everything else. // client-side), the last cached copy for everything else.
+41 -14
View File
@@ -9,17 +9,20 @@ type ChannelMessagesModule = {
default?: ChannelMessages; default?: ChannelMessages;
}; };
type ChannelMessagesLoader = () => Promise<ChannelMessagesModule>;
const modules = import.meta.glob<ChannelMessagesModule>( const modules = import.meta.glob<ChannelMessagesModule>(
"../../../nanobot/channels/*/webui/locales/*.json", "../../../nanobot/channels/*/webui/locales/*.json",
{ eager: true },
); );
const loadersByChannel = new Map<
string,
Map<SupportedLocale, ChannelMessagesLoader>
>();
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>(); const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code)); const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));
for (const [modulePath, module] of Object.entries(modules)) { for (const [modulePath, loader] of Object.entries(modules)) {
const messages = module.default;
if (!messages) continue;
const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/); const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/);
if (!match) { if (!match) {
throw new Error(`Cannot derive channel locale identity from '${modulePath}'`); 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)) { if (!supportedLocaleCodes.has(locale)) {
throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`); throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`);
} }
const translations = translationsByChannel.get(channel) ?? new Map(); const loaders = loadersByChannel.get(channel) ?? new Map();
if (translations.has(locale as SupportedLocale)) { if (loaders.has(locale as SupportedLocale)) {
throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`); throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`);
} }
translations.set(locale as SupportedLocale, messages); loaders.set(locale as SupportedLocale, loader);
translationsByChannel.set(channel, translations); loadersByChannel.set(channel, loaders);
} }
export function channelLocaleNamespaces(): string[] { export function channelLocaleNamespaces(): string[] {
return [...translationsByChannel.keys()].map(channelNamespace); return [...loadersByChannel.keys()].map(channelNamespace);
} }
export function channelLocaleResources(locale: SupportedLocale): Record<string, unknown> { export async function channelLocaleResources(
return Object.fromEntries( locale: SupportedLocale,
[...translationsByChannel.keys()].map((channel) => [ ): Promise<Record<string, ChannelMessages>> {
return Object.fromEntries(await Promise.all(
[...loadersByChannel.keys()].map(async (channel) => [
channelNamespace(channel), channelNamespace(channel),
channelLocaleMessages(channel, locale) ?? {}, await loadChannelLocale(channel, locale),
]), ]),
); ));
} }
export function channelLocaleMessages( export function channelLocaleMessages(
@@ -63,3 +68,25 @@ export function registeredChannelLocales(): ReadonlyMap<
> { > {
return translationsByChannel; return translationsByChannel;
} }
async function loadChannelLocale(
channel: string,
locale: SupportedLocale,
): Promise<ChannelMessages> {
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;
}
+1 -1
View File
@@ -8,7 +8,7 @@ type ChannelUiContributionModule = {
}; };
const modules = import.meta.glob<ChannelUiContributionModule>( const modules = import.meta.glob<ChannelUiContributionModule>(
"../../../nanobot/channels/*/webui/**/*.{ts,tsx}", "../../../nanobot/channels/*/webui/index.{ts,tsx}",
{ {
eager: true, eager: true,
}, },
+23 -2
View File
@@ -55,6 +55,7 @@ import {
type ChatGroupLabels, type ChatGroupLabels,
} from "@/lib/chat-groups"; } from "@/lib/chat-groups";
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat"; import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@@ -616,6 +617,7 @@ export const ChatList = memo(function ChatList({
: updated.has(s.chatId) && !topicActive : updated.has(s.chatId) && !topicActive
? "updated" ? "updated"
: null; : null;
const canDragSession = !topicActive && !deleteSelectionMode;
return ( return (
<li <li
key={s.key} key={s.key}
@@ -648,12 +650,21 @@ export const ChatList = memo(function ChatList({
} }
if (!topicActive) onSelect(s.key); if (!topicActive) onSelect(s.key);
}} }}
draggable={false} draggable={canDragSession}
onDragStart={(event) => {
if (!canDragSession) {
event.preventDefault();
return;
}
writeDraggedSession(event.dataTransfer, s.key);
}}
onDragEnd={clearDraggedSession}
aria-current={topicActive ? "page" : undefined} aria-current={topicActive ? "page" : undefined}
aria-pressed={deleteSelectionMode ? tabSelected : undefined} aria-pressed={deleteSelectionMode ? tabSelected : undefined}
title={tooltipTitle} title={tooltipTitle}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left", "flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
canDragSession && "cursor-grab active:cursor-grabbing",
deleteSelectionMode && "cursor-default", deleteSelectionMode && "cursor-default",
compact ? "py-1" : "py-1.5", compact ? "py-1" : "py-1.5",
projectMode && "pl-7", projectMode && "pl-7",
@@ -1050,6 +1061,7 @@ function ActivePaneRows({
const selected = selectedDeleteKeys.has(pane.key); const selected = selectedDeleteKeys.has(pane.key);
const isPinned = pinned.has(pane.key); const isPinned = pinned.has(pane.key);
const isArchived = archived.has(pane.key); const isArchived = archived.has(pane.key);
const canDragSession = !active && !deleteSelectionMode;
return ( return (
<li <li
@@ -1079,12 +1091,21 @@ function ActivePaneRows({
} }
onSelectPane?.(group.tabKey, pane.key); onSelectPane?.(group.tabKey, pane.key);
}} }}
draggable={false} draggable={canDragSession}
onDragStart={(event) => {
if (!canDragSession) {
event.preventDefault();
return;
}
writeDraggedSession(event.dataTransfer, pane.key);
}}
onDragEnd={clearDraggedSession}
aria-current={active ? "true" : undefined} aria-current={active ? "true" : undefined}
aria-pressed={deleteSelectionMode ? selected : undefined} aria-pressed={deleteSelectionMode ? selected : undefined}
title={pane.title} title={pane.title}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left font-medium leading-5", "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", compact ? "py-1" : "py-1.5",
deleteSelectionMode && "cursor-default", deleteSelectionMode && "cursor-default",
)} )}
@@ -104,37 +104,6 @@ export function splitCapabilityMentionSegments(
return segments.length ? segments : [{ kind: "text", text: value }]; 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 <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CapabilityMentionToken
key={`${segment.kind}-${index}`}
segment={segment}
variant="message"
/>
);
})}
</>
);
}
export function CapabilityMentionToken({ export function CapabilityMentionToken({
segment, segment,
variant, variant,
@@ -1,4 +1,10 @@
import { useEffect, useMemo, useState, type ComponentType } from "react"; import {
Suspense,
useEffect,
useMemo,
useState,
type ComponentType,
} from "react";
import { import {
Check, Check,
ChevronDown, ChevronDown,
@@ -135,6 +141,7 @@ export function ChannelSetupPanel({
const PluginPanel = uiContribution?.Panel; const PluginPanel = uiContribution?.Panel;
if (PluginPanel) { if (PluginPanel) {
return ( return (
<Suspense fallback={<ChannelPluginLoading />}>
<PluginPanel <PluginPanel
token={token} token={token}
feature={feature} feature={feature}
@@ -144,6 +151,7 @@ export function ChannelSetupPanel({
onAction={onAction} onAction={onAction}
onFeaturesUpdate={onFeaturesUpdate} onFeaturesUpdate={onFeaturesUpdate}
/> />
</Suspense>
); );
} }
if (feature.instances !== undefined) { if (feature.instances !== undefined) {
@@ -432,6 +440,7 @@ function ChannelSetupSurface({
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} /> <ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
{mode === "connect" && ConnectFlow ? ( {mode === "connect" && ConnectFlow ? (
<Suspense fallback={<ChannelPluginLoading compact />}>
<ConnectFlow <ConnectFlow
token={token} token={token}
feature={feature} feature={feature}
@@ -439,6 +448,7 @@ function ChannelSetupSurface({
connectRequestId={connectRequestId} connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate} onFeaturesUpdate={onFeaturesUpdate}
/> />
</Suspense>
) : mode === "connect" ? ( ) : mode === "connect" ? (
<> <>
<div className="mt-3 flex flex-wrap justify-end gap-2"> <div className="mt-3 flex flex-wrap justify-end gap-2">
@@ -566,3 +576,19 @@ function ChannelSetupSurface({
</form> </form>
); );
} }
function ChannelPluginLoading({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation();
return (
<div
role="status"
className={cn(
"flex items-center justify-center gap-2 text-sm text-muted-foreground",
compact ? "min-h-12" : "min-h-48",
)}
>
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden />
{t("settings.status.loading")}
</div>
);
}
@@ -69,6 +69,8 @@ export function ThinkingReasoningShell({
</button> </button>
<div <div
{...(!expanded ? { inert: "" } : {})}
aria-hidden={!expanded}
className={cn( className={cn(
"grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none", "grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
expanded expanded
@@ -84,7 +86,6 @@ export function ThinkingReasoningShell({
data-fade-bottom={fadeBottom} data-fade-bottom={fadeBottom}
onScroll={onScroll} onScroll={onScroll}
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
aria-hidden={!expanded}
> >
<div ref={contentRef} className="flex flex-col gap-0.5"> <div ref={contentRef} className="flex flex-col gap-0.5">
{children} {children}
+91 -35
View File
@@ -1,4 +1,4 @@
import i18n from "i18next"; import i18n, { type InitOptions } from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import { import {
@@ -14,47 +14,88 @@ import {
normalizeLocale, normalizeLocale,
persistLocale, persistLocale,
resolveInitialLocale, resolveInitialLocale,
supportedLocales,
type SupportedLocale, type SupportedLocale,
} from "./config"; } from "./config";
import enCommon from "./locales/en/common.json"; type CommonMessages = typeof import("./locales/en/common.json");
import zhCNCommon from "./locales/zh-CN/common.json"; type CommonMessagesModule = { default: CommonMessages };
import zhTWCommon from "./locales/zh-TW/common.json"; type LocaleResource = { common: CommonMessages } & Record<string, unknown>;
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";
export const resources = { const commonModules = import.meta.glob<CommonMessagesModule>(
en: { common: enCommon, ...channelLocaleResources("en") }, "./locales/*/common.json",
"zh-CN": { common: zhCNCommon, ...channelLocaleResources("zh-CN") }, );
"zh-TW": { common: zhTWCommon, ...channelLocaleResources("zh-TW") }, const commonLoaders = new Map<SupportedLocale, () => Promise<CommonMessagesModule>>();
fr: { common: frCommon, ...channelLocaleResources("fr") }, const resourcePromises = new Map<SupportedLocale, Promise<LocaleResource>>();
ja: { common: jaCommon, ...channelLocaleResources("ja") }, const supportedLocaleCodes = new Set<string>(supportedLocales.map(({ code }) => code));
ko: { common: koCommon, ...channelLocaleResources("ko") },
es: { common: esCommon, ...channelLocaleResources("es") }, for (const [modulePath, loader] of Object.entries(commonModules)) {
"pt-BR": { common: ptBRCommon, ...channelLocaleResources("pt-BR") }, const match = modulePath.match(/locales\/([^/]+)\/common\.json$/);
vi: { common: viCommon, ...channelLocaleResources("vi") }, if (!match || !supportedLocaleCodes.has(match[1])) continue;
id: { common: idCommon, ...channelLocaleResources("id") }, commonLoaders.set(match[1] as SupportedLocale, loader);
} as const; }
// 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<SupportedLocale, LocaleResource>;
let initialization: Promise<typeof i18n> | undefined;
let localeListenerBound = false;
export function currentLocale(): SupportedLocale { export function currentLocale(): SupportedLocale {
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale); return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
} }
export async function setAppLanguage(locale: SupportedLocale): Promise<void> { export async function loadLocaleResources(
await i18n.changeLanguage(locale); locale: SupportedLocale,
): Promise<LocaleResource> {
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;
} }
if (!i18n.isInitialized) { export async function loadAllLocaleResources(): Promise<void> {
void i18n await Promise.all(supportedLocales.map(({ code }) => loadLocaleResources(code)));
}
export async function initializeI18n(): Promise<typeof i18n> {
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) .use(initReactI18next)
.init({ .init({
resources, resources: Object.fromEntries(
lng: resolveInitialLocale(), startupLocales.map((locale) => [locale, resources[locale]]),
) as InitOptions["resources"],
lng: initialLocale,
fallbackLng: fallbackLocale, fallbackLng: fallbackLocale,
defaultNS: "common", defaultNS: "common",
ns: ["common", ...channelLocaleNamespaces()], ns: ["common", ...channelLocaleNamespaces()],
@@ -62,18 +103,33 @@ if (!i18n.isInitialized) {
escapeValue: false, escapeValue: false,
}, },
returnNull: false, returnNull: false,
supportedLngs: Object.keys(resources), 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;
} }
const syncLocaleSideEffects = (language: string) => { export async function setAppLanguage(locale: SupportedLocale): Promise<void> {
await initializeI18n();
await loadLocaleResources(locale);
await i18n.changeLanguage(locale);
}
function syncLocaleSideEffects(language: string) {
const locale = normalizeLocale(language); const locale = normalizeLocale(language);
applyDocumentLocale(locale); applyDocumentLocale(locale);
persistLocale(locale); persistLocale(locale);
}; }
syncLocaleSideEffects(currentLocale());
i18n.on("languageChanged", syncLocaleSideEffects);
export { LOCALE_STORAGE_KEY }; export { LOCALE_STORAGE_KEY };
export default i18n; export default i18n;
+8 -3
View File
@@ -2,7 +2,7 @@ import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import "./globals.css"; import "./globals.css";
import "./i18n"; import { initializeI18n } from "./i18n";
import { initializeLoopbackRuntimeHost } from "./lib/runtime"; import { initializeLoopbackRuntimeHost } from "./lib/runtime";
// `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost). // `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost).
@@ -26,8 +26,13 @@ if (!root) throw new Error("root element missing");
initializeLoopbackRuntimeHost(); initializeLoopbackRuntimeHost();
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */ async function renderWebui(container: HTMLElement) {
ReactDOM.createRoot(root).render(<App />); await initializeI18n();
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
ReactDOM.createRoot(container).render(<App />);
}
void renderWebui(root);
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
window.addEventListener("load", () => { window.addEventListener("load", () => {
@@ -105,8 +105,11 @@ describe("channel locale registry", () => {
const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8"); const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8");
expect(localeRegistry).toContain("webui/locales/*.json"); expect(localeRegistry).toContain("webui/locales/*.json");
expect(localeRegistry).not.toContain("eager: true");
expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i); expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i);
expect(i18nEntry).toContain("channel-plugins/locale-registry"); 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"); expect(i18nEntry).not.toContain("channel-plugins/registry");
}); });
}); });
+4 -3
View File
@@ -12,8 +12,8 @@ import {
describe("channel UI contributions", () => { describe("channel UI contributions", () => {
it("selects channel-owned UI only through the backend manifest entry", () => { it("selects channel-owned UI only through the backend manifest entry", () => {
expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function"); expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeDefined();
expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function"); expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeDefined();
expect(channelUiContribution("feishu", undefined)).toBeUndefined(); expect(channelUiContribution("feishu", undefined)).toBeUndefined();
expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined(); expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined();
expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined(); expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined();
@@ -56,7 +56,8 @@ describe("channel UI contributions", () => {
"utf8", "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"'); expect(source).not.toContain('"./*/index.tsx"');
}); });
+19 -4
View File
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { readDraggedSession, SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary { function session(overrides: Partial<ChatSummary>): ChatSummary {
@@ -24,7 +25,7 @@ describe("ChatList", () => {
vi.unstubAllGlobals(); 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( render(
<ChatList <ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]} sessions={[session({ chatId: "root", title: "Root topic" })]}
@@ -33,7 +34,7 @@ describe("ChatList", () => {
"websocket:root": { "websocket:root": {
tabKey: "websocket:root", tabKey: "websocket:root",
title: "Root topic", title: "Root topic",
activePaneKey: "websocket:child", activePaneKey: "websocket:root",
panes: [ panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" }, { key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" }, { key: "websocket:child", chatId: "child", title: "Research pane" },
@@ -50,8 +51,22 @@ describe("ChatList", () => {
expect(screen.getByRole("button", { name: "Tab: Root topic" })) expect(screen.getByRole("button", { name: "Tab: Root topic" }))
.toHaveAttribute("draggable", "false"); .toHaveAttribute("draggable", "false");
expect(screen.getByRole("button", { name: "Research pane" })) const pane = screen.getByRole("button", { name: "Research pane" });
.toHaveAttribute("draggable", "false"); 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-drag-overlay]")).not.toBeInTheDocument();
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
}); });
+15
View File
@@ -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(/<meta\s+name="viewport"\s+content="([^"]+)"/i)?.[1];
expect(viewport).toContain("width=device-width");
expect(viewport).not.toContain("user-scalable=no");
expect(viewport).not.toMatch(/maximum-scale\s*=\s*1(?:\.0)?(?:,|$)/);
});
});
+3
View File
@@ -1,3 +1,4 @@
import { waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const render = vi.fn(); const render = vi.fn();
@@ -37,6 +38,8 @@ describe("main entry crypto shim", () => {
expect(globalThis.crypto.randomUUID()).toMatch( 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}$/, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
); );
await waitFor(() => {
expect(createRoot).toHaveBeenCalledWith(document.getElementById("root")); expect(createRoot).toHaveBeenCalledWith(document.getElementById("root"));
}); });
});
}); });
+16 -4
View File
@@ -270,7 +270,11 @@ describe("Settings channels", () => {
expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument();
expect(screen.queryByText("nanobot channels login feishu")).not.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" })); fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => await waitFor(() =>
@@ -324,7 +328,11 @@ describe("Settings channels", () => {
renderSettingsView({ initialSection: "channels" }); renderSettingsView({ initialSection: "channels" });
expect(await screen.findByRole("button", { name: "View Feishu settings" })).toBeInTheDocument(); 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" })); fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => await waitFor(() =>
@@ -1158,9 +1166,13 @@ describe("Settings channels", () => {
for (const [, displayName, guideLabel] of channels) { for (const [, displayName, guideLabel] of channels) {
fireEvent.click(await screen.findByRole("button", { name: `View ${displayName} settings` })); fireEvent.click(await screen.findByRole("button", { name: `View ${displayName} settings` }));
if (displayName === "Feishu") { 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).toHaveAttribute("href", expect.stringMatching(/^https:\/\//));
expect(guide.querySelector("span[aria-hidden] img, span[aria-hidden] svg")).not.toBeNull(); expect(guide.querySelector("span[aria-hidden] img, span[aria-hidden] svg")).not.toBeNull();
} }
+7 -2
View File
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest"; 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 { function createTestStorage(): Storage {
const store = new Map<string, string>(); const store = new Map<string, string>();
@@ -53,6 +53,11 @@ if (!("randomUUID" in globalThis.crypto)) {
}); });
} }
beforeAll(async () => {
await initializeI18n();
await loadAllLocaleResources();
});
beforeEach(async () => { beforeEach(async () => {
await i18n.changeLanguage("en"); await i18n.changeLanguage("en");
document.documentElement.lang = "en"; document.documentElement.lang = "en";
+45 -1
View File
@@ -126,6 +126,7 @@ describe("service worker", () => {
expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1); expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).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 () => { 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}/`, 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.js`, new Response("v2"));
await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css")); 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.js`, new Response("v1"));
await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css")); 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"); await sw.fire("activate");
@@ -144,8 +153,10 @@ describe("service worker", () => {
expect(sw.claimMock).toHaveBeenCalledTimes(1); expect(sw.claimMock).toHaveBeenCalledTimes(1);
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true); expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).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.js`)).toBe(true);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).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.js`)).toBe(false);
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).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); 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<Response>) => {
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 () => { it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => {
const sw = loadSw(); const sw = loadSw();
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"])); 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 // Online: network response wins, refreshes the cached shell, and prunes
// entries the new index.html no longer references. // entries the new index.html no longer references.
const freshShell = indexHtml(["/assets/index-v3.js"]); 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 = { const onlineEvent = {
request: new Request(`${ORIGIN}/`), request: new Request(`${ORIGIN}/`),
respondWith: vi.fn(), respondWith: vi.fn(),
@@ -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(
<ThinkingReasoningShell
active={false}
expanded={expanded}
label="Thought"
viewportRef={() => undefined}
contentRef={() => undefined}
fadeTop={false}
fadeBottom={false}
onToggle={vi.fn()}
onScroll={vi.fn()}
>
<button type="button">Hidden action</button>
</ThinkingReasoningShell>,
);
}
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(
<ThinkingReasoningShell
active={false}
expanded
label="Thought"
viewportRef={() => undefined}
contentRef={() => undefined}
fadeTop={false}
fadeBottom={false}
onToggle={vi.fn()}
onScroll={vi.fn()}
>
<button type="button">Hidden action</button>
</ThinkingReasoningShell>,
);
expect(disclosure.nextElementSibling).not.toHaveAttribute("inert");
expect(disclosure.nextElementSibling).toHaveAttribute("aria-hidden", "false");
});
});
+1
View File
@@ -130,6 +130,7 @@ export default defineConfig(({ mode }) => {
build: { build: {
outDir: path.resolve(__dirname, "../nanobot/web/dist"), outDir: path.resolve(__dirname, "../nanobot/web/dist"),
emptyOutDir: true, emptyOutDir: true,
manifest: "asset-manifest.json",
sourcemap: false, sourcemap: false,
rollupOptions: { rollupOptions: {
output: { output: {