Skip to content
Draft
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
273 changes: 260 additions & 13 deletions apps/app/src/components/commands/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
}));
Expand Down Expand Up @@ -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(
<Provider store={store}>
<CompactViewportOverrideProvider isCompactViewport={compact}>
Expand All @@ -312,10 +333,7 @@ function renderPalette({ compact = false }: { compact?: boolean } = {}) {
<Handler command="terminal.open" />
<Handler command="composer.focus" />
<Handler command="browser.reload" />
<CommandPalette
threadId={null}
projectId={null}
/>
<CommandPalette threadId={null} projectId={null} />
<LocationProbe />
</AppCommandProvider>
</MemoryRouter>
Expand Down Expand Up @@ -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");
Expand All @@ -375,6 +402,7 @@ afterEach(() => {
modeState.recentLoading = false;
modeState.recentError = false;
modeState.searchLoading = false;
openThreadInSplitMock.mockReset();
routeNavigateMock.mockReset();
window.localStorage.clear();
});
Expand Down Expand Up @@ -524,14 +552,61 @@ 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",
});
const threadDescriptionId = threadInput.getAttribute("aria-describedby");
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" });
Expand Down Expand Up @@ -634,6 +709,7 @@ describe("CommandPalette", () => {
).toBeTruthy();
expect(testState.calls).toEqual([]);
expect(routeNavigateMock).not.toHaveBeenCalled();
expect(openThreadInSplitMock).not.toHaveBeenCalled();
},
);

Expand All @@ -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();
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/components/commands/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export function CommandPalette({
? undefined
: `${optionIdPrefix}-${activeIndex}`
}
footerKeys={[]}
inputDescription={PALETTE_INPUT_DESCRIPTION}
inputLabel={PALETTE_INPUT_LABEL}
listId={listId}
Expand Down
Loading