Skip to content

Commit f436d2f

Browse files
Vrtak-CZclaude
andcommitted
feat(ui): add Cmd+K global session search
Add a command palette-style search modal accessible via Cmd+K / Ctrl+K that searches session titles across all projects with client-side filtering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8179165 commit f436d2f

11 files changed

Lines changed: 733 additions & 73 deletions

File tree

src/frontend/App.css

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,139 @@
10241024
font-size: 0.75rem;
10251025
}
10261026

1027+
/* Search modal */
1028+
.search-overlay {
1029+
position: fixed;
1030+
top: 0;
1031+
left: 0;
1032+
right: 0;
1033+
bottom: 0;
1034+
background: rgba(0, 0, 0, 0.4);
1035+
z-index: 200;
1036+
display: flex;
1037+
justify-content: center;
1038+
padding-top: 15vh;
1039+
}
1040+
1041+
.search-modal {
1042+
width: 560px;
1043+
max-height: 480px;
1044+
background: var(--bg-primary);
1045+
border: 1px solid var(--border);
1046+
border-radius: var(--radius-lg);
1047+
box-shadow: var(--shadow-lg);
1048+
display: flex;
1049+
flex-direction: column;
1050+
overflow: hidden;
1051+
}
1052+
1053+
.search-input-wrapper {
1054+
padding: 12px 16px;
1055+
border-bottom: 1px solid var(--border-light);
1056+
}
1057+
1058+
.search-input {
1059+
width: 100%;
1060+
padding: 8px 0;
1061+
border: none;
1062+
background: none;
1063+
color: var(--text-primary);
1064+
font-size: 1rem;
1065+
outline: none;
1066+
font-family: inherit;
1067+
}
1068+
1069+
.search-input::placeholder {
1070+
color: var(--text-muted);
1071+
}
1072+
1073+
.search-results {
1074+
flex: 1;
1075+
overflow-y: auto;
1076+
padding: 4px 0;
1077+
}
1078+
1079+
.search-result-item {
1080+
padding: 8px 16px;
1081+
cursor: pointer;
1082+
transition: background 0.1s;
1083+
}
1084+
1085+
.search-result-item:hover,
1086+
.search-result-item.highlighted {
1087+
background: var(--bg-secondary);
1088+
}
1089+
1090+
.search-result-title {
1091+
font-size: 0.85rem;
1092+
font-weight: 500;
1093+
color: var(--text-primary);
1094+
overflow: hidden;
1095+
text-overflow: ellipsis;
1096+
white-space: nowrap;
1097+
display: flex;
1098+
align-items: center;
1099+
gap: 6px;
1100+
}
1101+
1102+
.search-result-meta {
1103+
font-size: 0.75rem;
1104+
color: var(--text-muted);
1105+
margin-top: 2px;
1106+
overflow: hidden;
1107+
text-overflow: ellipsis;
1108+
white-space: nowrap;
1109+
}
1110+
1111+
.search-empty {
1112+
padding: 24px 16px;
1113+
text-align: center;
1114+
color: var(--text-muted);
1115+
font-size: 0.85rem;
1116+
}
1117+
1118+
.search-footer {
1119+
padding: 8px 16px;
1120+
border-top: 1px solid var(--border-light);
1121+
font-size: 0.7rem;
1122+
color: var(--text-muted);
1123+
display: flex;
1124+
gap: 16px;
1125+
}
1126+
1127+
.search-footer kbd {
1128+
display: inline-block;
1129+
padding: 1px 5px;
1130+
background: var(--bg-tertiary);
1131+
border-radius: var(--radius-sm);
1132+
font-size: 0.65rem;
1133+
font-family: inherit;
1134+
}
1135+
1136+
/* Sidebar search button */
1137+
.btn-sidebar-search {
1138+
margin-left: auto;
1139+
padding: 4px 8px;
1140+
border: 1px solid var(--border);
1141+
border-radius: var(--radius-sm);
1142+
background: var(--bg-primary);
1143+
color: var(--text-muted);
1144+
font-size: 0.75rem;
1145+
cursor: pointer;
1146+
display: flex;
1147+
align-items: center;
1148+
gap: 4px;
1149+
transition:
1150+
color 0.15s,
1151+
border-color 0.15s;
1152+
font-family: inherit;
1153+
}
1154+
1155+
.btn-sidebar-search:hover {
1156+
color: var(--text-primary);
1157+
border-color: var(--text-muted);
1158+
}
1159+
10271160
/* Fullscreen */
10281161
.fullscreen {
10291162
position: fixed;

src/frontend/App.tsx

Lines changed: 123 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type React from "react";
22
import { useCallback, useEffect, useState } from "react";
33
import { createRoot } from "react-dom/client";
44
import faviconUrl from "../../favicon.svg";
5-
import type { Project, SessionSummary } from "../shared/types.ts";
5+
import type { GlobalSessionResult, Project, SessionSummary } from "../shared/types.ts";
66
import { DashboardStats } from "./components/dashboard/DashboardStats.tsx";
77
import { Header } from "./components/layout/Header.tsx";
88
import { Layout } from "./components/layout/Layout.tsx";
99
import { SubAgentView } from "./components/message/SubAgentView.tsx";
1010
import { HiddenProjectList } from "./components/project/HiddenProjectList.tsx";
1111
import { ProjectList } from "./components/project/ProjectList.tsx";
1212
import { SessionList } from "./components/project/SessionList.tsx";
13+
import { SearchModal } from "./components/search/SearchModal.tsx";
1314
import { SessionPresentation } from "./components/session/SessionPresentation.tsx";
1415
import { SessionView } from "./components/session/SessionView.tsx";
1516
import { SubAgentPresentation } from "./components/session/SubAgentPresentation.tsx";
@@ -156,6 +157,8 @@ function App() {
156157
const { hiddenIds, hide, unhide } = useHiddenProjects();
157158
const [view, setView] = useState<ViewState>({ kind: "home" });
158159
const [ready, setReady] = useState(false);
160+
const [searchOpen, setSearchOpen] = useState(false);
161+
const [searchSessions, setSearchSessions] = useState<GlobalSessionResult[]>([]);
159162

160163
// Restore view from URL hash on mount
161164
useEffect(() => {
@@ -208,12 +211,56 @@ function App() {
208211
}
209212
}, [view]);
210213

214+
const fetchSearchSessions = useCallback(() => {
215+
fetch("/api/search/sessions")
216+
.then((res) => res.json())
217+
.then((data: { sessions: GlobalSessionResult[] }) => setSearchSessions(data.sessions))
218+
.catch(() => {});
219+
}, []);
220+
221+
const openSearch = useCallback(() => {
222+
setSearchOpen(true);
223+
fetchSearchSessions();
224+
}, [fetchSearchSessions]);
225+
226+
const handleSearchSelect = useCallback(async (encodedPath: string, sessionId: string) => {
227+
setSearchOpen(false);
228+
try {
229+
const [projectsRes, sessionsRes] = await Promise.all([
230+
fetch("/api/projects"),
231+
fetch(`/api/projects/${encodedPath}/sessions`),
232+
]);
233+
const projectsData = await projectsRes.json();
234+
const sessionsData = await sessionsRes.json();
235+
const project = projectsData.projects.find((p: Project) => p.encodedPath === encodedPath);
236+
const session = sessionsData.sessions.find((s: SessionSummary) => s.sessionId === sessionId);
237+
if (project && session) {
238+
setView({ kind: "session", project, session, presenting: false });
239+
}
240+
} catch {
241+
// ignore
242+
}
243+
}, []);
244+
245+
// Cmd+K / Ctrl+K toggles search
246+
useEffect(() => {
247+
function handleCmdK(e: KeyboardEvent) {
248+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
249+
e.preventDefault();
250+
setSearchOpen((prev) => {
251+
if (!prev) fetchSearchSessions();
252+
return !prev;
253+
});
254+
}
255+
}
256+
window.addEventListener("keydown", handleCmdK);
257+
return () => window.removeEventListener("keydown", handleCmdK);
258+
}, [fetchSearchSessions]);
259+
211260
// Global keyboard shortcuts: p = toggle presentation, +/- = font size
212261
useEffect(() => {
213262
function handleKeyDown(e: KeyboardEvent) {
214-
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
215-
return;
216-
}
263+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
217264
if (e.ctrlKey || e.metaKey || e.altKey) return;
218265

219266
switch (e.key) {
@@ -257,71 +304,80 @@ function App() {
257304
}
258305

259306
return (
260-
<Layout sidebar={sidebarContent} hideSidebar={isPresenting}>
261-
<Header
262-
title={headerTitle}
263-
breadcrumb={breadcrumb}
264-
copyCommand={
265-
view.kind === "session" ? `claude --resume ${view.session.sessionId}` : undefined
266-
}
267-
backHref={
268-
view.kind === "subagent" ? `#/${view.project.encodedPath}/${view.sessionId}` : undefined
269-
}
270-
sessionType={view.kind === "session" ? view.session.sessionType : undefined}
271-
themeSetting={themeSetting}
272-
onCycleTheme={cycleTheme}
273-
fontSize={fontSize}
274-
onIncreaseFontSize={increase}
275-
onDecreaseFontSize={decrease}
276-
presentationActive={isPresenting}
277-
onTogglePresentation={togglePresentation}
278-
showPresentationToggle={canPresent}
279-
/>
280-
{view.kind === "home" && (
281-
<>
307+
<>
308+
{searchOpen && (
309+
<SearchModal
310+
sessions={searchSessions}
311+
onSelect={handleSearchSelect}
312+
onClose={() => setSearchOpen(false)}
313+
/>
314+
)}
315+
<Layout sidebar={sidebarContent} hideSidebar={isPresenting} onSearchClick={openSearch}>
316+
<Header
317+
title={headerTitle}
318+
breadcrumb={breadcrumb}
319+
copyCommand={
320+
view.kind === "session" ? `claude --resume ${view.session.sessionId}` : undefined
321+
}
322+
backHref={
323+
view.kind === "subagent" ? `#/${view.project.encodedPath}/${view.sessionId}` : undefined
324+
}
325+
sessionType={view.kind === "session" ? view.session.sessionType : undefined}
326+
themeSetting={themeSetting}
327+
onCycleTheme={cycleTheme}
328+
fontSize={fontSize}
329+
onIncreaseFontSize={increase}
330+
onDecreaseFontSize={decrease}
331+
presentationActive={isPresenting}
332+
onTogglePresentation={togglePresentation}
333+
showPresentationToggle={canPresent}
334+
/>
335+
{view.kind === "home" && (
336+
<>
337+
<div className="empty-state">
338+
<img src={faviconUrl} alt="" width="64" height="64" className="empty-state-logo" />
339+
<div className="empty-state-title">Welcome to Klovi</div>
340+
<p>Select a project from the sidebar to browse your Claude Code sessions</p>
341+
</div>
342+
<DashboardStats />
343+
</>
344+
)}
345+
{view.kind === "hidden" && (
346+
<HiddenProjectList hiddenIds={hiddenIds} onUnhide={unhide} onBack={goHome} />
347+
)}
348+
{view.kind === "project" && (
282349
<div className="empty-state">
283-
<img src={faviconUrl} alt="" width="64" height="64" className="empty-state-logo" />
284-
<div className="empty-state-title">Welcome to Klovi</div>
285-
<p>Select a project from the sidebar to browse your Claude Code sessions</p>
350+
<div className="empty-state-title">Select a session</div>
351+
<p>Choose a conversation from the sidebar</p>
286352
</div>
287-
<DashboardStats />
288-
</>
289-
)}
290-
{view.kind === "hidden" && (
291-
<HiddenProjectList hiddenIds={hiddenIds} onUnhide={unhide} onBack={goHome} />
292-
)}
293-
{view.kind === "project" && (
294-
<div className="empty-state">
295-
<div className="empty-state-title">Select a session</div>
296-
<p>Choose a conversation from the sidebar</p>
297-
</div>
298-
)}
299-
{view.kind === "session" &&
300-
(view.presenting ? (
301-
<SessionPresentation
302-
sessionId={view.session.sessionId}
303-
project={view.project.encodedPath}
304-
onExit={togglePresentation}
305-
/>
306-
) : (
307-
<SessionView sessionId={view.session.sessionId} project={view.project.encodedPath} />
308-
))}
309-
{view.kind === "subagent" &&
310-
(view.presenting ? (
311-
<SubAgentPresentation
312-
sessionId={view.sessionId}
313-
project={view.project.encodedPath}
314-
agentId={view.agentId}
315-
onExit={togglePresentation}
316-
/>
317-
) : (
318-
<SubAgentView
319-
sessionId={view.sessionId}
320-
project={view.project.encodedPath}
321-
agentId={view.agentId}
322-
/>
323-
))}
324-
</Layout>
353+
)}
354+
{view.kind === "session" &&
355+
(view.presenting ? (
356+
<SessionPresentation
357+
sessionId={view.session.sessionId}
358+
project={view.project.encodedPath}
359+
onExit={togglePresentation}
360+
/>
361+
) : (
362+
<SessionView sessionId={view.session.sessionId} project={view.project.encodedPath} />
363+
))}
364+
{view.kind === "subagent" &&
365+
(view.presenting ? (
366+
<SubAgentPresentation
367+
sessionId={view.sessionId}
368+
project={view.project.encodedPath}
369+
agentId={view.agentId}
370+
onExit={togglePresentation}
371+
/>
372+
) : (
373+
<SubAgentView
374+
sessionId={view.sessionId}
375+
project={view.project.encodedPath}
376+
agentId={view.agentId}
377+
/>
378+
))}
379+
</Layout>
380+
</>
325381
);
326382
}
327383

src/frontend/components/layout/Layout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import { Sidebar } from "./Sidebar.tsx";
44
interface LayoutProps {
55
sidebar: React.ReactNode;
66
hideSidebar?: boolean;
7+
onSearchClick?: () => void;
78
children: React.ReactNode;
89
}
910

10-
export function Layout({ sidebar, hideSidebar, children }: LayoutProps) {
11+
export function Layout({ sidebar, hideSidebar, onSearchClick, children }: LayoutProps) {
1112
return (
1213
<div className={`app-layout ${hideSidebar ? "sidebar-hidden" : ""}`}>
13-
<Sidebar>{sidebar}</Sidebar>
14+
<Sidebar onSearchClick={onSearchClick}>{sidebar}</Sidebar>
1415
<div className="main-content">{children}</div>
1516
</div>
1617
);

0 commit comments

Comments
 (0)