mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-24 18:42:35 +00:00
feat(webui): improve sidebar performance
This commit is contained in:
parent
cb7daa77db
commit
d660573b18
@ -565,6 +565,21 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
const plainCommandK =
|
||||||
|
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
|
||||||
|
if (!plainCommandK) return;
|
||||||
|
if (event.key.toLowerCase() !== "k") return;
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenSessionSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onOpenSessionSearch]);
|
||||||
|
|
||||||
const onSelectSearchResult = useCallback(
|
const onSelectSearchResult = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
setSessionSearchOpen(false);
|
setSessionSearchOpen(false);
|
||||||
@ -776,7 +791,6 @@ function Shell({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showMainSidebar ? (
|
|
||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open={sessionSearchOpen}
|
open={sessionSearchOpen}
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
@ -786,7 +800,6 @@ function Shell({
|
|||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
onSelect={onSelectSearchResult}
|
onSelect={onSelectSearchResult}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
@ -19,6 +25,9 @@ import { deriveTitle, relativeTime } from "@/lib/format";
|
|||||||
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";
|
||||||
|
|
||||||
|
const INITIAL_VISIBLE_SESSIONS = 160;
|
||||||
|
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||||
|
|
||||||
interface ChatListProps {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
@ -42,7 +51,7 @@ interface ChatListProps {
|
|||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatList({
|
export const ChatList = memo(function ChatList({
|
||||||
sessions,
|
sessions,
|
||||||
activeKey,
|
activeKey,
|
||||||
onSelect,
|
onSelect,
|
||||||
@ -65,6 +74,52 @@ export function ChatList({
|
|||||||
emptyLabel,
|
emptyLabel,
|
||||||
}: ChatListProps) {
|
}: ChatListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
||||||
|
const labels = useMemo(() => ({
|
||||||
|
pinned: t("chat.groups.pinned"),
|
||||||
|
all: t("chat.groups.all"),
|
||||||
|
today: t("chat.groups.today"),
|
||||||
|
yesterday: t("chat.groups.yesterday"),
|
||||||
|
earlier: t("chat.groups.earlier"),
|
||||||
|
archived: t("chat.groups.archived"),
|
||||||
|
fallbackTitle: t("chat.newChat"),
|
||||||
|
}), [t]);
|
||||||
|
const groups = useMemo(
|
||||||
|
() => groupSessions(sessions, labels, {
|
||||||
|
pinnedKeys,
|
||||||
|
archivedKeys,
|
||||||
|
titleOverrides,
|
||||||
|
showArchived,
|
||||||
|
sort,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
archivedKeys,
|
||||||
|
labels,
|
||||||
|
pinnedKeys,
|
||||||
|
sessions,
|
||||||
|
showArchived,
|
||||||
|
sort,
|
||||||
|
titleOverrides,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const limitedGroups = useMemo(
|
||||||
|
() => limitGroups(groups, visibleLimit, activeKey),
|
||||||
|
[activeKey, groups, visibleLimit],
|
||||||
|
);
|
||||||
|
const totalSessionCount = useMemo(
|
||||||
|
() => groups.reduce((total, group) => total + group.sessions.length, 0),
|
||||||
|
[groups],
|
||||||
|
);
|
||||||
|
const visibleSessionCount = useMemo(
|
||||||
|
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
|
||||||
|
[limitedGroups],
|
||||||
|
);
|
||||||
|
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
|
}, [showArchived, sort]);
|
||||||
|
|
||||||
if (loading && sessions.length === 0) {
|
if (loading && sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
@ -81,21 +136,6 @@ export function ChatList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = groupSessions(sessions, {
|
|
||||||
pinned: t("chat.groups.pinned"),
|
|
||||||
all: t("chat.groups.all"),
|
|
||||||
today: t("chat.groups.today"),
|
|
||||||
yesterday: t("chat.groups.yesterday"),
|
|
||||||
earlier: t("chat.groups.earlier"),
|
|
||||||
archived: t("chat.groups.archived"),
|
|
||||||
fallbackTitle: t("chat.newChat"),
|
|
||||||
}, {
|
|
||||||
pinnedKeys,
|
|
||||||
archivedKeys,
|
|
||||||
titleOverrides,
|
|
||||||
showArchived,
|
|
||||||
sort,
|
|
||||||
});
|
|
||||||
const pinned = new Set(pinnedKeys);
|
const pinned = new Set(pinnedKeys);
|
||||||
const archived = new Set(archivedKeys);
|
const archived = new Set(archivedKeys);
|
||||||
const running = new Set(runningChatIds);
|
const running = new Set(runningChatIds);
|
||||||
@ -105,7 +145,7 @@ export function ChatList({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||||
<div className="min-w-0 space-y-3 px-2 py-1.5">
|
<div className="min-w-0 space-y-3 px-2 py-1.5">
|
||||||
{groups.map((group) => (
|
{limitedGroups.map((group) => (
|
||||||
<section key={group.label} aria-label={group.label}>
|
<section key={group.label} aria-label={group.label}>
|
||||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||||
{group.label}
|
{group.label}
|
||||||
@ -228,10 +268,25 @@ export function ChatList({
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
{hiddenSessionCount > 0 ? (
|
||||||
|
<div className="px-2 pb-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleLimit((limit) =>
|
||||||
|
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
{t("chat.showMore", { count: hiddenSessionCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function SessionActivityIndicator({
|
function SessionActivityIndicator({
|
||||||
state,
|
state,
|
||||||
@ -366,6 +421,45 @@ function groupSessions(
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limitGroups(
|
||||||
|
groups: Array<{ label: string; sessions: ChatSummary[] }>,
|
||||||
|
limit: number,
|
||||||
|
activeKey: string | null,
|
||||||
|
): Array<{ label: string; sessions: ChatSummary[] }> {
|
||||||
|
let remaining = Math.max(0, limit);
|
||||||
|
let activeVisible = !activeKey;
|
||||||
|
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const visible = remaining > 0
|
||||||
|
? group.sessions.slice(0, remaining)
|
||||||
|
: [];
|
||||||
|
remaining -= visible.length;
|
||||||
|
if (activeKey && visible.some((session) => session.key === activeKey)) {
|
||||||
|
activeVisible = true;
|
||||||
|
}
|
||||||
|
if (visible.length > 0) {
|
||||||
|
out.push({ label: group.label, sessions: visible });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeVisible || !activeKey) return out;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const active = group.sessions.find((session) => session.key === activeKey);
|
||||||
|
if (!active) continue;
|
||||||
|
const existing = out.find((item) => item.label === group.label);
|
||||||
|
if (existing) {
|
||||||
|
existing.sessions = [...existing.sessions, active];
|
||||||
|
} else {
|
||||||
|
out.push({ label: group.label, sessions: [active] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function sortSessions(
|
function sortSessions(
|
||||||
sessions: ChatSummary[],
|
sessions: ChatSummary[],
|
||||||
sort: SidebarSortMode,
|
sort: SidebarSortMode,
|
||||||
|
|||||||
@ -37,13 +37,16 @@ export function SessionSearchDialog({
|
|||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const results = useMemo(() => {
|
const sessionResults = useMemo(() => {
|
||||||
|
if (!open) return [];
|
||||||
if (!normalizedQuery) return sessions;
|
if (!normalizedQuery) return sessions;
|
||||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||||
return sessions.filter((session) =>
|
return sessions.filter((session) =>
|
||||||
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
|
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
|
||||||
);
|
);
|
||||||
}, [normalizedQuery, sessions, titleOverrides]);
|
}, [normalizedQuery, open, sessions, titleOverrides]);
|
||||||
|
const itemCount = sessionResults.length;
|
||||||
|
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -58,9 +61,9 @@ export function SessionSearchDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHighlightedIndex((index) =>
|
setHighlightedIndex((index) =>
|
||||||
results.length === 0 ? 0 : Math.min(index, results.length - 1),
|
itemCount === 0 ? 0 : Math.min(index, itemCount - 1),
|
||||||
);
|
);
|
||||||
}, [results.length]);
|
}, [itemCount]);
|
||||||
|
|
||||||
const handleSelect = (key: string) => {
|
const handleSelect = (key: string) => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@ -71,17 +74,19 @@ export function SessionSearchDialog({
|
|||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHighlightedIndex((index) =>
|
setHighlightedIndex((index) =>
|
||||||
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
|
itemCount === 0 ? 0 : (index + 1) % itemCount,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "ArrowUp") {
|
if (event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHighlightedIndex((index) => Math.max(index - 1, 0));
|
setHighlightedIndex((index) =>
|
||||||
|
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
const highlighted = results[highlightedIndex];
|
const highlighted = sessionResults[highlightedIndex];
|
||||||
if (!highlighted) return;
|
if (!highlighted) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSelect(highlighted.key);
|
handleSelect(highlighted.key);
|
||||||
@ -125,9 +130,13 @@ export function SessionSearchDialog({
|
|||||||
aria-label={t("sidebar.searchAria")}
|
aria-label={t("sidebar.searchAria")}
|
||||||
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
|
||||||
/>
|
/>
|
||||||
|
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||||
|
{shortcutLabel}
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
|
||||||
|
<section>
|
||||||
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
|
||||||
{sectionLabel}
|
{sectionLabel}
|
||||||
</div>
|
</div>
|
||||||
@ -136,13 +145,13 @@ export function SessionSearchDialog({
|
|||||||
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||||||
{t("chat.loading")}
|
{t("chat.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : sessionResults.length === 0 ? (
|
||||||
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
<div className="px-3 py-7 text-[13px] text-muted-foreground">
|
||||||
{emptyLabel}
|
{emptyLabel}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{results.map((session, index) => {
|
{sessionResults.map((session, index) => {
|
||||||
const title = titleOverrides[session.key]?.trim() ||
|
const title = titleOverrides[session.key]?.trim() ||
|
||||||
session.title?.trim() ||
|
session.title?.trim() ||
|
||||||
deriveTitle(session.preview, t("chat.newChat"));
|
deriveTitle(session.preview, t("chat.newChat"));
|
||||||
@ -189,6 +198,7 @@ export function SessionSearchDialog({
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -211,3 +221,13 @@ function sessionMatchesTerms(
|
|||||||
|
|
||||||
return terms.every((term) => haystack.includes(term));
|
return terms.every((term) => haystack.includes(term));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSearchShortcutLabel() {
|
||||||
|
if (typeof navigator === "undefined") return "Ctrl K";
|
||||||
|
const platform = navigator.platform.toLowerCase();
|
||||||
|
const apple =
|
||||||
|
platform.includes("mac") ||
|
||||||
|
platform.includes("iphone") ||
|
||||||
|
platform.includes("ipad");
|
||||||
|
return apple ? "⌘K" : "Ctrl K";
|
||||||
|
}
|
||||||
|
|||||||
@ -271,6 +271,7 @@
|
|||||||
"fallbackTitle": "Chat {{id}}",
|
"fallbackTitle": "Chat {{id}}",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"noSessions": "No sessions yet.",
|
"noSessions": "No sessions yet.",
|
||||||
|
"showMore": "Show {{count}} more",
|
||||||
"actions": "Chat actions for {{title}}",
|
"actions": "Chat actions for {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "Chat {{id}}",
|
"fallbackTitle": "Chat {{id}}",
|
||||||
"loading": "Cargando…",
|
"loading": "Cargando…",
|
||||||
"noSessions": "Todavía no hay sesiones.",
|
"noSessions": "Todavía no hay sesiones.",
|
||||||
|
"showMore": "Mostrar {{count}} más",
|
||||||
"actions": "Acciones del chat {{title}}",
|
"actions": "Acciones del chat {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "Discussion {{id}}",
|
"fallbackTitle": "Discussion {{id}}",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"noSessions": "Aucune session pour le moment.",
|
"noSessions": "Aucune session pour le moment.",
|
||||||
|
"showMore": "Afficher {{count}} de plus",
|
||||||
"actions": "Actions de la discussion {{title}}",
|
"actions": "Actions de la discussion {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "Obrolan {{id}}",
|
"fallbackTitle": "Obrolan {{id}}",
|
||||||
"loading": "Memuat…",
|
"loading": "Memuat…",
|
||||||
"noSessions": "Belum ada sesi.",
|
"noSessions": "Belum ada sesi.",
|
||||||
|
"showMore": "Tampilkan {{count}} lagi",
|
||||||
"actions": "Aksi obrolan untuk {{title}}",
|
"actions": "Aksi obrolan untuk {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "チャット {{id}}",
|
"fallbackTitle": "チャット {{id}}",
|
||||||
"loading": "読み込み中…",
|
"loading": "読み込み中…",
|
||||||
"noSessions": "まだセッションがありません。",
|
"noSessions": "まだセッションがありません。",
|
||||||
|
"showMore": "さらに {{count}} 件表示",
|
||||||
"actions": "「{{title}}」のチャット操作",
|
"actions": "「{{title}}」のチャット操作",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "채팅 {{id}}",
|
"fallbackTitle": "채팅 {{id}}",
|
||||||
"loading": "불러오는 중…",
|
"loading": "불러오는 중…",
|
||||||
"noSessions": "아직 세션이 없습니다.",
|
"noSessions": "아직 세션이 없습니다.",
|
||||||
|
"showMore": "{{count}}개 더 보기",
|
||||||
"actions": "{{title}} 채팅 작업",
|
"actions": "{{title}} 채팅 작업",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "Trò chuyện {{id}}",
|
"fallbackTitle": "Trò chuyện {{id}}",
|
||||||
"loading": "Đang tải…",
|
"loading": "Đang tải…",
|
||||||
"noSessions": "Chưa có phiên nào.",
|
"noSessions": "Chưa có phiên nào.",
|
||||||
|
"showMore": "Hiển thị thêm {{count}}",
|
||||||
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
|
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
|
|||||||
@ -259,6 +259,7 @@
|
|||||||
"fallbackTitle": "对话 {{id}}",
|
"fallbackTitle": "对话 {{id}}",
|
||||||
"loading": "加载中…",
|
"loading": "加载中…",
|
||||||
"noSessions": "还没有会话。",
|
"noSessions": "还没有会话。",
|
||||||
|
"showMore": "再显示 {{count}} 个",
|
||||||
"actions": "“{{title}}” 的会话操作",
|
"actions": "“{{title}}” 的会话操作",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent 正在运行",
|
"running": "Agent 正在运行",
|
||||||
|
|||||||
@ -224,6 +224,7 @@
|
|||||||
"fallbackTitle": "對話 {{id}}",
|
"fallbackTitle": "對話 {{id}}",
|
||||||
"loading": "載入中…",
|
"loading": "載入中…",
|
||||||
"noSessions": "目前還沒有會話。",
|
"noSessions": "目前還沒有會話。",
|
||||||
|
"showMore": "再顯示 {{count}} 個",
|
||||||
"actions": "「{{title}}」的會話操作",
|
"actions": "「{{title}}」的會話操作",
|
||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent 正在執行",
|
"running": "Agent 正在執行",
|
||||||
|
|||||||
@ -992,6 +992,73 @@ describe("App layout", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens search from the keyboard shortcut", async () => {
|
||||||
|
mockSessions = [
|
||||||
|
{
|
||||||
|
key: "websocket:chat-a",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-a",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
|
preview: "Existing chat",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
||||||
|
expect(within(dialog).queryByText("Global actions")).not.toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText("Existing chat")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const textbox = within(dialog).getByRole("textbox", { name: "Search" });
|
||||||
|
fireEvent.change(textbox, { target: { value: "missing" } });
|
||||||
|
expect(within(dialog).queryByText("Existing chat")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(textbox, { target: { value: "existing" } });
|
||||||
|
expect(within(dialog).getByText("Existing chat")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(textbox, { key: "Enter" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(createChatSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps large sidebars light while search still covers every chat", async () => {
|
||||||
|
mockSessions = Array.from({ length: 170 }, (_, index) => {
|
||||||
|
const chatId = `chat-${index}`;
|
||||||
|
return {
|
||||||
|
key: `websocket:${chatId}`,
|
||||||
|
channel: "websocket" as const,
|
||||||
|
chatId,
|
||||||
|
createdAt: new Date(Date.UTC(2026, 3, 16, 12, 0 - index)).toISOString(),
|
||||||
|
updatedAt: new Date(Date.UTC(2026, 3, 16, 12, 0 - index)).toISOString(),
|
||||||
|
title: index === 169 ? "Hidden target" : `Bulk chat ${index}`,
|
||||||
|
preview: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(within(sidebar).getByRole("button", { name: "Bulk chat 0" })).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(within(sidebar).queryByText("Hidden target")).not.toBeInTheDocument();
|
||||||
|
expect(within(sidebar).getByRole("button", { name: "Show 10 more" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
||||||
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
|
||||||
|
target: { value: "hidden" },
|
||||||
|
});
|
||||||
|
expect(within(dialog).getByText("Hidden target")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a blank start page without creating an empty chat", async () => {
|
it("opens a blank start page without creating an empty chat", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -78,6 +78,7 @@ describe("webui i18n", () => {
|
|||||||
const common = resource.common;
|
const common = resource.common;
|
||||||
expect(common.app.system.restarting).toBeTruthy();
|
expect(common.app.system.restarting).toBeTruthy();
|
||||||
expect(common.sidebar.settings).toBeTruthy();
|
expect(common.sidebar.settings).toBeTruthy();
|
||||||
|
expect(common.chat.showMore).toBeTruthy();
|
||||||
expect(common.settings.sidebar.title).toBeTruthy();
|
expect(common.settings.sidebar.title).toBeTruthy();
|
||||||
expect(common.settings.backToChat).toBeTruthy();
|
expect(common.settings.backToChat).toBeTruthy();
|
||||||
for (const key of SETTINGS_NAV_KEYS) {
|
for (const key of SETTINGS_NAV_KEYS) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user