Skip to content
104 changes: 104 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import {
filterCommandPaletteGroups,
findHighlightedCommandPaletteItem,
reduceCommandPaletteUiState,
createCommandPaletteSearchState,
reduceCommandPaletteSearchState,
type CommandPaletteActionItem,
type CommandPaletteGroup,
type CommandPaletteUiState,
} from "./CommandPalette.logic";

describe("linked pull request thread navigation", () => {
Expand Down Expand Up @@ -894,3 +897,104 @@ describe("virtualized command palette rows", () => {
expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
});
});

describe("command palette search return", () => {
const picker = { addonIcon: null, groups: [] };

it("restores each parent query when backing out of nested pickers", () => {
let state = createCommandPaletteSearchState(">new thread");
state = reduceCommandPaletteSearchState(state, { type: "push", view: picker });
expect(state.query).toBe("");
state = reduceCommandPaletteSearchState(state, { type: "query", query: "remote project" });
state = reduceCommandPaletteSearchState(state, {
type: "push",
view: { ...picker, initialQuery: "~/" },
});
expect(state.query).toBe("~/");

state = reduceCommandPaletteSearchState(state, { type: "query", query: "" });
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state.query).toBe("remote project");
expect(state.views).toHaveLength(1);
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state.query).toBe(">new thread");
expect(state.views).toHaveLength(0);
});

it("cannot return to an old picker after a new search replaces it", () => {
let state = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old"), {
type: "push",
view: picker,
});
state = reduceCommandPaletteSearchState(state, { type: "reset", query: "fresh" });
state = reduceCommandPaletteSearchState(state, { type: "back" });
expect(state.query).toBe("fresh");
expect(state.views).toHaveLength(0);
});
});

describe("returning from file and content search", () => {
const root: CommandPaletteUiState = { open: true, mode: "command", openIntent: null };
const query = "https://github.com/acme/web/pull/7";
const context = {
query,
linkedThreadSearch: {
kind: "search" as const,
query,
linkedThreads: {
environmentId: EnvironmentId.make("remote"),
threads: [
{
id: ThreadId.make("linked-thread"),
projectId: ProjectId.make("project"),
title: "Completed work",
archivedAt: "2026-09-01T00:00:00.000Z",
},
],
},
},
};

it.each(["files", "content"] as const)(
"Escape from %s restores the query and linked-thread results",
(mode) => {
const child = reduceCommandPaletteUiState(root, { _tag: "OpenChildSearch", mode, context });
expect(child.mode).toBe(mode);
const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
expect(returned).toEqual({ ...root, restoredSearch: context });
expect(reduceCommandPaletteUiState(returned, { _tag: "BackToCommand" })).toEqual(returned);
},
);

it("closing a child search discards its return context before reopening", () => {
const child: CommandPaletteUiState = { ...root, mode: "files", parentSearch: context };
const closed = reduceCommandPaletteUiState(child, { _tag: "SetOpen", open: false });
expect(closed).toEqual({ open: false, mode: "files", openIntent: null });
expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toEqual(closed);
expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root);
});

it("a fresh overlay shortcut has no parent search to return to", () => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "files",
context,
});
const fresh = reduceCommandPaletteUiState(child, { _tag: "ToggleMode", mode: "content" });
expect(fresh).toEqual({ open: true, mode: "content", openIntent: null });
expect(reduceCommandPaletteUiState(fresh, { _tag: "BackToCommand" })).toEqual(root);
});

it("a direct search intent replaces restored query and linked-thread context", () => {
const child = reduceCommandPaletteUiState(root, {
_tag: "OpenChildSearch",
mode: "content",
context,
});
const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" });
expect(reduceCommandPaletteUiState(returned, { _tag: "OpenSearch", query: "fresh" })).toEqual({
...root,
openIntent: { kind: "search", query: "fresh" },
});
});
});
68 changes: 65 additions & 3 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface CommandPaletteUiState {
readonly open: boolean;
readonly mode: SearchOverlayMode;
readonly openIntent: CommandPaletteOpenIntent | null;
readonly parentSearch?: CommandPaletteReturnContext;
readonly restoredSearch?: CommandPaletteReturnContext;
}

export type CommandPaletteUiAction =
Expand All @@ -78,7 +80,13 @@ export type CommandPaletteUiAction =
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "OpenNewThreadIn" }
| { readonly _tag: "OpenChangeTheme" }
| { readonly _tag: "ClearOpenIntent" };
| { readonly _tag: "ClearOpenIntent" }
| {
readonly _tag: "OpenChildSearch";
readonly mode: "files" | "content";
readonly context: CommandPaletteReturnContext;
}
| { readonly _tag: "BackToCommand" };

export function reduceCommandPaletteUiState(
state: CommandPaletteUiState,
Expand All @@ -88,11 +96,22 @@ export function reduceCommandPaletteUiState(
case "SetOpen":
return action.open
? { open: true, mode: "command", openIntent: state.openIntent }
: { ...state, open: false, openIntent: null };
: { open: false, mode: state.mode, openIntent: null };
case "ToggleMode":
return state.open && state.mode === action.mode
? { ...state, open: false, openIntent: null }
? { open: false, mode: state.mode, openIntent: null }
: { open: true, mode: action.mode, openIntent: null };
case "OpenChildSearch":
return { open: true, mode: action.mode, openIntent: null, parentSearch: action.context };
case "BackToCommand":
return state.open && state.mode !== "command"
? {
open: true,
mode: "command",
openIntent: null,
...(state.parentSearch ? { restoredSearch: state.parentSearch } : {}),
}
: state;
case "OpenSearch":
return {
open: true,
Expand Down Expand Up @@ -162,6 +181,49 @@ export interface CommandPaletteView {
readonly initialQuery?: string;
}

// Child search unmounts the palette; retain search data without its view closures.
export interface CommandPaletteReturnContext {
readonly query: string;
readonly linkedThreadSearch: Extract<CommandPaletteOpenIntent, { kind: "search" }> | null;
}

export interface CommandPaletteSearchState {
readonly query: string;
readonly views: ReadonlyArray<{
readonly view: CommandPaletteView;
readonly parentQuery: string;
}>;
}

export function createCommandPaletteSearchState(query = ""): CommandPaletteSearchState {
return { query, views: [] };
}

export function reduceCommandPaletteSearchState(
state: CommandPaletteSearchState,
action:
| { readonly type: "query"; readonly query: string }
| { readonly type: "push"; readonly view: CommandPaletteView }
| { readonly type: "back" }
| { readonly type: "reset"; readonly query?: string },
): CommandPaletteSearchState {
switch (action.type) {
case "query":
return { ...state, query: action.query };
case "push":
return {
query: action.view.initialQuery ?? "",
views: [...state.views, { view: action.view, parentQuery: state.query }],
};
case "back": {
const parent = state.views.at(-1);
return parent ? { query: parent.parentQuery, views: state.views.slice(0, -1) } : state;
}
case "reset":
return createCommandPaletteSearchState(action.query);
}
}

export type CommandPaletteRow =
| {
readonly kind: "label";
Expand Down
Loading
Loading