Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions e2e-tests/app_search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }) => {
Expand Down
76 changes: 76 additions & 0 deletions e2e-tests/command_palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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(
Comment thread
keppo-bot[bot] marked this conversation as resolved.
Outdated
/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("env vars");
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("");
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();
});
5 changes: 5 additions & 0 deletions rules/adding-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions rules/base-ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>` by default. Wrapping another button-like element (`<button>`, `<Button>`, `<DropdownMenuTrigger>`, `<PopoverTrigger>`, `<MiniSelectTrigger>`, `<ToggleGroupItem>`) inside it creates invalid nested `<button>` HTML. Use the `render` prop instead:
Expand Down
22 changes: 21 additions & 1 deletion src/app/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -129,6 +134,21 @@ export const TitleBar = () => {
<TooltipContent>{displayText}</TooltipContent>
</Tooltip>
{isDyadPro && <DyadProButton isDyadProEnabled={isDyadProEnabled} />}
<Button
type="button"
variant="outline"
size="sm"
className="no-app-region-drag ml-2 flex h-7 gap-1.5 px-2 text-xs"
onClick={onOpenCommandPalette}
aria-label="Open command palette"
data-testid="command-palette-trigger"
>
<Search className="size-3.5" />
<span className="hidden @3xl:inline">Commands</span>
<kbd className="hidden text-[10px] text-muted-foreground @xl:inline">
{platform === "darwin" ? "⌘P" : "Ctrl P"}
</kbd>
</Button>
</div>

<div className="flex-1 min-w-0 overflow-hidden self-end">
Expand Down
68 changes: 66 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,6 +55,13 @@ 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 {
announceCommandPaletteOpening,
CHAT_SCOPE_PREFIX,
hasBlockingAlertDialogOpen,
isTerminalShortcutTarget,
} from "@/lib/commandPalette";

export default function RootLayout({ children }: { children: ReactNode }) {
const { streamMessage } = useStreamChat({ hasChatId: false });
Expand Down Expand Up @@ -111,8 +124,53 @@ 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 = "") => {
if (hasBlockingAlertDialogOpen()) return;
announceCommandPaletteOpening();
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;
}

if (
Comment thread
keppo-bot[bot] marked this conversation as resolved.
event.ctrlKey &&
!event.metaKey &&
isTerminalShortcutTarget(event.target)
) {
return;
}

event.preventDefault();
Comment thread
keppo-bot[bot] marked this conversation as resolved.
if (event.repeat) return;
openCommandPalette(
event.key.toLowerCase() === "k" ? CHAT_SCOPE_PREFIX : "",
);
};

window.addEventListener("keydown", handleCommandPaletteShortcut, true);
return () =>
window.removeEventListener("keydown", handleCommandPaletteShortcut, true);
}, [openCommandPalette]);

// Initialize plan events listener
usePlanEvents();
useIntegrationEvents();
Expand Down Expand Up @@ -194,7 +252,7 @@ function RootLayoutContent({ children }: { children: ReactNode }) {
<ThemeProvider>
<DeepLinkProvider>
<SidebarProvider defaultOpen={false}>
<TitleBar />
<TitleBar onOpenCommandPalette={() => openCommandPalette("")} />
<AppSidebar />
<div className="flex h-screenish min-w-0 flex-1 flex-col overflow-hidden mt-[var(--layout-title-bar-offset)] border-l border-border bg-background">
<SubscriptionStatusBanner />
Expand All @@ -212,6 +270,12 @@ function RootLayoutContent({ children }: { children: ReactNode }) {
/>
<ReleaseNotesDialog />
<ForceCloseDialog />
<CommandPalette
open={isCommandPaletteOpen}
query={commandPaletteQuery}
onOpenChange={handleCommandPaletteOpenChange}
onQueryChange={setCommandPaletteQuery}
/>
</SidebarProvider>
</DeepLinkProvider>
</ThemeProvider>
Expand Down
14 changes: 0 additions & 14 deletions src/components/AppSearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ type AppSearchDialogProps = {
onOpenChange: (open: boolean) => void;
onSelectApp: (appId: number) => void;
allApps: AppSearchResult[];
disableShortcut?: boolean;
};

export function AppSearchDialog({
open,
onOpenChange,
onSelectApp,
allApps,
disableShortcut,
}: AppSearchDialogProps) {
const [searchQuery, setSearchQuery] = useState<string>("");
function useDebouncedValue<T>(value: T, delay: number): T {
Expand Down Expand Up @@ -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 (
<CommandDialog
open={open}
Expand Down
11 changes: 0 additions & 11 deletions src/components/ChatSearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,6 @@ export function ChatSearchDialog({
return { before, match, after, raw: before + match + after };
}

useEffect(() => {
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 (
<CommandDialog
open={open}
Expand Down
Loading
Loading