feat(webui): improve sidebar performance

This commit is contained in:
Xubin Ren 2026-05-22 01:55:37 +08:00
parent cb7daa77db
commit d660573b18
14 changed files with 299 additions and 95 deletions

View File

@ -565,6 +565,21 @@ function Shell({
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(
(key: string) => {
setSessionSearchOpen(false);
@ -776,7 +791,6 @@ function Shell({
</Sheet>
) : null}
{showMainSidebar ? (
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
@ -786,7 +800,6 @@ function Shell({
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div

View File

@ -1,3 +1,9 @@
import {
memo,
useEffect,
useMemo,
useState,
} from "react";
import {
Archive,
ArchiveRestore,
@ -19,6 +25,9 @@ import { deriveTitle, relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160;
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
@ -42,7 +51,7 @@ interface ChatListProps {
emptyLabel?: string;
}
export function ChatList({
export const ChatList = memo(function ChatList({
sessions,
activeKey,
onSelect,
@ -65,6 +74,52 @@ export function ChatList({
emptyLabel,
}: ChatListProps) {
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) {
return (
<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 archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
@ -105,7 +145,7 @@ export function ChatList({
return (
<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">
{groups.map((group) => (
{limitedGroups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
@ -228,10 +268,25 @@ export function ChatList({
</ul>
</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>
);
}
});
function SessionActivityIndicator({
state,
@ -366,6 +421,45 @@ function groupSessions(
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(
sessions: ChatSummary[],
sort: SidebarSortMode,

View File

@ -37,13 +37,16 @@ export function SessionSearchDialog({
const [highlightedIndex, setHighlightedIndex] = useState(0);
const normalizedQuery = query.trim().toLowerCase();
const results = useMemo(() => {
const sessionResults = useMemo(() => {
if (!open) return [];
if (!normalizedQuery) return sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return sessions.filter((session) =>
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
);
}, [normalizedQuery, sessions, titleOverrides]);
}, [normalizedQuery, open, sessions, titleOverrides]);
const itemCount = sessionResults.length;
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
useEffect(() => {
if (!open) return;
@ -58,9 +61,9 @@ export function SessionSearchDialog({
useEffect(() => {
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) => {
onOpenChange(false);
@ -71,17 +74,19 @@ export function SessionSearchDialog({
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
itemCount === 0 ? 0 : (index + 1) % itemCount,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) => Math.max(index - 1, 0));
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
);
return;
}
if (event.key === "Enter") {
const highlighted = results[highlightedIndex];
const highlighted = sessionResults[highlightedIndex];
if (!highlighted) return;
event.preventDefault();
handleSelect(highlighted.key);
@ -125,9 +130,13 @@ export function SessionSearchDialog({
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"
/>
<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 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">
{sectionLabel}
</div>
@ -136,13 +145,13 @@ export function SessionSearchDialog({
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : results.length === 0 ? (
) : sessionResults.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
{results.map((session, index) => {
{sessionResults.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
@ -189,6 +198,7 @@ export function SessionSearchDialog({
})}
</ul>
)}
</section>
</div>
</DialogContent>
</Dialog>
@ -211,3 +221,13 @@ function sessionMatchesTerms(
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";
}

View File

@ -271,6 +271,7 @@
"fallbackTitle": "Chat {{id}}",
"loading": "Loading…",
"noSessions": "No sessions yet.",
"showMore": "Show {{count}} more",
"actions": "Chat actions for {{title}}",
"activity": {
"running": "Agent running",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "Chat {{id}}",
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"showMore": "Mostrar {{count}} más",
"actions": "Acciones del chat {{title}}",
"activity": {
"running": "Agent running",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "Discussion {{id}}",
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"showMore": "Afficher {{count}} de plus",
"actions": "Actions de la discussion {{title}}",
"activity": {
"running": "Agent running",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "Obrolan {{id}}",
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"showMore": "Tampilkan {{count}} lagi",
"actions": "Aksi obrolan untuk {{title}}",
"activity": {
"running": "Agent running",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "チャット {{id}}",
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"showMore": "さらに {{count}} 件表示",
"actions": "「{{title}}」のチャット操作",
"activity": {
"running": "Agent running",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "채팅 {{id}}",
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"showMore": "{{count}}개 더 보기",
"actions": "{{title}} 채팅 작업",
"activity": {
"running": "Agent running",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "Trò chuyện {{id}}",
"loading": "Đang tải…",
"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}}",
"activity": {
"running": "Agent running",

View File

@ -259,6 +259,7 @@
"fallbackTitle": "对话 {{id}}",
"loading": "加载中…",
"noSessions": "还没有会话。",
"showMore": "再显示 {{count}} 个",
"actions": "“{{title}}” 的会话操作",
"activity": {
"running": "Agent 正在运行",

View File

@ -224,6 +224,7 @@
"fallbackTitle": "對話 {{id}}",
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"showMore": "再顯示 {{count}} 個",
"actions": "「{{title}}」的會話操作",
"activity": {
"running": "Agent 正在執行",

View File

@ -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 () => {
mockSessions = [
{

View File

@ -78,6 +78,7 @@ describe("webui i18n", () => {
const common = resource.common;
expect(common.app.system.restarting).toBeTruthy();
expect(common.sidebar.settings).toBeTruthy();
expect(common.chat.showMore).toBeTruthy();
expect(common.settings.sidebar.title).toBeTruthy();
expect(common.settings.backToChat).toBeTruthy();
for (const key of SETTINGS_NAV_KEYS) {