diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index a2571ab23d..14710012da 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -157,7 +157,13 @@ function Handler({ command }: { command: AppCommandId }) { return null; } -function renderPalette(isCompactViewport = false) { +function renderPalette({ + isCompactViewport = false, + onSplit, +}: { + isCompactViewport?: boolean; + onSplit?: () => void; +} = {}) { const result = render( @@ -168,7 +174,7 @@ function renderPalette(isCompactViewport = false) { - + , @@ -277,8 +283,24 @@ describe("CommandPalette", () => { expect(document.activeElement).toBe(screen.getByTestId("origin")); }); + it("runs Split as an internal palette action without an app command", async () => { + const onSplit = vi.fn(); + renderPalette({ onSplit }); + openPalette(); + await waitFor(() => expect(searchField()).toBeTruthy()); + + fireEvent.change(searchField(), { target: { value: ">split" } }); + await waitFor(() => + expect(selectedOption()?.textContent).toContain("Split"), + ); + fireEvent.keyDown(searchField(), { key: "Enter" }); + + await waitFor(() => expect(onSplit).toHaveBeenCalledOnce()); + expect(testState.calls).toEqual([]); + }); + it("runs a compact selection once after restoring focus", async () => { - renderPalette(true); + renderPalette({ isCompactViewport: true }); openPalette(); await waitFor(() => expect(searchField()).toBeTruthy()); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 513b5c79bf..4d93d41df2 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -47,13 +47,19 @@ export interface CommandPaletteProps { /** The surface's thread and project, handed to plugin rows. */ threadId: string | null; projectId: string | null; + /** Internal layout action; intentionally not a public app command. */ + onSplit?: () => void; } /** * Type to filter the commands that apply right now, then run one with Enter. * Mounted once by `AppLayout` and opened by `palette.open`. */ -export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { +export function CommandPalette({ + threadId, + projectId, + onSplit, +}: CommandPaletteProps) { const navigate = useNavigate(); const runner = useAppCommandRunner(); const shortcuts = useAppCommandShortcuts(PALETTE_COMMAND_IDS); @@ -83,6 +89,17 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { dispatch: runner.dispatch, shortcuts, }), + ...(onSplit === undefined + ? [] + : [ + { + id: "internal:thread.split", + group: "Threads", + title: "Split", + shortcut: null, + run: onSplit, + } satisfies PaletteAction, + ]), ...buildPluginPaletteActions({ slots: getPluginSlotSnapshot().commandPaletteActions, threadId, @@ -92,6 +109,7 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) { ], [ projectId, + onSplit, runner.dispatch, runner.isCommandAvailable, shortcuts, diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index 0ee3ec60de..1f47c8146b 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -1,10 +1,21 @@ // @vitest-environment jsdom -import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import type { ReactNode } from "react"; +import { createStore, Provider } from "jotai"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppLayout } from "./AppLayout"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { listPanes } from "@/lib/split-layout"; +import { getPromptDraftAccessor } from "@/hooks/usePromptDraftStorage"; const ROOT_COMPOSE_PROJECT_ID_STORAGE_KEY = "bb.root-compose.project-id"; @@ -13,8 +24,14 @@ const mockUseThreadDetailBootstrap = vi.hoisted(() => vi.fn()); const commandHandlers = vi.hoisted(() => new Map boolean>()); vi.mock("@/components/commands/AppCommandProvider", () => ({ - useAppCommandHandler: (command: string, handler: () => boolean) => { - commandHandlers.set(command, handler); + useAppCommandHandler: ( + command: string, + handler: () => boolean, + _priority = 0, + enabled = true, + ) => { + if (enabled) commandHandlers.set(command, handler); + else commandHandlers.delete(command); }, useAppCommandShortcut: () => null, useAppCommandShortcuts: () => new Map(), @@ -26,7 +43,15 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({ })); vi.mock("@/components/sidebar/AppSidebar", () => ({ - AppSidebar: () =>