Skip to content
28 changes: 25 additions & 3 deletions apps/app/src/components/commands/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter>
<AppCommandProvider>
Expand All @@ -168,7 +174,7 @@ function renderPalette(isCompactViewport = false) {
<Handler command="thread.next" />
<Handler command="panel.toggle" />
<Handler command="terminal.open" />
<CommandPalette threadId={null} projectId={null} />
<CommandPalette threadId={null} projectId={null} onSplit={onSplit} />
<LocationProbe />
</AppCommandProvider>
</MemoryRouter>,
Expand Down Expand Up @@ -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());

Expand Down
20 changes: 19 additions & 1 deletion apps/app/src/components/commands/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -92,6 +109,7 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) {
],
[
projectId,
onSplit,
runner.dispatch,
runner.isCommandAvailable,
shortcuts,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,8 +24,14 @@ const mockUseThreadDetailBootstrap = vi.hoisted(() => vi.fn());
const commandHandlers = vi.hoisted(() => new Map<string, () => 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(),
Expand All @@ -26,7 +43,15 @@ vi.mock("@/components/commands/AppCommandProvider", () => ({
}));

vi.mock("@/components/sidebar/AppSidebar", () => ({
AppSidebar: () => <aside data-testid="app-sidebar" />,
AppSidebar: ({ onSplit }: { onSplit?: () => void }) => (
<aside data-testid="app-sidebar">
{onSplit ? (
<button type="button" onClick={onSplit}>
Split
</button>
) : null}
</aside>
),
}));

vi.mock("@/hooks/queries/system-queries", () => ({
Expand Down Expand Up @@ -233,4 +258,67 @@ describe("AppLayout root compose project preference", () => {
window.localStorage.getItem(ROOT_COMPOSE_PROJECT_ID_STORAGE_KEY),
).toBe("proj_last_run");
});

it("enters Split with two independently writable draft slots and left focus", async () => {
const store = createStore();

render(
<Provider store={store}>
<MemoryRouter
initialEntries={["/projects/proj_opened/threads/thr_opened"]}
>
<AppLayout>
<div>Thread route</div>
</AppLayout>
</MemoryRouter>
</Provider>,
);

fireEvent.click(await screen.findByRole("button", { name: "Split" }));
expect(commandHandlers.has("thread.split")).toBe(false);

const layout = store.get(splitLayoutAtom);
expect(layout).not.toBeNull();
const panes = listPanes(layout!.root);
expect(panes).toHaveLength(2);
expect(layout?.focusedPaneId).toBe(panes[0]?.paneId);
const leftContent = panes[0]?.content;
const rightContent = panes[1]?.content;
expect(leftContent?.kind).toBe("new-thread");
expect(rightContent?.kind).toBe("new-thread");
if (
leftContent?.kind !== "new-thread" ||
rightContent?.kind !== "new-thread"
) {
throw new Error("Split did not create two New thread panes.");
}
expect(leftContent.draftSlotId).not.toBe(rightContent.draftSlotId);

const destination = { projectId: "proj_opened", sectionId: null };
const leftDraft = getPromptDraftAccessor({
kind: "new-thread",
slotId: leftContent.draftSlotId,
destination,
});
const rightDraft = getPromptDraftAccessor({
kind: "new-thread",
slotId: rightContent.draftSlotId,
destination,
});
act(() => {
leftDraft.setDraft({
text: "Left pane draft",
mentions: [],
attachments: [],
});
rightDraft.setDraft({
text: "Right pane draft",
mentions: [],
attachments: [],
});
});

expect(leftDraft.getCurrent().text).toBe("Left pane draft");
expect(rightDraft.getCurrent().text).toBe("Right pane draft");
});
});
28 changes: 26 additions & 2 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ import {
useMobileVisualViewportHeight,
} from "./useMobileVisualViewportHeight";
import { wsManager } from "@/lib/ws";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import { findPaneByThread } from "@/lib/split-layout";
import { maximizedPaneIdAtom, splitLayoutAtom } from "@/lib/split-layout/atoms";
import { findPaneByThread, replaceWithTwoPaneLayout } from "@/lib/split-layout";
import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation";
import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { createNewThreadDraftSlotId } from "@/lib/prompt-draft-slots";
import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state";

const SIDEBAR_WIDTH_KEY = "bb.sidebar.width";
const SIDEBAR_OPEN_KEY = "bb.sidebar.open";
Expand Down Expand Up @@ -489,6 +491,26 @@ export function AppLayout({ children }: AppLayoutProps) {
});
return true;
});
const startTwoPaneCompose = useCallback(() => {
if (isCompactViewport) return;
if (projectId !== undefined) {
setRootComposeProjectId(projectId);
}
const leftDraftSlotId = createNewThreadDraftSlotId();
const rightDraftSlotId = createNewThreadDraftSlotId();
store.set(
splitLayoutAtom,
replaceWithTwoPaneLayout(
store.get(splitLayoutAtom),
{ kind: "new-thread", draftSlotId: leftDraftSlotId },
{ kind: "new-thread", draftSlotId: rightDraftSlotId },
),
);
store.set(maximizedPaneIdAtom, null);
void navigate(getRootComposeRoutePath(), {
state: withRootComposeDraftSlotId(null, leftDraftSlotId),
});
}, [isCompactViewport, navigate, projectId, setRootComposeProjectId, store]);
useAppCommandHandler("settings.open", () => {
void navigate(settingsRoutePath);
return true;
Expand Down Expand Up @@ -815,6 +837,7 @@ export function AppLayout({ children }: AppLayoutProps) {
settingsRoutePath={settingsRoutePath}
toolsBackRoutePath={toolsBackRoutePath}
toolsRoutePath={toolsRoutePath}
onSplit={isCompactViewport ? undefined : startTwoPaneCompose}
/>
<SidebarInset>
<div
Expand Down Expand Up @@ -854,6 +877,7 @@ export function AppLayout({ children }: AppLayoutProps) {
<CommandPalette
threadId={threadId ?? null}
projectId={projectId ?? null}
onSplit={isCompactViewport ? undefined : startTwoPaneCompose}
/>
<ProjectPathDialog
target={quickCreateProject.projectPathDialog.target}
Expand Down
4 changes: 4 additions & 0 deletions apps/app/src/components/layout/AppLayoutSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface AppLayoutSidebarProps {
settingsRoutePath: string;
toolsBackRoutePath: string;
toolsRoutePath?: string;
onSplit?: () => void;
}

/**
Expand All @@ -38,6 +39,7 @@ export function AppLayoutSidebar({
settingsRoutePath,
toolsBackRoutePath,
toolsRoutePath,
onSplit,
}: AppLayoutSidebarProps) {
const { isCompactViewport, isMobileSidebarClosing } = useSidebar();
const holdCurrentMode = isCompactViewport && isMobileSidebarClosing;
Expand All @@ -58,6 +60,7 @@ export function AppLayoutSidebar({
showTopReserve={true}
settingsRoutePath={settingsRoutePath}
toolsRoutePath={toolsRoutePath}
onSplit={onSplit}
mobileHosted={{ hidden: renderedMode !== "app" }}
/>
{renderedMode === "settings" ? (
Expand Down Expand Up @@ -111,6 +114,7 @@ export function AppLayoutSidebar({
showTopReserve={true}
settingsRoutePath={settingsRoutePath}
toolsRoutePath={toolsRoutePath}
onSplit={onSplit}
/>
);
}
3 changes: 3 additions & 0 deletions apps/app/src/components/sidebar/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ interface AppSidebarProps {
showTopReserve: boolean;
settingsRoutePath: string;
toolsRoutePath?: string;
onSplit?: () => void;
/**
* Compact drawer hosting. When set, the sidebar renders its body only,
* inside a persistent `<Sidebar>` panel owned by AppLayoutSidebar, and stays
Expand All @@ -141,6 +142,7 @@ export function AppSidebar({
showTopReserve,
settingsRoutePath,
toolsRoutePath,
onSplit,
mobileHosted,
}: AppSidebarProps) {
const quickCreateProject = useQuickCreateProjectController();
Expand Down Expand Up @@ -342,6 +344,7 @@ export function AppSidebar({
splitEnabled
newThreadSplit={newThreadSplit}
onNewChat={handleNewChat}
onSplit={onSplit}
onSearchThreads={closeOnMobile}
/>
{toolsRoutePath ? (
Expand Down
Loading
Loading