From 512bbedf9d2d8c8ec8c918a8221731400f7fc014 Mon Sep 17 00:00:00 2001 From: chengzhang Date: Wed, 3 Jun 2026 15:11:56 +0800 Subject: [PATCH 1/4] fix scan page issues --- .../components/cards/SkillInUseCard.test.tsx | 45 ++++++++++- .../components/cards/SkillInUseCard.tsx | 76 ++++++++++++++++++- .../components/cards/SkillsInUseList.tsx | 5 +- frontend/src/features/skills/i18n.ts | 16 +++- .../skills/screens/ScanConfigPage.test.tsx | 43 +++++++++++ .../skills/screens/ScanConfigPage.tsx | 28 +++++-- .../skills/screens/SkillsInUsePage.tsx | 1 + frontend/src/styles/components/cards.css | 43 +++++++++++ 8 files changed, 244 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/skills/components/cards/SkillInUseCard.test.tsx b/frontend/src/features/skills/components/cards/SkillInUseCard.test.tsx index 9303f23..02396bd 100644 --- a/frontend/src/features/skills/components/cards/SkillInUseCard.test.tsx +++ b/frontend/src/features/skills/components/cards/SkillInUseCard.test.tsx @@ -2,6 +2,7 @@ import type { ComponentType } from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { LOCALE_STORAGE_KEY, LocaleProvider } from "../../../../i18n"; import { SkillInUseCard } from "./SkillInUseCard"; const SkillInUseCardSubject = SkillInUseCard as unknown as ComponentType>; @@ -9,6 +10,8 @@ const SkillInUseCardSubject = SkillInUseCard as unknown as ComponentType) { const onRequestRemove = vi.fn(); const onRequestDelete = vi.fn(); + const onOpenSkill = vi.fn(); + const onToggleHarness = vi.fn(); const props = { row: { skillRef: "shared:trace-lens", @@ -25,15 +28,26 @@ function renderCard(overrides?: Record) { pendingStructuralAction: null, selected: false, checked: false, - onOpenSkill: vi.fn(), + onOpenSkill, onToggleChecked: vi.fn(), + onToggleHarness, onSetAllHarnesses: vi.fn(), onRequestRemove, onRequestDelete, ...overrides, }; - return { ...render(), onRequestRemove, onRequestDelete }; + return { + ...render( + + + , + ), + onOpenSkill, + onToggleHarness, + onRequestRemove, + onRequestDelete, + }; } describe("SkillInUseCard", () => { @@ -80,4 +94,31 @@ describe("SkillInUseCard", () => { expect(screen.queryByRole("button", { name: "More actions for Trace Lens" })).not.toBeInTheDocument(); }); + + it("renders every interactive harness as a card toggle", () => { + const { onOpenSkill, onToggleHarness } = renderCard(); + + const enabledHarness = screen.getByRole("button", { name: "Disable Trace Lens on Codex" }); + const disabledHarness = screen.getByRole("button", { name: "Enable Trace Lens on Cursor" }); + + expect(enabledHarness).toHaveAttribute("aria-pressed", "true"); + expect(disabledHarness).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(disabledHarness); + + expect(onToggleHarness).toHaveBeenCalledWith( + expect.objectContaining({ skillRef: "shared:trace-lens" }), + expect.objectContaining({ harness: "cursor", state: "disabled" }), + ); + expect(onOpenSkill).not.toHaveBeenCalled(); + }); + + it("localizes harness toggle labels", () => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + + renderCard(); + + expect(screen.getByRole("button", { name: "在 Codex 上禁用 Trace Lens" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "在 Cursor 上启用 Trace Lens" })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/features/skills/components/cards/SkillInUseCard.tsx b/frontend/src/features/skills/components/cards/SkillInUseCard.tsx index 29364bb..a0fe04c 100644 --- a/frontend/src/features/skills/components/cards/SkillInUseCard.tsx +++ b/frontend/src/features/skills/components/cards/SkillInUseCard.tsx @@ -2,12 +2,13 @@ import { useMemo } from "react"; import { Loader2, PackageOpen, Power, Trash2 } from "lucide-react"; import { CardMenu, type CardMenuItem } from "../../../../components/cards/CardMenu"; +import { UiTooltip } from "../../../../components/ui/UiTooltip"; +import { getHarnessPresentation } from "../../../../components/harness/harnessPresentation"; import { useSkillsCopy } from "../../i18n"; import { cellActionKey } from "../../model/pending"; import type { CellActionKey, StructuralSkillAction } from "../../model/pending"; -import type { SkillListRow } from "../../model/types"; +import type { HarnessCell, SkillListRow } from "../../model/types"; import { CardTitleRow } from "./CardTitleRow"; -import { HarnessChipStack } from "./HarnessChipStack"; interface SkillInUseCardProps { row: SkillListRow; @@ -17,6 +18,7 @@ interface SkillInUseCardProps { checked: boolean; onOpenSkill: (skillRef: string) => void; onToggleChecked: (skillRef: string) => void; + onToggleHarness: (row: SkillListRow, cell: HarnessCell) => void; onSetAllHarnesses: ( skillRef: string, target: "enabled" | "disabled", @@ -33,6 +35,7 @@ export function SkillInUseCard({ checked, onOpenSkill, onToggleChecked, + onToggleHarness, onSetAllHarnesses, onRequestRemove, onRequestDelete, @@ -101,7 +104,12 @@ export function SkillInUseCard({ {row.description ?

{row.description}

: null}
- + + + ); + })} +
+ + {enabledCount}/{cells.length} + + + ); +} diff --git a/frontend/src/features/skills/components/cards/SkillsInUseList.tsx b/frontend/src/features/skills/components/cards/SkillsInUseList.tsx index cc6b131..0203220 100644 --- a/frontend/src/features/skills/components/cards/SkillsInUseList.tsx +++ b/frontend/src/features/skills/components/cards/SkillsInUseList.tsx @@ -1,5 +1,5 @@ import type { CellActionKey, StructuralSkillAction } from "../../model/pending"; -import type { SkillListRow } from "../../model/types"; +import type { HarnessCell, SkillListRow } from "../../model/types"; import { useSkillsCopy } from "../../i18n"; import { SkillInUseCard } from "./SkillInUseCard"; @@ -12,6 +12,7 @@ interface SkillsInUseListProps { checkedRefs: ReadonlySet; onOpenSkill: (skillRef: string) => void; onToggleChecked: (skillRef: string) => void; + onToggleHarness: (row: SkillListRow, cell: HarnessCell) => void; onSetAllHarnesses: ( skillRef: string, target: "enabled" | "disabled", @@ -29,6 +30,7 @@ export function SkillsInUseList({ checkedRefs, onOpenSkill, onToggleChecked, + onToggleHarness, onSetAllHarnesses, onRequestRemove, onRequestDelete, @@ -47,6 +49,7 @@ export function SkillsInUseList({ checked={checkedRefs.has(row.skillRef)} onOpenSkill={onOpenSkill} onToggleChecked={onToggleChecked} + onToggleHarness={onToggleHarness} onSetAllHarnesses={onSetAllHarnesses} onRequestRemove={onRequestRemove} onRequestDelete={onRequestDelete} diff --git a/frontend/src/features/skills/i18n.ts b/frontend/src/features/skills/i18n.ts index fa67943..c848eb5 100644 --- a/frontend/src/features/skills/i18n.ts +++ b/frontend/src/features/skills/i18n.ts @@ -14,6 +14,10 @@ const englishSkillsCopy = { emptyBody: "Review local skill folders or install something from the marketplace to start controlling harness coverage here.", filterAria: (label: string) => `Filter: ${label}`, + harnessToggleAria: (enabled: number, total: number) => `Enabled on ${enabled} of ${total} harnesses`, + harnessToggleTooltip: (label: string, enabled: boolean) => `${label} - ${enabled ? "enabled" : "disabled"}`, + enableHarnessAria: (skillName: string, harnessLabel: string) => `Enable ${skillName} on ${harnessLabel}`, + disableHarnessAria: (skillName: string, harnessLabel: string) => `Disable ${skillName} on ${harnessLabel}`, pills: { all: "All", enabled: "Enabled", @@ -36,7 +40,9 @@ const englishSkillsCopy = { noConfigsTitle: "No scan configs yet", noConfigsBody: "Add an LLM configuration before running semantic security scans.", configsAria: "LLM scan configurations", - deleteConfigConfirm: (name: string) => `Delete scan config "${name}"?`, + deleteConfigTitle: (name: string) => `Delete ${name}?`, + deleteConfigDescription: "This removes the saved LLM scan configuration. Existing scan results are not deleted.", + deletingConfig: "Deleting", table: { name: "Name", model: "Model", @@ -220,6 +226,10 @@ export const skillsCopy = { emptyTitle: "还没有使用中的 Skill", emptyBody: "确认本地 Skill 文件夹,或从商城安装内容,然后在这里控制 harness 覆盖范围。", filterAria: (label: string) => `筛选:${label}`, + harnessToggleAria: (enabled: number, total: number) => `已在 ${enabled}/${total} 个 harness 上启用`, + harnessToggleTooltip: (label: string, enabled: boolean) => `${label}:${enabled ? "已启用" : "已禁用"}`, + enableHarnessAria: (skillName: string, harnessLabel: string) => `在 ${harnessLabel} 上启用 ${skillName}`, + disableHarnessAria: (skillName: string, harnessLabel: string) => `在 ${harnessLabel} 上禁用 ${skillName}`, pills: { all: "全部", enabled: "已启用", @@ -242,7 +252,9 @@ export const skillsCopy = { noConfigsTitle: "还没有扫描配置", noConfigsBody: "先添加一个 LLM 配置,再运行语义安全扫描。", configsAria: "LLM 扫描配置", - deleteConfigConfirm: (name: string) => `删除扫描配置“${name}”?`, + deleteConfigTitle: (name: string) => `删除 ${name}?`, + deleteConfigDescription: "这会移除已保存的 LLM 扫描配置,不会删除已有扫描结果。", + deletingConfig: "正在删除", table: { name: "名称", model: "模型", diff --git a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx index 002b1db..db63096 100644 --- a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx +++ b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx @@ -69,6 +69,10 @@ describe("ScanConfigPage", () => { errorCode: null, }, }, + { + match: (url, _input, init) => url === "/api/scan/configs/1" && init?.method === "DELETE", + response: {}, + }, { match: "/api/scan/configs", response: configsPayload }, ]), ); @@ -140,6 +144,45 @@ describe("ScanConfigPage", () => { expect(screen.getByRole("button", { name: "Update" })).not.toBeDisabled(); }); + it("opens a styled delete confirmation dialog before deleting a scan config", async () => { + renderPage(); + + await waitFor(() => expect(screen.getByText("Backup")).toBeInTheDocument()); + const backupRow = screen.getAllByRole("row").find((row) => within(row).queryByText("Backup")); + expect(backupRow).toBeDefined(); + + fireEvent.click(within(backupRow as HTMLElement).getByRole("button", { name: "Delete" })); + + expect(screen.getByRole("heading", { name: "Delete Backup?" })).toBeInTheDocument(); + expect( + screen.getByText("This removes the saved LLM scan configuration. Existing scan results are not deleted."), + ).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalledWith( + "/api/scan/configs/1", + expect.objectContaining({ method: "DELETE" }), + ); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Delete Backup?" })).not.toBeInTheDocument(), + ); + expect(fetchMock).not.toHaveBeenCalledWith( + "/api/scan/configs/1", + expect.objectContaining({ method: "DELETE" }), + ); + + fireEvent.click(within(backupRow as HTMLElement).getByRole("button", { name: "Delete" })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/scan/configs/1", + expect.objectContaining({ method: "DELETE" }), + ), + ); + }); + it("requires API key for new configs and can toggle API key visibility", async () => { renderPage(); diff --git a/frontend/src/features/skills/screens/ScanConfigPage.tsx b/frontend/src/features/skills/screens/ScanConfigPage.tsx index 3ec89e3..7b6c70e 100644 --- a/frontend/src/features/skills/screens/ScanConfigPage.tsx +++ b/frontend/src/features/skills/screens/ScanConfigPage.tsx @@ -2,10 +2,12 @@ import { useMemo, useState } from "react"; import { CheckCircle2, Pencil, Plus, Trash2 } from "lucide-react"; import type { ScanConfigItem } from "../api/scan-types"; +import { ConfirmActionDialog } from "../../../components/ConfirmActionDialog"; import { ErrorBanner } from "../../../components/ErrorBanner"; import { LoadingSpinner } from "../../../components/LoadingSpinner"; import { PageHeader } from "../../../components/PageHeader"; import { ScanConfigDetailModal } from "../components/scan/ScanConfigDetailModal"; +import { useCommonCopy } from "../../../i18n"; import { useSkillsCopy } from "../i18n"; import { useSkillScan } from "../model/use-skill-scan"; @@ -20,6 +22,7 @@ function providerLabel(config: ScanConfigItem): string { export default function ScanConfigPage() { const copy = useSkillsCopy().scan; + const common = useCommonCopy(); const { configs, activeConfigId, @@ -32,6 +35,7 @@ export default function ScanConfigPage() { configLoaded, } = useSkillScan(); const [editor, setEditor] = useState(null); + const [pendingDeleteConfig, setPendingDeleteConfig] = useState(null); const [pendingConfigId, setPendingConfigId] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -67,14 +71,15 @@ export default function ScanConfigPage() { setEditor({ mode: "edit", config }); } - async function deleteConfig(config: ScanConfigItem) { - if (!window.confirm(copy.deleteConfigConfirm(config.name))) { + async function executeDeleteConfig() { + if (!pendingDeleteConfig) { return; } - setPendingConfigId(config.id); + setPendingConfigId(pendingDeleteConfig.id); setErrorMessage(null); try { - await removeConfig(config.id); + await removeConfig(pendingDeleteConfig.id); + setPendingDeleteConfig(null); } catch (error) { setErrorMessage(error instanceof Error ? error.message : String(error)); } finally { @@ -195,7 +200,7 @@ export default function ScanConfigPage() { type="button" className="action-pill action-pill--danger" disabled={pending} - onClick={() => void deleteConfig(config)} + onClick={() => setPendingDeleteConfig(config)} > {copy.table.delete} @@ -221,6 +226,19 @@ export default function ScanConfigPage() { onValidateConfig={validateConfig} onRevealApiKey={revealConfigApiKey} /> + + { + if (!open) setPendingDeleteConfig(null); + }} + onConfirm={executeDeleteConfig} + /> ); } diff --git a/frontend/src/features/skills/screens/SkillsInUsePage.tsx b/frontend/src/features/skills/screens/SkillsInUsePage.tsx index c390d0d..2809c00 100644 --- a/frontend/src/features/skills/screens/SkillsInUsePage.tsx +++ b/frontend/src/features/skills/screens/SkillsInUsePage.tsx @@ -256,6 +256,7 @@ export default function SkillsInUsePage() { checkedRefs={multiSelectedRefs} onOpenSkill={onOpenSkill} onToggleChecked={onToggleMultiSelect} + onToggleHarness={onToggleCell} onSetAllHarnesses={onSetSkillAllHarnesses} onRequestRemove={(row) => requestSkillConfirm("unmanage", row)} onRequestDelete={(row) => requestSkillConfirm("delete", row)} diff --git a/frontend/src/styles/components/cards.css b/frontend/src/styles/components/cards.css index f466f27..2e69a54 100644 --- a/frontend/src/styles/components/cards.css +++ b/frontend/src/styles/components/cards.css @@ -170,6 +170,49 @@ border-radius: 4px; } +.skill-card__harness-toggle-stack { + flex: 1; +} + +.skill-card__harness-toggle { + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + transition: opacity 120ms ease, filter 120ms ease, transform 120ms ease; +} + +.skill-card__harness-toggle:hover:not(:disabled), +.skill-card__harness-toggle:focus-visible:not(:disabled) { + transform: translateY(-1px); +} + +.skill-card__harness-toggle:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-accent-soft); + border-radius: var(--radius-xs); +} + +.skill-card__harness-toggle:disabled { + cursor: progress; +} + +.skill-card__harness-toggle[data-state="disabled"] { + opacity: 0.36; + filter: grayscale(1); +} + +.skill-card__harness-toggle[data-state="enabled"] { + opacity: 1; + filter: none; +} + +.skill-card__harness-toggle[data-pending="true"] { + opacity: 0.7; + cursor: progress; +} + .skill-card__harness-count { margin-left: auto; font-family: var(--font-mono); From 4ad8eb2ff8eb5683afed82a038bdf47b90150174 Mon Sep 17 00:00:00 2001 From: chengzhang Date: Wed, 3 Jun 2026 15:43:01 +0800 Subject: [PATCH 2/4] fix scan page issues --- .../components/McpHarnessLogoStack.test.tsx | 57 +++++++++++++++++++ .../mcp/components/McpHarnessLogoStack.tsx | 45 +++++++++------ .../features/mcp/components/McpServerCard.tsx | 2 +- frontend/src/features/mcp/styles/pages.css | 5 ++ 4 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 frontend/src/features/mcp/components/McpHarnessLogoStack.test.tsx diff --git a/frontend/src/features/mcp/components/McpHarnessLogoStack.test.tsx b/frontend/src/features/mcp/components/McpHarnessLogoStack.test.tsx new file mode 100644 index 0000000..96fe293 --- /dev/null +++ b/frontend/src/features/mcp/components/McpHarnessLogoStack.test.tsx @@ -0,0 +1,57 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { McpBindingDto, McpInventoryColumnDto } from "../api/management-types"; +import { McpHarnessLogoStack } from "./McpHarnessLogoStack"; + +describe("McpHarnessLogoStack", () => { + const columns: McpInventoryColumnDto[] = [ + { harness: "codex", label: "Codex", logoKey: "codex", installed: true, configPresent: true, mcpWritable: true }, + { harness: "claude", label: "Claude", logoKey: "claude", installed: true, configPresent: true, mcpWritable: true }, + { harness: "cursor", label: "Cursor", logoKey: "cursor", installed: true, configPresent: true, mcpWritable: true }, + { + harness: "openclaw", + label: "OpenClaw", + logoKey: "openclaw", + installed: true, + configPresent: true, + mcpWritable: false, + }, + ]; + const bindings: McpBindingDto[] = [ + { harness: "codex", state: "managed" }, + { harness: "claude", state: "missing" }, + ]; + + it("renders only active writable harnesses by default", () => { + const { container } = render( + , + ); + + const stackItems = Array.from(container.querySelectorAll(".harness-stack__item")); + + expect(stackItems).toHaveLength(1); + expect(stackItems[0]).toHaveAttribute("data-state", "enabled"); + expect(container.querySelector(".skill-card__harness-count")).toHaveTextContent("1/3"); + }); + + it("renders every writable harness and marks missing bindings as disabled when requested", () => { + const { container } = render( + , + ); + + const stackItems = Array.from(container.querySelectorAll(".harness-stack__item")); + + expect(stackItems).toHaveLength(3); + expect(stackItems.map((item) => item.getAttribute("data-state"))).toEqual([ + "enabled", + "disabled", + "disabled", + ]); + expect(container.querySelector(".skill-card__harness-count")).toHaveTextContent("1/3"); + }); +}); diff --git a/frontend/src/features/mcp/components/McpHarnessLogoStack.tsx b/frontend/src/features/mcp/components/McpHarnessLogoStack.tsx index e91a490..d898e06 100644 --- a/frontend/src/features/mcp/components/McpHarnessLogoStack.tsx +++ b/frontend/src/features/mcp/components/McpHarnessLogoStack.tsx @@ -6,24 +6,30 @@ import { isMcpHarnessAddressable } from "../model/selectors"; interface McpHarnessLogoStackProps { bindings: McpBindingDto[]; columns: McpInventoryColumnDto[]; + showAllWritable?: boolean; } /** * Stack of harness logos for one MCP server. - * - Shows logos for harnesses where state is `managed` or `drifted`, - * restricted to harnesses with verified MCP write capability - * (isMcpHarnessAddressable). + * - By default, shows writable harnesses where state is `managed` or `drifted`. + * - In MCP server cards, `showAllWritable` also shows writable missing + * harnesses as disabled so the card mirrors the Skills in-use coverage UI. * - Different-config entries get an orange dot overlay (CSS via data-drifted). * - Trailing "X/N" count = managed / addressable. */ -export function McpHarnessLogoStack({ bindings, columns }: McpHarnessLogoStackProps) { +export function McpHarnessLogoStack({ bindings, columns, showAllWritable = false }: McpHarnessLogoStackProps) { + const bindingByHarness = new Map(bindings.map((binding) => [binding.harness, binding])); const labelByHarness = new Map(columns.map((c) => [c.harness, c.label])); const logoByHarness = new Map(columns.map((c) => [c.harness, c.logoKey ?? c.harness])); - const addressable = new Set(columns.filter(isMcpHarnessAddressable).map((c) => c.harness)); + const addressableColumns = columns.filter(isMcpHarnessAddressable); + const addressable = new Set(addressableColumns.map((c) => c.harness)); + const visibleColumns = showAllWritable + ? addressableColumns + : addressableColumns.filter((column) => { + const state = bindingByHarness.get(column.harness)?.state; + return state === "managed" || state === "drifted"; + }); - const visible = bindings.filter( - (b) => addressable.has(b.harness) && (b.state === "managed" || b.state === "drifted"), - ); const managedCount = bindings.filter( (b) => addressable.has(b.harness) && b.state === "managed", ).length; @@ -33,19 +39,24 @@ export function McpHarnessLogoStack({ bindings, columns }: McpHarnessLogoStackPr return (
- {visible.map((binding, index) => { - const presentation = getHarnessPresentation(logoByHarness.get(binding.harness) ?? null); - const label = labelByHarness.get(binding.harness) ?? binding.harness; + {visibleColumns.map((column, index) => { + const binding = bindingByHarness.get(column.harness); + const state = binding?.state === "managed" ? "enabled" : binding?.state === "drifted" ? "drifted" : "disabled"; + const presentation = getHarnessPresentation(logoByHarness.get(column.harness) ?? null); + const label = labelByHarness.get(column.harness) ?? column.harness; const title = - binding.state === "drifted" - ? `${label} — Different config${binding.driftDetail ? ` (${binding.driftDetail})` : ""}` - : label; + state === "drifted" + ? `${label} — Different config${binding?.driftDetail ? ` (${binding.driftDetail})` : ""}` + : state === "enabled" + ? label + : `${label} — disabled`; return ( - + {presentation ? ( diff --git a/frontend/src/features/mcp/components/McpServerCard.tsx b/frontend/src/features/mcp/components/McpServerCard.tsx index 042ecac..8f4e9df 100644 --- a/frontend/src/features/mcp/components/McpServerCard.tsx +++ b/frontend/src/features/mcp/components/McpServerCard.tsx @@ -130,7 +130,7 @@ export function McpServerCard({ ) : null}
- +