diff --git a/webui/src/App.tsx b/webui/src/App.tsx index aa3cf0cf8..8c6127829 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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,17 +791,15 @@ function Shell({ ) : null} - {showMainSidebar ? ( - - ) : null} +
({ + 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 ( -
  • - -
  • - ); - })} -
- )} + {showPreview ? ( + + {preview} + + ) : null} + + + + ); + })} + + )} +
@@ -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) {