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 ( - + ; +function Dialog({ + actionsRef, + closeOnCommandPaletteOpen = true, + ...props +}: DialogPrimitive.Root.Props & { + closeOnCommandPaletteOpen?: boolean; +}) { + const internalActionsRef = React.useRef(null); + const dialogActionsRef = actionsRef ?? internalActionsRef; + + React.useEffect(() => { + const closeForCommandPalette = () => { + if (closeOnCommandPaletteOpen) dialogActionsRef.current?.close(); + }; + window.addEventListener( + COMMAND_PALETTE_OPENING_EVENT, + closeForCommandPalette, + ); + return () => + window.removeEventListener( + COMMAND_PALETTE_OPENING_EVENT, + closeForCommandPalette, + ); + }, [closeOnCommandPaletteOpen, dialogActionsRef]); + + return ( + + ); } function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { diff --git a/src/hooks/useSelectChat.ts b/src/hooks/useSelectChat.ts index 1124482463..71d5cd7557 100644 --- a/src/hooks/useSelectChat.ts +++ b/src/hooks/useSelectChat.ts @@ -62,6 +62,8 @@ export function useSelectChat() { // Ignore navigation errors here; navigation handling is centralized. }); } + + return navigationResult; }, }; } diff --git a/src/lib/commandPalette.test.ts b/src/lib/commandPalette.test.ts index 2739ee9763..c3c0a20417 100644 --- a/src/lib/commandPalette.test.ts +++ b/src/lib/commandPalette.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { + getCommandPaletteSnippet, + hasBlockingAlertDialogOpen, parseCommandPaletteQuery, revealCommandPaletteTarget, scoreCommandPaletteItem, @@ -27,6 +29,29 @@ describe("scoreCommandPaletteItem", () => { }); }); +describe("getCommandPaletteSnippet", () => { + it("bounds long content while preserving the matched context", () => { + const text = `${"a".repeat(80)}needle${"b".repeat(80)}`; + const snippet = getCommandPaletteSnippet(text, "needle", 10); + + expect(snippet).toBe(`…${"a".repeat(10)}needle${"b".repeat(10)}…`); + expect(snippet.length).toBeLessThan(text.length); + }); +}); + +describe("hasBlockingAlertDialogOpen", () => { + it("protects an open destructive confirmation from palette replacement", () => { + const alert = document.createElement("div"); + alert.dataset.slot = "alert-dialog-content"; + alert.dataset.open = ""; + document.body.append(alert); + + expect(hasBlockingAlertDialogOpen()).toBe(true); + alert.remove(); + expect(hasBlockingAlertDialogOpen()).toBe(false); + }); +}); + describe("revealCommandPaletteTarget", () => { it("waits for a destination, scrolls it, and highlights it", async () => { vi.useFakeTimers(); diff --git a/src/lib/commandPalette.ts b/src/lib/commandPalette.ts index 9fe8efc84d..2dc58ca737 100644 --- a/src/lib/commandPalette.ts +++ b/src/lib/commandPalette.ts @@ -1,4 +1,5 @@ export const CHAT_SCOPE_PREFIX = "chat: "; +export const COMMAND_PALETTE_OPENING_EVENT = "dyad:command-palette-opening"; export type CommandPaletteQuery = | { scope: "all"; term: string } @@ -37,6 +38,35 @@ export function scoreCommandPaletteItem( : 0; } +export function getCommandPaletteSnippet( + text: string, + query: string, + radius = 50, +): string { + const trimmedQuery = query.trim(); + const matchIndex = text.toLowerCase().indexOf(trimmedQuery.toLowerCase()); + + if (!trimmedQuery || matchIndex === -1) { + return text.length > radius * 2 ? `${text.slice(0, radius * 2)}…` : text; + } + + const start = Math.max(0, matchIndex - radius); + const end = Math.min(text.length, matchIndex + trimmedQuery.length + radius); + return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`; +} + +export function announceCommandPaletteOpening(): void { + window.dispatchEvent(new Event(COMMAND_PALETTE_OPENING_EVENT)); +} + +export function hasBlockingAlertDialogOpen( + root: Pick = document, +): boolean { + return Boolean( + root.querySelector('[data-slot="alert-dialog-content"][data-open]'), + ); +} + export async function revealCommandPaletteTarget( id: string, options: { attempts?: number; delayMs?: number } = {}, From e76b5c0bff2c4f82c52a7afabcdae5c8aedbc994 Mon Sep 17 00:00:00 2001 From: Will Chen <7344640+wwwillchen@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:45:58 -0700 Subject: [PATCH 3/6] Address command palette review comments --- e2e-tests/command_palette.spec.ts | 2 +- rules/adding-settings.md | 5 +++ src/app/layout.tsx | 9 +++++ src/components/CommandPalette.tsx | 58 ++++++++++++++++++++++++----- src/components/SettingsList.tsx | 17 +-------- src/hooks/useScrollAndNavigateTo.ts | 16 ++------ src/lib/commandPalette.test.ts | 19 ++++++++++ src/lib/commandPalette.ts | 41 ++++++++++++-------- src/lib/scrollAndHighlight.ts | 26 +++++++++++++ src/lib/settingsSearchIndex.test.ts | 6 +++ src/lib/settingsSearchIndex.ts | 43 ++++++++++++++++----- 11 files changed, 178 insertions(+), 64 deletions(-) create mode 100644 src/lib/scrollAndHighlight.ts diff --git a/e2e-tests/command_palette.spec.ts b/e2e-tests/command_palette.spec.ts index 4c07cdcac8..a0c4bd03c1 100644 --- a/e2e-tests/command_palette.spec.ts +++ b/e2e-tests/command_palette.spec.ts @@ -35,7 +35,7 @@ test("command palette supports scoped chat and unfiltered configuration search", await expect(po.page.locator("#app-settings-overview")).toBeVisible(); await po.page.keyboard.press("Control+p"); - await input.fill("environment variables"); + await input.fill("env vars"); await po.page .getByTestId("command-palette-app-setting-environment-variables") .click(); diff --git a/rules/adding-settings.md b/rules/adding-settings.md index b35074ff8d..94bcd9119a 100644 --- a/rules/adding-settings.md +++ b/rules/adding-settings.md @@ -9,6 +9,11 @@ When adding a new toggle/setting to the Settings page: 5. Import and add the switch to the relevant section in `src/pages/settings.tsx` 6. Adding a field to `DEFAULT_SETTINGS` breaks the inline snapshots in `src/main/settings.test.ts`. The snapshot helper sorts keys alphabetically, so place a manually added field in alphabetical order or, after confirming the diff is limited to the new default, regenerate with `npm test -- src/main/settings.test.ts -u`. +Every settings search-index entry must point to an element that renders with the +same ID. If the control is conditional (for example, Pro-only), encode that +availability in the index and filter search surfaces with the same condition so +they never offer a destination that cannot render. + If the setting adds a built-in default, update the inline snapshots in `src/main/settings.test.ts`; otherwise `npm test` will fail with default settings snapshot mismatches. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e2d8472ce9..98df72f680 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -60,6 +60,7 @@ import { announceCommandPaletteOpening, CHAT_SCOPE_PREFIX, hasBlockingAlertDialogOpen, + isTerminalShortcutTarget, } from "@/lib/commandPalette"; export default function RootLayout({ children }: { children: ReactNode }) { @@ -150,6 +151,14 @@ function RootLayoutContent({ children }: { children: ReactNode }) { return; } + if ( + event.ctrlKey && + !event.metaKey && + isTerminalShortcutTarget(event.target) + ) { + return; + } + event.preventDefault(); if (event.repeat) return; openCommandPalette( diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 635a1b45ee..7199ced9ed 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -39,16 +39,20 @@ import { useSearchApps } from "@/hooks/useSearchApps"; import { useSearchChats } from "@/hooks/useSearchChats"; import { useSelectChat } from "@/hooks/useSelectChat"; import { useDebounce } from "@/hooks/useDebounce"; +import { useSettings } from "@/hooks/useSettings"; import { ipc } from "@/ipc/types"; import { queryKeys } from "@/lib/queryKeys"; -import { SETTINGS_SEARCH_INDEX } from "@/lib/settingsSearchIndex"; +import { + searchSettings, + SETTINGS_SEARCH_INDEX, +} from "@/lib/settingsSearchIndex"; import { getCommandPaletteSnippet, parseCommandPaletteQuery, revealCommandPaletteTarget, scoreCommandPaletteItem, } from "@/lib/commandPalette"; -import type { ChatSearchResult } from "@/lib/schemas"; +import { isDyadProEnabled, type ChatSearchResult } from "@/lib/schemas"; import { showError } from "@/lib/toast"; type CommandPaletteProps = { @@ -134,10 +138,12 @@ export function CommandPalette({ const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const setActiveSettingsSection = useSetAtom(activeSettingsSectionAtom); const { apps } = useLoadApps(); + const { settings } = useSettings(); const { chats, invalidateChats } = useChats(selectedAppId); const { selectChat } = useSelectChat(); const parsedQuery = useMemo(() => parseCommandPaletteQuery(query), [query]); const debouncedTerm = useDebounce(parsedQuery.term, 150); + const isDebouncing = parsedQuery.term !== debouncedTerm; const { apps: searchedApps, loading: appsLoading } = useSearchApps( parsedQuery.scope === "all" ? debouncedTerm : "", ); @@ -146,7 +152,28 @@ export function CommandPalette({ debouncedTerm, ); const selectedApp = apps.find((app) => app.id === selectedAppId) ?? null; - const chatResults = parsedQuery.term ? searchedChats : chats; + const chatResults = debouncedTerm + ? searchedChats + : parsedQuery.term + ? [] + : chats; + const availableSettings = useMemo( + () => + SETTINGS_SEARCH_INDEX.filter( + (setting) => + !setting.requiresPro || + (settings !== null && isDyadProEnabled(settings)), + ), + [settings], + ); + const settingsScoreById = useMemo(() => { + if (!parsedQuery.term) return new Map(); + return new Map( + searchSettings(parsedQuery.term, availableSettings).map( + (result, index) => [result.item.id, 100 - Math.min(index, 90)], + ), + ); + }, [availableSettings, parsedQuery.term]); const targetChat = chats.find((chat) => chat.id === selectedChatId) ?? chats[0] ?? null; @@ -185,8 +212,17 @@ export function CommandPalette({ selectChat({ chatId, appId: selectedAppId }); }; - const commandFilter = (value: string, _search: string, keywords?: string[]) => - scoreCommandPaletteItem(value, parsedQuery.term, keywords); + const commandFilter = ( + value: string, + _search: string, + keywords?: string[], + ) => { + if (value.startsWith("setting:")) { + if (!parsedQuery.term) return 1; + return settingsScoreById.get(value.slice("setting:".length)) ?? 0; + } + return scoreCommandPaletteItem(value, parsedQuery.term, keywords); + }; return ( {parsedQuery.scope === "chat" && !selectedAppId ? "Select an app to search chats" - : appsLoading || chatsLoading + : appsLoading || chatsLoading || isDebouncing ? "Searching..." : "No results found"} @@ -275,10 +311,10 @@ export function CommandPalette({ - {SETTINGS_SEARCH_INDEX.map((setting) => ( + {availableSettings.map((setting) => ( @@ -367,7 +403,8 @@ export function CommandPalette({ return ( closeAndRun(() => @@ -403,7 +440,8 @@ export function CommandPalette({ return ( closeAndRun(() => diff --git a/src/components/SettingsList.tsx b/src/components/SettingsList.tsx index 358801221e..9ef1b8f554 100644 --- a/src/components/SettingsList.tsx +++ b/src/components/SettingsList.tsx @@ -4,8 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo"; import { useAtom } from "jotai"; import { activeSettingsSectionAtom } from "@/atoms/viewAtoms"; -import { SECTION_IDS, SETTINGS_SEARCH_INDEX } from "@/lib/settingsSearchIndex"; -import Fuse from "fuse.js"; +import { SECTION_IDS, searchSettings } from "@/lib/settingsSearchIndex"; import { SearchIcon, XIcon } from "lucide-react"; type SettingsSection = { @@ -26,18 +25,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ { id: SECTION_IDS.dangerZone, label: "Danger Zone" }, ]; -const fuse = new Fuse(SETTINGS_SEARCH_INDEX, { - keys: [ - { name: "label", weight: 2 }, - { name: "description", weight: 1 }, - { name: "keywords", weight: 1.5 }, - { name: "sectionLabel", weight: 0.5 }, - ], - threshold: 0.4, - includeScore: true, - ignoreLocation: true, -}); - export function SettingsList({ show }: { show: boolean }) { const [activeSection, setActiveSection] = useAtom(activeSettingsSectionAtom); const [searchQuery, setSearchQuery] = useState(""); @@ -56,7 +43,7 @@ export function SettingsList({ show }: { show: boolean }) { const searchResults = useMemo(() => { if (!searchQuery.trim()) return null; - return fuse.search(searchQuery.trim()); + return searchSettings(searchQuery); }, [searchQuery]); useEffect(() => { diff --git a/src/hooks/useScrollAndNavigateTo.ts b/src/hooks/useScrollAndNavigateTo.ts index 24f5cdd65c..ca2d99ed81 100644 --- a/src/hooks/useScrollAndNavigateTo.ts +++ b/src/hooks/useScrollAndNavigateTo.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useSetAtom } from "jotai"; import { activeSettingsSectionAtom } from "@/atoms/viewAtoms"; +import { scrollAndHighlightElement } from "@/lib/scrollAndHighlight"; type ScrollOptions = { behavior?: ScrollBehavior; @@ -26,25 +27,14 @@ export function useScrollAndNavigateTo( await navigate({ to }); const element = document.getElementById(id); if (element) { - element.scrollIntoView({ + scrollAndHighlightElement(element, { behavior: options?.behavior ?? "smooth", block: options?.block ?? "start", inline: options?.inline, + highlight: options?.highlight, }); setActiveSection(sectionId ?? id); options?.onScrolled?.(id, element); - - if (options?.highlight) { - element.classList.remove("settings-highlight"); - void element.offsetWidth; // force reflow to restart animation - element.classList.add("settings-highlight"); - const onEnd = () => { - element.classList.remove("settings-highlight"); - }; - element.addEventListener("animationend", onEnd, { once: true }); - element.addEventListener("animationcancel", onEnd, { once: true }); - } - return true; } return false; diff --git a/src/lib/commandPalette.test.ts b/src/lib/commandPalette.test.ts index c3c0a20417..468fcf5601 100644 --- a/src/lib/commandPalette.test.ts +++ b/src/lib/commandPalette.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { getCommandPaletteSnippet, hasBlockingAlertDialogOpen, + isTerminalShortcutTarget, parseCommandPaletteQuery, revealCommandPaletteTarget, scoreCommandPaletteItem, @@ -26,6 +27,12 @@ describe("scoreCommandPaletteItem", () => { expect(scoreCommandPaletteItem("Theme", "database", ["appearance"])).toBe( 0, ); + expect( + scoreCommandPaletteItem("Configure environment variables", "env vars", [ + "env", + "secrets", + ]), + ).toBeGreaterThan(0); }); }); @@ -52,6 +59,18 @@ describe("hasBlockingAlertDialogOpen", () => { }); }); +describe("isTerminalShortcutTarget", () => { + it("recognizes descendants of the xterm surface", () => { + const terminal = document.createElement("div"); + terminal.dataset.testid = "terminal-xterm"; + const child = document.createElement("textarea"); + terminal.append(child); + + expect(isTerminalShortcutTarget(child)).toBe(true); + expect(isTerminalShortcutTarget(document.body)).toBe(false); + }); +}); + describe("revealCommandPaletteTarget", () => { it("waits for a destination, scrolls it, and highlights it", async () => { vi.useFakeTimers(); diff --git a/src/lib/commandPalette.ts b/src/lib/commandPalette.ts index 2dc58ca737..4050c797a3 100644 --- a/src/lib/commandPalette.ts +++ b/src/lib/commandPalette.ts @@ -1,3 +1,6 @@ +import Fuse from "fuse.js"; +import { scrollAndHighlightElement } from "./scrollAndHighlight"; + export const CHAT_SCOPE_PREFIX = "chat: "; export const COMMAND_PALETTE_OPENING_EVENT = "dyad:command-palette-opening"; @@ -31,11 +34,17 @@ export function scoreCommandPaletteItem( return 100 - Math.min(valueIndex, 90); } - return keywords.some((keyword) => - keyword.toLowerCase().includes(normalizedTerm), - ) - ? 50 - : 0; + const fuzzyMatch = new Fuse([{ value, keywords }], { + keys: [ + { name: "value", weight: 2 }, + { name: "keywords", weight: 1.5 }, + ], + threshold: 0.4, + includeScore: true, + ignoreLocation: true, + }).search(normalizedTerm)[0]; + if (!fuzzyMatch) return 0; + return Math.max(1, Math.round((1 - (fuzzyMatch.score ?? 1)) * 50)); } export function getCommandPaletteSnippet( @@ -67,6 +76,13 @@ export function hasBlockingAlertDialogOpen( ); } +export function isTerminalShortcutTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + Boolean(target.closest('[data-testid="terminal-xterm"], .xterm')) + ); +} + export async function revealCommandPaletteTarget( id: string, options: { attempts?: number; delayMs?: number } = {}, @@ -77,17 +93,10 @@ export async function revealCommandPaletteTarget( 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, + scrollAndHighlightElement(element, { + behavior: "smooth", + block: "start", + highlight: true, }); return true; } diff --git a/src/lib/scrollAndHighlight.ts b/src/lib/scrollAndHighlight.ts new file mode 100644 index 0000000000..c4a4fb8ec3 --- /dev/null +++ b/src/lib/scrollAndHighlight.ts @@ -0,0 +1,26 @@ +export function scrollAndHighlightElement( + element: HTMLElement, + options: { + behavior?: ScrollBehavior; + block?: ScrollLogicalPosition; + inline?: ScrollLogicalPosition; + highlight?: boolean; + } = {}, +): void { + element.scrollIntoView({ + behavior: options.behavior ?? "smooth", + block: options.block ?? "start", + inline: options.inline, + }); + + if (!options.highlight) return; + + 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 }); +} diff --git a/src/lib/settingsSearchIndex.test.ts b/src/lib/settingsSearchIndex.test.ts index 7bc86f081c..d9dad025b8 100644 --- a/src/lib/settingsSearchIndex.test.ts +++ b/src/lib/settingsSearchIndex.test.ts @@ -3,6 +3,7 @@ import { SECTION_IDS, SETTING_IDS, SETTINGS_SEARCH_INDEX, + searchSettings, } from "./settingsSearchIndex"; describe("SETTINGS_SEARCH_INDEX", () => { @@ -154,6 +155,11 @@ describe("SETTINGS_SEARCH_INDEX", () => { ], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }); }); + + it("exposes the shared fuzzy settings ranking", () => { + expect(searchSettings("theme")[0]?.item.label).toBe("Theme"); + }); }); diff --git a/src/lib/settingsSearchIndex.ts b/src/lib/settingsSearchIndex.ts index 49908c8910..ead1a5577e 100644 --- a/src/lib/settingsSearchIndex.ts +++ b/src/lib/settingsSearchIndex.ts @@ -1,3 +1,5 @@ +import Fuse from "fuse.js"; + export const SECTION_IDS = { general: "general-settings", workflow: "workflow-settings", @@ -17,7 +19,6 @@ export const SETTING_IDS = { autoUpdate: "setting-auto-update", releaseChannel: "setting-release-channel", runtimeMode: "setting-runtime-mode", - nodeRuntime: "setting-node-runtime", nodePath: "setting-node-path", customAppsFolder: "setting-custom-apps-folder", defaultChatMode: "setting-default-chat-mode", @@ -64,6 +65,7 @@ export type SearchableSettingItem = { keywords: string[]; sectionId: string; sectionLabel: string; + requiresPro?: boolean; }; export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ @@ -116,14 +118,6 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ sectionId: SECTION_IDS.general, sectionLabel: "General", }, - { - id: SETTING_IDS.nodeRuntime, - label: "Node Runtime", - description: "Choose between system Node.js and Dyad-managed Node.js", - keywords: ["node", "nodejs", "runtime", "managed", "system"], - sectionId: SECTION_IDS.general, - sectionLabel: "General", - }, { id: SETTING_IDS.customAppsFolder, label: "Customize Apps Folder", @@ -541,6 +535,7 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ keywords: ["sub-agent", "explorer", "agent", "research", "pro"], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }, { id: SETTING_IDS.enableAutoReview, @@ -549,6 +544,7 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ keywords: ["sub-agent", "review", "automatic", "agent", "pro"], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }, { id: SETTING_IDS.enableReviewButton, @@ -557,6 +553,7 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ keywords: ["sub-agent", "review", "manual", "button", "agent", "pro"], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }, { id: SETTING_IDS.enableImplementerSubagent, @@ -565,6 +562,7 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ keywords: ["sub-agent", "implementer", "write", "agent", "pro"], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }, { id: SETTING_IDS.enableAdvancedSubagents, @@ -583,6 +581,7 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ ], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }, { id: SETTING_IDS.autoFixReviewIssues, @@ -591,6 +590,7 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ keywords: ["sub-agent", "review", "fix", "automatic", "pro"], sectionId: SECTION_IDS.experiments, sectionLabel: "Experiments", + requiresPro: true, }, { @@ -634,3 +634,28 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ sectionLabel: "Danger Zone", }, ]; + +const SETTINGS_SEARCH_OPTIONS = { + keys: [ + { name: "label", weight: 2 }, + { name: "description", weight: 1 }, + { name: "keywords", weight: 1.5 }, + { name: "sectionLabel", weight: 0.5 }, + ], + threshold: 0.4, + includeScore: true, + ignoreLocation: true, +}; + +const settingsSearch = new Fuse(SETTINGS_SEARCH_INDEX, SETTINGS_SEARCH_OPTIONS); + +export function searchSettings( + query: string, + items: SearchableSettingItem[] = SETTINGS_SEARCH_INDEX, +) { + const fuse = + items === SETTINGS_SEARCH_INDEX + ? settingsSearch + : new Fuse(items, SETTINGS_SEARCH_OPTIONS); + return fuse.search(query.trim()); +} From 9274dd83718b8ff55dacc3f422fa31aa5bf2229f Mon Sep 17 00:00:00 2001 From: Will Chen <7344640+wwwillchen@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:51:42 -0700 Subject: [PATCH 4/6] Hide unavailable mobile palette command --- src/components/CommandPalette.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 7199ced9ed..bb70741400 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useSetAtom } from "jotai"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AppWindow, Blocks, @@ -152,6 +152,14 @@ export function CommandPalette({ debouncedTerm, ); const selectedApp = apps.find((app) => app.id === selectedAppId) ?? null; + const { data: isSelectedAppCapacitor } = useQuery({ + queryKey: queryKeys.appUpgrades.isCapacitor({ + appId: selectedAppId, + }), + queryFn: () => + ipc.capacitor.isCapacitor({ appId: selectedAppId as number }), + enabled: selectedAppId !== null, + }); const chatResults = debouncedTerm ? searchedChats : parsedQuery.term @@ -342,7 +350,9 @@ export function CommandPalette({ - {APP_DETAIL_TARGETS.map((item) => { + {APP_DETAIL_TARGETS.filter( + (item) => item.id !== "mobile" || isSelectedAppCapacitor, + ).map((item) => { const Icon = item.icon; return ( Date: Thu, 20 Aug 2026 19:24:26 -0700 Subject: [PATCH 5/6] Address latest command palette review comments --- e2e-tests/command_palette.spec.ts | 23 +++++++++++++---------- rules/base-ui-components.md | 11 ++++++----- rules/e2e-testing.md | 5 +++++ src/app/layout.tsx | 4 ++-- src/components/AppSearchDialog.tsx | 1 + src/components/ChatSearchDialog.tsx | 1 + src/components/CommandPalette.tsx | 15 ++++++--------- src/components/ConfirmationDialog.tsx | 7 ++++++- src/components/SettingsList.tsx | 17 ++++++++++++++--- src/components/ui/command.tsx | 2 +- src/components/ui/dialog.tsx | 25 +++++++++++++++++++------ src/lib/commandPalette.test.ts | 19 ++++++++++++++----- src/lib/commandPalette.ts | 6 ++++-- src/lib/settingsSearchIndex.test.ts | 10 ++++++++++ src/lib/settingsSearchIndex.ts | 6 ++++++ 15 files changed, 108 insertions(+), 44 deletions(-) diff --git a/e2e-tests/command_palette.spec.ts b/e2e-tests/command_palette.spec.ts index a0c4bd03c1..7a829657b4 100644 --- a/e2e-tests/command_palette.spec.ts +++ b/e2e-tests/command_palette.spec.ts @@ -24,9 +24,17 @@ test("command palette supports scoped chat and unfiltered configuration search", 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 expect(po.page.locator("#setting-theme")).toBeVisible(); + + await po.page + .getByRole("button", { name: "Reset Everything", exact: true }) + .click(); + const confirmationDialog = po.page.getByTestId("confirmation-dialog"); + await expect(confirmationDialog).toBeVisible(); + await po.page.keyboard.press("Control+p"); + await expect(confirmationDialog).toBeVisible(); + await expect(palette).not.toBeVisible(); + await confirmationDialog.getByRole("button", { name: "Cancel" }).click(); await po.page.keyboard.press("Control+p"); await input.fill("manage selected app"); @@ -43,9 +51,6 @@ test("command palette supports scoped chat and unfiltered configuration search", 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(""); @@ -57,10 +62,8 @@ test("command palette supports scoped chat and unfiltered configuration search", 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 expect(po.page.getByTestId("manual-context-files-input")).toBeVisible(); + await expect(palette).not.toBeVisible(); await po.page.keyboard.press("Escape"); await po.navigation.goToAppsTab(); diff --git a/rules/base-ui-components.md b/rules/base-ui-components.md index 8ce9b9a74d..ce0a4d4d0c 100644 --- a/rules/base-ui-components.md +++ b/rules/base-ui-components.md @@ -48,11 +48,12 @@ restore focus with `{ preventScroll: true }`. 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. +shortcut opens a modal, make dismissal opt-in in the shared Dialog wrapper and +use Base UI's imperative `actionsRef.close()` only for disposable dialogs such +as search pickers. Suppress the new modal while input-bearing dialogs or any +`AlertDialog` are open so a mistyped shortcut cannot discard pending work. +Legacy confirmation overlays must expose the same blocking marker as the shared +`AlertDialog` until they are migrated. ## TooltipTrigger render prop diff --git a/rules/e2e-testing.md b/rules/e2e-testing.md index c3232dd516..5c8ad8487f 100644 --- a/rules/e2e-testing.md +++ b/rules/e2e-testing.md @@ -25,6 +25,11 @@ context and assert both the intended reload and non-reload paths. Do NOT write lots of e2e test cases for one feature. Each e2e test case adds a significant amount of overhead, so instead prefer just one or two E2E test cases that each have broad coverage of the feature in question. +Do not assert transient animation classes in E2E tests. Animation completion, +cancellation, or reduced-motion settings can remove them before Playwright +observes the state; assert a durable result such as navigation and target +visibility instead. + **IMPORTANT: You MUST run `npm run build` before running E2E tests.** E2E tests run against the built application binary, not the source code. If you make any changes to application code (anything outside of `e2e-tests/`), you MUST re-run `npm run build` before running E2E tests, otherwise you'll be testing the old version of the application. ```sh diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 98df72f680..564c64217b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -59,7 +59,7 @@ import { CommandPalette } from "@/components/CommandPalette"; import { announceCommandPaletteOpening, CHAT_SCOPE_PREFIX, - hasBlockingAlertDialogOpen, + hasBlockingCommandPaletteDialogOpen, isTerminalShortcutTarget, } from "@/lib/commandPalette"; @@ -129,7 +129,7 @@ function RootLayoutContent({ children }: { children: ReactNode }) { useSyncDefaultChatMode(); const openCommandPalette = useCallback((query: string = "") => { - if (hasBlockingAlertDialogOpen()) return; + if (hasBlockingCommandPaletteDialogOpen()) return; announceCommandPaletteOpening(); setCommandPaletteQuery(query); setIsCommandPaletteOpen(true); diff --git a/src/components/AppSearchDialog.tsx b/src/components/AppSearchDialog.tsx index 6d39644948..0e8c8d6266 100644 --- a/src/components/AppSearchDialog.tsx +++ b/src/components/AppSearchDialog.tsx @@ -92,6 +92,7 @@ export function AppSearchDialog({ onOpenChange={onOpenChange} data-testid="app-search-dialog" filter={commandFilter} + closeOnCommandPaletteOpen > - SETTINGS_SEARCH_INDEX.filter( - (setting) => - !setting.requiresPro || - (settings !== null && isDyadProEnabled(settings)), - ), + () => getAvailableSettings(Boolean(settings && isDyadProEnabled(settings))), [settings], ); const settingsScoreById = useMemo(() => { @@ -201,7 +196,8 @@ export function CommandPalette({ } else if (selectedAppId) { await navigate({ to, search: { appId: selectedAppId } }); } - await revealCommandPaletteTarget(targetId); + const revealed = await revealCommandPaletteTarget(targetId); + if (!revealed) showError("Couldn't open that setting. Please try again."); }; const openConfigureTarget = async (targetId: string) => { @@ -209,7 +205,8 @@ export function CommandPalette({ await selectChat({ chatId: targetChat.id, appId: selectedAppId }); setIsPreviewOpen(true); setPreviewMode("configure"); - await revealCommandPaletteTarget(targetId); + const revealed = await revealCommandPaletteTarget(targetId); + if (!revealed) showError("Couldn't open that setting. Please try again."); }; const createChat = async () => { diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 359192e007..2fae426ad7 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -33,7 +33,12 @@ export default function ConfirmationDialog({ onClick={onCancel} /> -
+
diff --git a/src/components/SettingsList.tsx b/src/components/SettingsList.tsx index 9ef1b8f554..2ad2ce44d3 100644 --- a/src/components/SettingsList.tsx +++ b/src/components/SettingsList.tsx @@ -4,8 +4,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useScrollAndNavigateTo } from "@/hooks/useScrollAndNavigateTo"; import { useAtom } from "jotai"; import { activeSettingsSectionAtom } from "@/atoms/viewAtoms"; -import { SECTION_IDS, searchSettings } from "@/lib/settingsSearchIndex"; +import { + getAvailableSettings, + SECTION_IDS, + searchSettings, +} from "@/lib/settingsSearchIndex"; import { SearchIcon, XIcon } from "lucide-react"; +import { useSettings } from "@/hooks/useSettings"; +import { isDyadProEnabled } from "@/lib/schemas"; type SettingsSection = { id: string; @@ -29,6 +35,11 @@ export function SettingsList({ show }: { show: boolean }) { const [activeSection, setActiveSection] = useAtom(activeSettingsSectionAtom); const [searchQuery, setSearchQuery] = useState(""); const inputRef = useRef(null); + const { settings } = useSettings(); + const availableSettings = useMemo( + () => getAvailableSettings(Boolean(settings && isDyadProEnabled(settings))), + [settings], + ); const scrollAndNavigateTo = useScrollAndNavigateTo("/settings", { behavior: "smooth", @@ -43,8 +54,8 @@ export function SettingsList({ show }: { show: boolean }) { const searchResults = useMemo(() => { if (!searchQuery.trim()) return null; - return searchSettings(searchQuery); - }, [searchQuery]); + return searchSettings(searchQuery, availableSettings); + }, [availableSettings, searchQuery]); useEffect(() => { if (!show) return; diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 677ff4c46a..36f54e8a30 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -35,7 +35,7 @@ function CommandDialog({ children, className, showCloseButton = true, - closeOnCommandPaletteOpen = true, + closeOnCommandPaletteOpen = false, filter, open, onOpenChange, diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index a3825b1a10..62a1d066fe 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -5,9 +5,11 @@ import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { COMMAND_PALETTE_OPENING_EVENT } from "@/lib/commandPalette"; +const CommandPaletteDismissibleContext = React.createContext(false); + function Dialog({ actionsRef, - closeOnCommandPaletteOpen = true, + closeOnCommandPaletteOpen = false, ...props }: DialogPrimitive.Root.Props & { closeOnCommandPaletteOpen?: boolean; @@ -31,11 +33,15 @@ function Dialog({ }, [closeOnCommandPaletteOpen, dialogActionsRef]); return ( - + + + ); } @@ -75,11 +81,18 @@ function DialogContent({ }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; }) { + const isCommandPaletteDismissible = React.useContext( + CommandPaletteDismissibleContext, + ); + return ( { }); }); -describe("hasBlockingAlertDialogOpen", () => { - it("protects an open destructive confirmation from palette replacement", () => { +describe("hasBlockingCommandPaletteDialogOpen", () => { + it("protects open confirmations and dialogs with pending input", () => { const alert = document.createElement("div"); alert.dataset.slot = "alert-dialog-content"; alert.dataset.open = ""; document.body.append(alert); - expect(hasBlockingAlertDialogOpen()).toBe(true); + expect(hasBlockingCommandPaletteDialogOpen()).toBe(true); alert.remove(); - expect(hasBlockingAlertDialogOpen()).toBe(false); + + const dialog = document.createElement("div"); + dialog.dataset.slot = "dialog-content"; + dialog.dataset.open = ""; + document.body.append(dialog); + expect(hasBlockingCommandPaletteDialogOpen()).toBe(true); + + dialog.dataset.commandPaletteDismissible = "true"; + expect(hasBlockingCommandPaletteDialogOpen()).toBe(false); + dialog.remove(); }); }); diff --git a/src/lib/commandPalette.ts b/src/lib/commandPalette.ts index 4050c797a3..7277509c4a 100644 --- a/src/lib/commandPalette.ts +++ b/src/lib/commandPalette.ts @@ -68,11 +68,13 @@ export function announceCommandPaletteOpening(): void { window.dispatchEvent(new Event(COMMAND_PALETTE_OPENING_EVENT)); } -export function hasBlockingAlertDialogOpen( +export function hasBlockingCommandPaletteDialogOpen( root: Pick = document, ): boolean { return Boolean( - root.querySelector('[data-slot="alert-dialog-content"][data-open]'), + root.querySelector( + '[data-slot="alert-dialog-content"][data-open], [data-slot="dialog-content"][data-open]:not([data-command-palette-dismissible="true"]):not([data-testid="command-palette"])', + ), ); } diff --git a/src/lib/settingsSearchIndex.test.ts b/src/lib/settingsSearchIndex.test.ts index d9dad025b8..1e6e0d6b2c 100644 --- a/src/lib/settingsSearchIndex.test.ts +++ b/src/lib/settingsSearchIndex.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + getAvailableSettings, SECTION_IDS, SETTING_IDS, SETTINGS_SEARCH_INDEX, @@ -162,4 +163,13 @@ describe("SETTINGS_SEARCH_INDEX", () => { it("exposes the shared fuzzy settings ranking", () => { expect(searchSettings("theme")[0]?.item.label).toBe("Theme"); }); + + it("hides Pro-only destinations when Pro is unavailable", () => { + expect(getAvailableSettings(false).some((item) => item.requiresPro)).toBe( + false, + ); + expect(getAvailableSettings(true).some((item) => item.requiresPro)).toBe( + true, + ); + }); }); diff --git a/src/lib/settingsSearchIndex.ts b/src/lib/settingsSearchIndex.ts index ead1a5577e..86c439cf0a 100644 --- a/src/lib/settingsSearchIndex.ts +++ b/src/lib/settingsSearchIndex.ts @@ -635,6 +635,12 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ }, ]; +export function getAvailableSettings(isProEnabled: boolean) { + return SETTINGS_SEARCH_INDEX.filter( + (setting) => !setting.requiresPro || isProEnabled, + ); +} + const SETTINGS_SEARCH_OPTIONS = { keys: [ { name: "label", weight: 2 }, From 9573973b6c0864154684672b2dd279a7f2cafaad Mon Sep 17 00:00:00 2001 From: Will Chen <7344640+wwwillchen@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:50:25 -0700 Subject: [PATCH 6/6] Address additional command palette review comments --- e2e-tests/command_palette.spec.ts | 4 ++ rules/adding-settings.md | 5 ++- rules/base-ui-components.md | 5 +++ src/app/layout.tsx | 9 ++-- src/components/CommandPalette.tsx | 64 ++++++++++++++++------------- src/components/SettingsList.tsx | 3 +- src/components/ui/dialog.tsx | 7 +++- src/lib/commandPalette.test.ts | 40 +++++++++++++++++- src/lib/commandPalette.ts | 58 ++++++++++++++++++++------ src/lib/settingsSearchIndex.test.ts | 33 +++++++++++++-- src/lib/settingsSearchIndex.ts | 49 ++++++++++++++++++---- 11 files changed, 214 insertions(+), 63 deletions(-) diff --git a/e2e-tests/command_palette.spec.ts b/e2e-tests/command_palette.spec.ts index 7a829657b4..fb9b23ae7e 100644 --- a/e2e-tests/command_palette.spec.ts +++ b/e2e-tests/command_palette.spec.ts @@ -21,6 +21,10 @@ test("command palette supports scoped chat and unfiltered configuration search", await po.page.keyboard.press("Control+p"); await expect(input).toHaveValue(""); + await input.fill("GitHub Integration"); + await expect( + po.page.getByTestId("command-palette-setting-setting-github"), + ).toHaveCount(0); await input.fill("Theme"); await po.page.getByTestId("command-palette-setting-setting-theme").click(); await expect(po.page).toHaveURL(/\/settings/); diff --git a/rules/adding-settings.md b/rules/adding-settings.md index 94bcd9119a..601b869336 100644 --- a/rules/adding-settings.md +++ b/rules/adding-settings.md @@ -12,7 +12,10 @@ When adding a new toggle/setting to the Settings page: Every settings search-index entry must point to an element that renders with the same ID. If the control is conditional (for example, Pro-only), encode that availability in the index and filter search surfaces with the same condition so -they never offer a destination that cannot render. +they never offer a destination that cannot render. This includes connection- +gated integrations, not only entitlement flags. When a shared fuzzy index is +searched on each keystroke, cache the index by the stable filtered-items array +instead of rebuilding it inside the search function. If the setting adds a built-in default, update the inline snapshots in `src/main/settings.test.ts`; otherwise `npm test` will fail with diff --git a/rules/base-ui-components.md b/rules/base-ui-components.md index ce0a4d4d0c..95b2249edd 100644 --- a/rules/base-ui-components.md +++ b/rules/base-ui-components.md @@ -55,6 +55,11 @@ as search pickers. Suppress the new modal while input-bearing dialogs or any Legacy confirmation overlays must expose the same blocking marker as the shared `AlertDialog` until they are migrated. +Before claiming a capture-phase shortcut, check whether an embedded editor owns +the chord. Monaco should retain both Cmd and Ctrl chords; terminal exemptions +may be modifier-specific. Opt-in dialog coordination should only attach its +imperative ref and global listener for dialogs that actually opt in. + ## TooltipTrigger render prop `TooltipTrigger` from `@base-ui/react/tooltip` (wrapped in `src/components/ui/tooltip.tsx`) renders a `