mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
feat(webui): share workspace and logs search fields (#8024)
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user