From b90d7efddbc04f0363ba5c024d9cf538511d6ae1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 08:00:05 -0700 Subject: [PATCH] fix(ui): surface command-palette chat search failures (#124648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette's session search swallowed every gateway error and rendered the plain "No results" empty state — a failed search (e.g. sessions.list rejecting with a store-needs-doctor migration error) was indistinguishable from a successful search with zero matches. Silent failure on a default path is the worst bug class in this repo. Track a sessionSearchFailed flag (set only for the current request id while the palette is open, cleared on every new query/clear), and render "Chat search failed — check the gateway logs and retry" in the empty state instead. Navigation commands stay usable throughout. --- ui/src/components/command-palette.test.ts | 26 +++++++++++++++++++++++ ui/src/components/command-palette.ts | 19 +++++++++++++++-- ui/src/i18n/locales/en.ts | 1 + 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ui/src/components/command-palette.test.ts b/ui/src/components/command-palette.test.ts index 8f2b636588e1..8313217d144b 100644 --- a/ui/src/components/command-palette.test.ts +++ b/ui/src/components/command-palette.test.ts @@ -204,6 +204,32 @@ describe("CommandPalette lifecycle", () => { expect(palette.textContent).not.toContain("Stale chat"); }); + it("shows a search failure instead of a false empty result", async () => { + const { gateway } = createGateway(true); + const list = vi + .fn["sessions"]["list"]>() + .mockRejectedValueOnce(new Error("store needs doctor migration")) + .mockResolvedValueOnce(createSessionResult("agent:main:zz", "Recovered chat")); + const { palette } = await mountPalette(createContext(gateway, list)); + // The query matches no navigation item, so a swallowed search failure + // would render the plain "No results" empty state. + await enterQuery(palette, "zzz-unmatched"); + await vi.advanceTimersByTimeAsync(250); + await palette.updateComplete; + + expect(list).toHaveBeenCalledOnce(); + expect(palette.textContent).toContain("Chat search failed"); + expect(palette.textContent).not.toContain("No results"); + + // A new keystroke clears the failure state and retries cleanly. + await enterQuery(palette, "zz"); + await palette.updateComplete; + expect(palette.textContent).not.toContain("Chat search failed"); + await vi.advanceTimersByTimeAsync(250); + await palette.updateComplete; + expect(palette.textContent).toContain("Recovered chat"); + }); + it("navigates to the plugin manager from search", async () => { const { gateway } = createGateway(true); const { palette } = await mountPalette( diff --git a/ui/src/components/command-palette.ts b/ui/src/components/command-palette.ts index d40049c93b52..fbf3c720d718 100644 --- a/ui/src/components/command-palette.ts +++ b/ui/src/components/command-palette.ts @@ -124,6 +124,7 @@ type CommandPaletteProps = { query: string; activeIndex: number; sessionItems: readonly PaletteItem[]; + sessionSearchFailed: boolean; onToggle: () => void; onQueryChange: (query: string) => void; onActiveIndexChange: (index: number) => void; @@ -315,7 +316,11 @@ function renderCommandPalette(props: CommandPaletteProps) { ${icons.search} - ${t("palette.noResults")} + ${props.sessionSearchFailed + ? t("palette.searchFailed") + : t("palette.noResults")} ` : grouped.map( ([category, groupedItems]) => html` @@ -369,6 +374,7 @@ export class CommandPalette extends OpenClawLightDomContentsElement { @state() private query = ""; @state() private activeIndex = 0; @state() private sessionItems: readonly PaletteItem[] = []; + @state() private sessionSearchFailed = false; private readonly subscriptions = new SubscriptionsController(this); private sessionSearchTimer: ReturnType | null = null; @@ -458,6 +464,7 @@ export class CommandPalette extends OpenClawLightDomContentsElement { } this.sessionSearchId += 1; this.sessionItems = []; + this.sessionSearchFailed = false; } private scheduleSessionSearch(query: string) { @@ -469,6 +476,7 @@ export class CommandPalette extends OpenClawLightDomContentsElement { // repopulate selectable stale rows during the debounce window. this.sessionSearchId += 1; this.sessionItems = []; + this.sessionSearchFailed = false; const search = normalizeOptionalString(query); if (!this.open || !search || !this.onSelectSession) { return; @@ -551,7 +559,13 @@ export class CommandPalette extends OpenClawLightDomContentsElement { })); this.activeIndex = 0; } catch { - // Session search is best-effort; navigation commands stay usable. + // Session search is best-effort; navigation commands stay usable. But a + // failed search must not render as "No results" — that reads as a + // successful search with zero matches and hides gateway-side failures + // (e.g. a store needing doctor migration) from the operator. + if (requestId === this.sessionSearchId && this.open) { + this.sessionSearchFailed = true; + } } } @@ -573,6 +587,7 @@ export class CommandPalette extends OpenClawLightDomContentsElement { query: this.query, activeIndex: this.activeIndex, sessionItems: this.sessionItems, + sessionSearchFailed: this.sessionSearchFailed, desktopAvailable: this.desktopAvailable, onToggle: this.togglePalette, onQueryChange: (query) => { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 86c492f38f2d..17d9b56ce5de 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3857,6 +3857,7 @@ export const en: TranslationMap = { palette: { placeholder: "Search chats and commands…", noResults: "No results", + searchFailed: "Chat search failed — check the gateway logs and retry", categories: { search: "Search", navigation: "Navigation",