mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 23:29:16 +03:00
fix(webui): restore session drag and review findings
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
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
|
||||
|
||||
+64
-13
@@ -1,5 +1,6 @@
|
||||
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) => {
|
||||
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.
|
||||
|
||||
@@ -9,17 +9,20 @@ type ChannelMessagesModule = {
|
||||
default?: ChannelMessages;
|
||||
};
|
||||
|
||||
type ChannelMessagesLoader = () => Promise<ChannelMessagesModule>;
|
||||
|
||||
const modules = import.meta.glob<ChannelMessagesModule>(
|
||||
"../../../nanobot/channels/*/webui/locales/*.json",
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const loadersByChannel = new Map<
|
||||
string,
|
||||
Map<SupportedLocale, ChannelMessagesLoader>
|
||||
>();
|
||||
const translationsByChannel = new Map<string, Map<SupportedLocale, ChannelMessages>>();
|
||||
const supportedLocaleCodes = new Set<string>(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<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
[...translationsByChannel.keys()].map((channel) => [
|
||||
export async function channelLocaleResources(
|
||||
locale: SupportedLocale,
|
||||
): Promise<Record<string, ChannelMessages>> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ type ChannelUiContributionModule = {
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<ChannelUiContributionModule>(
|
||||
"../../../nanobot/channels/*/webui/**/*.{ts,tsx}",
|
||||
"../../../nanobot/channels/*/webui/index.{ts,tsx}",
|
||||
{
|
||||
eager: true,
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<li
|
||||
key={s.key}
|
||||
@@ -648,12 +650,21 @@ export const ChatList = memo(function ChatList({
|
||||
}
|
||||
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-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 (
|
||||
<li
|
||||
@@ -1079,12 +1091,21 @@ function ActivePaneRows({
|
||||
}
|
||||
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-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",
|
||||
)}
|
||||
|
||||
@@ -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 <span key={`text-${index}`}>{segment.text}</span>;
|
||||
}
|
||||
return (
|
||||
<CapabilityMentionToken
|
||||
key={`${segment.kind}-${index}`}
|
||||
segment={segment}
|
||||
variant="message"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilityMentionToken({
|
||||
segment,
|
||||
variant,
|
||||
|
||||
@@ -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 (
|
||||
<PluginPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
actionKey={actionKey}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
onAction={onAction}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
<Suspense fallback={<ChannelPluginLoading />}>
|
||||
<PluginPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
actionKey={actionKey}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
onAction={onAction}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (feature.instances !== undefined) {
|
||||
@@ -432,13 +440,15 @@ function ChannelSetupSurface({
|
||||
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
|
||||
|
||||
{mode === "connect" && ConnectFlow ? (
|
||||
<ConnectFlow
|
||||
token={token}
|
||||
feature={feature}
|
||||
idleLabel={setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect")}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
<Suspense fallback={<ChannelPluginLoading compact />}>
|
||||
<ConnectFlow
|
||||
token={token}
|
||||
feature={feature}
|
||||
idleLabel={setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect")}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</Suspense>
|
||||
) : mode === "connect" ? (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
@@ -566,3 +576,19 @@ function ChannelSetupSurface({
|
||||
</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>
|
||||
|
||||
<div
|
||||
{...(!expanded ? { inert: "" } : {})}
|
||||
aria-hidden={!expanded}
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
|
||||
expanded
|
||||
@@ -84,7 +86,6 @@ export function ThinkingReasoningShell({
|
||||
data-fade-bottom={fadeBottom}
|
||||
onScroll={onScroll}
|
||||
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">
|
||||
{children}
|
||||
|
||||
+101
-45
@@ -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<string, unknown>;
|
||||
|
||||
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<CommonMessagesModule>(
|
||||
"./locales/*/common.json",
|
||||
);
|
||||
const commonLoaders = new Map<SupportedLocale, () => Promise<CommonMessagesModule>>();
|
||||
const resourcePromises = new Map<SupportedLocale, Promise<LocaleResource>>();
|
||||
const supportedLocaleCodes = new Set<string>(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<SupportedLocale, LocaleResource>;
|
||||
|
||||
let initialization: Promise<typeof i18n> | undefined;
|
||||
let localeListenerBound = false;
|
||||
|
||||
export function currentLocale(): SupportedLocale {
|
||||
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
|
||||
}
|
||||
|
||||
export async function loadLocaleResources(
|
||||
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;
|
||||
}
|
||||
|
||||
export async function loadAllLocaleResources(): Promise<void> {
|
||||
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)
|
||||
.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<void> {
|
||||
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;
|
||||
|
||||
+8
-3
@@ -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(<App />);
|
||||
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(<App />);
|
||||
}
|
||||
|
||||
void renderWebui(root);
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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>): 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(
|
||||
<ChatList
|
||||
sessions={[session({ chatId: "root", title: "Root topic" })]}
|
||||
@@ -33,7 +34,7 @@ describe("ChatList", () => {
|
||||
"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();
|
||||
});
|
||||
|
||||
@@ -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)?(?:,|$)/);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const render = vi.fn();
|
||||
@@ -37,6 +38,8 @@ describe("main entry crypto shim", () => {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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 () => {
|
||||
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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user