feat(webui): share workspace and logs search fields (#8024)

This commit is contained in:
jinxin
2026-09-02 06:23:36 +00:00
committed by GitHub
parent be2d96e3dc
commit 4ba6a668af
6 changed files with 93 additions and 17 deletions

View File

@@ -69,3 +69,18 @@ test("SearchField omits the optional clear action", () => {
assert.doesNotMatch(html, /<button/);
});
test("SearchField provides a compact toolbar size", () => {
const rendered = SearchField({
value: "agent",
onChange: () => {},
placeholder: "Filter by target…",
"aria-label": "Filter by target…",
size: "sm",
});
const input = childByType(rendered, "input");
assert.match(input.props.className, /\bh-8\b/);
assert.match(input.props.className, /\btext-xs\b/);
assert.doesNotMatch(input.props.className, /\bh-9\b/);
});

View File

@@ -5,14 +5,37 @@ import { cn } from "../utils/cn";
type NativeSearchFieldProps = Omit<
ComponentPropsWithoutRef<"input">,
"aria-label" | "onChange" | "type" | "value"
"aria-label" | "onChange" | "size" | "type" | "value"
> & {
"aria-label": string;
onChange: (value: string) => void;
value: string;
};
type SearchFieldProps = NativeSearchFieldProps &
const sizeClasses = {
md: {
clear: "right-2 h-6 w-6",
clearIcon: "h-3.5 w-3.5",
icon: "left-3 h-4 w-4",
input: "h-9 rounded-[10px] pl-9 text-sm",
inputWithClear: "pr-9",
inputWithoutClear: "pr-3",
},
sm: {
clear: "right-1.5 h-6 w-6",
clearIcon: "h-3 w-3",
icon: "left-2.5 h-3.5 w-3.5",
input: "h-8 rounded-[8px] pl-8 text-xs",
inputWithClear: "pr-8",
inputWithoutClear: "pr-2.5",
},
};
export type SearchFieldSize = keyof typeof sizeClasses;
type SearchFieldProps = NativeSearchFieldProps & {
size?: SearchFieldSize;
} &
(
| { clearLabel: string; onClear: () => void }
| { clearLabel?: never; onClear?: never }
@@ -25,16 +48,21 @@ export function SearchField({
disabled = false,
onChange,
onClear,
size = "md",
value,
...rest
}: SearchFieldProps) {
const showClear = Boolean(value && onClear);
const styles = sizeClasses[size];
return (
<div className={cn("relative min-w-0", className)}>
<Icon
name="search"
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--v2-text-faint)]"
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-[var(--v2-text-faint)]",
styles.icon,
)}
/>
<input
{...rest}
@@ -44,12 +72,13 @@ export function SearchField({
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
className={cn(
"h-9 w-full rounded-[10px] border border-[var(--v2-panel-border)]",
"bg-[var(--v2-input-bg)] pl-9 text-sm text-[var(--v2-text-strong)] outline-none",
"w-full border border-[var(--v2-panel-border)]",
"bg-[var(--v2-input-bg)] text-[var(--v2-text-strong)] outline-none",
"appearance-none placeholder:text-[var(--v2-text-faint)] focus:border-[var(--v2-accent)]",
"[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden",
"disabled:cursor-not-allowed disabled:opacity-50",
showClear ? "pr-9" : "pr-3",
styles.input,
showClear ? styles.inputWithClear : styles.inputWithoutClear,
)}
/>
{showClear && (
@@ -59,12 +88,13 @@ export function SearchField({
disabled={disabled}
onClick={onClear}
className={cn(
"absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md",
"absolute top-1/2 grid -translate-y-1/2 place-items-center rounded-md",
"text-[var(--v2-text-faint)] hover:bg-[var(--v2-surface-muted)] hover:text-[var(--v2-text-strong)]",
"disabled:cursor-not-allowed disabled:opacity-50",
styles.clear,
)}
>
<Icon name="close" className="h-3.5 w-3.5" />
<Icon name="close" className={styles.clearIcon} />
</button>
)}
</div>

View File

@@ -47,9 +47,11 @@ function createLogsPageHarness(overrides = {}) {
const hookValues = [];
let hookCursor = 0;
function ConfirmDialog() {}
function SearchField() {}
function SelectMenu() {}
const context = {
ConfirmDialog,
SearchField,
SelectMenu,
globalThis: {},
React: {
@@ -91,6 +93,7 @@ function createLogsPageHarness(overrides = {}) {
vm.runInNewContext(logsPageSourceForTest(), context);
return {
ConfirmDialog,
SearchField,
SelectMenu,
render() {
hookCursor = 0;
@@ -355,6 +358,25 @@ test("LogsPage changes the log level through the compact shared SelectMenu", ()
assert.deepEqual(changes, ["warn"]);
});
test("LogsPage filters targets through the compact shared SearchField", () => {
const changes = [];
const harness = createLogsPageHarness({
targetFilter: "ironclaw::agent",
setTargetFilter: (value) => changes.push(value),
});
const [targetSearch] = componentProps(harness.render(), harness.SearchField);
assert.ok(targetSearch, "expected LogsPage to render the shared SearchField");
assert.equal(targetSearch.value, "ironclaw::agent");
assert.equal(targetSearch.size, "sm");
assert.equal(targetSearch["aria-label"], "logs.filterTarget");
assert.equal(targetSearch.clearLabel, "settings.clearSearch");
targetSearch.onChange("ironclaw::runtime");
targetSearch.onClear();
assert.deepEqual(changes, ["ironclaw::runtime", ""]);
});
test("LogsPage changes the server level through the compact shared SelectMenu", () => {
const changes = [];
const harness = createLogsPageHarness({

View File

@@ -2,6 +2,7 @@
import { useOutletContext } from "react-router";
import React from "react";
import { ConfirmDialog } from "../../design-system/confirm-dialog";
import { SearchField } from "../../design-system/search-field";
import { SelectMenu } from "../../design-system/select-menu";
import { useT } from "../../lib/i18n";
import { useLogs } from "./hooks/useLogs";
@@ -192,12 +193,15 @@ export function LogsPage() {
/>
{/* Target filter */}
<input
type="text"
<SearchField
value={targetFilter}
onInput={(e) => setTargetFilter(e.currentTarget.value)}
onChange={setTargetFilter}
onClear={() => setTargetFilter("")}
placeholder={t("logs.filterTarget")}
className="h-8 min-w-[10rem] flex-1 rounded-[8px] border border-[var(--v2-panel-border)] bg-[var(--v2-surface-muted)] px-3 text-xs text-[var(--v2-text-base)] placeholder:text-[var(--v2-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--v2-accent)]"
aria-label={t("logs.filterTarget")}
clearLabel={t("settings.clearSearch")}
size="sm"
className="min-w-[10rem] flex-1"
/>
<div className="flex items-center gap-2 ml-auto">

View File

@@ -19,6 +19,7 @@ vi.mock("../../../lib/i18n", () => ({
"workspace.area.memory": "Speicher",
"workspace.fileMeta": "{mime} · {size}",
"workspace.filterPlaceholder": "Nach Namen filtern…",
"settings.clearSearch": "Suche löschen",
"workspace.pickFileTitle": "Datei aus dem Arbeitsbereich auswählen",
"workspace.breadcrumbRoot": "Arbeitsbereich",
"workspace.unableOpenDirectory": "Ordner konnte nicht geöffnet werden",
@@ -94,13 +95,13 @@ test("workspace tree exposes hierarchy, expansion, selection, and roving focus s
assert.equal((html.match(/tabindex="0"/g) || []).length, 1);
});
test("workspace filter and breadcrumb have accessible names and landmarks", () => {
test("workspace uses the shared clearable search field and an accessible breadcrumb", () => {
const sidebar = renderToStaticMarkup(
<WorkspaceSidebar
rootEntries={[]}
selectedPath=""
expandedPaths={new Set()}
filter=""
filter="memory"
scopeKey="caller-default"
listDirectory={listDirectory}
onFilterChange={() => {}}
@@ -114,6 +115,8 @@ test("workspace filter and breadcrumb have accessible names and landmarks", () =
);
assert.match(sidebar, /aria-label="Nach Namen filtern…"/);
assert.match(sidebar, /<input[^>]*type="search"/);
assert.match(sidebar, /<button[^>]*aria-label="Suche löschen"/);
assert.match(breadcrumb, /<nav aria-label="Arbeitsbereich"/);
});

View File

@@ -1,5 +1,6 @@
import { useT } from "../../../lib/i18n";
import { Panel } from "../../../design-system/primitives";
import { SearchField } from "../../../design-system/search-field";
import { WorkspaceTree } from "./workspace-tree";
// Read-only navigation rail. The tree is rooted at the mount list (memory,
@@ -22,12 +23,13 @@ export function WorkspaceSidebar({
return (
<Panel className="flex min-h-[420px] flex-col overflow-hidden p-0 xl:min-h-0">
<div className="border-b border-white/10 p-3">
<input
<SearchField
value={filter}
onInput={(event) => onFilterChange(event.currentTarget.value)}
onChange={onFilterChange}
onClear={() => onFilterChange("")}
placeholder={t("workspace.filterPlaceholder")}
aria-label={t("workspace.filterPlaceholder")}
className="h-9 w-full rounded-md border border-white/10 bg-iron-950/80 px-3 text-sm text-white outline-none placeholder:text-iron-400 focus:border-signal/45"
clearLabel={t("settings.clearSearch")}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">