({
+ 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 (
@@ -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 (
- {groups.map((group) => (
+ {limitedGroups.map((group) => (
{group.label}
@@ -228,10 +268,25 @@ export function ChatList({
))}
+ {hiddenSessionCount > 0 ? (
+
+
+
+ ) : null}
);
-}
+});
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,
diff --git a/webui/src/components/SessionSearchDialog.tsx b/webui/src/components/SessionSearchDialog.tsx
index 3ddea0672..1e0f12044 100644
--- a/webui/src/components/SessionSearchDialog.tsx
+++ b/webui/src/components/SessionSearchDialog.tsx
@@ -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,70 +130,75 @@ 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"
/>
+
+ {shortcutLabel}
+
-
- {sectionLabel}
-
+
+
+ {sectionLabel}
+
- {loading && sessions.length === 0 ? (
-
- {t("chat.loading")}
-
- ) : results.length === 0 ? (
-
- {emptyLabel}
-
- ) : (
-
- {results.map((session, index) => {
- const title = titleOverrides[session.key]?.trim() ||
- session.title?.trim() ||
- deriveTitle(session.preview, t("chat.newChat"));
- const preview = session.preview.trim();
- const showPreview =
- preview.length > 0 &&
- preview.toLowerCase() !== title.trim().toLowerCase();
- const highlighted = index === highlightedIndex;
- const active = session.key === activeKey;
- return (
- -
-
+
+ );
+ })}
+
+ )}
+
@@ -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";
+}
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index f44332f95..68822ddd5 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index e658803e1..e031b3eef 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index a6f80e729..d49edf640 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index e64db6029..41901b475 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index c1bacd12b..b061c6c79 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -224,6 +224,7 @@
"fallbackTitle": "チャット {{id}}",
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
+ "showMore": "さらに {{count}} 件表示",
"actions": "「{{title}}」のチャット操作",
"activity": {
"running": "Agent running",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index f94936648..86a6c908e 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -224,6 +224,7 @@
"fallbackTitle": "채팅 {{id}}",
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
+ "showMore": "{{count}}개 더 보기",
"actions": "{{title}} 채팅 작업",
"activity": {
"running": "Agent running",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 805e82ced..25d4719b6 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 18089c7ad..1ac9fde0e 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -259,6 +259,7 @@
"fallbackTitle": "对话 {{id}}",
"loading": "加载中…",
"noSessions": "还没有会话。",
+ "showMore": "再显示 {{count}} 个",
"actions": "“{{title}}” 的会话操作",
"activity": {
"running": "Agent 正在运行",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index cf1cd4aa6..d4360af14 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -224,6 +224,7 @@
"fallbackTitle": "對話 {{id}}",
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
+ "showMore": "再顯示 {{count}} 個",
"actions": "「{{title}}」的會話操作",
"activity": {
"running": "Agent 正在執行",
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index c7ee44a06..1ae4d29c2 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -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(
);
+
+ 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(
);
+
+ 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 = [
{
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index d1359121c..e92c42500 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -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) {