fix(ui): surface command-palette chat search failures (#124648)

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.
This commit is contained in:
Peter Steinberger
2026-08-16 08:00:05 -07:00
committed by GitHub
parent 9873b0f6ad
commit b90d7efddb
3 changed files with 44 additions and 2 deletions
+26
View File
@@ -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<ApplicationContext<RouteId>["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(
+17 -2
View File
@@ -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) {
<span class="nav-item__icon" style="opacity:0.3;width:20px;height:20px"
>${icons.search}</span
>
<span>${t("palette.noResults")}</span>
<span
>${props.sessionSearchFailed
? t("palette.searchFailed")
: t("palette.noResults")}</span
>
</div>`
: 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<typeof globalThis.setTimeout> | 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) => {
+1
View File
@@ -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",