From 1cd67fc998cd94e5a1195b6ba8705b806ddc3378 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 01:58:38 -0700 Subject: [PATCH 1/8] Add two-pane split replacement operation --- apps/app/src/lib/split-layout/ops.test.ts | 62 +++++++++++++++++++++++ apps/app/src/lib/split-layout/ops.ts | 35 ++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/apps/app/src/lib/split-layout/ops.test.ts b/apps/app/src/lib/split-layout/ops.test.ts index 020dae913c..a15502647d 100644 --- a/apps/app/src/lib/split-layout/ops.test.ts +++ b/apps/app/src/lib/split-layout/ops.test.ts @@ -10,6 +10,7 @@ import { normalize, removePane, replacePaneContent, + replaceWithTwoPaneLayout, resizeSplit, setFocus, splitPane, @@ -68,6 +69,67 @@ function expectNormalizedSizes(layout: SplitLayout): void { } describe("split layout operations", () => { + it("creates two equal new-thread panes and focuses the left pane", () => { + const leftContent = newThreadContent("left-draft"); + const rightContent = newThreadContent("right-draft"); + + const layout = replaceWithTwoPaneLayout( + null, + leftContent, + rightContent, + ); + + expect(layout).toEqual({ + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + { type: "pane", paneId: "pane-1", content: leftContent }, + { type: "pane", paneId: "pane-2", content: rightContent }, + ], + }, + focusedPaneId: "pane-1", + }); + }); + + it.each([1, 2, MAX_PANES])( + "replaces a %i-pane workspace without retaining old panes or content", + (paneCount) => { + const current = layoutAtPaneCount(paneCount); + const previousPanes = listPanes(current.root); + const previousPaneIds = new Set(previousPanes.map((item) => item.paneId)); + const leftContent = newThreadContent(`left-draft-${paneCount}`); + const rightContent = newThreadContent(`right-draft-${paneCount}`); + + const replacement = replaceWithTwoPaneLayout( + current, + leftContent, + rightContent, + ); + const replacementPanes = listPanes(replacement.root); + + expect(countPanes(replacement.root)).toBe(2); + expect(replacement.root).toMatchObject({ + type: "split", + dir: "row", + sizes: [0.5, 0.5], + }); + expect(replacementPanes[0]?.content).toBe(leftContent); + expect(replacementPanes[1]?.content).toBe(rightContent); + expect(replacement.focusedPaneId).toBe(replacementPanes[0]?.paneId); + expect( + replacementPanes.every( + (item) => !previousPaneIds.has(item.paneId), + ), + ).toBe(true); + expect( + replacementPanes.some((item) => previousPanes.includes(item)), + ).toBe(false); + expect(countPanes(current.root)).toBe(paneCount); + }, + ); + it("keeps independent new-thread slots distinct and finds the exact binding", () => { const withFirstDraft = splitPane( singlePaneLayout(), diff --git a/apps/app/src/lib/split-layout/ops.ts b/apps/app/src/lib/split-layout/ops.ts index 47e9c72c65..ecfde7607c 100644 --- a/apps/app/src/lib/split-layout/ops.ts +++ b/apps/app/src/lib/split-layout/ops.ts @@ -110,8 +110,14 @@ function replacePaneNode( }; } -function nextPaneId(root: LayoutNode): string { - const existingIds = new Set(listPanes(root).map((pane) => pane.paneId)); +function nextPaneId( + root: LayoutNode | null, + reservedIds: readonly string[] = [], +): string { + const existingIds = new Set([ + ...(root === null ? [] : listPanes(root).map((pane) => pane.paneId)), + ...reservedIds, + ]); let sequence = 1; while (existingIds.has(`pane-${sequence}`)) { sequence += 1; @@ -119,6 +125,31 @@ function nextPaneId(root: LayoutNode): string { return `pane-${sequence}`; } +/** Replaces any existing workspace with two equally sized panes. The caller + * owns the content identities, while this operation guarantees that neither + * pane reuses an ID from the layout being replaced. */ +export function replaceWithTwoPaneLayout( + layout: SplitLayout | null, + leftContent: PaneContent, + rightContent: PaneContent, +): SplitLayout { + const previousRoot = layout?.root ?? null; + const leftPaneId = nextPaneId(previousRoot); + const rightPaneId = nextPaneId(previousRoot, [leftPaneId]); + return { + root: { + type: "split", + dir: "row", + sizes: [0.5, 0.5], + children: [ + { type: "pane", paneId: leftPaneId, content: leftContent }, + { type: "pane", paneId: rightPaneId, content: rightContent }, + ], + }, + focusedPaneId: leftPaneId, + }; +} + function splitDirection(side: SplitSide): SplitNode["dir"] { return side === "left" || side === "right" ? "row" : "col"; } From 61371050373537475ff4f9e599741d6932a25440 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 02:02:42 -0700 Subject: [PATCH 2/8] Add sidebar and palette Split entry points --- .../AppLayout.root-compose-project.test.tsx | 81 +++++++- apps/app/src/components/layout/AppLayout.tsx | 31 ++- .../app/src/components/sidebar/AppSidebar.tsx | 9 + .../src/components/sidebar/ProjectList.tsx | 105 +++++++--- .../sidebar/SidebarThreadSearchPanel.test.tsx | 25 ++- apps/app/src/lib/app-command-metadata.ts | 5 + .../palette-app-commands.test.ts | 10 +- .../src/services/system/app-keybindings.ts | 187 ++++++++++++------ .../test/system/app-keybindings.test.ts | 6 +- packages/domain/src/app-keybindings.ts | 1 + 10 files changed, 365 insertions(+), 95 deletions(-) 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..5f5e19a1b3 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 @@ -2,9 +2,13 @@ import { act, cleanup, render, 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 +17,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(), @@ -233,4 +243,71 @@ 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( + + + +
Thread route
+
+
+
, + ); + + await waitFor(() => { + expect(commandHandlers.has("thread.split")).toBe(true); + }); + act(() => { + expect(commandHandlers.get("thread.split")?.()).toBe(true); + }); + + 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"); + }); }); diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index c84322fc92..c12538c8c5 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -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"; @@ -489,6 +491,31 @@ export function AppLayout({ children }: AppLayoutProps) { }); return true; }); + useAppCommandHandler( + "thread.split", + () => { + 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), + }); + return true; + }, + 0, + !isCompactViewport, + ); useAppCommandHandler("settings.open", () => { void navigate(settingsRoutePath); return true; diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index 5824b3d9d0..ff9f55317f 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -60,6 +60,7 @@ import { } from "./sidebarThreadShortcuts"; import { useAppCommandHandler, + useAppCommandRunner, useAppCommandShortcut, useAppCommandShortcuts, useIsAppCommandModifierHeld, @@ -173,6 +174,7 @@ export function AppSidebar({ }); const closeOnMobile = useCloseMobileSidebar(); const { isCompactViewport, setOpen, setOpenMobile } = useSidebar(); + const appCommandRunner = useAppCommandRunner(); const [desktopInfo] = useState(getBbDesktopInfo); const [threadShortcutKeysById, setThreadShortcutKeysById] = useState< ReadonlyMap @@ -238,6 +240,12 @@ export function AppSidebar({ state: { focusPrompt: true }, }); }, [closeOnMobile, navigate]); + const handleSplit = useCallback(() => { + appCommandRunner.dispatch( + "thread.split", + typeof document === "undefined" ? null : document.activeElement, + ); + }, [appCommandRunner]); const showThreadShortcuts = useCallback(() => { const targets = getSidebarThreadShortcutTargets(sidebarRef.current); @@ -421,6 +429,7 @@ export function AppSidebar({ splitEnabled newThreadSplit={newThreadSplit} onNewChat={handleNewChat} + onSplit={isCompactViewport ? undefined : handleSplit} threadSearch={{ activeDescendantId: threadSearch.activeDescendantId, inputRef: threadSearch.inputRef, diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 4aab103b0e..1408986309 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -73,6 +73,11 @@ import { SidebarGroupContent, SidebarStickyStack, } from "@/components/ui/sidebar.js"; +import { + SIDEBAR_HOVER_ACTIONS_CLASS, + SIDEBAR_HOVER_ACTIONS_INSET_CLASS, + SIDEBAR_HOVER_ACTIONS_ROW_CLASS, +} from "@/components/ui/sidebar-hover-actions"; import { COARSE_POINTER_COMPACT_ICON_SIZE_CLASS, COARSE_POINTER_ICON_SIZE_CLASS, @@ -203,6 +208,7 @@ interface ProjectListActionButtonsProps { openInSplit(): void; }; onNewChat?: () => void; + onSplit?: () => void; threadSearch?: SidebarThreadSearchInputController; } @@ -988,6 +994,7 @@ export function ProjectListActionButtons({ splitEnabled = false, newThreadSplit, onNewChat, + onSplit, threadSearch, }: ProjectListActionButtonsProps) { const isNewChatDisabled = !onNewChat; @@ -1044,39 +1051,73 @@ export function ProjectListActionButtons({ ) : (
- + + {onSplit ? ( +
+ +
+ ) : null} +
{threadSearch ? ( diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx index 9d860943e4..7e9ccd7306 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { createRef } from "react"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadListEntry } from "@bb/domain"; @@ -414,6 +414,29 @@ describe("sidebar thread search navigation items", () => { }); describe("ProjectListActionButtons", () => { + it("reveals a keyboard-reachable Columns2 Split row action", () => { + const onSplit = vi.fn(); + + render(); + + const splitButton = screen.getByRole("button", { name: "Split" }); + expect(splitButton.tabIndex).toBe(0); + expect(splitButton.querySelector("svg")).not.toBeNull(); + expect(splitButton.parentElement?.classList).toContain( + "bb-sidebar-hover-actions", + ); + expect(splitButton.closest(".bb-sidebar-hover-actions-row")).not.toBeNull(); + + fireEvent.click(splitButton); + expect(onSplit).toHaveBeenCalledTimes(1); + }); + + it("omits the Split action when the caller marks it unavailable", () => { + render(); + + expect(screen.queryByRole("button", { name: "Split" })).toBeNull(); + }); + it("shows the compose pane position when New thread is open in a split", () => { const store = createStore(); store.set(splitLayoutAtom, { diff --git a/apps/app/src/lib/app-command-metadata.ts b/apps/app/src/lib/app-command-metadata.ts index 26a5f1f00d..4af131dd9e 100644 --- a/apps/app/src/lib/app-command-metadata.ts +++ b/apps/app/src/lib/app-command-metadata.ts @@ -86,6 +86,11 @@ export const APP_COMMAND_GROUPS: readonly AppCommandGroup[] = [ "Open quick palette", "Search and run bb commands from the keyboard.", ), + command( + "thread.split", + "Split", + "Open two blank thread composers side by side.", + ), command("window.new", "New window", "Open another bb desktop window."), command("settings.open", "Open settings", "Open bb settings."), command( diff --git a/apps/app/src/lib/command-palette/palette-app-commands.test.ts b/apps/app/src/lib/command-palette/palette-app-commands.test.ts index ca90f7d23c..4382dd16b3 100644 --- a/apps/app/src/lib/command-palette/palette-app-commands.test.ts +++ b/apps/app/src/lib/command-palette/palette-app-commands.test.ts @@ -28,6 +28,7 @@ function build( describe("PALETTE_COMMAND_IDS", () => { it("omits the numbered accelerator families and the palette's own command", () => { expect(PALETTE_COMMAND_IDS).toContain("thread.new"); + expect(PALETTE_COMMAND_IDS).toContain("thread.split"); expect(PALETTE_COMMAND_IDS).not.toContain("thread.jump.1"); expect(PALETTE_COMMAND_IDS).not.toContain("pane.focus.1"); expect(PALETTE_COMMAND_IDS).not.toContain("question.select.1"); @@ -77,8 +78,13 @@ describe("buildAppCommandActions", () => { }); it("leaves the shortcut null for a command the user has not bound", () => { - const { actions } = build(["thread.rename"]); - expect(actions[0]?.shortcut).toBeNull(); + const { actions } = build(["thread.split"]); + expect(actions[0]).toMatchObject({ + id: "app:thread.split", + group: "Window and layout", + shortcut: null, + title: "Split", + }); }); it("dispatches with the element that was focused before the palette opened", () => { diff --git a/apps/server/src/services/system/app-keybindings.ts b/apps/server/src/services/system/app-keybindings.ts index 229faf45ea..c34d63ce22 100644 --- a/apps/server/src/services/system/app-keybindings.ts +++ b/apps/server/src/services/system/app-keybindings.ts @@ -140,10 +140,17 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ // Browsers reserve Mod+N before the page receives a key event. Keep the // t3code-style alias available in web clients while desktop retains Mod+N. binding("thread.new", "o", { mod: true, shift: true }, mainWithoutModal), - binding("thread.new", "n", { mod: true }, { - ...mainWithoutModal, - desktopOnly: true, - }), + binding( + "thread.new", + "n", + { mod: true }, + { + ...mainWithoutModal, + desktopOnly: true, + }, + ), + // Discoverable in the palette without adding a default chord. + unassignedBinding("thread.split", mainWithoutModal), binding("thread.search", "k", { mod: true }, mainWithoutModal), unassignedBinding("thread.rename", mainWithoutModal), unassignedBinding("thread.archive", mainWithoutModal), @@ -155,20 +162,30 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ { control: true, shift: true }, webMainWithoutModal, ), - binding("thread.previous", "[", { mod: true, shift: true }, { - ...mainWithoutModal, - desktopOnly: true, - }), + binding( + "thread.previous", + "[", + { mod: true, shift: true }, + { + ...mainWithoutModal, + desktopOnly: true, + }, + ), binding( "thread.next", "]", { control: true, shift: true }, webMainWithoutModal, ), - binding("thread.next", "]", { mod: true, shift: true }, { - ...mainWithoutModal, - desktopOnly: true, - }), + binding( + "thread.next", + "]", + { mod: true, shift: true }, + { + ...mainWithoutModal, + desktopOnly: true, + }, + ), // Browsers reserve Mod+1…9 for native tab switching. Match Slack's web // navigation convention: Control+N on macOS and Ctrl+Shift+N elsewhere, // while keeping the shorter Mod chord on desktop. @@ -182,32 +199,57 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ { mod: true, shift: true }, splitWithoutModal, ), - binding( - "pane.close", - "x", - { mod: true, shift: true }, - splitWithoutModal, - ), + binding("pane.close", "x", { mod: true, shift: true }, splitWithoutModal), binding("panel.newTab", "t", { mod: true }, mainWithoutModal), binding("panel.close", "w", { mod: true }, mainWithoutModal), binding("panel.toggle", "j", { mod: true }, mainWithoutModal), binding("file.quickOpen", "p", { mod: true }, mainWithoutModal), - binding("diff.toggle", "d", { mod: true }, { - ...mainWithoutModal, - none: ["modalOpen", "editableFocus", "terminalFocus", "browserFocus"], - }), + binding( + "diff.toggle", + "d", + { mod: true }, + { + ...mainWithoutModal, + none: ["modalOpen", "editableFocus", "terminalFocus", "browserFocus"], + }, + ), // Browsers reserve Mod+Shift+T for reopening a closed tab before the page // receives the event. Use Enter as the web alias and retain T on desktop. - binding("terminal.open", "Enter", { mod: true, shift: true }, mainWithoutModal), - binding("terminal.open", "t", { mod: true, shift: true }, { - ...mainWithoutModal, - desktopOnly: true, - }), - binding("composer.focus", "c", { mod: true, shift: true }, composerWithoutModal), - binding("modelPicker.toggle", "m", { mod: true, shift: true }, composerWithoutModal), + binding( + "terminal.open", + "Enter", + { mod: true, shift: true }, + mainWithoutModal, + ), + binding( + "terminal.open", + "t", + { mod: true, shift: true }, + { + ...mainWithoutModal, + desktopOnly: true, + }, + ), + binding( + "composer.focus", + "c", + { mod: true, shift: true }, + composerWithoutModal, + ), + binding( + "modelPicker.toggle", + "m", + { mod: true, shift: true }, + composerWithoutModal, + ), // This later, scoped binding lets the same chord close the picker while the // general binding remains blocked by unrelated dialogs. - binding("modelPicker.toggle", "m", { mod: true, shift: true }, pickerOpenOnly), + binding( + "modelPicker.toggle", + "m", + { mod: true, shift: true }, + pickerOpenOnly, + ), // Rotate the composer's provider, model, and reasoning level in either // direction without opening the picker, scoped exactly like // `modelPicker.toggle` above. Alt is otherwise unused by bb, the browser, and @@ -221,14 +263,24 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ { alt: true, shift: true }, composerWithoutModal, ), - binding("modelPicker.cycleProvider", "p", { alt: true }, composerWithoutModal), + binding( + "modelPicker.cycleProvider", + "p", + { alt: true }, + composerWithoutModal, + ), binding( "modelPicker.cycleProviderBackward", "p", { alt: true, shift: true }, composerWithoutModal, ), - binding("modelPicker.cycleReasoning", "t", { alt: true }, composerWithoutModal), + binding( + "modelPicker.cycleReasoning", + "t", + { alt: true }, + composerWithoutModal, + ), binding( "modelPicker.cycleReasoningBackward", "t", @@ -259,32 +311,57 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [ { alt: true, shift: true }, pickerOpenOnly, ), - binding("browser.focusLocation", "l", { mod: true }, { - all: ["mainSurface", "browserFocus"], - desktopOnly: true, - none: ["modalOpen"], - }), - binding("browser.reload", "r", { mod: true }, { - all: ["mainSurface", "browserFocus"], - desktopOnly: true, - none: ["modalOpen"], - }), - binding("browser.find", "f", { mod: true }, { - all: ["mainSurface", "browserFocus"], - desktopOnly: true, - none: ["modalOpen"], - }), + binding( + "browser.focusLocation", + "l", + { mod: true }, + { + all: ["mainSurface", "browserFocus"], + desktopOnly: true, + none: ["modalOpen"], + }, + ), + binding( + "browser.reload", + "r", + { mod: true }, + { + all: ["mainSurface", "browserFocus"], + desktopOnly: true, + none: ["modalOpen"], + }, + ), + binding( + "browser.find", + "f", + { mod: true }, + { + all: ["mainSurface", "browserFocus"], + desktopOnly: true, + none: ["modalOpen"], + }, + ), binding("workspace.openPreferred", "o", { mod: true }, mainWithoutModal), ...QUESTION_SELECT_APP_COMMAND_IDS.map((command, index) => - binding(command, String(index + 1), {}, { - all: ["mainSurface", "questionOpen"], - none: ["modalOpen", "editableFocus"], - }), + binding( + command, + String(index + 1), + {}, + { + all: ["mainSurface", "questionOpen"], + none: ["modalOpen", "editableFocus"], + }, + ), + ), + binding( + "window.new", + "n", + { mod: true, shift: true }, + { + ...mainWithoutModal, + desktopOnly: true, + }, ), - binding("window.new", "n", { mod: true, shift: true }, { - ...mainWithoutModal, - desktopOnly: true, - }), // A diagnostics action, so it ships unbound: discoverable in the palette, // assignable in Settings → Keyboard. macOS-only because that is where the // desktop shell offers the log viewer. diff --git a/apps/server/test/system/app-keybindings.test.ts b/apps/server/test/system/app-keybindings.test.ts index c94ebf13b3..bd70f51911 100644 --- a/apps/server/test/system/app-keybindings.test.ts +++ b/apps/server/test/system/app-keybindings.test.ts @@ -99,7 +99,11 @@ describe("app keybindings", () => { ); expect(config.keybindingOverrides).toEqual([]); expect(assignedDefaultKeybindings).toEqual(config.keybindings); - for (const command of ["thread.rename", "thread.archive"] as const) { + for (const command of [ + "thread.split", + "thread.rename", + "thread.archive", + ] as const) { expect( config.defaultKeybindings.find( (binding) => binding.command === command, diff --git a/packages/domain/src/app-keybindings.ts b/packages/domain/src/app-keybindings.ts index 46bfc269e2..3c8ad1091c 100644 --- a/packages/domain/src/app-keybindings.ts +++ b/packages/domain/src/app-keybindings.ts @@ -38,6 +38,7 @@ export const PANE_FOCUS_APP_COMMAND_IDS = [ export const APP_COMMAND_IDS = [ "palette.open", "thread.new", + "thread.split", "thread.search", "thread.rename", "thread.archive", From e0d095638235eb9f2e3c4293471109b45011eeb6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 26 Aug 2026 02:06:16 -0700 Subject: [PATCH 3/8] Open sidebar drafts in splits --- .../sidebar/SidebarLifecycleRows.test.tsx | 73 ++++++++++++++++-- .../sidebar/SidebarLifecycleRows.tsx | 77 +++++++++++++++---- apps/app/src/lib/app-command-metadata.ts | 10 +-- .../palette-app-commands.test.ts | 2 +- 4 files changed, 135 insertions(+), 27 deletions(-) diff --git a/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx b/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx index f4d2e07775..34b66a08d2 100644 --- a/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx +++ b/apps/app/src/components/sidebar/SidebarLifecycleRows.test.tsx @@ -13,6 +13,7 @@ import { } from "@testing-library/react"; import type { ReactNode } from "react"; import { MemoryRouter } from "react-router-dom"; +import { createStore, Provider } from "jotai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SidebarContent } from "@/components/ui/sidebar.js"; import { @@ -20,6 +21,8 @@ import { SidebarDraftRows, type SidebarDraftRowItem, } from "./SidebarLifecycleRows"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { listPanes } from "@/lib/split-layout"; const threadActions = vi.hoisted(() => ({ archiveThreadAndChildren: vi.fn(), @@ -83,17 +86,22 @@ function createThread( function renderLifecycleRows( children: ReactNode, - { compact = false }: { compact?: boolean } = {}, + { + compact = false, + store = createStore(), + }: { compact?: boolean; store?: ReturnType } = {}, ) { const root = document.createElement("div"); root.id = "root"; document.body.appendChild(root); return render( - - - {children} - - , + + + + {children} + + + , { container: root }, ); } @@ -142,7 +150,7 @@ describe("SidebarDraftRows", () => { expect(onOpenDraft).toHaveBeenCalledWith("older"); }); - it("offers Delete draft and no Archive from both the overflow and right-click menus", () => { + it("offers Delete draft and Open in split, but no Archive, from both menus", () => { const drafts = createDrafts(); const { container } = renderLifecycleRows( , @@ -154,6 +162,9 @@ describe("SidebarDraftRows", () => { const overflowDelete = screen.getByRole("menuitem", { name: "Delete draft", }); + expect( + screen.getByRole("menuitem", { name: "Open in split" }), + ).not.toBeNull(); expect(screen.queryByRole("menuitem", { name: /archive/iu })).toBeNull(); fireEvent.click(overflowDelete); expect(drafts[0]?.delete).toHaveBeenCalledTimes(1); @@ -167,9 +178,57 @@ describe("SidebarDraftRows", () => { expect( within(contextMenu).getByRole("menuitem", { name: "Delete draft" }), ).not.toBeNull(); + expect( + within(contextMenu).getByRole("menuitem", { name: "Open in split" }), + ).not.toBeNull(); expect(within(contextMenu).queryByText(/archive/iu)).toBeNull(); }); + it("opens the selected draft slot in a split and omits the item when unavailable", () => { + const store = createStore(); + store.set(splitLayoutAtom, { + focusedPaneId: "pane-thread", + root: { + type: "pane", + paneId: "pane-thread", + content: { + kind: "thread", + projectId: "proj_test", + threadId: "thr_test", + }, + }, + }); + const drafts = createDrafts(); + const wide = renderLifecycleRows( + , + { store }, + ); + + fireEvent.pointerDown( + screen.getAllByRole("button", { name: "Draft actions" })[0], + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Open in split" })); + + const panes = listPanes(store.get(splitLayoutAtom)!.root); + expect(panes).toHaveLength(2); + expect(panes[1]?.content).toEqual({ + kind: "new-thread", + draftSlotId: "newest", + }); + + wide.unmount(); + renderLifecycleRows( + , + { compact: true }, + ); + fireEvent.pointerDown( + screen.getAllByRole("button", { name: "Draft actions" })[0], + ); + expect( + screen.queryByRole("menuitem", { name: "Open in split" }), + ).toBeNull(); + }); + it("uses the persistent compact drawer for a touch long-press", () => { vi.useFakeTimers(); const drafts = createDrafts(); diff --git a/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx b/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx index ef55e49e5b..5ed6b5ac85 100644 --- a/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx +++ b/apps/app/src/components/sidebar/SidebarLifecycleRows.tsx @@ -38,6 +38,7 @@ import { } from "@/components/ui/sidebar-hover-actions.js"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { getThreadRoutePath } from "@/lib/route-paths"; +import { useSplitWorkspaceActive } from "@/hooks/useSplitWorkspaceActive"; import { SIDEBAR_MORE_ACTION_TRIGGER_CLASS, SIDEBAR_ROW_BASE_CLASS, @@ -48,6 +49,7 @@ import { } from "./sidebarRowClasses"; import { SidebarWindowedItems } from "./SidebarWindowedItems"; import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; +import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag"; export interface SidebarDraftRowItem { id: string; @@ -62,14 +64,16 @@ interface SidebarDraftRowsProps { type DraftActionsMenuSurface = "context" | "dropdown"; -function DraftActionsMenuItem({ +function DraftActionsMenuItems({ onDelete, + onOpenInSplit, surface, }: { onDelete: () => void; + onOpenInSplit?: () => void; surface: DraftActionsMenuSurface; }) { - const content = ( + const deleteContent = ( <>