diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 1a0956560b..50439dc19d 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -11,6 +11,8 @@ import { } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { createStore, Provider } from "jotai"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { MAX_PANES, type SplitLayout } from "@/lib/split-layout"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultAppSettings, @@ -109,6 +111,7 @@ const modeState = vi.hoisted(() => ({ recentError: false, searchLoading: false, })); +const openThreadInSplitMock = vi.hoisted(() => vi.fn()); const routeNavigateMock = vi.hoisted(() => vi.fn()); function expectClasses( @@ -191,6 +194,10 @@ vi.mock("@/lib/app-query-client", () => ({ }, })); +vi.mock("@/lib/split-layout/openThreadInSplit", () => ({ + openThreadInSplit: openThreadInSplitMock, +})); + vi.mock("@/components/ui/app-route-anchor", () => ({ useRouteNavigate: () => routeNavigateMock, })); @@ -295,8 +302,22 @@ function makeThread( }; } -function renderPalette({ compact = false }: { compact?: boolean } = {}) { +function renderPalette({ + compact = false, + layout = { + root: { + type: "pane", + paneId: "origin", + content: { kind: "thread", projectId: "project-1", threadId: "origin" }, + }, + focusedPaneId: "origin", + }, +}: { + compact?: boolean; + layout?: SplitLayout | null; +} = {}) { const store = createStore(); + store.set(splitLayoutAtom, layout); const result = render( @@ -312,10 +333,7 @@ function renderPalette({ compact = false }: { compact?: boolean } = {}) { - + @@ -360,6 +378,15 @@ const selectedOption = () => .getAllByRole("option") .find((option) => option.getAttribute("aria-selected") === "true"); +async function requestShortcutHints() { + fireEvent.keyDown(window, { key: "Control", ctrlKey: true }); + await waitFor( + () => + expect(document.querySelector("[data-palette-footer]")).not.toBeNull(), + { timeout: 1500 }, + ); +} + afterEach(() => { cleanup(); removePluginSlotRegistrations("linear"); @@ -375,6 +402,7 @@ afterEach(() => { modeState.recentLoading = false; modeState.recentError = false; modeState.searchLoading = false; + openThreadInSplitMock.mockReset(); routeNavigateMock.mockReset(); window.localStorage.clear(); }); @@ -524,6 +552,53 @@ describe("CommandPalette", () => { expect(screen.queryByRole("button", { name: "Thread scope" })).toBeNull(); expect(screen.queryByRole("button", { name: "Open in split" })).toBeNull(); expect(document.querySelector("[data-palette-footer]")).toBeNull(); + await requestShortcutHints(); + const footer = screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"); + expectClasses( + footer, + "flex-wrap", + "bg-surface-recessed-soft-solid", + "border-border/40", + "px-3", + "py-2", + ); + expectAttribute(footer, "aria-hidden", "true"); + for (const keycap of footer?.querySelectorAll("kbd") ?? []) { + expectClasses( + keycap, + "rounded-sm", + "bg-state-hover", + "font-sans", + "font-normal", + "tabular-nums", + "text-subtle-foreground", + "opacity-60", + ); + expectNoClasses( + keycap, + "border-border/70", + "bg-background/70", + "font-mono", + "text-muted-foreground", + "shadow-xs", + ); + } + for (const label of footer?.querySelectorAll( + "[data-palette-footer-label]", + ) ?? []) { + expectClasses(label, "text-subtle-foreground"); + expectNoClasses(label, "opacity-50"); + expectClasses( + label.closest("[data-palette-footer]"), + "text-subtle-foreground", + ); + } + expect(footer?.textContent).not.toContain("Backspace"); + expect(footer?.textContent).not.toContain("Select"); + expect(footer?.textContent).not.toContain("Esc"); + expectText(footer, "Open in split"); const threadInput = screen.getByRole("combobox", { name: "Search threads", }); @@ -531,7 +606,7 @@ describe("CommandPalette", () => { expect(threadDescriptionId).not.toBeNull(); expectText( document.getElementById(threadDescriptionId ?? ""), - "Use Escape to return to commands.", + "Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.", ); fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); @@ -634,6 +709,7 @@ describe("CommandPalette", () => { ).toBeTruthy(); expect(testState.calls).toEqual([]); expect(routeNavigateMock).not.toHaveBeenCalled(); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); }, ); @@ -659,6 +735,131 @@ describe("CommandPalette", () => { }, ); + it("reveals split guidance on demand and hides it on release, input blur, no matches, and Commands", async () => { + modeState.activeRecents = [makeThread("selected")]; + renderPalette(); + openThreadSearch(); + await screen.findByRole("option"); + const palette = screen.getByTestId("command-palette"); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + await requestShortcutHints(); + expect( + palette.querySelector("[data-palette-footer]")?.textContent, + ).toContain("Ctrl+↵"); + fireEvent.keyUp(window, { key: "Control" }); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + await requestShortcutHints(); + act(() => screen.getByRole("button", { name: "Return to commands" }).focus()); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + act(() => searchField().focus()); + await requestShortcutHints(); + expect( + palette.querySelector("[data-palette-footer]")?.textContent, + ).not.toContain("Esc"); + fireEvent.change(searchField(), { target: { value: "no match" } }); + await screen.findByText("No matching threads"); + expectClasses( + screen.getByText("No matching threads").parentElement, + "px-3", + "py-4", + ); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + expect(searchField().hasAttribute("aria-activedescendant")).toBe(false); + fireEvent.keyDown(searchField(), { key: "Enter", ctrlKey: true }); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); + fireEvent.change(searchField(), { target: { value: "" } }); + await screen.findByRole("option"); + fireEvent.keyDown(searchField(), { key: "Escape" }); + await screen.findByRole("combobox", { name: "Search commands" }); + expect(palette.querySelector("[data-palette-footer]")).toBeNull(); + }); + + it("omits split guidance while searching and on compact layouts", async () => { + modeState.activeRecents = [makeThread("selected")]; + modeState.searchLoading = true; + renderPalette({ compact: true }); + openThreadSearch(); + await screen.findByRole("combobox", { name: "Search threads" }); + expect(screen.queryByRole("button", { name: "Open in split" })).toBeNull(); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + fireEvent.change(searchField(), { target: { value: "search" } }); + await screen.findByText("Searching threads"); + expect( + screen + .getByTestId("command-palette") + .querySelector("[data-palette-footer]"), + ).toBeNull(); + }); + + it("keeps the split shortcut working with keyboard hints disabled", async () => { + testState.showKeyboardHints = false; + modeState.activeRecents = [ + makeThread("first"), + makeThread("second", { updatedAt: 1 }), + ]; + renderPalette(); + openThreadSearch(); + await screen.findByRole("option", { name: /Title first/ }); + fireEvent.keyDown(window, { key: "Control", ctrlKey: true }); + await act(() => new Promise((resolve) => setTimeout(resolve, 800))); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + fireEvent.keyUp(window, { key: "Control" }); + fireEvent.keyDown(searchField(), { key: "ArrowDown" }); + expect(screen.queryByRole("button", { name: "Open in split" })).toBeNull(); + fireEvent.keyDown(searchField(), { key: "Enter", ctrlKey: true }); + await waitFor(() => + expect(openThreadInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ threadId: "second" }), + ), + ); + expect(openThreadInSplitMock).toHaveBeenCalledTimes(1); + expect(routeNavigateMock).not.toHaveBeenCalled(); + }); + + it.each(["missing workspace", "already open", "pane limit"])( + "removes split guidance for %s", + async (state) => { + modeState.activeRecents = [makeThread("selected")]; + const { store } = renderPalette(); + openThreadSearch(); + await screen.findByRole("option"); + await requestShortcutHints(); + const layout: SplitLayout = { + root: { + type: "split", + dir: "row", + sizes: Array(MAX_PANES).fill(1 / MAX_PANES), + children: Array.from({ length: MAX_PANES }, (_, index) => ({ + type: "pane", + paneId: `pane-${index}`, + content: { + kind: "thread", + projectId: "project-1", + threadId: + state === "already open" && index === 0 + ? "selected" + : `other-${index}`, + }, + })), + }, + focusedPaneId: "pane-0", + }; + act(() => + store.set( + splitLayoutAtom, + state === "missing workspace" ? null : layout, + ), + ); + expect(document.querySelector("[data-palette-footer]")).toBeNull(); + expect(screen.queryByRole("button", { name: "Open in split" })).toBeNull(); + expect(screen.getByRole("combobox")).toBeTruthy(); + }, + ); + it("explains Escape at the mode exit control without adding a footer hint", async () => { renderPalette(); openThreadSearch(); @@ -909,7 +1110,40 @@ describe("CommandPalette", () => { }, ); - it("opens an archived message match with its anchor", async () => { + it("opens a persisted thread result in a split with Command-Enter", async () => { + modeState.searchResponse = { + active: { + total: 1, + results: [{ thread: makeThread("matching-split"), matches: [] }], + }, + archived: { total: 0, results: [] }, + }; + renderPalette(); + openThreadSearch(); + const input = await screen.findByRole("combobox", { + name: "Search threads", + }); + fireEvent.change(input, { target: { value: "match" } }); + await waitFor(() => + expect(screen.getByRole("option").textContent).toContain( + "matching-split", + ), + ); + + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + + await waitFor(() => expect(openThreadInSplitMock).toHaveBeenCalledTimes(1)); + expect(openThreadInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + threadId: "matching-split", + }), + ); + }); + + it.each([false, true])( + "opens an archived message match with its anchor (split=%s)", + async (split) => { modeState.searchResponse = { active: { total: 0, results: [] }, archived: { @@ -940,18 +1174,31 @@ describe("CommandPalette", () => { expect( screen.getByRole("option").querySelector("mark")?.textContent, ).toBe("matching"); - fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.keyDown(input, { key: "Enter", metaKey: split }); const state = { searchMessageSeq: 42, searchThreadId: "archived-message", }; await waitFor(() => expect(screen.queryByRole("combobox")).toBeNull()); - expect(routeNavigateMock).toHaveBeenCalledWith( - "/projects/project-1/threads/archived-message", - { state }, - ); - }); + if (split) { + expect(openThreadInSplitMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + threadId: "archived-message", + state, + }), + ); + expect(routeNavigateMock).not.toHaveBeenCalled(); + } else { + expect(routeNavigateMock).toHaveBeenCalledWith( + "/projects/project-1/threads/archived-message", + { state }, + ); + expect(openThreadInSplitMock).not.toHaveBeenCalled(); + } + }, + ); it("filters as the user types and keeps the selection on a live row", async () => { renderPalette(); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 51c9811711..9a5cf37541 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -348,6 +348,7 @@ export function CommandPalette({ ? undefined : `${optionIdPrefix}-${activeIndex}` } + footerKeys={[]} inputDescription={PALETTE_INPUT_DESCRIPTION} inputLabel={PALETTE_INPUT_LABEL} listId={listId} diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index 156af0a7a2..4077e0df8b 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -1,5 +1,6 @@ import { useId, + useState, type KeyboardEventHandler, type ReactNode, type Ref, @@ -7,9 +8,16 @@ import { import { useComposedRefs } from "@radix-ui/react-compose-refs"; import { Icon } from "@bb/shared-ui/icon"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { cn } from "@bb/shared-ui/lib/utils"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import { TabPill } from "@/components/ui/tab-pill"; +import { APP_COMMAND_ACCESSORY_PILL_CLASS } from "./AppCommandShortcutHint"; +import { useIsAppCommandModifierHeld } from "./AppCommandProvider"; +export const PALETTE_FOOTER_KEYCAP_CLASS = cn( + APP_COMMAND_ACCESSORY_PILL_CLASS, + "min-w-5 py-0.5", +); export const PALETTE_FOOTER_LABEL_CLASS = "text-subtle-foreground opacity-70"; interface PaletteModeChipProps { @@ -22,6 +30,7 @@ interface PaletteModeChipProps { interface PaletteShellProps { activeDescendantId?: string; children: ReactNode; + footerKeys: readonly { keys: readonly string[]; label: string }[]; inputDescription: string; inputLabel: string; inputRef?: Ref; @@ -38,6 +47,7 @@ interface PaletteShellProps { export function PaletteShell({ activeDescendantId, children, + footerKeys, inputDescription, inputLabel, inputRef, @@ -51,6 +61,9 @@ export function PaletteShell({ value, }: PaletteShellProps) { const inputDescriptionId = useId(); + const [inputFocused, setInputFocused] = useState(false); + const modifierHeld = useIsAppCommandModifierHeld(); + const showFooter = inputFocused && modifierHeld && footerKeys.length > 0; const overflow = useScrollOverflowState({ measureOverflow: true, }); @@ -87,6 +100,8 @@ export function PaletteShell({ placeholder={placeholder} value={value} onChange={(event) => onInputChange(event.target.value)} + onFocus={() => setInputFocused(true)} + onBlur={() => setInputFocused(false)} onKeyDown={onInputKeyDown} /> @@ -95,7 +110,10 @@ export function PaletteShell({
+ {!showFooter ? null : ( +
+ {footerKeys.map((hint) => ( + + + {hint.keys.join(" / ")} + + + {hint.label} + + + ))} +
+ )} ); } diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index 47ff5fdd9c..ddabd25fb2 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -9,8 +9,10 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; +import { useAtomValue, useStore } from "jotai"; import { Icon } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; import { cn } from "@bb/shared-ui/lib/utils"; import { CHROME_SECTION_LABEL_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { @@ -39,6 +41,10 @@ import { ThreadListEmptyState, } from "@/components/thread/ThreadListEmptyState"; import { getThreadRoutePath } from "@/lib/route-paths"; +import { openThreadInSplit } from "@/lib/split-layout/openThreadInSplit"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { countPanes, findPaneByContent, MAX_PANES } from "@/lib/split-layout"; +import { isMacKeyboardPlatform } from "@bb/domain"; import { buildPaletteThreadSearchRows, type PaletteThreadSearchRow, @@ -56,7 +62,10 @@ export function ThreadSearchPaletteMode({ const optionIdPrefix = useId(); const inputRef = useRef(null); const listRef = useRef(null); + const store = useStore(); + const splitLayout = useAtomValue(splitLayoutAtom); const navigate = useRouteNavigate(); + const isCompact = useIsCompactViewport(); const [query, setQuery] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const [now] = useState(() => Date.now()); @@ -118,6 +127,21 @@ export function ThreadSearchPaletteMode({ !hasLoadError; const activeDescendantId = activeIndex < 0 ? undefined : `${optionIdPrefix}-${activeIndex}`; + const activeRow = result.rows[activeIndex]; + const canSplit = + activeRow !== undefined && + !isCompact && + splitLayout !== null && + findPaneByContent(splitLayout.root, { + kind: "thread", + projectId: activeRow.projectId, + threadId: activeRow.threadId, + }) === null && + countPanes(splitLayout.root) < MAX_PANES; + const splitShortcut = isMacKeyboardPlatform(navigator.platform) + ? "⌘↵" + : "Ctrl+↵"; + const scrollOnNextHighlightRef = useRef(false); useEffect(() => { if (!scrollOnNextHighlightRef.current) return; @@ -128,7 +152,7 @@ export function ThreadSearchPaletteMode({ }, [activeIndex]); const openRow = useCallback( - (row: PaletteThreadSearchRow) => { + (row: PaletteThreadSearchRow, split: boolean) => { runAfterClose(() => { const state = row.messageSeq === null @@ -137,6 +161,17 @@ export function ThreadSearchPaletteMode({ searchMessageSeq: row.messageSeq, searchThreadId: row.threadId, }; + if (split) { + openThreadInSplit({ + store, + navigate, + projectId: row.projectId, + threadId: row.threadId, + isCompact, + state, + }); + return; + } navigate( getThreadRoutePath({ projectId: row.projectId, @@ -146,7 +181,7 @@ export function ThreadSearchPaletteMode({ ); }); }, - [navigate, runAfterClose], + [isCompact, navigate, runAfterClose, store], ); const handleInputKeyDown = useCallback( @@ -186,7 +221,7 @@ export function ThreadSearchPaletteMode({ const row = result.rows[activeIndex]; if (row === undefined) return; event.preventDefault(); - openRow(row); + openRow(row, event.metaKey || event.ctrlKey); } }, [activeIndex, onExit, openRow, query.length, result.rows], @@ -216,7 +251,14 @@ export function ThreadSearchPaletteMode({ return ( setHighlightedIndex(index)} - onSelect={() => openRow(row)} + onSelect={() => openRow(row, false)} /> )) ) : showThreadListEmptyState || diff --git a/apps/app/src/lib/command-palette/palette-modes.ts b/apps/app/src/lib/command-palette/palette-modes.ts index dea93ac3ea..72896cf1a7 100644 --- a/apps/app/src/lib/command-palette/palette-modes.ts +++ b/apps/app/src/lib/command-palette/palette-modes.ts @@ -8,7 +8,7 @@ export const PALETTE_MODES: readonly PaletteModeRegistration[] = [ chip: { icon: "Search", label: "Threads" }, placeholder: "Search title, project, or message…", inputDescription: - "Use Escape to return to commands.", + "Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.", View: ThreadSearchPaletteMode, }, ]; diff --git a/apps/app/src/lib/split-layout/openThreadInSplit.test.ts b/apps/app/src/lib/split-layout/openThreadInSplit.test.ts new file mode 100644 index 0000000000..46534055d3 --- /dev/null +++ b/apps/app/src/lib/split-layout/openThreadInSplit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { openThreadInSplit } from "./openThreadInSplit"; +import type { SplitLayout } from "./index"; + +function layout(threadId = "thread-1"): SplitLayout { + return { + root: { + type: "pane", + paneId: "pane-1", + content: { kind: "thread", projectId: "project-1", threadId }, + }, + focusedPaneId: "pane-1", + }; +} + +describe("openThreadInSplit", () => { + it("preserves search deep-link state when creating a split", () => { + let current = layout(); + const navigate = vi.fn(); + openThreadInSplit({ + store: { + get: () => current, + set: (_atom, value) => { + current = value; + }, + }, + navigate, + projectId: "project-1", + threadId: "thread-2", + isCompact: false, + state: { searchMessageSeq: 42, searchThreadId: "thread-2" }, + }); + + expect(current.root.type).toBe("split"); + expect(navigate).toHaveBeenCalledWith( + "/projects/project-1/threads/thread-2", + { + state: { searchMessageSeq: 42, searchThreadId: "thread-2" }, + }, + ); + }); + + it("keeps replace semantics and state when the result is already open", () => { + const current = layout("thread-2"); + const navigate = vi.fn(); + openThreadInSplit({ + store: { get: () => current, set: vi.fn() }, + navigate, + projectId: "project-1", + threadId: "thread-2", + isCompact: false, + state: { searchMessageSeq: 7, searchThreadId: "thread-2" }, + }); + + expect(navigate).toHaveBeenCalledWith( + "/projects/project-1/threads/thread-2", + { + replace: true, + state: { searchMessageSeq: 7, searchThreadId: "thread-2" }, + }, + ); + }); +}); diff --git a/apps/app/src/lib/split-layout/openThreadInSplit.ts b/apps/app/src/lib/split-layout/openThreadInSplit.ts index 2504b894e3..5dfce7cb68 100644 --- a/apps/app/src/lib/split-layout/openThreadInSplit.ts +++ b/apps/app/src/lib/split-layout/openThreadInSplit.ts @@ -19,10 +19,14 @@ interface SplitLayoutStore { interface OpenThreadInSplitArgs { store: SplitLayoutStore; - navigate: (route: string, options?: { replace?: boolean }) => void; + navigate: ( + route: string, + options?: { replace?: boolean; state?: Record }, + ) => void; projectId: string; threadId: string; isCompact: boolean; + state?: Record; } export function openThreadInSplit({ @@ -31,11 +35,12 @@ export function openThreadInSplit({ projectId, threadId, isCompact, + state, }: OpenThreadInSplitArgs): void { const route = getThreadRoutePath({ projectId, threadId }); const layout = store.get(splitLayoutAtom); if (isCompact || layout === null) { - navigate(route); + navigate(route, state === undefined ? undefined : { state }); return; } const existing = findPaneByThread(layout.root, projectId, threadId); @@ -44,7 +49,10 @@ export function openThreadInSplit({ if (next !== layout) { store.set(splitLayoutAtom, next); } - navigate(route, { replace: true }); + navigate(route, { + replace: true, + ...(state === undefined ? {} : { state }), + }); return; } const decision = decideThreadDrop({ @@ -60,5 +68,5 @@ export function openThreadInSplit({ if (next !== layout) { store.set(splitLayoutAtom, next); } - navigate(route); + navigate(route, state === undefined ? undefined : { state }); }