From 9b130080ba73990dd1f692870b25276befb3ade6 Mon Sep 17 00:00:00 2001 From: saphid Date: Mon, 5 Oct 2026 17:15:10 +1100 Subject: [PATCH 1/4] fix(web): going back in the command palette keeps the search that led there Back from a nested picker (Backspace or the Back button) and Escape from file or content search now restore the palette query, and linked-thread results, that were showing before. Closing the palette, a fresh shortcut, or a direct intent still starts clean. Fixes #15936. Replaces #12873. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/CommandPalette.logic.test.ts | 95 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 71 +++++++++++++- apps/web/src/components/CommandPalette.tsx | 72 ++++++++------ 3 files changed, 206 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69f..cd6fb5762680 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,95 @@ describe("virtualized command palette rows", () => { expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); }); }); + +describe("command palette search return", () => { + const picker = { addonIcon: null, groups: [] }; + + it("Back restores the query that led into each nested picker", () => { + 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: "back" }); + expect(state.query).toBe("remote project"); + expect(state.views).toHaveLength(1); + state = reduceCommandPaletteSearchState(state, { type: "back" }); + expect(state).toEqual(createCommandPaletteSearchState(">new thread")); + expect(reduceCommandPaletteSearchState(state, { type: "back" })).toBe(state); + }); + + it("clearing a child's prefilled query before Back keeps the parent query", () => { + let state = createCommandPaletteSearchState(">add project"); + state = reduceCommandPaletteSearchState(state, { + type: "push", + view: { ...picker, initialQuery: "~/" }, + }); + state = reduceCommandPaletteSearchState(state, { type: "query", query: "" }); + state = reduceCommandPaletteSearchState(state, { type: "back" }); + expect(state.query).toBe(">add project"); + }); + + it("a reset drops the pickers it replaces", () => { + const nested = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old"), { + type: "push", + view: picker, + }); + const reset = reduceCommandPaletteSearchState(nested, { type: "reset", query: "linked" }); + expect(reduceCommandPaletteSearchState(reset, { type: "back" })).toEqual( + createCommandPaletteSearchState("linked"), + ); + }); +}); + +describe("returning from file and content search", () => { + const root: CommandPaletteUiState = { open: true, mode: "command", openIntent: null }; + const context = { query: "go to file", linkedThreadSearch: null }; + + it.each(["files", "content"] as const)("Escape from %s restores the palette query", (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 }); + }); + + it("restores linked-thread search results with the query", () => { + const linkedContext = { + query: "https://github.com/acme/web/pull/7", + linkedThreadSearch: { + kind: "search" as const, + query: "https://github.com/acme/web/pull/7", + linkedThreads: { environmentId: EnvironmentId.make("remote"), threads: [] }, + }, + }; + const child = reduceCommandPaletteUiState(root, { + _tag: "OpenChildSearch", + mode: "content", + context: linkedContext, + }); + const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" }); + expect(returned.restoredSearch).toEqual(linkedContext); + }); + + it("closing, a fresh shortcut, or a direct intent forgets the parent query", () => { + const child = reduceCommandPaletteUiState(root, { + _tag: "OpenChildSearch", + mode: "files", + context, + }); + const closed = reduceCommandPaletteUiState(child, { _tag: "SetOpen", open: false }); + expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toBe(closed); + expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root); + + const fresh = reduceCommandPaletteUiState(child, { _tag: "ToggleMode", mode: "content" }); + expect(reduceCommandPaletteUiState(fresh, { _tag: "BackToCommand" })).toEqual(root); + + const direct = reduceCommandPaletteUiState(child, { _tag: "OpenChangeTheme" }); + expect(direct.parentSearch).toBeUndefined(); + expect(direct.restoredSearch).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49e963f68313..2bff965bb917 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -72,6 +72,8 @@ export interface CommandPaletteUiState { readonly open: boolean; readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; + readonly parentSearch?: CommandPaletteReturnContext; + readonly restoredSearch?: CommandPaletteReturnContext; } export type CommandPaletteUiAction = @@ -85,7 +87,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, @@ -95,11 +103,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, @@ -172,6 +191,52 @@ export interface CommandPaletteView { readonly initialQuery?: string; } +// File and content search replace the palette, so Escape remounts it. Keep +// its input and linked-thread data to restore, not views whose actions +// belonged to the unmounted palette. +export interface CommandPaletteReturnContext { + readonly query: string; + readonly linkedThreadSearch: Extract | null; +} + +// Each nested view remembers the query that led into it, so Back restores it. +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 e2071ccf8847..45627ff48079 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -179,6 +179,7 @@ import { type CommandPaletteActionItem, type CommandPaletteOpenIntent, type CommandPaletteProject, + type CommandPaletteReturnContext, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -188,6 +189,8 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, + createCommandPaletteSearchState, + reduceCommandPaletteSearchState, type SearchOverlayMode, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; @@ -378,6 +381,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), [], ); + const backToCommand = useCallback(() => dispatch({ _tag: "BackToCommand" }), []); + const openChildSearch = useCallback( + (mode: "files" | "content", context: CommandPaletteReturnContext) => + dispatch({ _tag: "OpenChildSearch", mode, context }), + [], + ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); @@ -406,11 +415,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { if (event.isComposing || event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); - toggleMode("command"); + backToCommand(); }; window.addEventListener("keydown", onEscapeKeyDown, true); return () => window.removeEventListener("keydown", onEscapeKeyDown, true); - }, [state.mode, state.open, toggleMode]); + }, [backToCommand, state.mode, state.open]); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { @@ -518,7 +527,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenChange={(open, eventDetails) => { if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); - toggleMode("command"); + backToCommand(); return; } setOpen(open); @@ -532,7 +541,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { mode={state.mode} openIntent={state.openIntent} setOpen={setOpen} - openOverlayMode={toggleMode} + openOverlayMode={openChildSearch} + initialContext={state.restoredSearch} clearOpenIntent={clearOpenIntent} /> @@ -544,7 +554,11 @@ function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; - readonly openOverlayMode: (mode: SearchOverlayMode) => void; + readonly openOverlayMode: ( + mode: "files" | "content", + context: CommandPaletteReturnContext, + ) => void; + readonly initialContext: CommandPaletteReturnContext | undefined; readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); @@ -579,6 +593,7 @@ function CommandPaletteDialog(props: { openIntent={props.openIntent} setOpen={props.setOpen} openOverlayMode={props.openOverlayMode} + initialContext={props.initialContext} clearOpenIntent={props.clearOpenIntent} /> )} @@ -589,15 +604,26 @@ function CommandPaletteDialog(props: { function OpenCommandPaletteDialog(props: { readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; - readonly openOverlayMode: (mode: SearchOverlayMode) => void; + readonly openOverlayMode: ( + 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 [searchState, dispatchSearch] = useReducer( + reduceCommandPaletteSearchState, + createCommandPaletteSearchState( + props.initialContext?.query ?? (openIntent?.kind === "search" ? openIntent.query : ""), + ), + ); + const { query, views: viewStack } = searchState; + const setQuery = useCallback((query: string) => dispatchSearch({ type: "query", query }), []); const [linkedThreadSearch, setLinkedThreadSearch] = useState( - openIntent?.kind === "search" ? openIntent : null, + props.initialContext?.linkedThreadSearch ?? (openIntent?.kind === "search" ? openIntent : null), ); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); @@ -738,8 +764,7 @@ function OpenCommandPaletteDialog(props: { } return map; }, [environments, primaryEnvironmentId, providers]); - const [viewStack, setViewStack] = useState([]); - const currentView = viewStack.at(-1) ?? null; + const currentView = viewStack.at(-1)?.view ?? null; const environmentIds = useMemo( () => environments @@ -1408,17 +1433,9 @@ function OpenCommandPaletteDialog(props: { const pushPaletteView = useCallback( (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 ?? ""); }, [browseNavigation], ); @@ -1441,9 +1458,8 @@ function OpenCommandPaletteDialog(props: { // The machine switcher may have moved off the sources view's machine. setAddProjectEnvironmentId(newProjectFlow.sourcesEnvironmentId); } - setViewStack((previousViews) => previousViews.slice(0, -1)); + dispatchSearch({ type: "back" }); setHighlightedItemValue(null); - setQuery(""); } function handleQueryChange(nextQuery: string): void { @@ -1763,9 +1779,8 @@ function OpenCommandPaletteDialog(props: { setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); setNewProjectFlow(null); - setViewStack([]); + dispatchSearch({ type: "reset", query: openIntent.query }); setLinkedThreadSearch(openIntent); - setQuery(openIntent.query); clearOpenIntent(); }, [browseNavigation, clearOpenIntent, openIntent]); @@ -1785,8 +1800,7 @@ function OpenCommandPaletteDialog(props: { browseNavigation.invalidate(); setAddProjectCloneFlow(null); setNewProjectFlow(null); - setViewStack([]); - setQuery(""); + dispatchSearch({ type: "reset" }); // projectThreadItems already lists the current project first. pushPaletteView({ addonIcon: , @@ -1957,7 +1971,7 @@ function OpenCommandPaletteDialog(props: { keepOpen: true, shortcutCommand: "filePicker.toggle", run: async () => { - openOverlayMode("files"); + openOverlayMode("files", { query, linkedThreadSearch }); }, }); @@ -1970,7 +1984,7 @@ function OpenCommandPaletteDialog(props: { keepOpen: true, shortcutCommand: "projectSearch.toggle", run: async () => { - openOverlayMode("content"); + openOverlayMode("content", { query, linkedThreadSearch }); }, }); @@ -2122,7 +2136,7 @@ function OpenCommandPaletteDialog(props: { setIsRemoteProjectLookingUp(false); setAddProjectCloneFlow(null); setNewProjectFlow(null); - setViewStack([]); + dispatchSearch({ type: "reset" }); pushPaletteView({ addonIcon: , groups: [{ value: "themes", label: "Change theme", items: [] }], @@ -2789,7 +2803,7 @@ function OpenCommandPaletteDialog(props: { return; } if (sourcesEnvironmentId !== null) { - setViewStack((previousViews) => previousViews.slice(0, -1)); + dispatchSearch({ type: "back" }); } popView(); startAddProjectSourceSelection(environmentId); From 8087effc4935e7ce69eafb8d91b0ca0706e69bf3 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Wed, 14 Oct 2026 03:56:45 +0000 Subject: [PATCH 2/4] test(web): cover every way back into the command palette Direct intents (add project, new thread, change theme, search) all drop the query a child search would return to, a second Back in the same keypress keeps the restored query, and linked-thread results survive repeated trips into file and content search. Co-Authored-By: Claude Opus 5.5 --- .../components/CommandPalette.logic.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index cd6fb5762680..66ae6de47f06 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -988,4 +988,53 @@ describe("returning from file and content search", () => { expect(direct.parentSearch).toBeUndefined(); expect(direct.restoredSearch).toBeUndefined(); }); + + it.each([ + { _tag: "OpenAddProject" }, + { _tag: "OpenNewThreadIn" }, + { _tag: "OpenChangeTheme" }, + { _tag: "OpenSearch", query: "fresh" }, + ] as const)("$_tag from a child search forgets the parent query", (action) => { + const child = reduceCommandPaletteUiState(root, { + _tag: "OpenChildSearch", + mode: "content", + context, + }); + const direct = reduceCommandPaletteUiState(child, action); + expect(direct.mode).toBe("command"); + expect(direct.parentSearch).toBeUndefined(); + expect(direct.restoredSearch).toBeUndefined(); + }); + + it("a second Back in the same keypress keeps the restored query", () => { + const child = reduceCommandPaletteUiState(root, { + _tag: "OpenChildSearch", + mode: "files", + context, + }); + const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" }); + expect(reduceCommandPaletteUiState(returned, { _tag: "BackToCommand" })).toBe(returned); + }); + + it("keeps linked-thread results across repeated trips into search", () => { + const linkedContext = { + query: "https://github.com/acme/web/pull/7", + linkedThreadSearch: { + kind: "search" as const, + query: "https://github.com/acme/web/pull/7", + linkedThreads: { environmentId: EnvironmentId.make("remote"), threads: [] }, + }, + }; + let state = root; + for (const mode of ["files", "content", "files"] as const) { + // The remounted palette hands its restored context to the next child search. + state = reduceCommandPaletteUiState(state, { + _tag: "OpenChildSearch", + mode, + context: state.restoredSearch ?? linkedContext, + }); + state = reduceCommandPaletteUiState(state, { _tag: "BackToCommand" }); + expect(state.restoredSearch).toEqual(linkedContext); + } + }); }); From dcfe21c586b09629948bd822b900d6b0a6ce04a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:05:08 +0000 Subject: [PATCH 3/4] test(web): trim palette search restoration regressions --- .../components/CommandPalette.logic.test.ts | 148 +++++++----------- 1 file changed, 54 insertions(+), 94 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 66ae6de47f06..101759fc6c42 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -901,7 +901,7 @@ describe("virtualized command palette rows", () => { describe("command palette search return", () => { const picker = { addonIcon: null, groups: [] }; - it("Back restores the query that led into each nested picker", () => { + 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(""); @@ -911,130 +911,90 @@ describe("command palette search return", () => { 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).toEqual(createCommandPaletteSearchState(">new thread")); - expect(reduceCommandPaletteSearchState(state, { type: "back" })).toBe(state); + expect(state.query).toBe(">new thread"); + expect(state.views).toHaveLength(0); }); - it("clearing a child's prefilled query before Back keeps the parent query", () => { - let state = createCommandPaletteSearchState(">add project"); - state = reduceCommandPaletteSearchState(state, { - type: "push", - view: { ...picker, initialQuery: "~/" }, - }); - state = reduceCommandPaletteSearchState(state, { type: "query", query: "" }); - state = reduceCommandPaletteSearchState(state, { type: "back" }); - expect(state.query).toBe(">add project"); - }); - - it("a reset drops the pickers it replaces", () => { - const nested = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old"), { + it("cannot return to an old picker after a new search replaces it", () => { + let state = reduceCommandPaletteSearchState(createCommandPaletteSearchState("old"), { type: "push", view: picker, }); - const reset = reduceCommandPaletteSearchState(nested, { type: "reset", query: "linked" }); - expect(reduceCommandPaletteSearchState(reset, { type: "back" })).toEqual( - createCommandPaletteSearchState("linked"), - ); + 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 context = { query: "go to file", linkedThreadSearch: null }; - - it.each(["files", "content"] as const)("Escape from %s restores the palette query", (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 }); - }); - - it("restores linked-thread search results with the query", () => { - const linkedContext = { - query: "https://github.com/acme/web/pull/7", - linkedThreadSearch: { - kind: "search" as const, - query: "https://github.com/acme/web/pull/7", - linkedThreads: { environmentId: EnvironmentId.make("remote"), threads: [] }, + 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", + }, + ], }, - }; - const child = reduceCommandPaletteUiState(root, { - _tag: "OpenChildSearch", - mode: "content", - context: linkedContext, - }); - const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" }); - expect(returned.restoredSearch).toEqual(linkedContext); + }, + }; + + 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("closing, a fresh shortcut, or a direct intent forgets the parent query", () => { + it("a fresh overlay shortcut has no parent search to return to", () => { const child = reduceCommandPaletteUiState(root, { _tag: "OpenChildSearch", mode: "files", context, }); - const closed = reduceCommandPaletteUiState(child, { _tag: "SetOpen", open: false }); - expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toBe(closed); - expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root); - const fresh = reduceCommandPaletteUiState(child, { _tag: "ToggleMode", mode: "content" }); + expect(fresh).toEqual({ open: true, mode: "content", openIntent: null }); expect(reduceCommandPaletteUiState(fresh, { _tag: "BackToCommand" })).toEqual(root); - - const direct = reduceCommandPaletteUiState(child, { _tag: "OpenChangeTheme" }); - expect(direct.parentSearch).toBeUndefined(); - expect(direct.restoredSearch).toBeUndefined(); }); - it.each([ - { _tag: "OpenAddProject" }, - { _tag: "OpenNewThreadIn" }, - { _tag: "OpenChangeTheme" }, - { _tag: "OpenSearch", query: "fresh" }, - ] as const)("$_tag from a child search forgets the parent query", (action) => { + it("a direct search intent replaces restored query and linked-thread context", () => { const child = reduceCommandPaletteUiState(root, { _tag: "OpenChildSearch", mode: "content", context, }); - const direct = reduceCommandPaletteUiState(child, action); - expect(direct.mode).toBe("command"); - expect(direct.parentSearch).toBeUndefined(); - expect(direct.restoredSearch).toBeUndefined(); - }); - - it("a second Back in the same keypress keeps the restored query", () => { - const child = reduceCommandPaletteUiState(root, { - _tag: "OpenChildSearch", - mode: "files", - context, - }); const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" }); - expect(reduceCommandPaletteUiState(returned, { _tag: "BackToCommand" })).toBe(returned); - }); - - it("keeps linked-thread results across repeated trips into search", () => { - const linkedContext = { - query: "https://github.com/acme/web/pull/7", - linkedThreadSearch: { - kind: "search" as const, - query: "https://github.com/acme/web/pull/7", - linkedThreads: { environmentId: EnvironmentId.make("remote"), threads: [] }, - }, - }; - let state = root; - for (const mode of ["files", "content", "files"] as const) { - // The remounted palette hands its restored context to the next child search. - state = reduceCommandPaletteUiState(state, { - _tag: "OpenChildSearch", - mode, - context: state.restoredSearch ?? linkedContext, - }); - state = reduceCommandPaletteUiState(state, { _tag: "BackToCommand" }); - expect(state.restoredSearch).toEqual(linkedContext); - } + expect(reduceCommandPaletteUiState(returned, { _tag: "OpenSearch", query: "fresh" })).toEqual({ + ...root, + openIntent: { kind: "search", query: "fresh" }, + }); }); }); From f8592d6d20bd390a853a0acc85876ab35699f4f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:11:14 +0000 Subject: [PATCH 4/4] fix(web): simplify palette search restoration --- .../src/components/CommandPalette.logic.ts | 5 +- apps/web/src/components/CommandPalette.tsx | 74 +++++++------------ 2 files changed, 28 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 50b2d83637cf..3037ef72159d 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -181,15 +181,12 @@ export interface CommandPaletteView { readonly initialQuery?: string; } -// File and content search replace the palette, so Escape remounts it. Keep -// its input and linked-thread data to restore, not views whose actions -// belonged to the unmounted palette. +// Child search unmounts the palette; retain search data without its view closures. export interface CommandPaletteReturnContext { readonly query: string; readonly linkedThreadSearch: Extract | null; } -// Each nested view remembers the query that led into it, so Back restores it. export interface CommandPaletteSearchState { readonly query: string; readonly views: ReadonlyArray<{ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 88bc4c767674..11fef9dbed39 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -495,7 +495,6 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openAddProject = () => dispatch({ _tag: "OpenAddProject" }); const openNewThreadIn = () => dispatch({ _tag: "OpenNewThreadIn" }); const clearOpenIntent = () => dispatch({ _tag: "ClearOpenIntent" }); - const backToCommand = () => dispatch({ _tag: "BackToCommand" }); const openChildSearch = (mode: "files" | "content", context: CommandPaletteReturnContext) => dispatch({ _tag: "OpenChildSearch", mode, context }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -520,11 +519,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { if (event.isComposing || event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); - backToCommand(); + dispatch({ _tag: "BackToCommand" }); }; window.addEventListener("keydown", onEscapeKeyDown, true); return () => window.removeEventListener("keydown", onEscapeKeyDown, true); - }, [backToCommand, state.mode, state.open]); + }, [state.mode, state.open]); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { @@ -630,7 +629,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenChange={(open, eventDetails) => { if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); - backToCommand(); + dispatch({ _tag: "BackToCommand" }); return; } setOpen(open); @@ -643,7 +642,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { mode={state.mode} openIntent={state.openIntent} setOpen={setOpen} - openOverlayMode={openChildSearch} + openChildSearch={openChildSearch} initialContext={state.restoredSearch} clearOpenIntent={clearOpenIntent} /> @@ -656,7 +655,7 @@ function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; - readonly openOverlayMode: ( + readonly openChildSearch: ( mode: "files" | "content", context: CommandPaletteReturnContext, ) => void; @@ -696,7 +695,7 @@ function CommandPaletteDialog(props: { @@ -708,7 +707,7 @@ function CommandPaletteDialog(props: { function OpenCommandPaletteDialog(props: { readonly openIntent: CommandPaletteOpenIntent | null; readonly setOpen: (open: boolean) => void; - readonly openOverlayMode: ( + readonly openChildSearch: ( mode: "files" | "content", context: CommandPaletteReturnContext, ) => void; @@ -717,18 +716,19 @@ function OpenCommandPaletteDialog(props: { }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); - const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; + 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, - createCommandPaletteSearchState( - props.initialContext?.query ?? (openIntent?.kind === "search" ? openIntent.query : ""), - ), + initialSearch.query, + createCommandPaletteSearchState, ); const { query, views: viewStack } = searchState; const setQuery = (query: string) => dispatchSearch({ type: "query", query }); - const [linkedThreadSearch, setLinkedThreadSearch] = useState( - props.initialContext?.linkedThreadSearch ?? (openIntent?.kind === "search" ? openIntent : null), - ); + const [linkedThreadSearch, setLinkedThreadSearch] = useState(initialSearch.linkedThreadSearch); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -1307,14 +1307,6 @@ function OpenCommandPaletteDialog(props: { setHighlightedItemValue(null); }); - function pushView(item: CommandPaletteSubmenuItem): void { - pushPaletteView({ - addonIcon: item.addonIcon, - groups: item.groups, - ...(item.initialQuery ? { initialQuery: item.initialQuery } : {}), - }); - } - function popView(): void { browseNavigation.invalidate(); setAddProjectCloneFlow(null); @@ -1796,7 +1788,7 @@ function OpenCommandPaletteDialog(props: { keepOpen: true, shortcutCommand: "filePicker.toggle", run: async () => { - openOverlayMode("files", { query, linkedThreadSearch }); + openChildSearch("files", { query, linkedThreadSearch }); }, }); @@ -1809,7 +1801,7 @@ function OpenCommandPaletteDialog(props: { keepOpen: true, shortcutCommand: "projectSearch.toggle", run: async () => { - openOverlayMode("content", { query, linkedThreadSearch }); + openChildSearch("content", { query, linkedThreadSearch }); }, }); @@ -1956,40 +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); - dispatchSearch({ type: "reset", query: intent.query }); - setLinkedThreadSearch(intent); - clearOpenIntent(); return; case "add-project": - clearOpenIntent(); openAddProjectFlow(); return; case "new-thread-in": - clearOpenIntent(); - browseNavigation.invalidate(); - setAddProjectCloneFlow(null); - setNewProjectFlow(null); - dispatchSearch({ type: "reset" }); 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); - dispatchSearch({ type: "reset" }); pushPaletteView({ addonIcon: , groups: [{ value: "themes", label: "Change theme", items: [] }], @@ -2995,7 +2975,7 @@ function OpenCommandPaletteDialog(props: { } if (item.kind === "submenu") { - pushView(item); + pushPaletteView(item); return; }