diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69f..101759fc6c42 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -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", () => { @@ -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" }, + }); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 729069cdfa29..3037ef72159d 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -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 = @@ -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, @@ -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, @@ -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 | 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"; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a7b93881e8b6..11fef9dbed39 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -188,6 +188,7 @@ import { type CommandPaletteActionItem, type CommandPaletteOpenIntent, type CommandPaletteProject, + type CommandPaletteReturnContext, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -197,6 +198,8 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, + createCommandPaletteSearchState, + reduceCommandPaletteSearchState, type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; @@ -492,6 +495,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openAddProject = () => dispatch({ _tag: "OpenAddProject" }); const openNewThreadIn = () => dispatch({ _tag: "OpenNewThreadIn" }); const clearOpenIntent = () => dispatch({ _tag: "ClearOpenIntent" }); + const openChildSearch = (mode: "files" | "content", context: CommandPaletteReturnContext) => + dispatch({ _tag: "OpenChildSearch", mode, context }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); const composerHandleRef = useRef(null); @@ -514,11 +519,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { if (event.isComposing || event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); - toggleMode("command"); + dispatch({ _tag: "BackToCommand" }); }; window.addEventListener("keydown", onEscapeKeyDown, true); return () => window.removeEventListener("keydown", onEscapeKeyDown, true); - }, [state.mode, state.open, toggleMode]); + }, [state.mode, state.open]); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { @@ -624,7 +629,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenChange={(open, eventDetails) => { if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); - toggleMode("command"); + dispatch({ _tag: "BackToCommand" }); return; } setOpen(open); @@ -637,7 +642,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { mode={state.mode} openIntent={state.openIntent} setOpen={setOpen} - openOverlayMode={toggleMode} + openChildSearch={openChildSearch} + initialContext={state.restoredSearch} clearOpenIntent={clearOpenIntent} /> @@ -649,7 +655,11 @@ function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; - readonly openOverlayMode: (mode: SearchOverlayMode) => void; + readonly openChildSearch: ( + mode: "files" | "content", + context: CommandPaletteReturnContext, + ) => void; + readonly initialContext: CommandPaletteReturnContext | undefined; readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); @@ -685,7 +695,8 @@ function CommandPaletteDialog(props: { )} @@ -696,16 +707,28 @@ function CommandPaletteDialog(props: { function OpenCommandPaletteDialog(props: { readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; - readonly openOverlayMode: (mode: SearchOverlayMode) => void; + readonly openChildSearch: ( + mode: "files" | "content", + context: CommandPaletteReturnContext, + ) => void; + readonly initialContext: CommandPaletteReturnContext | undefined; readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); - const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; - const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); - const [linkedThreadSearch, setLinkedThreadSearch] = useState( - openIntent?.kind === "search" ? openIntent : null, + const { clearOpenIntent, openIntent, openChildSearch, setOpen } = props; + const initialSearch = props.initialContext ?? { + query: openIntent?.kind === "search" ? openIntent.query : "", + linkedThreadSearch: openIntent?.kind === "search" ? openIntent : null, + }; + const [searchState, dispatchSearch] = useReducer( + reduceCommandPaletteSearchState, + initialSearch.query, + createCommandPaletteSearchState, ); + const { query, views: viewStack } = searchState; + const setQuery = (query: string) => dispatchSearch({ type: "query", query }); + const [linkedThreadSearch, setLinkedThreadSearch] = useState(initialSearch.linkedThreadSearch); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -822,8 +845,7 @@ function OpenCommandPaletteDialog(props: { ); }), ); - const [viewStack, setViewStack] = useState([]); - const currentView = viewStack.at(-1) ?? null; + const currentView = viewStack.at(-1)?.view ?? null; const environmentIds = environments .filter((environment) => environment.connection.phase === "connected") .map((environment) => environment.environmentId); @@ -1280,27 +1302,11 @@ function OpenCommandPaletteDialog(props: { // highlight ref it writes. const pushPaletteView = useStableCallback((view: CommandPaletteView): void => { browseNavigation.invalidate(); - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); + dispatchSearch({ type: "push", view }); highlightClearedRef.current = true; setHighlightedItemValue(null); - setQuery(view.initialQuery ?? ""); }); - function pushView(item: CommandPaletteSubmenuItem): void { - pushPaletteView({ - addonIcon: item.addonIcon, - groups: item.groups, - ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), - }); - } - function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); @@ -1310,9 +1316,8 @@ function OpenCommandPaletteDialog(props: { } else if (newProjectFlow?.sourcesEnvironmentId) { setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); } - setViewStack((previousViews) => previousViews.slice(0, -1)); + dispatchSearch({ type: "back" }); setHighlightedItemValue(null); - setQuery(""); } function handleQueryChange(nextQuery: string): void { @@ -1783,7 +1788,7 @@ function OpenCommandPaletteDialog(props: { keepOpen: true, shortcutCommand: "filePicker.toggle", run: async () => { - openOverlayMode("files"); + openChildSearch("files", { query, linkedThreadSearch }); }, }); @@ -1796,7 +1801,7 @@ function OpenCommandPaletteDialog(props: { keepOpen: true, shortcutCommand: "projectSearch.toggle", run: async () => { - openOverlayMode("content"); + openChildSearch("content", { query, linkedThreadSearch }); }, }); @@ -1943,42 +1948,28 @@ function OpenCommandPaletteDialog(props: { // parent's intent and may navigate, so it runs after commit but before paint. const projectThreadItemCount = projectThreadItems.length; const applyOpenIntent = useEffectEvent((intent: CommandPaletteOpenIntent) => { + clearOpenIntent(); + browseNavigation.invalidate(); + cloneLookupGeneration.current += 1; + setIsRemoteProjectLookingUp(false); + setAddProjectEnvironmentId(null); + setAddProjectCloneFlow(null); + setNewProjectFlow(null); + setLinkedThreadSearch(intent.kind === "search" ? intent : null); + dispatchSearch({ type: "reset", query: intent.kind === "search" ? intent.query : "" }); switch (intent.kind) { case "search": - browseNavigation.invalidate(); - cloneLookupGeneration.current += 1; - setIsRemoteProjectLookingUp(false); - setAddProjectCloneFlow(null); - setNewProjectFlow(null); - setViewStack([]); - setLinkedThreadSearch(intent); - setQuery(intent.query); - clearOpenIntent(); return; case "add-project": - clearOpenIntent(); openAddProjectFlow(); return; case "new-thread-in": - clearOpenIntent(); - browseNavigation.invalidate(); - setAddProjectCloneFlow(null); - setNewProjectFlow(null); - setViewStack([]); - setQuery(""); pushPaletteView({ addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); return; case "change-theme": - clearOpenIntent(); - browseNavigation.invalidate(); - cloneLookupGeneration.current += 1; - setIsRemoteProjectLookingUp(false); - setAddProjectCloneFlow(null); - setNewProjectFlow(null); - setViewStack([]); pushPaletteView({ addonIcon: , groups: [{ value: "themes", label: "Change theme", items: [] }], @@ -2604,7 +2595,7 @@ function OpenCommandPaletteDialog(props: { return; } if (sourcesEnvironmentId !== null) { - setViewStack((previousViews) => previousViews.slice(0, -1)); + dispatchSearch({ type: "back" }); } popView(); startAddProjectSourceSelection(environmentId); @@ -2984,7 +2975,7 @@ function OpenCommandPaletteDialog(props: { } if (item.kind === "submenu") { - pushView(item); + pushPaletteView(item); return; }