Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
83 changes: 83 additions & 0 deletions e2e-tests/command_palette.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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("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/);
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");
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 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")).toBeVisible();
await expect(palette).not.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();
});
8 changes: 8 additions & 0 deletions rules/adding-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ 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. 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
default settings snapshot mismatches.
Expand Down
16 changes: 16 additions & 0 deletions rules/base-ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ 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, 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.

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 `<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
5 changes: 5 additions & 0 deletions rules/e2e-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
69 changes: 67 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,
hasBlockingCommandPaletteDialogOpen,
shouldPreserveCommandPaletteShortcut,
} from "@/lib/commandPalette";

export default function RootLayout({ children }: { children: ReactNode }) {
const { streamMessage } = useStreamChat({ hasChatId: false });
Expand Down Expand Up @@ -111,8 +124,54 @@ 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 (hasBlockingCommandPaletteDialogOpen()) 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.
shouldPreserveCommandPaletteShortcut(event.target, {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
})
) {
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 +253,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 +271,12 @@ function RootLayoutContent({ children }: { children: ReactNode }) {
/>
<ReleaseNotesDialog />
<ForceCloseDialog />
<CommandPalette
open={isCommandPaletteOpen}
query={commandPaletteQuery}
onOpenChange={handleCommandPaletteOpenChange}
onQueryChange={setCommandPaletteQuery}
/>
</SidebarProvider>
</DeepLinkProvider>
</ThemeProvider>
Expand Down
15 changes: 1 addition & 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,24 +86,13 @@ 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}
onOpenChange={onOpenChange}
data-testid="app-search-dialog"
filter={commandFilter}
closeOnCommandPaletteOpen
>
<CommandInput
placeholder="Search apps"
Expand Down
12 changes: 1 addition & 11 deletions src/components/ChatSearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,23 +87,13 @@ 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}
onOpenChange={onOpenChange}
data-testid="chat-search-dialog"
filter={commandFilter}
closeOnCommandPaletteOpen
>
<CommandInput
placeholder="Search chats"
Expand Down
Loading
Loading