From d2ceb0c34e3420e4ee5b70557c121d88a81fe9ce Mon Sep 17 00:00:00 2001 From: tazztone <62671577+tazztone@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:33:25 +0200 Subject: [PATCH 1/3] feat(extensions): add bulk sync to agents and bulk delete - Add "Sync to agents" action to install selected extensions at Global scope across enabled agents, with per-agent skip reasons (already installed, unsupported hooks/transport, no source). - Add "Delete" action with a confirmation dialog that bulk-removes selected extensions and warns about full CLI bundle uninstall. - Introduce `sync-pairs` helper that pairs selected extension groups with target agents, plus unit tests. - Wire both dialogs into the extensions page selection toolbar. - Add i18n strings for sync and bulkDelete sections in en, zh, and zh-TW. --- package-lock.json | 28 +- .../extensions/__tests__/sync-pairs.test.ts | 287 ++++++++++++++++ .../extensions/bulk-delete-dialog.tsx | 169 ++++++++++ src/components/extensions/sync-pairs.ts | 152 +++++++++ .../extensions/sync-to-agents-dialog.tsx | 307 ++++++++++++++++++ src/lib/i18n/locales/en/extensions.json | 28 ++ src/lib/i18n/locales/zh-TW/extensions.json | 28 ++ src/lib/i18n/locales/zh/extensions.json | 28 ++ src/pages/extensions.tsx | 32 ++ 9 files changed, 1041 insertions(+), 18 deletions(-) create mode 100644 src/components/extensions/__tests__/sync-pairs.test.ts create mode 100644 src/components/extensions/bulk-delete-dialog.tsx create mode 100644 src/components/extensions/sync-pairs.ts create mode 100644 src/components/extensions/sync-to-agents-dialog.tsx diff --git a/package-lock.json b/package-lock.json index 564bdac6..1b782082 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,7 +148,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -672,7 +671,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -721,7 +719,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -743,7 +740,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2217,7 +2213,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2345,7 +2342,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2356,7 +2352,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2755,7 +2750,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3116,15 +3110,15 @@ "version": "0.0.1595872", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.331", @@ -3588,7 +3582,6 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -4130,6 +4123,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4948,7 +4942,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4991,6 +4984,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5006,6 +5000,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -5130,7 +5125,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5140,7 +5134,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5180,7 +5173,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -5789,7 +5783,6 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5976,7 +5969,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/components/extensions/__tests__/sync-pairs.test.ts b/src/components/extensions/__tests__/sync-pairs.test.ts new file mode 100644 index 00000000..91feb795 --- /dev/null +++ b/src/components/extensions/__tests__/sync-pairs.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import type { + AgentInfo, + ConfigScope, + Extension, + GroupedExtension, +} from "@/lib/types"; +import { buildSyncPairs } from "../sync-pairs"; + +const GLOBAL: ConfigScope = { type: "global" }; + +function ext(over: Partial & { id: string }): Extension { + return { + kind: "skill", + name: "x", + description: "", + source: { origin: "local", url: null, version: null, commit_hash: null }, + agents: ["claude"], + tags: [], + pack: null, + permissions: [], + enabled: true, + trust_score: null, + installed_at: "2026-01-01", + updated_at: "2026-01-01", + source_path: null, + cli_parent_id: null, + cli_meta: null, + install_meta: null, + scope: GLOBAL, + ...over, + }; +} + +function agent(over: Partial & { name: string }): AgentInfo { + return { + detected: true, + extension_count: 0, + path: "", + enabled: true, + capabilities: { + project_install: { skill: true, mcp: true, hook: true, cli: true }, + hooks_supported: true, + global_hook_install: true, + mcp_remote: { http: true, sse: true }, + }, + ...over, + }; +} + +function group( + over: Partial & { groupKey: string }, +): GroupedExtension { + const instances = over.instances ?? [ext({ id: `${over.groupKey}-i` })]; + return { + name: "x", + kind: "skill", + description: "", + source: { origin: "local", url: null, version: null, commit_hash: null }, + agents: ["claude"], + tags: [], + pack: null, + permissions: [], + enabled: true, + trust_score: null, + installed_at: "2026-01-01", + updated_at: "2026-01-01", + instances, + ...over, + }; +} + +describe("buildSyncPairs", () => { + it("returns empty for empty inputs without throwing", () => { + expect(buildSyncPairs([], [agent({ name: "a" })], [])).toEqual({ + pairs: [], + skipped: [], + }); + expect(buildSyncPairs([group({ groupKey: "g" })], [], [])).toEqual({ + pairs: [], + skipped: [], + }); + }); + + it("skips already-installed agents", () => { + const g = group({ + groupKey: "skill-a", + kind: "skill", + instances: [ext({ id: "i1", agents: ["codex"] })], + }); + const { pairs, skipped } = buildSyncPairs( + [g], + [agent({ name: "codex" }), agent({ name: "claude" })], + g.instances, + ); + expect(skipped).toContainEqual({ + groupKey: "skill-a", + targetAgent: "codex", + reason: "already-installed", + }); + expect(pairs).toHaveLength(1); + expect(pairs[0]).toMatchObject({ + groupKey: "skill-a", + sourceId: "i1", + targetAgent: "claude", + }); + }); + + it("reports hook-unsupported before global-hook-blocked", () => { + const g = group({ + groupKey: "hook-a", + kind: "hook", + instances: [ext({ id: "i1", kind: "hook", agents: [] })], + }); + const noHooks = agent({ + name: "nohooks", + capabilities: { + project_install: { skill: true, mcp: true, hook: true, cli: true }, + hooks_supported: false, + global_hook_install: false, + }, + }); + const { skipped } = buildSyncPairs([g], [noHooks], g.instances); + expect(skipped).toEqual([ + { + groupKey: "hook-a", + targetAgent: "nohooks", + reason: "hook-unsupported", + }, + ]); + }); + + it("reports global-hook-blocked when hooks supported but global install off", () => { + const g = group({ + groupKey: "hook-b", + kind: "hook", + instances: [ext({ id: "i1", kind: "hook", agents: [] })], + }); + const kiroLike = agent({ + name: "kiro", + capabilities: { + project_install: { skill: true, mcp: true, hook: true, cli: true }, + hooks_supported: true, + global_hook_install: false, + }, + }); + const { skipped, pairs } = buildSyncPairs([g], [kiroLike], g.instances); + expect(pairs).toEqual([]); + expect(skipped).toEqual([ + { + groupKey: "hook-b", + targetAgent: "kiro", + reason: "global-hook-blocked", + }, + ]); + }); + + it("skips remote MCP transports the agent cannot express", () => { + const g = group({ + groupKey: "mcp-a", + kind: "mcp", + instances: [ + ext({ id: "i1", kind: "mcp", agents: [], mcp_transport: "sse" }), + ], + }); + const httpOnly = agent({ + name: "codex", + capabilities: { + project_install: { skill: true, mcp: true, hook: true, cli: true }, + hooks_supported: true, + global_hook_install: true, + mcp_remote: { http: true, sse: false }, + }, + }); + const { skipped, pairs } = buildSyncPairs([g], [httpOnly], g.instances); + expect(pairs).toEqual([]); + expect(skipped).toEqual([ + { + groupKey: "mcp-a", + targetAgent: "codex", + reason: "transport-unsupported", + }, + ]); + }); + + it("dedupes CLI children by name+kind and skips unsupported transports", () => { + const cliInst = ext({ + id: "cli-1", + kind: "cli", + name: "bundle", + pack: "p", + }); + const g = group({ + groupKey: "cli-bundle", + kind: "cli", + name: "bundle", + pack: "p", + instances: [cliInst], + }); + const children = [ + ext({ + id: "c1", + kind: "skill", + name: "s", + pack: "p", + agents: ["claude"], + }), + // Same name+kind: merged group sibling — only one pair. + ext({ id: "c2", kind: "skill", name: "s", pack: "p", agents: ["codex"] }), + ext({ + id: "c3", + kind: "mcp", + name: "m", + pack: "p", + agents: ["claude"], + mcp_transport: "sse", + }), + ]; + const httpOnly = agent({ + name: "codex", + capabilities: { + project_install: { skill: true, mcp: true, hook: true, cli: true }, + hooks_supported: true, + global_hook_install: true, + mcp_remote: { http: true, sse: false }, + }, + }); + const { pairs, skipped } = buildSyncPairs( + [g], + [httpOnly], + [cliInst, ...children], + ); + expect(pairs).toHaveLength(1); + expect(pairs[0]).toMatchObject({ + groupKey: "cli-bundle", + sourceId: "c1", + targetAgent: "codex", + }); + expect(skipped).toContainEqual({ + groupKey: "cli-bundle", + targetAgent: "codex", + reason: "transport-unsupported", + }); + }); + + it("prefers the global copy as install source", () => { + const g = group({ + groupKey: "skill-b", + kind: "skill", + instances: [ + ext({ + id: "proj-copy", + agents: ["claude"], + scope: { type: "project", name: "p", path: "/p" }, + }), + ext({ id: "global-copy", agents: ["claude"], scope: GLOBAL }), + ], + }); + const { pairs } = buildSyncPairs( + [g], + [agent({ name: "codex" })], + g.instances, + ); + expect(pairs).toEqual([ + { + groupKey: "skill-b", + sourceId: "global-copy", + targetAgent: "codex", + isHermesSkill: false, + }, + ]); + }); + + it("marks hermes skill pairs", () => { + const g = group({ + groupKey: "skill-c", + kind: "skill", + instances: [ext({ id: "i1", agents: ["claude"] })], + }); + const { pairs } = buildSyncPairs( + [g], + [agent({ name: "hermes" })], + g.instances, + ); + expect(pairs[0].isHermesSkill).toBe(true); + }); +}); diff --git a/src/components/extensions/bulk-delete-dialog.tsx b/src/components/extensions/bulk-delete-dialog.tsx new file mode 100644 index 00000000..6f2bbc90 --- /dev/null +++ b/src/components/extensions/bulk-delete-dialog.tsx @@ -0,0 +1,169 @@ +import { clsx } from "clsx"; +import { Check, Loader2, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Modal } from "@/components/ui/modal"; +import { parseError } from "@/lib/error-types"; +import type { GroupedExtension } from "@/lib/types"; +import { useExtensionStore } from "@/stores/extension-store"; +import { toast } from "@/stores/toast-store"; + +export function BulkDeleteDialog({ + groups, + onClose, +}: { + groups: GroupedExtension[]; + onClose: () => void; +}) { + const { t } = useTranslation("extensions"); + const { t: tc } = useTranslation("common"); + const [checked, setChecked] = useState>( + () => new Set(groups.map((g) => g.groupKey)), + ); + const [running, setRunning] = useState(false); + + const hasCli = groups.some( + (g) => g.kind === "cli" && checked.has(g.groupKey), + ); + + function toggle(key: string) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + async function confirm() { + if (running) return; + setRunning(true); + const failures: { group: string; message: string }[] = []; + let deleted = 0; + const store = useExtensionStore.getState(); + for (const g of groups) { + if (!checked.has(g.groupKey)) continue; + try { + // CLI uninstall is all-or-nothing server-side; the id list is + // ignored for `cli` (extension-store.ts:611-634). + await store.deleteInstances( + g.groupKey, + g.instances.map((i) => i.id), + ); + deleted += 1; + } catch (e) { + failures.push({ group: g.name, message: parseError(e).message }); + } + } + setRunning(false); + // The store deletes optimistically with a 5s pendingDelete timer and + // restores rows + toasts on timer-time refusal — late refusals surface + // through the store toasts, not here. + if (failures.length > 0) { + toast.error( + t("bulkDelete.failed", { + count: failures.length, + message: failures[0].message, + }), + ); + } else { + toast.success(t("bulkDelete.deleted", { count: deleted })); + } + store.clearSelection(); + onClose(); + } + + return ( + +
+

{t("bulkDelete.title")}

+ {!running && ( + + )} +
+ +
+
+ + {t("delete.selectItems")} + + {groups.length > 1 && ( + + )} +
+
    + {groups.map((g) => { + const sel = checked.has(g.groupKey); + return ( +
  • + {/* biome-ignore lint/a11y/useSemanticElements: rows are visually list items but semantically toggle-able (multi-select); aria-pressed/role=checkbox keeps the multi-select semantics on a styled +
  • + ); + })} +
+ {hasCli && ( +

+ {t("bulkDelete.warningCli")} +

+ )} +
+ +
+ + +
+
+ ); +} diff --git a/src/components/extensions/sync-pairs.ts b/src/components/extensions/sync-pairs.ts new file mode 100644 index 00000000..99d814e8 --- /dev/null +++ b/src/components/extensions/sync-pairs.ts @@ -0,0 +1,152 @@ +import { + canInstallAtScope, + canReceiveMcpTransport, +} from "@/lib/agent-capabilities"; +import type { AgentInfo, Extension, GroupedExtension } from "@/lib/types"; +import { + findCliChildren, + instancesInScope, + pickSourceInstance, +} from "@/stores/extension-helpers"; + +export interface SyncPair { + groupKey: string; + sourceId: string; + targetAgent: string; + isHermesSkill: boolean; +} + +export type SyncSkipReason = + | "already-installed" + | "hook-unsupported" + | "global-hook-blocked" + | "transport-unsupported" + | "no-source"; + +export interface SyncSkip { + groupKey: string; + targetAgent: string; + reason: SyncSkipReason; +} + +/** Global scope every bulk-sync install targets. */ +const GLOBAL_SCOPE = { type: "global" } as const; + +/** + * Expand selected groups × target agents into installable pairs plus + * preflight skips. Predicates mirror `extension-detail.tsx` verbatim — + * same capability flags, same order, same CLI child dedupe. + */ +export function buildSyncPairs( + groups: GroupedExtension[], + targetAgents: AgentInfo[], + extensions: Extension[], +): { pairs: SyncPair[]; skipped: SyncSkip[] } { + const pairs: SyncPair[] = []; + const skipped: SyncSkip[] = []; + if (groups.length === 0 || targetAgents.length === 0) { + return { pairs, skipped }; + } + + for (const group of groups) { + for (const agent of targetAgents) { + // Already has a copy at Global scope — mirrors `agentsInTargetScope` + // (extension-detail.tsx:114-120). + const installedAgents = instancesInScope( + group.instances, + GLOBAL_SCOPE, + ).flatMap((i) => i.agents); + if (installedAgents.includes(agent.name)) { + skipped.push({ + groupKey: group.groupKey, + targetAgent: agent.name, + reason: "already-installed", + }); + continue; + } + + if (group.kind === "cli") { + const children = findCliChildren( + extensions, + group.instances[0]?.id, + group.pack, + ); + const seen = new Set(); + for (const child of children) { + const dedupeKey = child.name + child.kind; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + if ( + !canInstallAtScope(agent, child.kind, GLOBAL_SCOPE) || + (child.kind === "mcp" && + !canReceiveMcpTransport(agent, child.mcp_transport)) + ) { + skipped.push({ + groupKey: group.groupKey, + targetAgent: agent.name, + reason: "transport-unsupported", + }); + continue; + } + pairs.push({ + groupKey: group.groupKey, + sourceId: child.id, + targetAgent: agent.name, + isHermesSkill: child.kind === "skill" && agent.name === "hermes", + }); + } + continue; + } + + const hookUnsupported = + group.kind === "hook" && !agent.capabilities.hooks_supported; + if (hookUnsupported) { + skipped.push({ + groupKey: group.groupKey, + targetAgent: agent.name, + reason: "hook-unsupported", + }); + continue; + } + const globalHookBlocked = + group.kind === "hook" && !agent.capabilities.global_hook_install; + if (globalHookBlocked) { + skipped.push({ + groupKey: group.groupKey, + targetAgent: agent.name, + reason: "global-hook-blocked", + }); + continue; + } + const transportUnsupported = + group.kind === "mcp" && + !canReceiveMcpTransport(agent, group.instances[0]?.mcp_transport); + if (transportUnsupported) { + skipped.push({ + groupKey: group.groupKey, + targetAgent: agent.name, + reason: "transport-unsupported", + }); + continue; + } + + const source = pickSourceInstance(group.instances, GLOBAL_SCOPE); + if (!source) { + skipped.push({ + groupKey: group.groupKey, + targetAgent: agent.name, + reason: "no-source", + }); + continue; + } + pairs.push({ + groupKey: group.groupKey, + sourceId: source.id, + targetAgent: agent.name, + isHermesSkill: group.kind === "skill" && agent.name === "hermes", + }); + } + } + + return { pairs, skipped }; +} diff --git a/src/components/extensions/sync-to-agents-dialog.tsx b/src/components/extensions/sync-to-agents-dialog.tsx new file mode 100644 index 00000000..39db4627 --- /dev/null +++ b/src/components/extensions/sync-to-agents-dialog.tsx @@ -0,0 +1,307 @@ +import { clsx } from "clsx"; +import { Check, Loader2, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + buildSyncPairs, + type SyncSkipReason, +} from "@/components/extensions/sync-pairs"; +import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; +import { Modal } from "@/components/ui/modal"; +import { parseError } from "@/lib/error-types"; +import { api } from "@/lib/invoke"; +import { + agentDisplayName, + type ConfigScope, + type GroupedExtension, + sortAgents, +} from "@/lib/types"; +import { useAgentStore } from "@/stores/agent-store"; +import { useExtensionStore } from "@/stores/extension-store"; +import { toast } from "@/stores/toast-store"; + +const TARGET: ConfigScope = { type: "global" }; +type ReasonSuffix = + | "alreadyInstalled" + | "hookUnsupported" + | "globalHookBlocked" + | "transportUnsupported" + | "noSource"; + +const REASON_KEY: Record = { + "already-installed": "alreadyInstalled", + "hook-unsupported": "hookUnsupported", + "global-hook-blocked": "globalHookBlocked", + "transport-unsupported": "transportUnsupported", + "no-source": "noSource", +}; + +interface AttemptResult { + groupKey: string; + agent: string; + status: "installed" | "failed"; + message?: string; +} + +export function SyncToAgentsDialog({ + groups, + onClose, + onDone, +}: { + groups: GroupedExtension[]; + onClose: () => void; + onDone: () => void; +}) { + const { t } = useTranslation("extensions"); + const { t: tc } = useTranslation("common"); + const agents = useAgentStore((s) => s.agents); + const agentOrder = useAgentStore((s) => s.agentOrder); + const extensions = useExtensionStore((s) => s.extensions); + + const detected = useMemo( + () => + sortAgents( + agents.filter((a) => a.detected && a.enabled), + agentOrder, + ), + [agents, agentOrder], + ); + const [checked, setChecked] = useState>( + () => new Set(detected.map((a) => a.name)), + ); + const [running, setRunning] = useState(false); + const [results, setResults] = useState(null); + + const targets = useMemo( + () => detected.filter((a) => checked.has(a.name)), + [detected, checked], + ); + const { pairs, skipped } = useMemo( + () => buildSyncPairs(groups, targets, extensions), + [groups, targets, extensions], + ); + const nameByKey = useMemo( + () => new Map(groups.map((g) => [g.groupKey, g.name])), + [groups], + ); + + const done = results !== null; + const installedCount = + results?.filter((r) => r.status === "installed").length ?? 0; + + function toggleAgent(name: string) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + async function run() { + if (running || done) return; + setRunning(true); + // Bulk uses the default Hermes category for the whole run — no + // per-pair picker (single-item detail keeps its picker). + let hermesCategory: string | undefined; + if (pairs.some((p) => p.isHermesSkill)) { + const cats = await api.listHermesCategories().catch(() => []); + hermesCategory = cats[0] ?? "local"; + } + const out: AttemptResult[] = []; + const store = useExtensionStore.getState(); + for (const pair of pairs) { + try { + await store.installToAgent( + pair.sourceId, + pair.targetAgent, + TARGET, + pair.isHermesSkill ? hermesCategory : undefined, + ); + out.push({ + groupKey: pair.groupKey, + agent: pair.targetAgent, + status: "installed", + }); + } catch (e) { + out.push({ + groupKey: pair.groupKey, + agent: pair.targetAgent, + status: "failed", + message: parseError(e).message, + }); + } + } + setResults(out); + setRunning(false); + toast.success( + t("sync.installed", { + count: out.filter((r) => r.status === "installed").length, + }), + ); + } + + return ( + +
+

{t("sync.title")}

+ {!running && ( + + )} +
+ +
+ {!done && ( + <> +

+ {t("sync.globalNote")} +

+
+
+ + {t("sync.targetAgents")} + + {detected.length > 1 && ( + + )} +
+ {detected.length === 0 ? ( +

+ {t("sync.noAgents")} +

+ ) : ( +
+ {detected.map((a) => { + const sel = checked.has(a.name); + return ( + // biome-ignore lint/a11y/useSemanticElements: agent tiles are visually buttons but semantically toggle-able (multi-select); aria-pressed/role=checkbox keeps the multi-select semantics on a styled + ); + })} +
+ )} +
+ + )} + + {done && results && ( +
+

+ {t("sync.installed", { count: installedCount })} +

+
    + {results.map((r, i) => ( +
  • + + {nameByKey.get(r.groupKey) ?? r.groupKey} →{" "} + {agentDisplayName(r.agent)} + + {r.status === "installed" ? ( + ✓ + ) : ( + + {r.message ?? "failed"} + + )} +
  • + ))} + {skipped.map((s) => ( +
  • + + {nameByKey.get(s.groupKey) ?? s.groupKey} →{" "} + {agentDisplayName(s.targetAgent)} + + + {t(`sync.reason.${REASON_KEY[s.reason]}`)} + +
  • + ))} +
+
+ )} +
+ +
+ {!done ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} diff --git a/src/lib/i18n/locales/en/extensions.json b/src/lib/i18n/locales/en/extensions.json index eb77216f..d0ade02b 100644 --- a/src/lib/i18n/locales/en/extensions.json +++ b/src/lib/i18n/locales/en/extensions.json @@ -12,6 +12,8 @@ "disableSelectedAria": "Disable selected extensions", "enable": "Enable", "disable": "Disable", + "syncSelected": "Sync to agents", + "deleteSelected": "Delete", "enabledCount_one": "{{count}} extension enabled", "enabledCount_other": "{{count}} extensions enabled", "disabledCount_one": "{{count}} extension disabled", @@ -168,6 +170,32 @@ "removeCount_one": "Remove {{count}} item", "removeCount_other": "Remove {{count}} items" }, + "sync": { + "title": "Sync to agents", + "targetAgents": "Target agents", + "globalNote": "Installs at Global scope — no project needed. Skills sent to Hermes use its default category.", + "noAgents": "No enabled agents detected.", + "start": "Start Sync ({{count}})", + "installed_one": "{{count}} extension installed", + "installed_other": "{{count}} extensions installed", + "close": "Close sync dialog", + "done": "Done", + "reason": { + "alreadyInstalled": "already installed", + "hookUnsupported": "hooks not supported", + "globalHookBlocked": "global hooks not supported", + "transportUnsupported": "transport not supported", + "noSource": "no installable copy" + } + }, + "bulkDelete": { + "title": "Delete extensions", + "confirm": "Delete ({{count}})", + "deleted_one": "{{count}} extension deleted", + "deleted_other": "{{count}} extensions deleted", + "failed": "Failed to delete {{count}} extension(s): {{message}}", + "warningCli": "CLI bundles uninstall completely — every bundled item is removed." + }, "detailPaths": { "paths": "Paths", "event_one": "Event", diff --git a/src/lib/i18n/locales/zh-TW/extensions.json b/src/lib/i18n/locales/zh-TW/extensions.json index 4318a02c..555483cf 100644 --- a/src/lib/i18n/locales/zh-TW/extensions.json +++ b/src/lib/i18n/locales/zh-TW/extensions.json @@ -12,6 +12,8 @@ "disableSelectedAria": "停用所選擴充", "enable": "啟用", "disable": "停用", + "syncSelected": "同步到 Agent", + "deleteSelected": "刪除", "enabledCount_one": "已啟用 {{count}} 個擴充", "enabledCount_other": "已啟用 {{count}} 個擴充", "disabledCount_one": "已停用 {{count}} 個擴充", @@ -168,6 +170,32 @@ "removeCount_one": "移除 {{count}} 項", "removeCount_other": "移除 {{count}} 項" }, + "sync": { + "title": "同步到 Agent", + "targetAgents": "目標 Agent", + "globalNote": "安裝到全域範圍 — 無需專案。傳送到 Hermes 的 skill 將使用其預設分類。", + "noAgents": "未偵測到已啟用的 Agent。", + "start": "開始同步 ({{count}})", + "installed_one": "已安裝 {{count}} 個擴充", + "installed_other": "已安裝 {{count}} 個擴充", + "close": "關閉同步對話框", + "done": "完成", + "reason": { + "alreadyInstalled": "已安裝", + "hookUnsupported": "不支援 hook", + "globalHookBlocked": "不支援全域 hook", + "transportUnsupported": "不支援該傳輸類型", + "noSource": "無可安裝副本" + } + }, + "bulkDelete": { + "title": "刪除擴充", + "confirm": "刪除 ({{count}})", + "deleted_one": "已刪除 {{count}} 個擴充", + "deleted_other": "已刪除 {{count}} 個擴充", + "failed": "刪除失敗 {{count}} 個擴充:{{message}}", + "warningCli": "CLI 套件將完整解除安裝 — 所有捆綁項目都會被移除。" + }, "detailPaths": { "paths": "路徑", "event_one": "事件", diff --git a/src/lib/i18n/locales/zh/extensions.json b/src/lib/i18n/locales/zh/extensions.json index 2bd2eb30..da8868f9 100644 --- a/src/lib/i18n/locales/zh/extensions.json +++ b/src/lib/i18n/locales/zh/extensions.json @@ -12,6 +12,8 @@ "disableSelectedAria": "禁用所选扩展", "enable": "启用", "disable": "禁用", + "syncSelected": "同步到 Agent", + "deleteSelected": "删除", "enabledCount_one": "已启用 {{count}} 个扩展", "enabledCount_other": "已启用 {{count}} 个扩展", "disabledCount_one": "已禁用 {{count}} 个扩展", @@ -168,6 +170,32 @@ "removeCount_one": "移除 {{count}} 项", "removeCount_other": "移除 {{count}} 项" }, + "sync": { + "title": "同步到 Agent", + "targetAgents": "目标 Agent", + "globalNote": "安装到全局范围 — 无需项目。发送到 Hermes 的 skill 将使用其默认分类。", + "noAgents": "未检测到已启用的 Agent。", + "start": "开始同步 ({{count}})", + "installed_one": "已安装 {{count}} 个扩展", + "installed_other": "已安装 {{count}} 个扩展", + "close": "关闭同步对话框", + "done": "完成", + "reason": { + "alreadyInstalled": "已安装", + "hookUnsupported": "不支持 hook", + "globalHookBlocked": "不支持全局 hook", + "transportUnsupported": "不支持该传输类型", + "noSource": "无可安装副本" + } + }, + "bulkDelete": { + "title": "删除扩展", + "confirm": "删除 ({{count}})", + "deleted_one": "已删除 {{count}} 个扩展", + "deleted_other": "已删除 {{count}} 个扩展", + "failed": "删除失败 {{count}} 个扩展:{{message}}", + "warningCli": "CLI 套件将完整卸载 — 所有捆绑项都会被移除。" + }, "detailPaths": { "paths": "路径", "event_one": "事件", diff --git a/src/pages/extensions.tsx b/src/pages/extensions.tsx index 4aedc264..92fa793a 100644 --- a/src/pages/extensions.tsx +++ b/src/pages/extensions.tsx @@ -2,10 +2,12 @@ import { ArrowDownCircle, Package, Plus, RefreshCw } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; +import { BulkDeleteDialog } from "@/components/extensions/bulk-delete-dialog"; import { ExtensionDetail } from "@/components/extensions/extension-detail"; import { ExtensionFilters } from "@/components/extensions/extension-filters"; import { ExtensionTable } from "@/components/extensions/extension-table"; import { NewSkillsDialog } from "@/components/extensions/new-skills-dialog"; +import { SyncToAgentsDialog } from "@/components/extensions/sync-to-agents-dialog"; import { useScope } from "@/hooks/use-scope"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; @@ -131,6 +133,8 @@ export default function ExtensionsPage() { const installNewRepoSkills = useExtensionStore((s) => s.installNewRepoSkills); const grouped = useExtensionStore((s) => s.grouped); const [showNewSkills, setShowNewSkills] = useState(false); + const [showSync, setShowSync] = useState(false); + const [showBulkDelete, setShowBulkDelete] = useState(false); const updatesAvailable = useMemo(() => { return grouped().filter((g) => g.instances.some( @@ -272,6 +276,18 @@ export default function ExtensionsPage() { > {t("page.disable")} + + - )} - - -
-
- - {t("delete.selectItems")} - - {groups.length > 1 && ( - - )} -
-
    - {groups.map((g) => { - const sel = checked.has(g.groupKey); - return ( -
  • - {/* biome-ignore lint/a11y/useSemanticElements: rows are visually list items but semantically toggle-able (multi-select); aria-pressed/role=checkbox keeps the multi-select semantics on a styled -
  • - ); - })} -
- {hasCli && ( -

- {t("bulkDelete.warningCli")} -

- )} -
- -
- - -
- - ); -} diff --git a/src/components/extensions/bulk-install-dialog.tsx b/src/components/extensions/bulk-install-dialog.tsx new file mode 100644 index 00000000..8ea47c70 --- /dev/null +++ b/src/components/extensions/bulk-install-dialog.tsx @@ -0,0 +1,645 @@ +import { clsx } from "clsx"; +import { Check, Loader2, X } from "lucide-react"; +import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { extensionDisplayName } from "@/components/extensions/extension-name"; +import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; +import { KindBadge } from "@/components/shared/kind-badge"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; +import { Modal } from "@/components/ui/modal"; +import { + buildInstallPlan, + groupBy, + type InstallItemRef, + type InstallPair, + type InstallPlan, + type InstallSkip, + type InstallSkipReason, +} from "@/lib/install-plan"; +import { api } from "@/lib/invoke"; +import { + type AgentInfo, + agentDisplayName, + type ConfigScope, + type GroupedExtension, + sortAgents, +} from "@/lib/types"; +import { useAgentStore } from "@/stores/agent-store"; +import { resolveInstallTargetScope } from "@/stores/extension-helpers"; +import { useExtensionStore } from "@/stores/extension-store"; +import { useScopeStore } from "@/stores/scope-store"; + +const REASON_KEY = { + "already-installed": "bulkInstall.reason.alreadyInstalled", + "kind-unsupported": "bulkInstall.reason.kindUnsupported", + "hook-unsupported": "bulkInstall.reason.hookUnsupported", + "mcp-unsupported": "bulkInstall.reason.mcpUnsupported", + "global-hook-blocked": "bulkInstall.reason.globalHookBlocked", + "scope-unsupported": "bulkInstall.reason.scopeUnsupported", + "transport-unsupported": "bulkInstall.reason.transportUnsupported", + source: "bulkInstall.sourceCopy", + duplicate: "bulkInstall.reason.includedElsewhere", + "no-source": "bulkInstall.reason.noSource", +} as const satisfies Record; + +/** The holder of an overwrite's source isn't skipped in any sense the + * user cares about: its mascot is lit and it isn't counted. */ +const isSource = (s: InstallSkip) => s.reason === "source"; +const countSkipped = (plan: InstallPlan) => + plan.skipped.filter((s) => !isSource(s) && s.reason !== "duplicate").length; + +/** A selected group whose every item is planned through other selected + * groups (a CLI bundle whose parts are selected on their own): those + * groups, or null. Such a group gets one line, not a row of agents. */ +function coveredBy(plan: InstallPlan, groupKey: string): string[] | null { + if (plan.pairs.some((p) => p.groupKey === groupKey)) return null; + const skips = plan.skipped.filter( + (s) => s.groupKey === groupKey && !isSource(s), + ); + if (skips.length === 0 || !skips.every((s) => s.reason === "duplicate")) + return null; + return [...new Set(skips.flatMap((s) => s.via ?? []))]; +} + +interface AttemptResult { + pair: InstallPair; + error?: string; +} + +/** A run in progress or finished. The plan is snapshotted when the run + * starts: after the closing refetch every success would re-plan as + * "already installed". */ +interface Run extends InstallPlan { + groups: GroupedExtension[]; + agents: AgentInfo[]; + results: AttemptResult[]; + done: boolean; +} + +/** Bulk counterpart of the detail panel's "Install to Agent": every + * selected group goes to every chosen agent, at the picked target scope + * (defaults to the active scope; All mode has no default). During and + * after the run: one row per extension whose agent mascots light up as + * each copy lands, then a summary — failures, then what was skipped and + * why on demand. */ +export function BulkInstallDialog({ + groups, + onClose, + onDone, +}: { + groups: GroupedExtension[]; + onClose: () => void; + /** Called from the results screen; the caller clears the selection. */ + onDone: () => void; +}) { + const { t } = useTranslation("extensions"); + const { t: tc } = useTranslation("common"); + const { t: tm } = useTranslation("marketplace"); + const agents = useAgentStore((s) => s.agents); + const agentOrder = useAgentStore((s) => s.agentOrder); + const extensions = useExtensionStore((s) => s.extensions); + const installPairs = useExtensionStore((s) => s.installPairs); + const scope = useScopeStore((s) => s.current); + const agentsLabelId = useId(); + const skipListId = useId(); + + // Same contract as the detail panel: the active scope is the default + // target, the picker can point anywhere else, and All mode needs an + // explicit pick before anything can be planned. + const [pickedTarget, setPickedTarget] = useState(null); + const target = useMemo( + () => pickedTarget ?? resolveInstallTargetScope(scope), + [scope, pickedTarget], + ); + + const detected = useMemo( + () => + sortAgents( + agents.filter((a) => a.detected && a.enabled), + agentOrder, + ), + [agents, agentOrder], + ); + // Nothing is pre-selected: with 15 agents, a pre-ticked list makes the + // first click a 15-way install. + const [checked, setChecked] = useState>(() => new Set()); + const [overwrite, setOverwrite] = useState(false); + const [run, setRun] = useState(null); + const [showSkipped, setShowSkipped] = useState(false); + + // Every detected agent can be ticked; a tile says nothing about what + // the agent would get. What was skipped and why is reported after the + // run. + const active = useMemo( + () => (target ? detected.filter((a) => checked.has(a.name)) : []), + [target, detected, checked], + ); + // Frozen once a run starts: the placeholder rows landing would re-plan + // every time. + const plan = useMemo( + () => + target && !run + ? buildInstallPlan(groups, active, extensions, target, { overwrite }) + : { pairs: [], skipped: [] }, + [groups, active, extensions, target, overwrite, run], + ); + + function toggleAgent(name: string) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + async function start() { + if (run || !target || plan.pairs.length === 0) return; + setRun({ ...plan, groups, agents: active, results: [], done: false }); + // Bulk uses the default Hermes category for the whole run — no + // per-pair picker (the single-item detail keeps its picker). + let hermesCategory: string | undefined; + if (plan.pairs.some((p) => p.isHermesSkill)) { + const cats = await api.listHermesCategories().catch(() => []); + hermesCategory = cats[0] ?? "local"; + } + await installPairs(plan.pairs, target, { + hermesCategory, + onResult: (pair, error) => + setRun((r) => r && { ...r, results: [...r.results, { pair, error }] }), + }); + setRun((r) => r && { ...r, done: true }); + } + + // Keep the newest row in view as copies land, and end on the summary. + const endRef = useRef(null); + const landed = run?.results.length ?? 0; + const runDone = run?.done ?? false; + // biome-ignore lint/correctness/useExhaustiveDependencies: the two counters are the triggers; the body only touches the ref. + useEffect(() => { + if (!run) return; + // Optional chaining: neither exists under jsdom. + const reduced = window.matchMedia?.( + "(prefers-reduced-motion: reduce)", + ).matches; + endRef.current?.scrollIntoView?.({ + block: "nearest", + behavior: reduced ? "auto" : "smooth", + }); + }, [landed, runDone]); + + const running = run !== null && !run.done; + const done = run?.done ?? false; + const close = done ? onDone : onClose; + const installedCount = run?.results.filter((r) => !r.error).length ?? 0; + const failedCount = (run?.results.length ?? 0) - installedCount; + const runSkipped = run ? countSkipped(run) : 0; + + return ( + +
+

{t("bulkInstall.title")}

+ {!running && ( + + )} +
+ +
+ {!run && ( + <> + +
+
+ + {t("bulkInstall.targetAgents")} + + {detected.length > 1 && ( + + )} +
+ {detected.length === 0 ? ( +

+ {t("bulkInstall.noAgents")} +

+ ) : ( +
+ {detected.map((a) => { + const sel = checked.has(a.name); + return ( + // biome-ignore lint/a11y/useSemanticElements: agent tiles are visually buttons but semantically toggle-able (multi-select); role=checkbox keeps the multi-select semantics on a styled + ); + })} +
+ )} +
+ + {plan.pairs.some((p) => p.isHermesSkill) && ( +

+ {t("bulkInstall.hermesNote")} +

+ )} + + )} + + {run && ( +
+
+
+ + {!done && } + {done + ? t("bulkInstall.done") + : t("bulkInstall.installing", { + done: run.results.length, + total: run.pairs.length, + })} + + +
+
+
+
+
+ + + + {done && ( +
+

+ + + {t("bulkInstall.installed", { count: installedCount })} + + {failedCount > 0 && ( + + · {t("bulkInstall.failed", { count: failedCount })} + + )} + {runSkipped > 0 && ( + + ·{" "} + {/* Why something was left out matters once the run + is over, not before: the count opens the list. */} + + + )} +

+ {failedCount > 0 && ( +
    + {run.results + .filter((r) => r.error) + .map((r) => ( +
  • + + + + {itemLabel(run.groups, r.pair)} →{" "} + {agentDisplayName(r.pair.targetAgent)} + + + {r.error} + +
  • + ))} +
+ )} + {/* A bundle whose parts were selected on their own has no + row above; say where it went, always. */} + + {/* Failures first: they are what needs attention. */} + {showSkipped && runSkipped > 0 && ( +
+ +
+ )} +
+ )} +
+
+ )} +
+ +
+ {done ? ( + + ) : ( + <> + {/* Modifies the commit, so it sits next to it: ticking it + changes the count on the button right beside it. */} + {!run && ( + + )} + + + + )} +
+ + ); +} + +/** "name" or "bundle / child" for anything the plan refers to. */ +function itemLabel(groups: GroupedExtension[], ref: InstallItemRef): string { + const g = groups.find((x) => x.groupKey === ref.groupKey); + const name = g ? extensionDisplayName(g.kind, g.name) : ref.groupKey; + return ref.itemName ? `${name} / ${ref.itemName}` : name; +} + +/** The run view: one row per selected extension, one mascot per chosen + * agent. A mascot lights up when its copy lands (or, under Overwrite, + * when the agent holds the newest copy everyone else is copied from), + * stays dimmed while pending or skipped, and gets a red dot on failure. + * Reasons and errors are on hover. */ +function PlanRows({ + groups, + agents, + plan, + results, +}: { + groups: GroupedExtension[]; + agents: AgentInfo[]; + plan: InstallPlan; + results: AttemptResult[]; +}) { + const { t } = useTranslation("extensions"); + return ( +
    + {groups.map((g) => { + if (coveredBy(plan, g.groupKey)) return null; + const pairs = plan.pairs.filter((p) => p.groupKey === g.groupKey); + const skips = plan.skipped.filter((s) => s.groupKey === g.groupKey); + return ( +
  • + + {extensionDisplayName(g.kind, g.name)} + + + + {agents.map((a) => { + const mine = pairs.filter((p) => p.targetAgent === a.name); + const mySkips = skips.filter((s) => s.targetAgent === a.name); + const landed = results.filter((r) => mine.includes(r.pair)); + const failed = landed.find((r) => r.error); + const lit = + landed.some((r) => !r.error) || mySkips.some(isSource); + const title = [ + agentDisplayName(a.name), + failed?.error, + ...mySkips.map((s) => + s.itemName + ? `${s.itemName}: ${t(REASON_KEY[s.reason])}` + : t(REASON_KEY[s.reason]), + ), + ] + .filter(Boolean) + .join(" · "); + return ( + + + {/* Same dot as the table's update marker, in red. */} + {failed && ( + + )} + + ); + })} + +
  • + ); + })} +
+ ); +} + +/** What wasn't written, one line per extension: the agents skipped, + * grouped by reason. The overwrite source and covered bundles aren't + * skips and aren't listed; a reason that covers every chosen agent is + * stated once, without a row of mascots. */ +function SkipSummary({ + id, + groups, + plan, + agentCount, +}: { + id: string; + groups: GroupedExtension[]; + plan: InstallPlan; + agentCount: number; +}) { + const { t } = useTranslation("extensions"); + // Same filter as `countSkipped`, so the list matches the count. + const byItem = groupBy( + plan.skipped.filter((s) => !isSource(s) && s.reason !== "duplicate"), + (s) => `${s.groupKey}\0${s.itemName ?? ""}`, + ); + return ( +
+ {[...byItem].map(([key, items]) => ( + +
+ {itemLabel(groups, items[0])} +
+
+ {[...groupBy(items, (s) => s.reason)].map( + ([reason, ofReason], i) => ( + + {i > 0 && ·} + {ofReason.length < agentCount && + ofReason.map((s) => ( + + + + ))} + + {t(REASON_KEY[reason as InstallSkipReason])} + + + ), + )} +
+
+ ))} +
+ ); +} + +/** One line per selected bundle whose parts were all selected on their + * own: it has no row in the run view, so this says where it went. */ +function CoveredLines({ + groups, + plan, +}: { + groups: GroupedExtension[]; + plan: InstallPlan; +}) { + const { t } = useTranslation("extensions"); + const groupName = (key: string) => { + const g = groups.find((x) => x.groupKey === key); + return g ? extensionDisplayName(g.kind, g.name) : key; + }; + const covered = groups + .map((g) => [g.groupKey, coveredBy(plan, g.groupKey)] as const) + .filter((e): e is readonly [string, string[]] => e[1] !== null); + if (covered.length === 0) return null; + return ( +
+ {covered.map(([key, via]) => ( + +
{groupName(key)}
+
+ {t("bulkInstall.reason.coveredBy", { + names: via.map(groupName).join(t("bulkInstall.nameSeparator")), + })} +
+
+ ))} +
+ ); +} diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 888d5c1f..6e8be90a 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -25,15 +25,20 @@ import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker"; import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { - canInstallAtScope, - canReceiveMcpTransport, + INSTALLABLE_KINDS, + installBlockReason, isVendorBaseline, } from "@/lib/agent-capabilities"; import { copyPathToClipboard } from "@/lib/copy-path"; import i18n from "@/lib/i18n"; +import { buildInstallPlan } from "@/lib/install-plan"; import { api } from "@/lib/invoke"; import { isDesktop } from "@/lib/transport"; -import type { ConfigScope, ExtensionContent as ExtContent } from "@/lib/types"; +import type { + AgentInfo, + ConfigScope, + ExtensionContent as ExtContent, +} from "@/lib/types"; import { agentDisplayName, extensionGroupKey, @@ -50,7 +55,6 @@ import { enabledAgentSet, findCliChildren, instancesInScope, - pickSourceInstance, resolveInstallTargetScope, } from "@/stores/extension-helpers"; import { useExtensionStore } from "@/stores/extension-store"; @@ -78,7 +82,7 @@ export function ExtensionDetail() { const updateStatuses = useExtensionStore((s) => s.updateStatuses); const updateExtension = useExtensionStore((s) => s.updateExtension); const updatePack = useExtensionStore((s) => s.updatePack); - const installToAgent = useExtensionStore((s) => s.installToAgent); + const installPairs = useExtensionStore((s) => s.installPairs); const deleteInstances = useExtensionStore((s) => s.deleteInstances); const extensions = useExtensionStore((s) => s.extensions); const group = grouped().find((g) => g.groupKey === selectedId); @@ -94,18 +98,14 @@ export function ExtensionDetail() { const auditResults = useAuditStore((s) => s.results); const agentOrder = useAgentStore((s) => s.agentOrder); const scope = useScopeStore((s) => s.current); - // Install to Agent targets the active scope. In All-scopes mode the user - // must pick a target via ScopeTargetField (null until picked) — the same - // contract as the Marketplace install panel. + // Install to Agent defaults to the active scope but the picker is always + // there, so a copy can go to another project without a sidebar switch. + // In All-scopes mode there is no default: the user must pick (null until + // then) — the same contract as the Marketplace install panel. const [installTargetScope, setInstallTargetScope] = useState(null); - const effectiveTarget: ConfigScope | null = - scope.type === "all" - ? installTargetScope - : resolveInstallTargetScope(scope); - const sourceInstance = group - ? pickSourceInstance(group.instances, effectiveTarget ?? { type: "global" }) - : undefined; + const effectiveTarget = + installTargetScope ?? resolveInstallTargetScope(scope); // Agents that already hold a copy of this group in the target scope — // rendered as installed (mascot + check) rather than hidden. Before an // All-mode target is picked (effectiveTarget null) nothing is marked @@ -141,6 +141,65 @@ export function ExtensionDetail() { const [hermesCategoryPicker, setHermesCategoryPicker] = useState(false); const [hermesCategories, setHermesCategories] = useState([]); const [hermesDeployCategory, setHermesDeployCategory] = useState("local"); + + // The one install path from this panel — the agent tile, and the Hermes + // confirm button once a category is picked. The plan expands a CLI + // bundle and skips what the agent can't take (wrong scope, a remote MCP + // transport it can't express) instead of failing mid-loop; a Hermes + // skill waits for a category first. + const runInstall = async (agent: AgentInfo, hermesCategory?: string) => { + if (!group || !effectiveTarget) return; + const { pairs, skipped } = buildInstallPlan( + [group], + [agent], + extensions, + effectiveTarget, + ); + if (!hermesCategory && pairs.some((p) => p.isHermesSkill)) { + const cats = await api.listHermesCategories().catch(() => []); + setHermesCategories(cats); + setHermesDeployCategory(cats[0] ?? "local"); + setHermesCategoryPicker(true); + return; + } + const childSkips = skipped.filter( + (sk) => sk.itemName && sk.reason !== "already-installed", + ).length; + if (childSkips > 0) { + toast.info(t("detail.cliChildrenSkipped", { count: childSkips })); + } + // Nothing to write (bare CLI, every child already there or skipped): + // no spinner and no "Installed", but don't leave the click silent. + if (pairs.length === 0) { + setHermesCategoryPicker(false); + if (childSkips === 0) { + toast.info( + t("detail.nothingToInstall", { agent: agentDisplayName(agent.name) }), + ); + } + return; + } + setDeploying(agent.name); + let failed = false; + await installPairs(pairs, effectiveTarget, { + hermesCategory, + onResult: (_pair, error) => { + if (error) failed = true; + }, + }); + setDeploying(null); + setHermesCategoryPicker(false); + if (failed) { + toast.error( + t("detail.installToFailed", { agent: agentDisplayName(agent.name) }), + ); + return; + } + flashInstalled(agent.name); + toast.success( + t("detail.installToSuccess", { agent: agentDisplayName(agent.name) }), + ); + }; const [activeInstanceId, setActiveInstanceId] = useState(null); const [showDelete, setShowDelete] = useState(false); const [deleteAgents, setDeleteAgents] = useState>(new Set()); @@ -195,6 +254,7 @@ export function ExtensionDetail() { } setShowDelete(false); setDeleteAgents(new Set()); + setHermesCategoryPicker(false); }, [group?.groupKey]); // Load content + skill locations for any instances added after the initial load @@ -228,9 +288,9 @@ export function ExtensionDetail() { } }, [group?.instances.length]); - // Reset install-target state when the active scope changes: a pending - // Hermes install would otherwise silently retarget the new scope, and a - // previously picked All-mode target no longer matches the picker UI. + // Reset install-target state when the active scope changes: the picker + // falls back to the new scope's default, and an open Hermes picker is + // closed rather than left pointing at the old target. // biome-ignore lint/correctness/useExhaustiveDependencies: `scope` is the trigger — the effect must re-run on every scope switch even though the body doesn't read it. useEffect(() => { setHermesCategoryPicker(false); @@ -611,91 +671,47 @@ export function ExtensionDetail() {
- {(group.kind === "skill" || - group.kind === "mcp" || - group.kind === "hook" || - group.kind === "cli") && + {INSTALLABLE_KINDS.has(group.kind) && (() => { const detectedAgents = sortAgents( agents.filter((a) => a.detected && a.enabled), agentOrder, ); if (detectedAgents.length === 0) return null; + const mcpTransport = group.instances[0]?.mcp_transport; return (
-
-

- {t("detail.installToAgent")} -

- {/* Single-scope mode: inline "📁 scope" hint next to the - * header; All-scopes mode renders the required picker on - * its own row below (Marketplace parity). */} - {scope.type !== "all" && ( - - )} +

+ {t("detail.installToAgent")} +

+
+ { + setInstallTargetScope(s); + // A category picked for one target must not land + // in another. + setHermesCategoryPicker(false); + }} + />
- {scope.type === "all" && ( -
- -
- )}
{detectedAgents.map((agent) => { - // All gating reads the backend-derived capabilities - // (AgentInfo.capabilities) — same source of truth the - // service deploys with. - const scopeForCheck = effectiveTarget ?? scope; - const hookUnsupported = - group.kind === "hook" && - !agent.capabilities.hooks_supported; - const mcpUnsupported = - group.kind === "mcp" && - agent.capabilities.mcp_supported === false; - // Blocks global-targeted hook installs for agents that - // load workspace hooks only; project-scope installs still - // go through. Currently every adapter reports true, so - // this branch is dormant — kept because the capability - // flag is the designed off-switch for future agents. - const globalHookBlocked = - group.kind === "hook" && - effectiveTarget?.type === "global" && - !agent.capabilities.global_hook_install; - const scopeIncapable = !canInstallAtScope( + // Same gate as the bulk install plan, so a tile greyed + // out here is exactly what a bulk run would skip. + const blockReason = installBlockReason( agent, group.kind, - scopeForCheck, + effectiveTarget ?? scope, + mcpTransport, ); - // Remote (HTTP/SSE) MCP servers can only go to agents - // whose config can express that transport — e.g. Codex - // takes Streamable HTTP but not SSE. The deployer - // enforces the same rule; greying out here means users - // never hit that error (issue #105). - const mcpTransport = - group.kind === "mcp" - ? group.instances[0]?.mcp_transport - : undefined; - const transportUnsupported = - group.kind === "mcp" && - !canReceiveMcpTransport(agent, mcpTransport); - const blocked = - hookUnsupported || - mcpUnsupported || - globalHookBlocked || - scopeIncapable || - transportUnsupported; + const blocked = blockReason !== null; // Already has a copy in the target scope: shown as // installed (check icon) instead of hidden, so the user // can see WHERE this extension already lives. const isInstalled = agentsInTargetScope.has(agent.name); const isFlashing = justInstalled.has(agent.name); - const isHermes = - agent.name === "hermes" && group.kind === "skill"; const disabled = !effectiveTarget || deploying !== null || @@ -709,18 +725,18 @@ export function ExtensionDetail() { title={ !effectiveTarget ? tm("detail.selectScopeFirst") - : hookUnsupported + : blockReason === "hook-unsupported" ? t("detail.hooksNotSupported") - : mcpUnsupported + : blockReason === "mcp-unsupported" ? t("detail.mcpNotSupported") - : globalHookBlocked + : blockReason === "global-hook-blocked" ? t("detail.kiroGlobalHooksPending") - : scopeIncapable + : blockReason === "scope-unsupported" ? t("detail.projectScopeUnsupported", { agent: agentDisplayName(agent.name), kind: group.kind, }) - : transportUnsupported + : blockReason === "transport-unsupported" ? t("detail.remoteTransportUnsupported", { agent: agentDisplayName(agent.name), transport: ( @@ -729,87 +745,8 @@ export function ExtensionDetail() { }) : undefined } - onClick={async () => { - if (blocked || isInstalled || !effectiveTarget) - return; - if (isHermes) { - // Show category picker before deploying - const cats = await api - .listHermesCategories() - .catch(() => []); - setHermesCategories(cats); - setHermesDeployCategory(cats[0] ?? "local"); - setHermesCategoryPicker(true); - return; - } - setDeploying(agent.name); - try { - if (group.kind === "cli") { - const children = findCliChildren( - extensions, - group.instances[0]?.id, - group.pack, - ); - const seen = new Set(); - // A CLI bundle can mix kinds (skills + MCP). - // Skip children the target agent can't take — - // wrong scope, or a remote MCP transport the - // agent can't express — instead of failing - // mid-loop with a partial install. - let skipped = 0; - for (const child of children) { - if (seen.has(child.name + child.kind)) continue; - seen.add(child.name + child.kind); - if ( - !canInstallAtScope( - agent, - child.kind, - scopeForCheck, - ) || - (child.kind === "mcp" && - !canReceiveMcpTransport( - agent, - child.mcp_transport, - )) - ) { - skipped += 1; - continue; - } - await installToAgent( - child.id, - agent.name, - effectiveTarget, - ); - } - if (skipped > 0) { - toast.info( - t("detail.cliChildrenSkipped", { - count: skipped, - }), - ); - } - } else if (sourceInstance) { - await installToAgent( - sourceInstance.id, - agent.name, - effectiveTarget, - ); - } - flashInstalled(agent.name); - toast.success( - t("detail.installToSuccess", { - agent: agentDisplayName(agent.name), - }), - ); - } catch { - toast.error( - t("detail.installToFailed", { - agent: agentDisplayName(agent.name), - }), - ); - } finally { - setDeploying(null); - } + onClick={() => { + if (!blocked && !isInstalled) runInstall(agent); }} className={clsx( "flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-[background-color,border-color] duration-300 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary/10 disabled:hover:border-border", @@ -859,39 +796,20 @@ export function ExtensionDetail() { categories={hermesCategories} value={hermesDeployCategory} onChange={setHermesDeployCategory} - disabled={deploying === "hermes"} + disabled={deploying !== null} />
- )} -
- -
- {!done && ( - <> -

- {t("sync.globalNote")} -

-
-
- - {t("sync.targetAgents")} - - {detected.length > 1 && ( - - )} -
- {detected.length === 0 ? ( -

- {t("sync.noAgents")} -

- ) : ( -
- {detected.map((a) => { - const sel = checked.has(a.name); - return ( - // biome-ignore lint/a11y/useSemanticElements: agent tiles are visually buttons but semantically toggle-able (multi-select); aria-pressed/role=checkbox keeps the multi-select semantics on a styled - ); - })} -
- )} -
- - )} - - {done && results && ( -
-

- {t("sync.installed", { count: installedCount })} -

-
    - {results.map((r, i) => ( -
  • - - {nameByKey.get(r.groupKey) ?? r.groupKey} →{" "} - {agentDisplayName(r.agent)} - - {r.status === "installed" ? ( - ✓ - ) : ( - - {r.message ?? "failed"} - - )} -
  • - ))} - {skipped.map((s) => ( -
  • - - {nameByKey.get(s.groupKey) ?? s.groupKey} →{" "} - {agentDisplayName(s.targetAgent)} - - - {t(`sync.reason.${REASON_KEY[s.reason]}`)} - -
  • - ))} -
-
- )} -
- -
- {!done ? ( - <> - - - - ) : ( - - )} -
- - ); -} diff --git a/src/components/shared/scope-target-field.tsx b/src/components/shared/scope-target-field.tsx index 3a7b7af3..841e300c 100644 --- a/src/components/shared/scope-target-field.tsx +++ b/src/components/shared/scope-target-field.tsx @@ -7,17 +7,17 @@ import { isWeb, webSelectStyle } from "@/lib/web-select"; import { useProjectStore } from "@/stores/project-store"; interface ScopeTargetFieldProps { - /** The currently chosen install target. In single-scope mode this is - * always the active scope; in All-scopes mode it starts as `null` - * (or the smart-default scope) and the user must pick. */ + /** The currently chosen install target. Without `alwaysPick` this is + * the active scope in single-scope mode; in All-scopes mode it starts + * as `null` (or the smart-default scope) and the user must pick. */ value: ConfigScope | null; onChange: (scope: ConfigScope | null) => void; /** Optional smart default to suggest in All-scopes mode. */ smartDefault?: ConfigScope; /** When true, always render the picker — even in single-scope mode. - * Used by NewSkillsDialog where the dialog appears unexpectedly - * (post Check Updates discovery) and the active UI scope is not - * necessarily where the user wants the new skills installed. */ + * Used wherever the active UI scope is only a default, not a given: + * Install to Agent (detail panel and bulk), and NewSkillsDialog, which + * appears unexpectedly after Check Updates. */ alwaysPick?: boolean; } @@ -41,7 +41,8 @@ export function ScopeTargetField({ ); } - // All-scopes mode: required dropdown + // The picker. In All-scopes mode it starts empty and must be picked; + // with `alwaysPick` in a single scope it starts on that scope. const selectedKey = value ? value.type === "global" ? "global" @@ -76,7 +77,12 @@ export function ScopeTargetField({ isWeb ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5", )} > - + {(scope.type === "all" || + !value || + (value.type === "project" && + !projects.some((p) => p.path === value.path))) && ( + + )} {projects.map((p) => (
); } diff --git a/src/stores/__tests__/extension-helpers.test.ts b/src/stores/__tests__/extension-helpers.test.ts index 90fedfff..6e781e2c 100644 --- a/src/stores/__tests__/extension-helpers.test.ts +++ b/src/stores/__tests__/extension-helpers.test.ts @@ -511,13 +511,11 @@ describe("resolveInstallTargetScope", () => { ).toEqual({ type: "project", name: "demo", path: "/tmp/demo" }); }); - it("falls back to Global in Global and All modes", () => { + it("targets Global in Global mode and has no default in All mode", () => { expect(resolveInstallTargetScope({ type: "global" })).toEqual({ type: "global", }); - expect(resolveInstallTargetScope({ type: "all" })).toEqual({ - type: "global", - }); + expect(resolveInstallTargetScope({ type: "all" })).toBeNull(); }); }); diff --git a/src/stores/__tests__/extension-store.test.ts b/src/stores/__tests__/extension-store.test.ts index 81a72f97..af805b97 100644 --- a/src/stores/__tests__/extension-store.test.ts +++ b/src/stores/__tests__/extension-store.test.ts @@ -144,3 +144,74 @@ describe("extension-store confirmDelete", () => { expect(useExtensionStore.getState().extensions).toEqual([]); }); }); + +describe("extension-store installPairs", () => { + const pair = { + groupKey: "skill-timer", + sourceId: "p1", + targetAgent: "codex", + isHermesSkill: false, + }; + + beforeEach(() => { + useExtensionStore.setState({ extensions: [dshPlugin] }); + vi.resetAllMocks(); + vi.mocked(api.installToAgent).mockResolvedValue("timer"); + vi.mocked(api.listExtensions).mockResolvedValue([]); + vi.mocked(api.getAllTags).mockResolvedValue([]); + vi.mocked(api.getAllPacks).mockResolvedValue([]); + vi.mocked(api.getCachedUpdateStatuses).mockResolvedValue([]); + }); + + it("shows each landed copy at once, reports once per pair, rescans once", async () => { + // Hold the rescan so the placeholder rows can be observed. + let release: (n: number) => void = () => {}; + vi.mocked(api.scanAndSync).mockImplementation( + () => new Promise((resolve) => (release = resolve)), + ); + const onResult = vi.fn(); + const gemini = { ...pair, targetAgent: "gemini" }; + vi.mocked(api.installToAgent) + .mockResolvedValueOnce("timer") + .mockRejectedValueOnce('{"kind":"Io","message":"Permission denied"}'); + const run = useExtensionStore + .getState() + .installPairs([pair, gemini], { type: "global" }, { onResult }); + await vi.waitFor(() => expect(onResult).toHaveBeenCalledTimes(2)); + expect(onResult).toHaveBeenNthCalledWith(1, pair, undefined); + expect(onResult).toHaveBeenNthCalledWith(2, gemini, "Permission denied"); + // The Codex placeholder is in the list before the rescan completes; + // the failed Gemini one never is. + const rows = useExtensionStore.getState().extensions; + expect(rows.filter((e) => e.agents.includes("codex"))).toHaveLength(1); + expect(rows.some((e) => e.agents.includes("gemini"))).toBe(false); + await vi.waitFor(() => expect(api.scanAndSync).toHaveBeenCalledTimes(1)); + release(0); + await run; + expect(api.listExtensions).toHaveBeenCalledTimes(1); + }); + + it("adds no placeholder when the agent already shows the item there", async () => { + // Hold the rescan so the list before it can be read. + let release: (n: number) => void = () => {}; + vi.mocked(api.scanAndSync).mockImplementation( + () => new Promise((resolve) => (release = resolve)), + ); + // dsh already shows `timer` at Global: an overwrite there adds no row. + const run = useExtensionStore + .getState() + .installPairs([{ ...pair, targetAgent: "dsh" }], { type: "global" }); + await vi.waitFor(() => expect(api.scanAndSync).toHaveBeenCalledTimes(1)); + expect(useExtensionStore.getState().extensions).toEqual([dshPlugin]); + release(0); + await run; + }); + + it("falls back to a plain fetch when the rescan throws", async () => { + vi.mocked(api.scanAndSync).mockRejectedValue(new Error("scan failed")); + await useExtensionStore.getState().installPairs([pair], { type: "global" }); + expect(api.listExtensions).toHaveBeenCalledTimes(1); + // The placeholder did not survive the refetch. + expect(useExtensionStore.getState().extensions).toEqual([]); + }); +}); diff --git a/src/stores/extension-helpers.ts b/src/stores/extension-helpers.ts index fc90a6aa..226fd015 100644 --- a/src/stores/extension-helpers.ts +++ b/src/stores/extension-helpers.ts @@ -138,11 +138,13 @@ export function groupHasEnabledAgent( ); } -/** Scope an Install-to-Agent action targets for a given active scope: - * the project itself in project mode, Global otherwise. All-mode callers - * use the explicit ScopeTargetField picker instead of calling this; the - * all→global branch remains as a safe fallback. */ -export function resolveInstallTargetScope(scope: ScopeValue): ConfigScope { +/** Default target of an Install-to-Agent action for the active scope: the + * project itself, Global, or null in All mode, where there is no sensible + * default and the user has to pick one in the ScopeTargetField. */ +export function resolveInstallTargetScope( + scope: ScopeValue, +): ConfigScope | null { + if (scope.type === "all") return null; return scope.type === "project" ? { type: "project", name: scope.name, path: scope.path } : { type: "global" }; diff --git a/src/stores/extension-store.ts b/src/stores/extension-store.ts index 6886862a..b3324762 100644 --- a/src/stores/extension-store.ts +++ b/src/stores/extension-store.ts @@ -1,15 +1,17 @@ import { create } from "zustand"; import { parseError } from "@/lib/error-types"; import i18n from "@/lib/i18n"; +import { type InstallPair, pendingCopyId } from "@/lib/install-plan"; import { api } from "@/lib/invoke"; -import type { - ConfigScope, - Extension, - ExtensionKind, - GroupedExtension, - NewRepoSkill, - ToggleOutcome, - UpdateStatus, +import { + type ConfigScope, + type Extension, + type ExtensionKind, + type GroupedExtension, + type NewRepoSkill, + scopeKey, + type ToggleOutcome, + type UpdateStatus, } from "@/lib/types"; import { useAgentStore } from "./agent-store"; import { @@ -127,11 +129,13 @@ interface ExtensionState { updateTags: (groupKey: string, tags: string[]) => Promise; updatePack: (groupKey: string, pack: string | null) => Promise; fetchPacks: () => Promise; - installToAgent: ( - id: string, - targetAgent: string, + installPairs: ( + pairs: InstallPair[], targetScope: ConfigScope, - hermesCategory?: string, + opts?: { + hermesCategory?: string; + onResult?: (pair: InstallPair, error?: string) => void; + }, ) => Promise; toggle: (groupKey: string, enabled: boolean) => Promise; batchToggle: (enabled: boolean) => Promise; @@ -333,9 +337,35 @@ export const useExtensionStore = create((set, get) => ({ }); }, - async installToAgent(id, targetAgent, targetScope, hermesCategory) { - await api.installToAgent(id, targetAgent, targetScope, hermesCategory); - await get().rescanAndFetch(); + /** Run a plan's installs in order. Each copy that lands is shown in the + * table at once (a placeholder row the closing rescan replaces), so a + * long run lights up one agent at a time without a rescan per + * install. `onResult` fires per pair, with the backend's message on + * failure; the run itself never throws. */ + async installPairs(pairs, targetScope, { hermesCategory, onResult } = {}) { + for (const pair of pairs) { + let error: string | undefined; + try { + await api.installToAgent( + pair.sourceId, + pair.targetAgent, + targetScope, + pair.isHermesSkill ? hermesCategory : undefined, + ); + } catch (e) { + error = parseError(e).message; + } + if (!error) set((s) => showPendingCopy(s.extensions, pair, targetScope)); + onResult?.(pair, error); + } + // One rescan for the whole run; if the scan itself fails, at least + // refetch so no placeholder row survives. + try { + await get().rescanAndFetch(); + } catch (e) { + console.error("Failed to rescan after install:", e); + await get().fetch(); + } }, async toggle(groupKey, enabled) { @@ -713,3 +743,35 @@ export function vendorBaselineByAgent(): Record { } return out; } + +/** The placeholder row for a copy that just landed: the source row under + * the target agent and scope. Nothing is added when that agent already + * shows the item there (an overwrite), so no duplicate row appears. */ +function showPendingCopy( + extensions: Extension[], + pair: InstallPair, + targetScope: ConfigScope, +): { extensions: Extension[] } | Record { + const src = extensions.find((e) => e.id === pair.sourceId); + if (!src) return {}; + const key = scopeKey(targetScope); + const shown = extensions.some( + (e) => + e.kind === src.kind && + e.name === src.name && + e.agents.includes(pair.targetAgent) && + scopeKey(e.scope) === key, + ); + if (shown) return {}; + // No path: the real one is only known after the rescan, and the source's + // path must not make this row look like the source file. + const copy: Extension = { + ...src, + id: pendingCopyId(pair, targetScope), + agents: [pair.targetAgent], + scope: targetScope, + source_path: null, + cli_parent_id: null, + }; + return { extensions: [...extensions, copy] }; +} From 2813b23becdacf9f251ec89f74b78754db83b529 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sat, 10 Oct 2026 22:50:22 +0800 Subject: [PATCH 3/3] feat(extensions): stop a bulk install mid-run; list covered bundles with skips - While installing, Cancel becomes Stop: the install in flight finishes, nothing after it is attempted, the table is rescanned once, and the summary reports how many were not run. - A CLI bundle covered by its separately selected parts is listed at the top of the skipped list (not counted), or on its own when nothing else was skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/bulk-install-dialog.test.tsx | 30 +++++ .../extensions/bulk-install-dialog.tsx | 117 +++++++++++------- src/lib/i18n/locales/en/extensions.json | 5 + src/lib/i18n/locales/zh-TW/extensions.json | 5 + src/lib/i18n/locales/zh/extensions.json | 5 + src/stores/__tests__/extension-store.test.ts | 18 +++ src/stores/extension-store.ts | 10 +- 7 files changed, 146 insertions(+), 44 deletions(-) diff --git a/src/components/extensions/__tests__/bulk-install-dialog.test.tsx b/src/components/extensions/__tests__/bulk-install-dialog.test.tsx index ff5bfe9d..022043f5 100644 --- a/src/components/extensions/__tests__/bulk-install-dialog.test.tsx +++ b/src/components/extensions/__tests__/bulk-install-dialog.test.tsx @@ -203,6 +203,36 @@ describe("BulkInstallDialog", () => { expect(screen.getByText("covered by gh-skill")).toBeInTheDocument(); }); + it("stops a run on request and reports what was not run", async () => { + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); + useAgentStore.setState({ + agents: [agent("claude"), agent("codex"), agent("gemini")], + agentOrder: ["claude", "codex", "gemini"], + } as never); + // The first install waits until the test lets it finish. + let finish: (id: string) => void = () => {}; + installToAgent.mockImplementationOnce( + () => new Promise((resolve) => (finish = resolve)), + ); + render( + {}} + onDone={() => {}} + />, + ); + await userEvent.click(screen.getByRole("checkbox", { name: "Codex" })); + await userEvent.click(screen.getByRole("checkbox", { name: "Gemini CLI" })); + await userEvent.click(screen.getByRole("button", { name: "Install (2)" })); + await userEvent.click(await screen.findByRole("button", { name: "Stop" })); + finish("pdf"); + + await screen.findByRole("button", { name: "Done" }); + expect(installToAgent).toHaveBeenCalledTimes(1); + expect(screen.getByText("Stopped")).toBeInTheDocument(); + expect(screen.getByText(/1 not run/)).toBeInTheDocument(); + }); + it("keeps every tile disabled in All mode until a target is picked", () => { useScopeStore.setState({ current: { type: "all" }, hydrated: true }); render( diff --git a/src/components/extensions/bulk-install-dialog.tsx b/src/components/extensions/bulk-install-dialog.tsx index 8ea47c70..5f2da163 100644 --- a/src/components/extensions/bulk-install-dialog.tsx +++ b/src/components/extensions/bulk-install-dialog.tsx @@ -42,6 +42,10 @@ const REASON_KEY = { "no-source": "bulkInstall.reason.noSource", } as const satisfies Record; +/** Two-column layout of the summary's name / reason lines. */ +const SUMMARY_GRID = + "grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-xs"; + /** The holder of an overwrite's source isn't skipped in any sense the * user cares about: its mascot is lit and it isn't counted. */ const isSource = (s: InstallSkip) => s.reason === "source"; @@ -126,6 +130,9 @@ export function BulkInstallDialog({ const [overwrite, setOverwrite] = useState(false); const [run, setRun] = useState(null); const [showSkipped, setShowSkipped] = useState(false); + // Stop: the install in flight finishes, nothing after it is attempted. + const stopRef = useRef(false); + const [stopping, setStopping] = useState(false); // Every detected agent can be ticked; a tile says nothing about what // the agent would get. What was skipped and why is reported after the @@ -167,6 +174,7 @@ export function BulkInstallDialog({ hermesCategory, onResult: (pair, error) => setRun((r) => r && { ...r, results: [...r.results, { pair, error }] }), + shouldStop: () => stopRef.current, }); setRun((r) => r && { ...r, done: true }); } @@ -194,6 +202,7 @@ export function BulkInstallDialog({ const installedCount = run?.results.filter((r) => !r.error).length ?? 0; const failedCount = (run?.results.length ?? 0) - installedCount; const runSkipped = run ? countSkipped(run) : 0; + const notRun = done && run ? run.pairs.length - run.results.length : 0; return ( {!done && } {done - ? t("bulkInstall.done") - : t("bulkInstall.installing", { - done: run.results.length, - total: run.pairs.length, - })} + ? notRun > 0 + ? t("bulkInstall.stopped") + : t("bulkInstall.done") + : stopping + ? t("bulkInstall.stopping") + : t("bulkInstall.installing", { + done: run.results.length, + total: run.pairs.length, + })} )} + {notRun > 0 && ( + + · {t("bulkInstall.notRun", { count: notRun })} + + )} {runSkipped > 0 && ( ·{" "} @@ -389,8 +407,13 @@ export function BulkInstallDialog({ )} {/* A bundle whose parts were selected on their own has no - row above; say where it went, always. */} - + row above. It leads the skipped list; with nothing else + skipped there is no list to open, so it shows as is. */} + {runSkipped === 0 && ( +
+ +
+ )} {/* Failures first: they are what needs attention. */} {showSkipped && runSkipped > 0 && (
@@ -437,14 +460,27 @@ export function BulkInstallDialog({ {t("bulkInstall.overwrite")} )} - + {running ? ( + + ) : ( + + )}