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 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
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"));
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user