diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 51c761a..d3ae3a4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -7,6 +7,7 @@ import { issueSummary } from "@/client/api";
import { SettingsModalContext } from "@/components/settings/SettingsModalContext";
import { SettingsModal } from "@/components/settings/SettingsModal";
import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache";
+import { GlobalSearchPalette } from "@/components/search/GlobalSearchPalette";
import { initWsClient, getWsClient } from "@/ws/hooks";
import type { WsClient } from "@/ws/client";
@@ -94,6 +95,7 @@ export function RootLayout() {
+
{showSettingsModal && }
diff --git a/src/components/search/GlobalSearchPalette.tsx b/src/components/search/GlobalSearchPalette.tsx
new file mode 100644
index 0000000..f36d4f5
--- /dev/null
+++ b/src/components/search/GlobalSearchPalette.tsx
@@ -0,0 +1,248 @@
+import { useEffect, useMemo, useState } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Boxes,
+ FolderGit2,
+ Hash,
+ Kanban,
+ LayoutDashboard,
+ MessageSquare,
+ Search,
+ Settings,
+ Sparkles,
+ Ticket,
+} from "lucide-react";
+import { search as globalSearch } from "@/client/api";
+import { useBoardsQuery } from "@/hooks/useBoardsQuery";
+import { useProjectInfo } from "@/hooks/useProjectInfo";
+import { useProjectsQuery } from "@/hooks/useProjectsQuery";
+import { useProjectReposQuery } from "@/hooks/useReposQuery";
+import { useRoomsQuery } from "@/hooks/useRoomsQuery";
+import { isProjectAdminRole } from "@/lib/project-permissions";
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "@/components/ui/command";
+import { OPEN_GLOBAL_SEARCH_EVENT } from "@/components/search/global-search-events";
+
+function useDebouncedValue(value: string, delay = 180) {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setDebounced(value), delay);
+ return () => window.clearTimeout(timer);
+ }, [delay, value]);
+
+ return debounced;
+}
+
+function getProjectNameFromPath(pathname: string) {
+ const first = pathname.split("/").filter(Boolean)[0];
+ if (!first || first === "me" || first === "auth" || first === "explore") return undefined;
+ return first;
+}
+
+export function GlobalSearchPalette() {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const debouncedQuery = useDebouncedValue(query.trim());
+ const location = useLocation();
+ const navigate = useNavigate();
+ const activeProjectName = getProjectNameFromPath(location.pathname);
+
+ const { data: projects = [] } = useProjectsQuery();
+ const { data: projectInfo } = useProjectInfo(activeProjectName);
+ const isProjectMember = !!projectInfo?.role;
+ const canManageProject = isProjectAdminRole(projectInfo?.role);
+ const { data: repos = [] } = useProjectReposQuery(isProjectMember ? activeProjectName : undefined);
+ const { data: roomsData } = useRoomsQuery(isProjectMember ? activeProjectName : undefined);
+ const { data: boards = [] } = useBoardsQuery(isProjectMember ? activeProjectName : undefined);
+
+ const remoteResults = useQuery({
+ queryKey: ["global-search-palette", debouncedQuery],
+ queryFn: async () => {
+ const res = await globalSearch({ q: debouncedQuery, type: "projects,repos,issues", page: 1, per_page: 8 });
+ return res.data?.data ?? null;
+ },
+ enabled: open && debouncedQuery.length >= 2,
+ staleTime: 30_000,
+ });
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.ctrlKey && event.altKey && event.key.toLowerCase() === "f") {
+ event.preventDefault();
+ setOpen((current) => !current);
+ }
+ };
+ const onOpen = () => setOpen(true);
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener(OPEN_GLOBAL_SEARCH_EVENT, onOpen);
+ };
+ }, []);
+
+ const rooms = roomsData?.rooms ?? [];
+ const quickActions = useMemo(() => {
+ if (!activeProjectName) {
+ return [
+ { label: "My Home", description: "Open personal overview", path: "/me", icon: LayoutDashboard },
+ { label: "Explore Projects", description: "Find public projects", path: "/explore", icon: Search },
+ { label: "Chat", description: "Open personal chat", path: "/me/chat", icon: MessageSquare },
+ ];
+ }
+
+ return [
+ { label: "Project Overview", description: activeProjectName, path: `/${activeProjectName}`, icon: LayoutDashboard },
+ { label: "Repositories", description: activeProjectName, path: `/${activeProjectName}/repos`, icon: FolderGit2 },
+ { label: "Issues", description: activeProjectName, path: `/${activeProjectName}/issues`, icon: Ticket },
+ { label: "Boards", description: activeProjectName, path: `/${activeProjectName}/board`, icon: Kanban },
+ { label: "Chat", description: activeProjectName, path: `/${activeProjectName}/chat`, icon: MessageSquare },
+ ...(canManageProject
+ ? [{ label: "Project Settings", description: activeProjectName, path: `/${activeProjectName}/settings`, icon: Settings }]
+ : []),
+ ];
+ }, [activeProjectName, canManageProject]);
+
+ const go = (path: string) => {
+ setOpen(false);
+ setQuery("");
+ navigate(path);
+ };
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) setQuery("");
+ };
+
+ return (
+
+
+
+ No results found.
+
+
+ {quickActions.map((item) => (
+ go(item.path)}>
+
+ {item.label}
+ {item.description}
+
+ ))}
+
+
+
+
+
+ {projects.map((project) => (
+ go(`/${project.name}`)}
+ >
+
+ {project.display_name || project.name}
+ {project.name}
+
+ ))}
+
+
+ {activeProjectName && (
+ <>
+
+
+ {rooms.map((room) => (
+ go(`/${activeProjectName}/channel/${room.id}`)}
+ >
+
+ {room.room_name}
+ room
+
+ ))}
+
+
+
+ {repos.map((repo) => (
+ go(`/${activeProjectName}/repo/${repo.repo_name}`)}
+ >
+
+ {repo.repo_name}
+ repo
+
+ ))}
+
+
+
+ {boards.map((board) => (
+ go(`/${activeProjectName}/board/${board.id}`)}
+ >
+
+ {board.name}
+ board
+
+ ))}
+
+ >
+ )}
+
+ {remoteResults.data && (
+ <>
+
+
+ {(remoteResults.data.projects?.items ?? []).map((project) => (
+ go(`/${project.name}`)}>
+
+ {project.display_name || project.name}
+ project
+
+ ))}
+ {(remoteResults.data.repos?.items ?? []).map((repo) => (
+ go(`/${repo.project_name}/repo/${repo.name}`)}>
+
+ {repo.name}
+ {repo.project_name}
+
+ ))}
+ {(remoteResults.data.issues?.items ?? []).map((issue) => (
+ go(`/${issue.project_name}/issues/${issue.number}`)}>
+
+ #{issue.number} {issue.title}
+ issue
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/search/global-search-events.ts b/src/components/search/global-search-events.ts
new file mode 100644
index 0000000..139c759
--- /dev/null
+++ b/src/components/search/global-search-events.ts
@@ -0,0 +1,5 @@
+export const OPEN_GLOBAL_SEARCH_EVENT = "app:open-global-search";
+
+export function openGlobalSearch() {
+ window.dispatchEvent(new Event(OPEN_GLOBAL_SEARCH_EVENT));
+}
diff --git a/src/lib/project-permissions.ts b/src/lib/project-permissions.ts
new file mode 100644
index 0000000..61e778f
--- /dev/null
+++ b/src/lib/project-permissions.ts
@@ -0,0 +1,10 @@
+import type { MemberRole } from "@/client/model";
+
+export function isProjectAdminRole(role?: MemberRole | string | null) {
+ const normalized = String(role ?? "").toLowerCase();
+ return normalized === "owner" || normalized === "admin";
+}
+
+export function isProjectOwnerRole(role?: MemberRole | string | null) {
+ return String(role ?? "").toLowerCase() === "owner";
+}