From 1ced497584b17c4906cd089a7c0d8784b5fe592b Mon Sep 17 00:00:00 2001
From: Will Chen <7344640+wwwillchen@users.noreply.github.com>
Date: Thu, 20 Aug 2026 15:14:55 -0700
Subject: [PATCH 1/6] Add scoped global command palette
---
e2e-tests/app_search.spec.ts | 30 +-
e2e-tests/command_palette.spec.ts | 46 ++
src/app/TitleBar.tsx | 22 +-
src/app/layout.tsx | 53 ++-
src/components/AppSearchDialog.tsx | 14 -
src/components/ChatSearchDialog.tsx | 11 -
src/components/CommandPalette.tsx | 421 ++++++++++++++++++
src/components/chat/HomeChatInput.tsx | 1 -
.../preview_panel/ConfigurePanel.tsx | 10 +-
src/lib/commandPalette.test.ts | 55 +++
src/lib/commandPalette.ts | 71 +++
src/lib/settingsSearchIndex.ts | 2 +-
src/pages/app-details.tsx | 92 ++--
13 files changed, 740 insertions(+), 88 deletions(-)
create mode 100644 e2e-tests/command_palette.spec.ts
create mode 100644 src/components/CommandPalette.tsx
create mode 100644 src/lib/commandPalette.test.ts
create mode 100644 src/lib/commandPalette.ts
diff --git a/e2e-tests/app_search.spec.ts b/e2e-tests/app_search.spec.ts
index b339c419cc..f77b20ae8f 100644
--- a/e2e-tests/app_search.spec.ts
+++ b/e2e-tests/app_search.spec.ts
@@ -35,12 +35,12 @@ test("app search - basic search dialog functionality", async ({ po }) => {
const dialog = po.page.getByTestId("app-search-dialog");
await dialog.waitFor({ state: "visible", timeout: 10000 });
- // Test 2: Close dialog with Ctrl+K (shortcut toggles)
- await po.page.keyboard.press("Control+k");
+ // Test 2: Close the dedicated dialog with Escape
+ await po.page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden", timeout: 5000 });
- // Test 3: Open dialog again with Ctrl+K (shortcut toggles)
- await po.page.keyboard.press("Control+k");
+ // Test 3: Open the dedicated dialog again from its button
+ await searchButton.click();
await dialog.waitFor({ state: "visible", timeout: 10000 });
// Test 4: Search for specific term
@@ -103,28 +103,26 @@ test("app search - search functionality with different terms", async ({
await po.page.keyboard.press("Escape");
});
-test("app search - keyboard shortcut functionality", async ({ po }) => {
+test("app search - command shortcut is owned by the global palette", async ({
+ po,
+}) => {
await po.setUp({ autoApprove: true });
// Create an app first
await po.sendPrompt("create sample application", { timeout: Timeout.LONG });
await po.navigation.goToAppsTab();
- // Test keyboard shortcut (Ctrl+K) to open dialog
+ // Ctrl+K opens the chat-scoped global palette, not the app search dialog.
await po.page.keyboard.press("Control+k");
- await po.page.getByTestId("app-search-dialog").waitFor();
+ await po.page.getByTestId("command-palette").waitFor();
+ await po.page
+ .getByTestId("command-palette-input")
+ .waitFor({ state: "visible" });
+ await po.page.getByTestId("app-search-dialog").waitFor({ state: "hidden" });
// Close with escape
await po.page.keyboard.press("Escape");
- await po.page.getByTestId("app-search-dialog").waitFor({ state: "hidden" });
-
- // Test keyboard shortcut again
- await po.page.keyboard.press("Control+k");
- await po.page.getByTestId("app-search-dialog").waitFor();
-
- // Close with Ctrl+K (toggle)
- await po.page.keyboard.press("Control+k");
- await po.page.getByTestId("app-search-dialog").waitFor({ state: "hidden" });
+ await po.page.getByTestId("command-palette").waitFor({ state: "hidden" });
});
test("app search - navigation and selection", async ({ po }) => {
diff --git a/e2e-tests/command_palette.spec.ts b/e2e-tests/command_palette.spec.ts
new file mode 100644
index 0000000000..8d7c055f5b
--- /dev/null
+++ b/e2e-tests/command_palette.spec.ts
@@ -0,0 +1,46 @@
+import { expect } from "@playwright/test";
+import { test } from "./helpers/test_helper";
+
+test("command palette supports scoped chat and unfiltered configuration search", async ({
+ po,
+}) => {
+ await po.setUp();
+ await po.sendPrompt("tc=1");
+
+ await po.page.keyboard.press("Control+k");
+ const palette = po.page.getByTestId("command-palette");
+ const input = po.page.getByTestId("command-palette-input");
+ await expect(palette).toBeVisible();
+ await expect(input).toHaveValue("chat: ");
+
+ await input.fill("chat: tc=1");
+ const chatResult = po.page.getByTestId(/^command-palette-chat-/).first();
+ await expect(chatResult).toBeVisible();
+ await chatResult.click();
+ await expect(palette).not.toBeVisible();
+
+ await po.page.keyboard.press("Control+p");
+ await expect(input).toHaveValue("");
+ await input.fill("Theme");
+ await po.page.getByTestId("command-palette-setting-setting-theme").click();
+ await expect(po.page).toHaveURL(/\/settings/);
+ await expect(po.page.locator("#setting-theme")).toHaveClass(
+ /settings-highlight/,
+ );
+
+ await po.page.keyboard.press("Control+p");
+ await input.fill("environment variables");
+ await po.page
+ .getByTestId("command-palette-app-setting-environment-variables")
+ .click();
+ await expect(po.page).toHaveURL(/\/chat/);
+ await expect(
+ po.page.locator("#app-config-environment-variables"),
+ ).toBeVisible();
+ await expect(
+ po.page.locator("#app-config-environment-variables"),
+ ).toHaveClass(/settings-highlight/);
+
+ await po.page.getByTestId("command-palette-trigger").click();
+ await expect(input).toHaveValue("");
+});
diff --git a/src/app/TitleBar.tsx b/src/app/TitleBar.tsx
index 6d260ac3a1..85e39b6030 100644
--- a/src/app/TitleBar.tsx
+++ b/src/app/TitleBar.tsx
@@ -30,8 +30,13 @@ import {
useFirstPromptSaga,
} from "@/first_prompt/FirstPromptProvider";
import type { UserSettings } from "@/lib/schemas";
+import { Search } from "lucide-react";
-export const TitleBar = () => {
+export const TitleBar = ({
+ onOpenCommandPalette,
+}: {
+ onOpenCommandPalette?: () => void;
+}) => {
const [selectedAppId] = useAtom(selectedAppIdAtom);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const { hasArmedPayload } = useFirstPromptSaga();
@@ -129,6 +134,21 @@ export const TitleBar = () => {
{displayText}
{isDyadPro && }
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1e74eb01da..4152864014 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,7 +4,13 @@ import { ThemeProvider } from "../contexts/ThemeContext";
import { DeepLinkProvider } from "../contexts/DeepLinkContext";
import { Toaster } from "sonner";
import { TitleBar } from "./TitleBar";
-import { useEffect, useMemo, type ReactNode } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import { useAppOutputSubscription } from "@/hooks/useRunApp";
import { useAtomValue, useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
@@ -49,6 +55,8 @@ import { useSyncDefaultChatMode } from "@/hooks/useSyncDefaultChatMode";
import { PreviewErrorFacadeProvider } from "@/app_wiring/preview_error_facade";
import { usePreviewErrorFacade } from "@/app_wiring/preview_error_facade";
import { PackageManagerWarningProvider } from "@/package_manager_warnings/PackageManagerWarningProvider";
+import { CommandPalette } from "@/components/CommandPalette";
+import { CHAT_SCOPE_PREFIX } from "@/lib/commandPalette";
export default function RootLayout({ children }: { children: ReactNode }) {
const { streamMessage } = useStreamChat({ hasChatId: false });
@@ -111,8 +119,43 @@ function RootLayoutContent({ children }: { children: ReactNode }) {
selectedComponentsPreviewAtom,
);
const selectedAppId = useAtomValue(selectedAppIdAtom);
+ const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
+ const [commandPaletteQuery, setCommandPaletteQuery] = useState("");
useSyncDefaultChatMode();
+ const openCommandPalette = useCallback((query: string = "") => {
+ setCommandPaletteQuery(query);
+ setIsCommandPaletteOpen(true);
+ }, []);
+
+ const handleCommandPaletteOpenChange = useCallback((open: boolean) => {
+ setIsCommandPaletteOpen(open);
+ if (!open) setCommandPaletteQuery("");
+ }, []);
+
+ useEffect(() => {
+ const handleCommandPaletteShortcut = (event: KeyboardEvent) => {
+ if (
+ !(event.metaKey || event.ctrlKey) ||
+ event.altKey ||
+ event.shiftKey ||
+ (event.key.toLowerCase() !== "k" && event.key.toLowerCase() !== "p")
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ if (event.repeat) return;
+ openCommandPalette(
+ event.key.toLowerCase() === "k" ? CHAT_SCOPE_PREFIX : "",
+ );
+ };
+
+ window.addEventListener("keydown", handleCommandPaletteShortcut);
+ return () =>
+ window.removeEventListener("keydown", handleCommandPaletteShortcut);
+ }, [openCommandPalette]);
+
// Initialize plan events listener
usePlanEvents();
useIntegrationEvents();
@@ -194,7 +237,7 @@ function RootLayoutContent({ children }: { children: ReactNode }) {
-
+ openCommandPalette("")} />
@@ -212,6 +255,12 @@ function RootLayoutContent({ children }: { children: ReactNode }) {
/>
+
diff --git a/src/components/AppSearchDialog.tsx b/src/components/AppSearchDialog.tsx
index 69ced8ed28..6d39644948 100644
--- a/src/components/AppSearchDialog.tsx
+++ b/src/components/AppSearchDialog.tsx
@@ -15,7 +15,6 @@ type AppSearchDialogProps = {
onOpenChange: (open: boolean) => void;
onSelectApp: (appId: number) => void;
allApps: AppSearchResult[];
- disableShortcut?: boolean;
};
export function AppSearchDialog({
@@ -23,7 +22,6 @@ export function AppSearchDialog({
onOpenChange,
onSelectApp,
allApps,
- disableShortcut,
}: AppSearchDialogProps) {
const [searchQuery, setSearchQuery] = useState
("");
function useDebouncedValue(value: T, delay: number): T {
@@ -88,18 +86,6 @@ export function AppSearchDialog({
return { before, match, after, raw: before + match + after };
}
- useEffect(() => {
- if (disableShortcut) return;
- const down = (e: KeyboardEvent) => {
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
- e.preventDefault();
- onOpenChange(!open);
- }
- };
- document.addEventListener("keydown", down);
- return () => document.removeEventListener("keydown", down);
- }, [open, onOpenChange, disableShortcut]);
-
return (
{
- const down = (e: KeyboardEvent) => {
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
- e.preventDefault();
- onOpenChange(!open);
- }
- };
- document.addEventListener("keydown", down);
- return () => document.removeEventListener("keydown", down);
- }, [open, onOpenChange]);
-
return (
void;
+ onQueryChange: (query: string) => void;
+};
+
+const APP_DETAIL_TARGETS = [
+ {
+ id: "manage-app",
+ label: "Manage selected app",
+ description: "Open app details and integrations",
+ keywords: ["app settings", "details", "configuration"],
+ targetId: "app-settings-overview",
+ icon: FolderCog,
+ },
+ {
+ id: "github",
+ label: "Configure GitHub",
+ description: "Connect the selected app to a GitHub repository",
+ keywords: ["repository", "git", "source control"],
+ targetId: "app-setting-github",
+ icon: Github,
+ },
+ {
+ id: "database",
+ label: "Configure database integration",
+ description: "Manage Supabase or Neon for the selected app",
+ keywords: ["supabase", "neon", "postgres", "integration"],
+ targetId: "app-setting-database",
+ icon: Database,
+ },
+ {
+ id: "mobile",
+ label: "Configure mobile app",
+ description: "Manage Capacitor configuration",
+ keywords: ["capacitor", "ios", "android"],
+ targetId: "app-setting-mobile",
+ icon: Smartphone,
+ },
+ {
+ id: "upgrades",
+ label: "Manage app upgrades",
+ description: "Review available app upgrades",
+ keywords: ["upgrade", "update", "migration"],
+ targetId: "app-setting-upgrades",
+ icon: Wrench,
+ },
+] as const;
+
+const CONFIGURE_TARGETS = [
+ {
+ id: "environment-variables",
+ label: "Configure environment variables",
+ description: "Manage local environment variables for the selected app",
+ keywords: ["env", "secrets", "configuration"],
+ targetId: "app-config-environment-variables",
+ icon: SlidersHorizontal,
+ },
+ {
+ id: "app-commands",
+ label: "Configure app commands",
+ description: "Set custom install and start commands",
+ keywords: ["install", "start", "command", "npm", "pnpm"],
+ targetId: "app-config-commands",
+ icon: Terminal,
+ },
+] as const;
+
+export function CommandPalette({
+ open,
+ query,
+ onOpenChange,
+ onQueryChange,
+}: CommandPaletteProps) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const selectedAppId = useAtomValue(selectedAppIdAtom);
+ const selectedChatId = useAtomValue(selectedChatIdAtom);
+ const setPreviewMode = useSetAtom(previewModeAtom);
+ const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
+ const setActiveSettingsSection = useSetAtom(activeSettingsSectionAtom);
+ const { apps } = useLoadApps();
+ const { chats, invalidateChats } = useChats(selectedAppId);
+ const { selectChat } = useSelectChat();
+ const parsedQuery = useMemo(() => parseCommandPaletteQuery(query), [query]);
+ const debouncedTerm = useDebounce(parsedQuery.term, 150);
+ const { apps: searchedApps, loading: appsLoading } = useSearchApps(
+ parsedQuery.scope === "all" ? debouncedTerm : "",
+ );
+ const { chats: searchedChats, loading: chatsLoading } = useSearchChats(
+ selectedAppId,
+ debouncedTerm,
+ );
+ const selectedApp = apps.find((app) => app.id === selectedAppId) ?? null;
+ const chatResults = parsedQuery.term ? searchedChats : chats;
+ const targetChat =
+ chats.find((chat) => chat.id === selectedChatId) ?? chats[0] ?? null;
+
+ const closeAndRun = (action: () => void | Promise) => {
+ onOpenChange(false);
+ Promise.resolve(action()).catch(showError);
+ };
+
+ const navigateAndReveal = async (
+ to: "/settings" | "/app-details",
+ targetId: string,
+ sectionId?: string,
+ ) => {
+ if (to === "/settings") {
+ await navigate({ to });
+ if (sectionId) setActiveSettingsSection(sectionId);
+ } else if (selectedAppId) {
+ await navigate({ to, search: { appId: selectedAppId } });
+ }
+ await revealCommandPaletteTarget(targetId);
+ };
+
+ const openConfigureTarget = async (targetId: string) => {
+ if (!targetChat || !selectedAppId) return;
+ selectChat({ chatId: targetChat.id, appId: selectedAppId });
+ setIsPreviewOpen(true);
+ setPreviewMode("configure");
+ await revealCommandPaletteTarget(targetId);
+ };
+
+ const createChat = async () => {
+ if (!selectedAppId) return;
+ const chatId = await ipc.chat.createChat({ appId: selectedAppId });
+ await invalidateChats();
+ await queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
+ selectChat({ chatId, appId: selectedAppId });
+ };
+
+ const commandFilter = (value: string, _search: string, keywords?: string[]) =>
+ scoreCommandPaletteItem(value, parsedQuery.term, keywords);
+
+ return (
+
+
+
+
+ {parsedQuery.scope === "chat" && !selectedAppId
+ ? "Select an app to search chats"
+ : appsLoading || chatsLoading
+ ? "Searching..."
+ : "No results found"}
+
+
+ {parsedQuery.scope === "all" && (
+ <>
+
+ closeAndRun(() => navigate({ to: "/" }))}
+ >
+
+ Go to Apps
+
+
+ closeAndRun(() => navigate({ to: "/settings" }))
+ }
+ >
+
+ Go to Settings
+
+ closeAndRun(() => navigate({ to: "/library" }))}
+ >
+
+ Go to Library
+
+
+ closeAndRun(() => navigate({ to: "/templates" }))
+ }
+ >
+
+ Go to Templates
+
+ closeAndRun(() => navigate({ to: "/plugins" }))}
+ >
+
+ Go to Plugins
+
+ {selectedAppId && (
+ closeAndRun(createChat)}
+ >
+
+
+ New chat for {selectedApp?.name ?? "selected app"}
+
+
+ )}
+
+
+
+ {SETTINGS_SEARCH_INDEX.map((setting) => (
+
+ closeAndRun(() =>
+ navigateAndReveal(
+ "/settings",
+ setting.id,
+ setting.sectionId,
+ ),
+ )
+ }
+ >
+
+
+
{setting.label}
+
+ {setting.sectionLabel} · {setting.description}
+
+
+
+ ))}
+
+
+ {selectedAppId && (
+
+ {APP_DETAIL_TARGETS.map((item) => {
+ const Icon = item.icon;
+ return (
+
+ closeAndRun(() =>
+ navigateAndReveal("/app-details", item.targetId),
+ )
+ }
+ >
+
+
+
{item.label}
+
+ {item.description}
+
+
+
+ );
+ })}
+ {targetChat &&
+ CONFIGURE_TARGETS.map((item) => {
+ const Icon = item.icon;
+ return (
+
+ closeAndRun(() => openConfigureTarget(item.targetId))
+ }
+ >
+
+
+
{item.label}
+
+ {item.description}
+
+
+
+ );
+ })}
+
+ )}
+
+ {parsedQuery.term && searchedApps.length > 0 && (
+
+ {searchedApps.map((app) => (
+
+ closeAndRun(() =>
+ navigate({
+ to: "/app-details",
+ search: { appId: app.id },
+ }),
+ )
+ }
+ >
+
+ {app.name}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {(parsedQuery.scope === "chat" || parsedQuery.term) &&
+ selectedAppId &&
+ chatResults.length > 0 && (
+
+ {chatResults.map((chat) => {
+ const matchedContent =
+ "matchedMessageContent" in chat
+ ? (chat as ChatSearchResult).matchedMessageContent
+ : null;
+ return (
+
+ closeAndRun(() =>
+ selectChat({ chatId: chat.id, appId: chat.appId }),
+ )
+ }
+ >
+
+
+
+ {chat.title || "Untitled Chat"}
+
+ {matchedContent && (
+
+ {matchedContent}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/chat/HomeChatInput.tsx b/src/components/chat/HomeChatInput.tsx
index bcfcc5cde5..202ea5c0fe 100644
--- a/src/components/chat/HomeChatInput.tsx
+++ b/src/components/chat/HomeChatInput.tsx
@@ -351,7 +351,6 @@ export function HomeChatInput({
open={appSearchOpen}
onOpenChange={setAppSearchOpen}
onSelectApp={handleSelectApp}
- disableShortcut
allApps={apps.map((a) => ({
id: a.id,
name: a.name,
diff --git a/src/components/preview_panel/ConfigurePanel.tsx b/src/components/preview_panel/ConfigurePanel.tsx
index 187e617ade..f9df76d0d5 100644
--- a/src/components/preview_panel/ConfigurePanel.tsx
+++ b/src/components/preview_panel/ConfigurePanel.tsx
@@ -135,7 +135,7 @@ const AppCommandsSection = ({
if (isLoadingApp) {
return (
-
+
@@ -158,7 +158,7 @@ const AppCommandsSection = ({
const commandsValid = hasInstallCommand === hasStartCommand;
return (
-
+
@@ -342,7 +342,7 @@ const IntegrationSection = () => {
: t("integrations.databaseSetup.providers.neon.name");
return (
-
+
@@ -540,7 +540,7 @@ export const ConfigurePanel = () => {
return (
-
+
@@ -559,7 +559,7 @@ export const ConfigurePanel = () => {
the integration prompt. */}
-
+
diff --git a/src/lib/commandPalette.test.ts b/src/lib/commandPalette.test.ts
new file mode 100644
index 0000000000..2739ee9763
--- /dev/null
+++ b/src/lib/commandPalette.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ parseCommandPaletteQuery,
+ revealCommandPaletteTarget,
+ scoreCommandPaletteItem,
+} from "./commandPalette";
+
+describe("parseCommandPaletteQuery", () => {
+ it.each([
+ ["chat: auth failure", { scope: "chat", term: "auth failure" }],
+ ["CHAT:auth failure", { scope: "chat", term: "auth failure" }],
+ [" chat : auth failure ", { scope: "chat", term: "auth failure" }],
+ ["chat: ", { scope: "chat", term: "" }],
+ ["theme", { scope: "all", term: "theme" }],
+ ])("parses %j", (query, expected) => {
+ expect(parseCommandPaletteQuery(query)).toEqual(expected);
+ });
+});
+
+describe("scoreCommandPaletteItem", () => {
+ it("prefers labels over keywords and rejects unrelated entries", () => {
+ expect(scoreCommandPaletteItem("Theme", "the", ["appearance"])).toBe(100);
+ expect(scoreCommandPaletteItem("Theme", "appear", ["appearance"])).toBe(50);
+ expect(scoreCommandPaletteItem("Theme", "database", ["appearance"])).toBe(
+ 0,
+ );
+ });
+});
+
+describe("revealCommandPaletteTarget", () => {
+ it("waits for a destination, scrolls it, and highlights it", async () => {
+ vi.useFakeTimers();
+ const promise = revealCommandPaletteTarget("destination", {
+ attempts: 3,
+ delayMs: 10,
+ });
+
+ const element = document.createElement("div");
+ element.id = "destination";
+ element.scrollIntoView = vi.fn();
+ document.body.append(element);
+ await vi.advanceTimersByTimeAsync(10);
+
+ await expect(promise).resolves.toBe(true);
+ expect(element.scrollIntoView).toHaveBeenCalledWith({
+ behavior: "smooth",
+ block: "start",
+ });
+ expect(element.classList.contains("settings-highlight")).toBe(true);
+
+ element.dispatchEvent(new Event("animationend"));
+ expect(element.classList.contains("settings-highlight")).toBe(false);
+ vi.useRealTimers();
+ });
+});
diff --git a/src/lib/commandPalette.ts b/src/lib/commandPalette.ts
new file mode 100644
index 0000000000..9fe8efc84d
--- /dev/null
+++ b/src/lib/commandPalette.ts
@@ -0,0 +1,71 @@
+export const CHAT_SCOPE_PREFIX = "chat: ";
+
+export type CommandPaletteQuery =
+ | { scope: "all"; term: string }
+ | { scope: "chat"; term: string };
+
+export function parseCommandPaletteQuery(query: string): CommandPaletteQuery {
+ const match = query.match(/^\s*chat\s*:\s*/i);
+ if (!match) {
+ return { scope: "all", term: query.trim() };
+ }
+
+ return {
+ scope: "chat",
+ term: query.slice(match[0].length).trim(),
+ };
+}
+
+export function scoreCommandPaletteItem(
+ value: string,
+ term: string,
+ keywords: readonly string[] = [],
+): number {
+ const normalizedTerm = term.trim().toLowerCase();
+ if (!normalizedTerm) return 1;
+
+ const normalizedValue = value.toLowerCase();
+ const valueIndex = normalizedValue.indexOf(normalizedTerm);
+ if (valueIndex >= 0) {
+ return 100 - Math.min(valueIndex, 90);
+ }
+
+ return keywords.some((keyword) =>
+ keyword.toLowerCase().includes(normalizedTerm),
+ )
+ ? 50
+ : 0;
+}
+
+export async function revealCommandPaletteTarget(
+ id: string,
+ options: { attempts?: number; delayMs?: number } = {},
+): Promise {
+ const attempts = options.attempts ?? 20;
+ const delayMs = options.delayMs ?? 50;
+
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ const element = document.getElementById(id);
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
+ element.classList.remove("settings-highlight");
+ void element.offsetWidth;
+ element.classList.add("settings-highlight");
+
+ const removeHighlight = () => {
+ element.classList.remove("settings-highlight");
+ };
+ element.addEventListener("animationend", removeHighlight, { once: true });
+ element.addEventListener("animationcancel", removeHighlight, {
+ once: true,
+ });
+ return true;
+ }
+
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, delayMs);
+ });
+ }
+
+ return false;
+}
diff --git a/src/lib/settingsSearchIndex.ts b/src/lib/settingsSearchIndex.ts
index 4feac26a7e..49908c8910 100644
--- a/src/lib/settingsSearchIndex.ts
+++ b/src/lib/settingsSearchIndex.ts
@@ -57,7 +57,7 @@ export const SETTING_IDS = {
reset: "setting-reset",
} as const;
-type SearchableSettingItem = {
+export type SearchableSettingItem = {
id: string;
label: string;
description: string;
diff --git a/src/pages/app-details.tsx b/src/pages/app-details.tsx
index 837cfaa0af..eac216cdf1 100644
--- a/src/pages/app-details.tsx
+++ b/src/pages/app-details.tsx
@@ -406,7 +406,10 @@ export default function AppDetailsPage() {
>
-
+
{selectedApp.name}
@@ -618,7 +621,10 @@ export default function AppDetailsPage() {
Open in Chat
-
+
{selectedApp.githubOrg && selectedApp.githubRepo && appId && (
@@ -626,50 +632,62 @@ export default function AppDetailsPage() {
)}
- {/* When providerFilter is set, show the selected connector only if the other provider isn't already active */}
- {providerFilter === "supabase" &&
- appId &&
- !selectedApp?.neonProjectId &&
}
- {providerFilter === "supabase" &&
- appId &&
- selectedApp?.neonProjectId && (
-
- )}
- {providerFilter === "neon" &&
- appId &&
- !selectedApp?.supabaseProjectId &&
}
- {providerFilter === "neon" &&
- appId &&
- selectedApp?.supabaseProjectId && (
-
- )}
- {/* When no providerFilter, show both with existing mutual exclusion */}
- {!providerFilter && (
- <>
- {appId &&
- !selectedApp?.neonProjectId &&
- !selectedApp?.supabaseProjectId && (
-
-
- {t("integrations.mutualExclusion.chooseOne")}
-
- )}
- {appId && !selectedApp?.neonProjectId && (
+
+ {/* When providerFilter is set, show the selected connector only if the other provider isn't already active */}
+ {providerFilter === "supabase" &&
+ appId &&
+ !selectedApp?.neonProjectId && (
)}
- {appId && selectedApp?.neonProjectId && (
+ {providerFilter === "supabase" &&
+ appId &&
+ selectedApp?.neonProjectId && (
)}
- {appId && !selectedApp?.supabaseProjectId && (
+ {providerFilter === "neon" &&
+ appId &&
+ !selectedApp?.supabaseProjectId && (
)}
- {appId && selectedApp?.supabaseProjectId && (
+ {providerFilter === "neon" &&
+ appId &&
+ selectedApp?.supabaseProjectId && (
)}
- >
+ {/* When no providerFilter, show both with existing mutual exclusion */}
+ {!providerFilter && (
+ <>
+ {appId &&
+ !selectedApp?.neonProjectId &&
+ !selectedApp?.supabaseProjectId && (
+
+
+ {t("integrations.mutualExclusion.chooseOne")}
+
+ )}
+ {appId && !selectedApp?.neonProjectId && (
+
+ )}
+ {appId && selectedApp?.neonProjectId && (
+
+ )}
+ {appId && !selectedApp?.supabaseProjectId && (
+
+ )}
+ {appId && selectedApp?.supabaseProjectId && (
+
+ )}
+ >
+ )}
+
+ {appId && (
+
+
+
)}
- {appId &&
}
-
+
{/* Rename Dialog */}
From cb4a39db732b703868250e02e41b888c750d533a Mon Sep 17 00:00:00 2001
From: Will Chen <7344640+wwwillchen@users.noreply.github.com>
Date: Thu, 20 Aug 2026 15:38:05 -0700
Subject: [PATCH 2/6] Fix command palette review findings
---
e2e-tests/command_palette.spec.ts | 30 +++++++++++++++++
rules/base-ui-components.md | 10 ++++++
src/app/layout.tsx | 12 +++++--
src/components/CommandPalette.tsx | 56 +++++++++++++++++++------------
src/components/ui/command.tsx | 8 ++++-
src/components/ui/dialog.tsx | 35 +++++++++++++++++--
src/hooks/useSelectChat.ts | 2 ++
src/lib/commandPalette.test.ts | 25 ++++++++++++++
src/lib/commandPalette.ts | 30 +++++++++++++++++
9 files changed, 180 insertions(+), 28 deletions(-)
diff --git a/e2e-tests/command_palette.spec.ts b/e2e-tests/command_palette.spec.ts
index 8d7c055f5b..4c07cdcac8 100644
--- a/e2e-tests/command_palette.spec.ts
+++ b/e2e-tests/command_palette.spec.ts
@@ -28,6 +28,12 @@ test("command palette supports scoped chat and unfiltered configuration search",
/settings-highlight/,
);
+ await po.page.keyboard.press("Control+p");
+ await input.fill("manage selected app");
+ await po.page.getByTestId("command-palette-app-setting-manage-app").click();
+ await expect(po.page).toHaveURL(/\/app-details/);
+ await expect(po.page.locator("#app-settings-overview")).toBeVisible();
+
await po.page.keyboard.press("Control+p");
await input.fill("environment variables");
await po.page
@@ -43,4 +49,28 @@ test("command palette supports scoped chat and unfiltered configuration search",
await po.page.getByTestId("command-palette-trigger").click();
await expect(input).toHaveValue("");
+ await po.page.keyboard.press("Control+p");
+ await expect(palette).toBeVisible();
+ await expect(input).toHaveValue("");
+ await po.page.keyboard.press("Escape");
+
+ await po.openContextFilesPicker();
+ await expect(po.page.getByTestId("manual-context-files-input")).toBeVisible();
+ await po.page.keyboard.press("Control+p");
+ await expect(
+ po.page.getByTestId("manual-context-files-input"),
+ ).not.toBeVisible();
+ await expect(palette).toBeVisible();
+ await po.page.keyboard.press("Escape");
+
+ await po.navigation.goToAppsTab();
+ await po.page.getByTestId("search-apps-button").click();
+ await expect(po.page.getByTestId("app-search-dialog")).toBeVisible();
+ await po.page.keyboard.press("Control+k");
+ await expect(po.page.getByTestId("app-search-dialog")).not.toBeVisible();
+ await expect(palette).toBeVisible();
+ await expect(input).toHaveValue("chat: ");
+ await po.page.keyboard.press("Escape");
+ await expect(palette).not.toBeVisible();
+ await expect(po.page.getByTestId("app-search-dialog")).not.toBeVisible();
});
diff --git a/rules/base-ui-components.md b/rules/base-ui-components.md
index 311e787a2c..8ce9b9a74d 100644
--- a/rules/base-ui-components.md
+++ b/rules/base-ui-components.md
@@ -44,6 +44,16 @@ a control and focus must follow it while persistence is pending, keep it
focusable with `aria-disabled`, guard repeat activation synchronously, and
restore focus with `{ preventScroll: true }`.
+## Global shortcuts that open dialogs
+
+Register truly global shortcuts in the capture phase so focused editors or
+dialog content that stops keydown propagation cannot swallow them. When the
+shortcut opens a modal, coordinate exclusivity in the shared Dialog wrapper
+using Base UI's imperative `actionsRef.close()`; closing only the dialogs known
+to the feature leaves other modal roots and focus traps stacked underneath.
+Do not automatically dismiss an open `AlertDialog`; suppress the new modal
+until the user resolves that blocking confirmation.
+
## TooltipTrigger render prop
`TooltipTrigger` from `@base-ui/react/tooltip` (wrapped in `src/components/ui/tooltip.tsx`) renders a `
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index 5e4ee21262..677ff4c46a 100644
--- a/src/components/ui/command.tsx
+++ b/src/components/ui/command.tsx
@@ -35,6 +35,7 @@ function CommandDialog({
children,
className,
showCloseButton = true,
+ closeOnCommandPaletteOpen = true,
filter,
open,
onOpenChange,
@@ -44,6 +45,7 @@ function CommandDialog({
description?: string;
className?: string;
showCloseButton?: boolean;
+ closeOnCommandPaletteOpen?: boolean;
filter?: (value: string, search: string, keywords?: string[]) => number;
"data-testid"?: string;
open?: boolean;
@@ -51,7 +53,11 @@ function CommandDialog({
children?: React.ReactNode;
}) {
return (
-