Skip to content

Commit fe24862

Browse files
authored
feat(ui): add settings sidebar search (#103138)
* feat(ui): add settings sidebar search * chore: keep release notes in PR body * fix(i18n): translate settings search labels
1 parent c6d7088 commit fe24862

48 files changed

Lines changed: 547 additions & 100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/web/control-ui.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ Appearance also has a browser-local Text size setting, stored with the rest of C
119119

120120
The sidebar pins navigation above a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header. **Overview** is the only destination pinned by default; expand **More** to reach every other destination. Select **Customize sidebar** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads.
121121

122+
Inside **Settings**, the dedicated sidebar starts with a **Search settings** field for quickly finding settings sections.
123+
122124
A **Search** field at the top of the sidebar opens the command palette (⌘K). The compact footer keeps connection status, **Settings**, **Docs**, mobile pairing, and the light/dark/system color-mode toggle together. The sidebar header also holds the collapse toggle (⌘B); collapsing shrinks the sidebar to an icon rail. The sidebar is the only navigation chrome on desktop, with no top bar. Narrow viewports swap the sidebar for a slide-over drawer behind a compact header row holding the drawer toggle, brand, and command-palette search. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds native back/forward buttons next to the window controls, plus trackpad swipe gestures.
123125

124126
## What it can do (today)

ui/src/app/app-host.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ class OpenClawShell extends OpenClawLightDomElement {
360360

361361
@state() private navDrawerOpen = false;
362362
@state() private activeSessionKey = "";
363+
@state() private settingsSearchQuery = "";
363364
@state() private routeState: ShellRouteState = {};
364365
@query("openclaw-command-palette") private commandPalette?: CommandPalette;
365366
private commandPaletteTarget?: CommandPaletteTargetDetail;
@@ -463,6 +464,7 @@ class OpenClawShell extends OpenClawLightDomElement {
463464
this.navDrawerTrigger = null;
464465
this.lastWorkspaceLocation = null;
465466
this.activeSessionKey = "";
467+
this.settingsSearchQuery = "";
466468
this.commandPaletteTarget = undefined;
467469
this.agentsListClient = null;
468470
this.agentsListSource = null;
@@ -743,6 +745,7 @@ class OpenClawShell extends OpenClawLightDomElement {
743745
this.ensureAgentsList(context.gateway.snapshot);
744746
}
745747
if (routeState.routeId && !isSettingsNavigationRoute(routeState.routeId)) {
748+
this.settingsSearchQuery = "";
746749
this.lastWorkspaceLocation = {
747750
routeId: routeState.routeId,
748751
search: routeState.location?.search ?? "",
@@ -826,9 +829,13 @@ class OpenClawShell extends OpenClawLightDomElement {
826829
context.config.current.serverVersion ??
827830
gatewaySnapshot.hello?.server?.version ??
828831
"",
832+
searchQuery: this.settingsSearchQuery,
829833
onExit: () => this.exitSettings(),
830834
onNavigate: (routeId) => this.navigate(routeId),
831835
onPreload: (routeId) => context.preload(routeId),
836+
onSearchQueryChange: (nextQuery) => {
837+
this.settingsSearchQuery = nextQuery;
838+
},
832839
preloadTimers: this.settingsPreloadTimers,
833840
})
834841
: html`<openclaw-app-sidebar

ui/src/components/form-controls.browser.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function readUiCss(): string {
2222
"ui/src/styles/base.css",
2323
"ui/src/styles/components.css",
2424
"ui/src/styles/config.css",
25+
"ui/src/styles/layout.css",
2526
"ui/src/styles/usage.css",
2627
"ui/src/styles/chat/layout.css",
2728
];
@@ -37,6 +38,7 @@ function controlsHtml() {
3738
<label class="field checkbox"><input type="checkbox" /><span>field checkbox</span></label>
3839
<label class="field checkbox"><input type="radio" /><span>field radio</span></label>
3940
<input class="config-search__input" value="search" />
41+
<input class="settings-sidebar__search-input" value="settings search" />
4042
<input class="settings-theme-import__input" value="theme" />
4143
<label class="config-raw-field"><textarea>raw config</textarea></label>
4244
<input class="cfg-input" value="config input" />
@@ -93,6 +95,7 @@ describeBrowserLayout("touch-primary form controls", () => {
9395
".field textarea",
9496
".field select",
9597
".config-search__input",
98+
".settings-sidebar__search-input",
9699
".settings-theme-import__input",
97100
".config-raw-field textarea",
98101
".cfg-input",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/* @vitest-environment jsdom */
2+
3+
import { render } from "lit";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { i18n } from "../i18n/index.ts";
6+
import { renderSettingsSidebar } from "./settings-sidebar.ts";
7+
8+
let container: HTMLDivElement;
9+
10+
beforeEach(async () => {
11+
await i18n.setLocale("en");
12+
container = document.createElement("div");
13+
document.body.append(container);
14+
});
15+
16+
afterEach(() => {
17+
container.remove();
18+
});
19+
20+
describe("settings sidebar search", () => {
21+
it("filters localized routes and groups while preserving navigation", () => {
22+
let searchQuery = "";
23+
const onNavigate = vi.fn();
24+
const rerender = () => {
25+
render(
26+
renderSettingsSidebar({
27+
basePath: "",
28+
activeRouteId: "config",
29+
connected: true,
30+
version: "",
31+
searchQuery,
32+
onExit: vi.fn(),
33+
onNavigate,
34+
onSearchQueryChange: (nextQuery) => {
35+
searchQuery = nextQuery;
36+
rerender();
37+
},
38+
preloadTimers: new Map(),
39+
}),
40+
container,
41+
);
42+
};
43+
const enterQuery = (query: string) => {
44+
const input = container.querySelector<HTMLInputElement>(".settings-sidebar__search-input");
45+
if (!input) {
46+
throw new Error("expected settings search input");
47+
}
48+
input.value = query;
49+
input.dispatchEvent(new Event("input", { bubbles: true }));
50+
};
51+
const labels = () =>
52+
[...container.querySelectorAll(".settings-sidebar__item-label")].map((item) =>
53+
item.textContent?.trim(),
54+
);
55+
56+
rerender();
57+
const allLabels = labels();
58+
const input = container.querySelector<HTMLInputElement>(".settings-sidebar__search-input");
59+
expect(input?.getAttribute("aria-label")).toBe("Search settings");
60+
expect(input?.placeholder).toBe("Search settings…");
61+
62+
enterQuery(" ThEmE ");
63+
expect(labels()).toEqual(["Appearance"]);
64+
65+
enterQuery("connections");
66+
expect(labels()).toEqual(["Channels", "Communications"]);
67+
68+
enterQuery("does-not-exist");
69+
expect(labels()).toEqual([]);
70+
expect(container.querySelector('[role="status"]')?.textContent?.trim()).toBe(
71+
"No matching settings.",
72+
);
73+
74+
container.querySelector<HTMLButtonElement>(".settings-sidebar__search-clear")?.click();
75+
expect(labels()).toEqual(allLabels);
76+
expect(document.activeElement).toBe(input);
77+
78+
enterQuery("channel");
79+
container
80+
.querySelector<HTMLAnchorElement>('.settings-sidebar__item[href="/settings/channels"]')
81+
?.click();
82+
expect(onNavigate).toHaveBeenCalledWith("channels");
83+
});
84+
});

ui/src/components/settings-sidebar.ts

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,56 @@ import {
66
scheduleRoutePreload,
77
SETTINGS_NAVIGATION_GROUPS,
88
settingsNavigationLabelForRoute,
9+
subtitleForRoute,
10+
titleForRoute,
911
} from "../app-navigation.ts";
1012
import { pathForRoute, type RouteId } from "../app-route-paths.ts";
1113
import { t } from "../i18n/index.ts";
14+
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
1215
import { icons } from "./icons.ts";
1316

1417
type SettingsSidebarProps = {
1518
basePath: string;
1619
activeRouteId: RouteId;
1720
connected: boolean;
1821
version: string;
22+
searchQuery: string;
1923
onExit: () => void;
2024
onNavigate: (routeId: RouteId) => void;
2125
onPreload?: (routeId: RouteId) => Promise<void> | void;
26+
onSearchQueryChange: (query: string) => void;
2227
preloadTimers: Map<EventTarget, ReturnType<typeof globalThis.setTimeout>>;
2328
};
2429

30+
type SettingsNavigationGroupView = {
31+
labelKey: string | null;
32+
routes: readonly RouteId[];
33+
};
34+
35+
function filterSettingsNavigationGroups(
36+
searchQuery: string,
37+
): readonly SettingsNavigationGroupView[] {
38+
const query = normalizeLowercaseStringOrEmpty(searchQuery);
39+
if (!query) {
40+
return SETTINGS_NAVIGATION_GROUPS;
41+
}
42+
return SETTINGS_NAVIGATION_GROUPS.map((group) => {
43+
const groupMatches = group.labelKey
44+
? normalizeLowercaseStringOrEmpty(t(group.labelKey)).includes(query)
45+
: false;
46+
const routes = groupMatches
47+
? group.routes
48+
: group.routes.filter((routeId) =>
49+
[
50+
settingsNavigationLabelForRoute(routeId),
51+
titleForRoute(routeId),
52+
subtitleForRoute(routeId),
53+
].some((value) => normalizeLowercaseStringOrEmpty(value).includes(query)),
54+
);
55+
return { labelKey: group.labelKey, routes };
56+
}).filter((group) => group.routes.length > 0);
57+
}
58+
2559
function renderItem(props: SettingsSidebarProps, routeId: RouteId) {
2660
const active = props.activeRouteId === routeId;
2761
return html`
@@ -64,6 +98,7 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
6498
const gatewayStatus = t("chat.gatewayStatus", {
6599
status: props.connected ? t("common.online") : t("common.offline"),
66100
});
101+
const navigationGroups = filterSettingsNavigationGroups(props.searchQuery);
67102
return html`
68103
<aside class="settings-sidebar">
69104
<header class="settings-sidebar__header">
@@ -74,17 +109,60 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
74109
</button>
75110
<h1 class="settings-sidebar__title">${t("nav.settings")}</h1>
76111
</header>
112+
<div class="settings-sidebar__search" role="search">
113+
<span class="settings-sidebar__search-icon" aria-hidden="true">${icons.search}</span>
114+
<input
115+
class="settings-sidebar__search-input"
116+
type="search"
117+
autocomplete="off"
118+
spellcheck="false"
119+
aria-label=${t("nav.settingsSearchLabel")}
120+
placeholder=${t("nav.settingsSearchPlaceholder")}
121+
.value=${props.searchQuery}
122+
@input=${(event: Event) =>
123+
props.onSearchQueryChange((event.currentTarget as HTMLInputElement).value)}
124+
@keydown=${(event: KeyboardEvent) => {
125+
if (event.key !== "Escape" || !props.searchQuery) {
126+
return;
127+
}
128+
event.preventDefault();
129+
props.onSearchQueryChange("");
130+
}}
131+
/>
132+
${props.searchQuery
133+
? html`
134+
<button
135+
type="button"
136+
class="settings-sidebar__search-clear"
137+
aria-label=${t("nav.settingsSearchClear")}
138+
@click=${(event: MouseEvent) => {
139+
const searchInput = (
140+
event.currentTarget as HTMLElement
141+
).parentElement?.querySelector<HTMLInputElement>("input");
142+
props.onSearchQueryChange("");
143+
searchInput?.focus();
144+
}}
145+
>
146+
${icons.x}
147+
</button>
148+
`
149+
: nothing}
150+
</div>
77151
<nav class="settings-sidebar__nav" aria-label=${t("common.settingsSections")}>
78-
${SETTINGS_NAVIGATION_GROUPS.map(
79-
(group) => html`
80-
<div class="settings-sidebar__group">
81-
${group.labelKey
82-
? html`<div class="settings-sidebar__group-label">${t(group.labelKey)}</div>`
83-
: nothing}
84-
${group.routes.map((routeId) => renderItem(props, routeId))}
85-
</div>
86-
`,
87-
)}
152+
${navigationGroups.length === 0
153+
? html`<p class="settings-sidebar__empty" role="status">
154+
${t("nav.settingsSearchNoResults")}
155+
</p>`
156+
: navigationGroups.map(
157+
(group) => html`
158+
<div class="settings-sidebar__group">
159+
${group.labelKey
160+
? html`<div class="settings-sidebar__group-label">${t(group.labelKey)}</div>`
161+
: nothing}
162+
${group.routes.map((routeId) => renderItem(props, routeId))}
163+
</div>
164+
`,
165+
)}
88166
</nav>
89167
<footer class="settings-sidebar__footer">
90168
<span

0 commit comments

Comments
 (0)