diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx index 20f9c93bc8..fbb6df01d0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-workbench.tsx @@ -1,5 +1,6 @@ +import {useRef, useState} from "react"; import {ContextHelp} from "./context-help"; -import { AlertTriangle, ShieldCheck, SlidersHorizontal } from "lucide-react"; +import { AlertTriangle, ShieldCheck, SlidersHorizontal, Search, X } from "lucide-react"; import type { CapabilityConfigurationCatalog } from "../../data/chat"; import { useWorkspaceI18n, type WorkspaceLocale, type WorkspaceTranslate } from "./i18n"; @@ -91,12 +92,35 @@ export function CapabilityCatalogNavigation({ showScope?: boolean; t: WorkspaceTranslate; }>) { - const memory = scope === "goal" ? memoryCapabilities(capabilities) : []; + const [query, setQuery] = useState(""); + const search = useRef(null); + const terms = query.trim().toLocaleLowerCase(locale).split(/\s+/u).filter(Boolean); + const visible = capabilities.filter(capability => { + const localized = localizeCapability(capability, locale); + const text = [capability.capability_id, capability.display_name, capability.description, + localized.display_name, localized.description, + isMemoryCapability(capability.capability_id) ? memorySettingsTitle(locale) : ""].join(" ").toLocaleLowerCase(locale); + return terms.every(term => text.includes(term)); + }); + const memory = scope === "goal" ? memoryCapabilities(visible) : []; const memorySelected = memory.some(item => item.capability_id === selectedCapabilityId); let memoryRendered = false; return ( +
+
+ + setQuery(event.target.value)} + onKeyDown={event => {if (event.key === "Escape" && query) {event.preventDefault(); event.stopPropagation(); setQuery("");}}}/> + {query ? : null} +
+ {terms.length ?

{visible.length + ? (locale === "zh-CN" ? `${visible.length} 项匹配` : `${visible.length} matching capabilities`) + : (locale === "zh-CN" ? "没有匹配的能力,请尝试其他关键词。" : "No matching capabilities. Try another search.")}

: null} +
); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index d0e55e5634..b746ddb227 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -483,7 +483,7 @@ assert.match(capabilityWorkbench, /localizeCapability\(rawCapability, locale\)/, assert.match(capabilityWorkbench, /capability\.capability_id === "multi_subagent"\) return 3/, "Immature child-agent capacity stays behind mature supported capabilities"); assert.match(capabilityWorkbench, /availability\?\.includes\("experimental"\)/, "Experimental capabilities share a stable late presentation tier"); assert.match(capabilityWorkbench, /configuration_editor\.writable_scopes\.length === 0/, "Provider-bound read-only capabilities follow directly actionable settings"); -assert.match(capabilityWorkbench, /orderCapabilitiesForPresentation\(capabilities, locale\)/, "Machine and Goal catalogs use one presentation-order policy"); +assert.match(capabilityWorkbench, /orderCapabilitiesForPresentation\(visible, locale\)/, "Machine and Goal search results use the shared presentation-order policy"); for (const capabilityId of [ "change_quality_qualification", "coordination_runtime_shadow", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 68a90578a8..2b09f3b470 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -994,6 +994,16 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-capability-scope-note p { margin: 0; font-size: var(--text-caption); line-height: var(--leading-caption); } .personal-capability-scope-note strong { display: block; color: var(--pw-text); } .personal-capability-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: stretch; min-width: 0; min-height: 0; overflow: hidden; } +.personal-capability-navigation { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; overflow: hidden; } +.personal-capability-navigation > .personal-capability-list { flex: 1; } +.personal-capability-search { display: flex; flex: none; align-items: center; gap: 6px; min-width: 0; min-height: 44px; padding-inline: 8px; border: 1px solid var(--pw-line-strong); border-radius: 6px; background: var(--pw-card); } +.personal-capability-search > svg { flex: none; color: var(--pw-muted); } +.personal-capability-search:focus-within { outline: 2px solid var(--pw-blue); outline-offset: 1px; } +.personal-capability-search input { flex: 1; width: 100%; min-width: 0; min-height: 42px; padding: 0; border: 0; background: transparent; color: var(--pw-text); font: inherit; font-size: var(--text-body); } +.personal-capability-search input:focus-visible { outline: none; } +.personal-capability-search input::-webkit-search-cancel-button { -webkit-appearance: none; } +.personal-capability-search button { display: grid; place-items: center; flex: none; width: 36px; min-height: 40px; border: 0; border-radius: 4px; background: transparent; color: var(--pw-muted); cursor: pointer; } +.personal-capability-search-result { margin: 0; padding-inline: 3px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } .personal-capability-list { display: grid; align-content: start; gap: 6px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 3px; } .personal-capability-list button { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; padding: 10px 12px; border: 1px solid transparent; border-radius: 6px; background: #fff; color: var(--pw-text); cursor: pointer; text-align: left; } .personal-capability-list button:hover { border-color: #b8c9f5; } diff --git a/examples/personal-workspace-browser/capability-scope.mjs b/examples/personal-workspace-browser/capability-scope.mjs index 21648672c8..63d5c01ec6 100644 --- a/examples/personal-workspace-browser/capability-scope.mjs +++ b/examples/personal-workspace-browser/capability-scope.mjs @@ -3,6 +3,52 @@ import assert from "node:assert/strict"; import { outputDir } from "./fixture.mjs"; import { openWorkspacePage } from "./scenario-context.mjs"; +// Independent ordering oracle: input order is deliberately unrelated to tier or +// name order. Exercise both actual settings consumers, not the sort helper. +async function verifyFilteredCatalogOrder(page) { + const snapshots = await page.evaluate(async () => ({ + machine: await (await fetch('/api/chat/machine-configuration')).json(), + goal: await (await fetch('/api/chat/goal-configuration?goal_id=product-release')).json(), + })); + const template = snapshots.goal.capability_catalog.capabilities.find(item => item.capability_id === 'periodic_report'); + const capability = (id, title, availability, writable = true) => ({...template, + capability_id: id, display_name: title, description: '', available_scopes: ['machine', 'goal'], machine_namespace: id, + availability, configuration_editor: {...template.configuration_editor, fields: [], + editable: writable, supported_scopes: ['machine', 'goal'], writable_scopes: writable ? ['machine', 'goal'] : []}, + }); + const capabilities = [ + capability('order_retired', 'Order Retired', 'retired', false), + capability('order_zulu', 'Order Zulu', 'supported_opt_in'), + capability('order_experiment', 'Order Experiment', 'experimental_opt_in'), + capability('order_readonly', 'Order Read only', 'supported_opt_in', false), + capability('unrelated', 'Unrelated', 'supported_explicit_override'), + capability('order_alpha', 'Order Alpha', 'supported_opt_in'), + capability('order_priority', 'Order Priority', 'supported_explicit_override'), + ]; + for (const [scope, snapshot] of Object.entries(snapshots)) { + await page.route(`**/api/chat/${scope}-configuration*`, async route => { + if (route.request().method() !== 'GET') return route.fallback(); + await route.fulfill({json: {...snapshot, capability_catalog: {...snapshot.capability_catalog, capabilities}}}); + }); + } + await page.reload({waitUntil: 'networkidle'}); + const expected = ['Order Priority', 'Unrelated', 'Order Alpha', 'Order Zulu', 'Order Read only', 'Order Experiment', 'Order Retired']; + for (const scope of ['Goal', 'Machine']) { + if (scope === 'Machine') await page.getByRole('radio', {name: 'This device', exact: true}).check(); + const catalog = page.getByRole('navigation', {name: `${scope} capability catalog`, exact: true}); + const labels = () => catalog.locator('button strong').allTextContents(); + const search = page.getByRole('searchbox', {name: 'Search capabilities', exact: true}); + await catalog.getByRole('button', {name: /^Order Priority/}).waitFor(); + assert.deepEqual(await labels(), expected, `${scope}: priority overrides precede actionable, read-only, experimental and retired entries; peers sort by name`); + await search.fill('order'); + assert.deepEqual(await labels(), expected.filter(label => label !== 'Unrelated'), `${scope}: filtering preserves tier and name order`); + await search.fill('order zulu'); + assert.deepEqual(await labels(), ['Order Zulu'], `${scope}: every term must match`); + await search.press('Escape'); + assert.deepEqual(await labels(), expected, `${scope}: clearing restores complete presentation order`); + } +} + export const capabilityScopeScenario = { id: "capability-scope", async run({ browser, collectCoverage, url }) { @@ -22,6 +68,40 @@ export const capabilityScopeScenario = { const goalScope = page.getByRole("radio", { name: "Goal 设置", exact: true }); if (!await defaults.isChecked()) throw new Error("Global settings must start at device defaults"); await page.getByRole("navigation", { name: "机器能力目录" }).waitFor(); + const search = page.getByRole('searchbox', {name: '搜索能力', exact: true}); + const machineCatalog = page.getByRole('navigation', {name: '机器能力目录'}); + const initialEditor = await page.locator('.personal-capability-detail h2').innerText(); + const allMachineEntries = await machineCatalog.getByRole('button').count(); + const toggleBox = await page.locator('.personal-capability-fields input[role="switch"]').first().boundingBox(); + assert(toggleBox && toggleBox.width <= 44 && toggleBox.height <= 28, 'Current capability toggles are compact, not oversized native checkboxes'); + console.log(`current capability switch: ${toggleBox.width}×${toggleBox.height} CSS pixels`); + + await search.fill(' PERIODIC_REPORT '); + assert.equal(await machineCatalog.getByRole('button').count(), 1); + assert.equal(await page.locator('.personal-capability-detail h2').innerText(), initialEditor, 'Filtering does not select or replace the editor'); + await search.fill('not-a-capability-[.*]'); + assert.equal(await machineCatalog.getByRole('button').count(), 0, 'Search is literal, not a regular expression'); + await page.getByText('没有匹配的能力,请尝试其他关键词。', {exact: true}).waitFor(); + await search.press('Escape'); + assert.equal(await search.inputValue(), ''); + assert(await search.evaluate(element => element === document.activeElement)); + for (const width of [1280, 960, 390]) { + await page.setViewportSize({width, height: width === 390 ? 844 : 820}); + await search.fill('周期'); + assert(await machineCatalog.getByRole('button').count() >= 1); + await machineCatalog.getByRole('button', {name: '周期报告', exact: true}).click(); + assert.equal(await page.locator('.personal-capability-detail h2').innerText(), '周期报告'); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + const box = await search.boundingBox(); + assert(box && box.width > 80 && box.height >= 40); + await page.screenshot({path: resolve(outputDir, `capability-search-${width}.png`), animations: 'disabled'}); + await page.getByRole('button', {name: '清除能力搜索', exact: true}).click(); + assert.equal(await search.inputValue(), ''); + assert.equal(await machineCatalog.getByRole('button').count(), allMachineEntries); + assert(await search.evaluate(element => element === document.activeElement)); + } + await page.setViewportSize({width: 1512, height: 982}); + await tabs.getByRole("button", { name: "管家", exact: true }).click(); await page.locator(".personal-capability-supported-scopes").filter({ hasText: "设备全局配置 · 不支持 Goal 覆盖" }).waitFor(); await tabs.getByRole("button", { name: "能力中心", exact: true }).click(); @@ -65,6 +145,13 @@ export const capabilityScopeScenario = { await catalog.getByRole("button", { name: /周期报告/ }).click(); assert.notEqual(await page.getByLabel(/^报告 Profile/u).inputValue(), "discarded-periodic-draft"); await page.getByLabel(/^报告 Profile/u).fill("unsaved-scope-test"); + await search.fill('multi_subagent'); + assert.equal(await catalog.getByRole('button').count(), 1); + assert.equal(await page.getByLabel(/^报告 Profile/u).inputValue(), 'unsaved-scope-test', 'Filtering preserves the unsaved draft'); + await search.fill('reward_memory'); + assert.equal(await catalog.getByRole('button', {name: '记忆与资料', exact: true}).count(), 1, 'A member ID finds its grouped memory entry'); + await search.fill(''); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); await page.getByText("锁定 revision 的变更预览", { exact: true }).waitFor(); if (api.goalConfigurationRequests.at(-1)?.goal_id !== "product-release") throw new Error("Preview targeted a different Goal"); @@ -189,6 +276,17 @@ export const capabilityScopeScenario = { await page.locator(".personal-goal-link", { hasText: "Product Release" }).click(); await page.getByRole("button", { name: "Goal 设置", exact: true }).click(); if (!await goalScope.isChecked() || await target.inputValue() !== "product-release") throw new Error("Goal settings entry lost its target"); + await page.evaluate(() => localStorage.setItem('loopx-pw-locale', 'en')); + await page.reload({waitUntil: 'networkidle'}); + const englishSearch = page.getByRole('searchbox', {name: 'Search capabilities', exact: true}); + await englishSearch.fill('periodic reports'); + const englishCatalog = page.getByRole('navigation', {name: 'Goal capability catalog'}); + await englishCatalog.getByRole('button', {name: /^Periodic reports/}).waitFor(); + await englishSearch.press('Tab'); + await page.getByRole('button', {name: 'Clear capability search', exact: true}).press('Enter'); + assert.equal(await englishSearch.inputValue(), ''); + assert(await englishSearch.evaluate(element => element === document.activeElement)); + await verifyFilteredCatalogOrder(page); if (context.errors.some(error => !/503/.test(error))) throw new Error(context.errors.join(" | ")); return { coverageEntries: await context.close(), note: "One capability destination; explicit device/Goal scope; fresh target reads; no cross-scope drafts or writes; Goal entry preserved; desktop/mobile verified." }; } catch (error) {