diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 89988d9b..837217a3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1373,6 +1373,39 @@ "The role catalog is unavailable": "The role catalog is unavailable", "Please try again later.": "Please try again later.", "No bundles available": "No bundles available", + "Content": "Content", + "Content language of the roles": "Content language of the roles", + "{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles", + "Update all ({{count}})": "Update all ({{count}})", + "Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…", + "{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.", + "{{count}} roles": "{{count}} roles", + "{{count}} new — none installed": "{{count}} new — none installed", + "All installed · up to date": "All installed · up to date", + "{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date", + "{{count}} new": "{{count}} new", + "{{count}} installed": "{{count}} installed", + "{{count}} updates": "{{count}} updates", + "Install bundle": "Install bundle", + "Install {{count}} selected": "Install {{count}} selected", + "Install bundle ({{count}})": "Install bundle ({{count}})", + "{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected", + "Select all": "Select all", + "Deselect all": "Deselect all", + "Skipped": "Skipped", + "v{{version}}": "v{{version}}", + "{{count}} roles installed": "{{count}} roles installed", + "{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed", + "{{count}} roles updated": "{{count}} roles updated", + "Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped", + "A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.", + "\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.", + "Rename & install": "Rename & install", + "Couldn’t load the catalog": "Couldn’t load the catalog", + "Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.", + "Retry": "Retry", + "The catalog is empty": "The catalog is empty", + "No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.", "Already up to date": "Already up to date", "Updated to the latest version": "Updated to the latest version", "This role is no longer in the catalog": "This role is no longer in the catalog", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 0c792632..ddde9041 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1235,6 +1235,39 @@ "The role catalog is unavailable": "Каталог ролей недоступен", "Please try again later.": "Попробуйте позже.", "No bundles available": "Наборы недоступны", + "Content": "Язык контента", + "Content language of the roles": "Язык контента ролей", + "{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}", + "Update all ({{count}})": "Обновить все ({{count}})", + "Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…", + "{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.", + "{{count}} roles": "ролей: {{count}}", + "{{count}} new — none installed": "новых: {{count}} — ничего не установлено", + "All installed · up to date": "Все установлены · актуальны", + "{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}", + "{{count}} new": "новых: {{count}}", + "{{count}} installed": "установлено: {{count}}", + "{{count}} updates": "обновлений: {{count}}", + "Install bundle": "Установить набор", + "Install {{count}} selected": "Установить выбранные ({{count}})", + "Install bundle ({{count}})": "Установить набор ({{count}})", + "{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}", + "Select all": "Выбрать все", + "Deselect all": "Снять выбор", + "Skipped": "Пропущено", + "v{{version}}": "v{{version}}", + "{{count}} roles installed": "Установлено ролей: {{count}}", + "{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}", + "{{count}} roles updated": "Обновлено ролей: {{count}}", + "Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}", + "A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.", + "\"{{name}}\" is already installed.": "«{{name}}» уже установлена.", + "Rename & install": "Переименовать и установить", + "Couldn’t load the catalog": "Не удалось загрузить каталог", + "Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.", + "Retry": "Повторить", + "The catalog is empty": "Каталог пуст", + "No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.", "No roles configured": "Роли не настроены", "Already up to date": "Уже актуальна", "Updated to the latest version": "Обновлено до последней версии", diff --git a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts index 555d11b9..9a6851cd 100644 --- a/apps/client/src/features/ai-chat/queries/ai-chat-query.ts +++ b/apps/client/src/features/ai-chat/queries/ai-chat-query.ts @@ -1,6 +1,7 @@ import { useInfiniteQuery, useMutation, + useQueries, useQuery, useQueryClient, } from "@tanstack/react-query"; @@ -307,6 +308,29 @@ export function useAiRoleCatalogBundleQuery( }); } +/** + * Eagerly open EVERY listed bundle's content in parallel for one language. The + * redesigned catalog shows each bundle's status summary in its COLLAPSED header, + * which needs every role's install state up front — so contents can no longer be + * lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one + * cached read per bundle, sharing the same cache keys as + * `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a + * resolved language) so nothing fetches while the modal is closed. + */ +export function useAiRoleCatalogBundlesQueries( + bundleIds: string[], + language: string, + enabled: boolean, +) { + return useQueries({ + queries: bundleIds.map((bundleId) => ({ + queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language), + queryFn: () => getAiRoleCatalogBundle(bundleId, language), + enabled: enabled && !!language, + })), + }); +} + export function useImportAiRolesFromCatalogMutation() { const queryClient = useQueryClient(); const { t } = useTranslation(); diff --git a/apps/client/src/features/ai-chat/queries/import-from-catalog-message.test.tsx b/apps/client/src/features/ai-chat/queries/import-from-catalog-message.test.tsx index 0338c21d..ae57ccab 100644 --- a/apps/client/src/features/ai-chat/queries/import-from-catalog-message.test.tsx +++ b/apps/client/src/features/ai-chat/queries/import-from-catalog-message.test.tsx @@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () => }); it("errors:[] -> only the summary notification (counts interpolated)", async () => { - await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] }); + await runMutation({ + created: 3, + renamed: 1, + skipped: 2, + errors: [], + createdRoles: [], + skippedRoles: [], + }); expect(notificationsShowMock).toHaveBeenCalledTimes(1); expect(notificationsShowMock).toHaveBeenCalledWith({ message: "Imported 3, renamed 1, skipped 2", @@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () => { slug: "a", message: "name taken" }, { slug: "b", message: "name taken" }, ], + createdRoles: [{ slug: "ok", name: "Ok" }], + skippedRoles: [], }); expect(notificationsShowMock).toHaveBeenCalledTimes(2); expect(notificationsShowMock).toHaveBeenNthCalledWith(1, { diff --git a/apps/client/src/features/ai-chat/types/ai-chat.types.ts b/apps/client/src/features/ai-chat/types/ai-chat.types.ts index 8ed54ab2..893e65b4 100644 --- a/apps/client/src/features/ai-chat/types/ai-chat.types.ts +++ b/apps/client/src/features/ai-chat/types/ai-chat.types.ts @@ -108,12 +108,25 @@ export interface IAiRoleImportPayload { conflict: "skip" | "rename"; } -/** Import result counts (mirrors `importFromCatalog()`). */ +/** + * Import result (mirrors `importFromCatalog()`). The counters (`created`, + * `skipped`, `renamed`) drive the summary notification; the per-role lists + * (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline + * result plaque — which roles were installed (and any rename) and which were + * skipped and why (so the plaque can name the conflicting role and offer + * "Rename & install"). + */ export interface IAiRoleImportResult { created: number; skipped: number; renamed: number; errors: { slug: string; message: string }[]; + createdRoles: { slug: string; name: string; renamedTo?: string }[]; + skippedRoles: { + slug: string; + name: string; + reason: "name-conflict" | "already-installed"; + }[]; } /** diff --git a/apps/client/src/features/ai-chat/utils/catalog-bundle-model.test.ts b/apps/client/src/features/ai-chat/utils/catalog-bundle-model.test.ts new file mode 100644 index 00000000..23806750 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/catalog-bundle-model.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { + bundlePhase, + installedLangForRole, + mapBundleRolesToView, + mapCatalogRoleToView, + type CatalogViewRole, +} from "./catalog-bundle-model.ts"; +import type { + IAiRole, + IAiRoleCatalogRole, +} from "@/features/ai-chat/types/ai-chat.types.ts"; + +function installedRole( + source: { slug: string; language: string; version: number }, + overrides: Partial = {}, +): IAiRole { + return { + id: `role-${source.slug}-${source.language}`, + name: source.slug, + emoji: null, + description: null, + enabled: true, + autoStart: true, + launchMessage: null, + source, + ...overrides, + }; +} + +function catalogRole( + overrides: Partial = {}, +): IAiRoleCatalogRole { + return { + slug: "writer", + emoji: "✍️", + name: "Writer", + description: "Drafts copy.", + instructions: "be a writer", + autoStart: true, + launchMessage: null, + version: 3, + ...overrides, + }; +} + +// Build a minimal view role for bundlePhase tests. +function viewRole(status: CatalogViewRole["status"]): CatalogViewRole { + return { slug: `s-${status}`, name: status, description: "", version: 1, status }; +} + +describe("bundlePhase", () => { + it("empty bundle -> empty", () => { + expect(bundlePhase([])).toBe("empty"); + }); + + it("all importable, none installed -> allNew", () => { + expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe( + "allNew", + ); + }); + + it("nothing to import or update -> allInstalled", () => { + expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe( + "allInstalled", + ); + }); + + it("updates present, nothing to import -> updates", () => { + expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe( + "updates", + ); + }); + + it("import + installed (no updates) -> mixed", () => { + expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe( + "mixed", + ); + }); + + it("import + update -> mixed", () => { + expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed"); + }); + + it("transient skipped roles are ignored (counted as neither) -> allInstalled", () => { + expect(bundlePhase([viewRole("skipped")])).toBe("allInstalled"); + }); +}); + +describe("installedLangForRole", () => { + it("returns the other language when the same slug is installed elsewhere", () => { + const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })]; + expect(installedLangForRole("writer", roles, "en")).toBe("ru"); + }); + + it("returns undefined when the same slug is installed in the SAME language", () => { + const roles = [installedRole({ slug: "writer", language: "en", version: 2 })]; + expect(installedLangForRole("writer", roles, "en")).toBeUndefined(); + }); + + it("returns undefined when no install of the slug exists", () => { + expect(installedLangForRole("writer", [], "en")).toBeUndefined(); + }); + + it("ignores manually-created roles (no source)", () => { + const roles = [ + installedRole({ slug: "writer", language: "ru", version: 2 }, { + source: null, + }), + ]; + expect(installedLangForRole("writer", roles, "en")).toBeUndefined(); + }); +}); + +describe("mapCatalogRoleToView", () => { + it("no install -> import status, catalog version, emoji preserved", () => { + const view = mapCatalogRoleToView(catalogRole(), [], "en"); + expect(view).toMatchObject({ + slug: "writer", + emoji: "✍️", + name: "Writer", + description: "Drafts copy.", + status: "import", + version: 3, + }); + expect(view.installedRoleId).toBeUndefined(); + expect(view.installedLang).toBeUndefined(); + }); + + it("import with the slug installed in another language -> installedLang set", () => { + const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })]; + const view = mapCatalogRoleToView(catalogRole(), roles, "en"); + expect(view.status).toBe("import"); + expect(view.installedLang).toBe("ru"); + }); + + it("installed (up to date) -> installed status, catalog version, installedRoleId", () => { + const installed = installedRole({ + slug: "writer", + language: "en", + version: 3, + }); + const view = mapCatalogRoleToView(catalogRole(), [installed], "en"); + expect(view).toMatchObject({ + status: "installed", + version: 3, + installedRoleId: installed.id, + }); + }); + + it("update -> version=from, newVersion=to, installedRoleId", () => { + const installed = installedRole({ + slug: "writer", + language: "en", + version: 1, + }); + const view = mapCatalogRoleToView(catalogRole(), [installed], "en"); + expect(view).toMatchObject({ + status: "update", + version: 1, + newVersion: 3, + installedRoleId: installed.id, + }); + }); + + it("missing emoji -> emoji undefined; null description -> empty string", () => { + const view = mapCatalogRoleToView( + catalogRole({ emoji: null, description: null }), + [], + "en", + ); + expect(view.emoji).toBeUndefined(); + expect(view.description).toBe(""); + }); +}); + +describe("mapBundleRolesToView", () => { + it("maps a bundle's roles preserving order", () => { + const roles = [ + catalogRole({ slug: "a", name: "A", version: 1 }), + catalogRole({ slug: "b", name: "B", version: 1 }), + ]; + const installed = [installedRole({ slug: "a", language: "en", version: 1 })]; + const view = mapBundleRolesToView(roles, installed, "en"); + expect(view.map((r) => r.slug)).toEqual(["a", "b"]); + expect(view[0].status).toBe("installed"); + expect(view[1].status).toBe("import"); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/catalog-bundle-model.ts b/apps/client/src/features/ai-chat/utils/catalog-bundle-model.ts new file mode 100644 index 00000000..c7102379 --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/catalog-bundle-model.ts @@ -0,0 +1,150 @@ +import type { + IAiRole, + IAiRoleCatalogRole, +} from "@/features/ai-chat/types/ai-chat.types.ts"; +import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts"; + +/** + * The redesigned catalog modal renders bundles as cards with a summary status + * (readable without expanding) and a single primary action. The per-role and + * per-bundle view model that drives that UI is derived here as PURE functions so + * the mapping, the "installed in another language" hint, and the bundle-phase + * computation are unit-testable without mounting the component (mirrors the + * `catalogRoleInstallState` precedent). + */ + +/** + * A role's status in the catalog view model. + * - `import` — not installed in the current content language. + * - `installed` — installed and up to date. + * - `update` — installed, but the catalog ships a newer version. + * - `skipped` — TRANSIENT client-only status set after a conflicted import + * (a name collision under `conflict:'skip'`); never from the + * backend. + */ +export type RoleStatus = "import" | "installed" | "update" | "skipped"; + +/** A catalog role mapped into the modal's view model. */ +export interface CatalogViewRole { + // Slug is the stable identity within a bundle; used as the row key and as the + // `slugs[]` payload for import. + slug: string; + // Optional in the catalog — the row reserves space and renders nothing when + // absent. + emoji?: string; + name: string; + description: string; + // For `installed`/`import`: the catalog version. For `update`: the installed + // (from) version, with `newVersion` holding the catalog (to) version. + version: number; + newVersion?: number; + status: RoleStatus; + // The language a same-slug role is installed under, when it differs from the + // current content language (drives the Р5 hint). Only set for `import` roles. + installedLang?: string; + // The workspace role id, present for `installed`/`update` — needed to call the + // update-from-catalog mutation. + installedRoleId?: string; +} + +/** + * The summary phase of a bundle, derived from its roles' statuses. Determines + * the collapsed-header summary and the bundle's single primary action. + * - `empty` — the bundle has no roles. + * - `allNew` — everything is importable, nothing installed. + * - `allInstalled` — nothing to import and nothing to update. + * - `updates` — updates available and nothing left to import. + * - `mixed` — any other combination. + * Transient `skipped` roles are ignored (they count as neither import, installed + * nor update), so a post-import conflict does not distort the header summary. + */ +export type BundlePhase = + | "empty" + | "allNew" + | "allInstalled" + | "updates" + | "mixed"; + +export function bundlePhase(roles: CatalogViewRole[]): BundlePhase { + if (roles.length === 0) return "empty"; + const imp = roles.filter((r) => r.status === "import").length; + const ups = roles.filter((r) => r.status === "update").length; + const installed = roles.filter((r) => r.status === "installed").length; + if (imp === 0 && ups === 0) return "allInstalled"; + if (ups > 0 && imp === 0) return "updates"; + if (imp > 0 && installed === 0 && ups === 0) return "allNew"; + return "mixed"; +} + +/** + * For a role NOT installed in the current `language`, find a workspace role with + * the same catalog `slug` installed under a DIFFERENT language, and return that + * language. Drives the "installed in another language" hint (Р5): a different + * language of the same slug is a separate install and appears as `import`. + */ +export function installedLangForRole( + slug: string, + workspaceRoles: IAiRole[], + language: string, +): string | undefined { + const other = workspaceRoles.find( + (r) => + r.source?.slug === slug && + !!r.source?.language && + r.source.language !== language, + ); + return other?.source?.language; +} + +/** + * Map one catalog role to the view model, computing its install status against + * the workspace roles (via `catalogRoleInstallState`) and, for importable roles, + * the other-language hint. + */ +export function mapCatalogRoleToView( + role: IAiRoleCatalogRole, + workspaceRoles: IAiRole[], + language: string, +): CatalogViewRole { + const state = catalogRoleInstallState(role, workspaceRoles, language); + const base = { + slug: role.slug, + emoji: role.emoji ?? undefined, + name: role.name, + description: role.description ?? "", + }; + if (state.state === "update") { + return { + ...base, + status: "update", + version: state.fromVersion, + newVersion: state.toVersion, + installedRoleId: state.installed.id, + }; + } + if (state.state === "installed") { + return { + ...base, + status: "installed", + version: role.version, + installedRoleId: state.installed.id, + }; + } + return { + ...base, + status: "import", + version: role.version, + installedLang: installedLangForRole(role.slug, workspaceRoles, language), + }; +} + +/** + * Map a whole bundle's catalog roles to the view model, preserving order. + */ +export function mapBundleRolesToView( + roles: IAiRoleCatalogRole[], + workspaceRoles: IAiRole[], + language: string, +): CatalogViewRole[] { + return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language)); +} diff --git a/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx b/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx index 9095396d..48bc4249 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-agent-roles-catalog-modal.tsx @@ -1,33 +1,43 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Accordion, Alert, + Anchor, Badge, + Box, Button, Center, Checkbox, Group, - Loader, Modal, - Radio, - Select, + SegmentedControl, + Skeleton, Stack, Text, + ThemeIcon, + Tooltip, } from "@mantine/core"; -import { IconAlertTriangle } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconCheck, + IconFolderOff, + IconInfoCircle, + IconRefresh, +} from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { - useAiRoleCatalogBundleQuery, + useAiRoleCatalogBundlesQueries, useAiRoleCatalogQuery, useImportAiRolesFromCatalogMutation, useUpdateAiRoleFromCatalogMutation, } from "@/features/ai-chat/queries/ai-chat-query.ts"; +import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts"; import { - IAiRole, - IAiRoleCatalogBundleSummary, - IAiRoleCatalogRole, -} from "@/features/ai-chat/types/ai-chat.types.ts"; -import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts"; + bundlePhase, + CatalogViewRole, + mapBundleRolesToView, + RoleStatus, +} from "@/features/ai-chat/utils/catalog-bundle-model.ts"; interface AiAgentRolesCatalogModalProps { opened: boolean; @@ -37,17 +47,53 @@ interface AiAgentRolesCatalogModalProps { roles: IAiRole[]; } -/** How a name collision with an existing role is handled on import. */ -type Conflict = "skip" | "rename"; +/** A bundle mapped into the modal's view model. */ +interface CatalogViewBundle { + id: string; + name: string; + description: string; + roles: CatalogViewRole[]; +} + +type ViewState = "loading" | "error" | "empty" | "ready"; + +/** A skipped role carried in a partial result so the plaque can name it. */ +interface SkippedItem { + slug: string; + name: string; + // Why it was skipped — 'name-conflict' offers "Rename & install"; an + // 'already-installed' race is purely informational (re-importing would just + // skip again, so no action button is shown). + reason: "name-conflict" | "already-installed"; +} + +/** The inline per-bundle result plaque shown after an import/update. */ +type ImportResult = + | { type: "success"; installed: number; renamed?: number } + | { type: "updated"; count: number } + | { type: "partial"; installed: number; skipped: SkippedItem[] }; + +/** Progress of a client-side "Update all" request series. */ +interface UpdateProgress { + scope: string; // a bundle id, or GLOBAL_SCOPE + current: number; + total: number; +} + +const GLOBAL_SCOPE = "__all__"; /** - * Admin modal: browse the curated role catalog, import roles, and update an - * imported role when the catalog ships a newer version. + * Admin modal: browse the curated role catalog as bundle CARDS. Each bundle's + * collapsed header shows a status summary (N new / all installed / N updates / + * mixed) and a single primary action (Install bundle / Update all / Installed); + * expanding it reveals per-role rows with checkboxes and per-role updates. * - * Import is per-bundle (the endpoint takes a single bundleId). Each bundle's - * Accordion panel has its own "Import" button that imports only that bundle's - * checked roles — the simplest mapping to the one-bundle-per-call API and the - * clearest UX. Selection state is tracked per bundle. + * Every listed bundle's content is loaded eagerly in parallel (statuses must be + * readable without expanding). Import is per-bundle on the current one-bundle API + * with `conflict:'skip'`; name collisions come back as skipped and surface an + * inline "Rename & install" that re-imports the one role with `conflict:'rename'`. + * "Update all" (per-bundle and global) is a client-side series of single-role + * update calls with progress on the button — there is no batch endpoint yet. */ export default function AiAgentRolesCatalogModal({ opened, @@ -61,27 +107,42 @@ export default function AiAgentRolesCatalogModal({ const baseLang = (i18n.language || "en").split("-")[0].toLowerCase(); // Fetch the catalog only while the modal is open. `language` drives both the - // catalog query (bundle names) and bundle reads (role content). Seed it - // synchronously from the base subtag so the first fetch already uses the - // user's language; the effect below still reconciles against the catalog's - // offered languages once they load. + // catalog query and the eager bundle-content reads. Seed it synchronously from + // the base subtag so the first fetch already uses the user's language; the + // effect below reconciles against the catalog's offered languages once loaded. const [language, setLanguage] = useState(() => baseLang); const catalogQuery = useAiRoleCatalogQuery(language || "en", opened); - // On name conflict: Skip (default) or Rename to a free " (N)" name. - const [conflict, setConflict] = useState("skip"); + const catalog = catalogQuery.data; + const bundleSummaries = catalog?.bundles ?? []; + const languages = catalog?.languages; - // The currently expanded bundle id (Accordion is single-open: one bundle's - // roles are fetched at a time). - const [expanded, setExpanded] = useState(null); + // Eagerly open every listed bundle's content in parallel. The result array is + // index-aligned with `bundleSummaries`. + const bundleQueries = useAiRoleCatalogBundlesQueries( + bundleSummaries.map((b) => b.id), + language, + opened && !!catalog, + ); - // Per-bundle selected slugs (import-state roles checked for import). - const [selected, setSelected] = useState>>({}); + const importMutation = useImportAiRolesFromCatalogMutation(); + const updateMutation = useUpdateAiRoleFromCatalogMutation(); - const languages = catalogQuery.data?.languages; + // Per-bundle UI state. `selected` is keyed by `${bundleId}:${slug}`. + const [selected, setSelected] = useState>({}); + const [results, setResults] = useState< + Record + >({}); + // Transient client-only skipped slugs per bundle (a name conflict under + // conflict:'skip'); overlaid onto still-importable roles as the "skipped" + // status until the user acts. Never persisted server-side. + const [skipped, setSkipped] = useState>>({}); + const [busyBundle, setBusyBundle] = useState(null); + const [busyRole, setBusyRole] = useState(null); + const [progress, setProgress] = useState(null); // Pick a sensible default language from the catalog once it loads: the i18n - // base subtag (e.g. "ru-RU" => "ru") if offered, else "en", else the first. + // base subtag if offered, else "en", else the first. useEffect(() => { if (!languages || languages.length === 0) return; if (language && languages.includes(language)) return; @@ -94,314 +155,949 @@ export default function AiAgentRolesCatalogModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [languages]); - // Reset per-language UI state when the language changes (the bundle content, - // hence the install computations, are language-specific). + // Reset all per-language UI state when the content language changes (bundle + // contents, hence the install computations, selection and result plaques, are + // language-specific). useEffect(() => { - setExpanded(null); setSelected({}); + setResults({}); + setSkipped({}); + setBusyBundle(null); + setBusyRole(null); + setProgress(null); }, [language]); + // A signature of the bundle-content reads so the derived model recomputes when + // any content arrives/changes (the query result array is a new reference every + // render, so it can't be a useMemo dependency directly). + const contentSignature = bundleQueries + .map((q) => `${q.status}:${q.dataUpdatedAt}`) + .join("|"); + + const viewBundles = useMemo( + () => + bundleSummaries.map((summary, i) => { + const content = bundleQueries[i]?.data; + const rolesView = content + ? mapBundleRolesToView(content.roles, roles, language) + : []; + // Overlay the transient "skipped" status onto still-importable roles. + const skippedSet = skipped[summary.id]; + const withSkipped = skippedSet + ? rolesView.map((r) => + r.status === "import" && skippedSet.has(r.slug) + ? { ...r, status: "skipped" as RoleStatus } + : r, + ) + : rolesView; + return { + id: summary.id, + name: summary.name, + description: summary.description ?? "", + roles: withSkipped, + }; + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [bundleSummaries, contentSignature, roles, language, skipped], + ); + + // Default-check every importable role as its bundle content becomes available, + // without clobbering user toggles (only fills keys not already present). + useEffect(() => { + setSelected((prev) => { + let changed = false; + const next = { ...prev }; + for (const b of viewBundles) { + for (const r of b.roles) { + if (r.status !== "import") continue; + const k = roleKey(b.id, r.slug); + if (!(k in next)) { + next[k] = true; + changed = true; + } + } + } + return changed ? next : prev; + }); + }, [viewBundles]); + + // --- derived --- + const view: ViewState = catalogQuery.isError + ? "error" + : catalogQuery.isLoading || !catalog + ? "loading" + : bundleSummaries.length === 0 + ? "empty" + : bundleQueries.some((q) => q.isError) + ? "error" + : bundleQueries.some((q) => q.isLoading) + ? "loading" + : "ready"; + + const importable = (b: CatalogViewBundle) => + b.roles.filter((r) => r.status === "import"); + const selectedImportable = (b: CatalogViewBundle) => + importable(b).filter((r) => selected[roleKey(b.id, r.slug)]); + + const bundlesWithUpdates = viewBundles.filter((b) => + b.roles.some((r) => r.status === "update"), + ); + const totalUpdates = viewBundles.reduce( + (n, b) => n + b.roles.filter((r) => r.status === "update").length, + 0, + ); + const otherLangInstalls = viewBundles + .flatMap((b) => b.roles) + .filter( + (r) => + r.status === "import" && + r.installedLang && + r.installedLang !== language, + ).length; + + // --- actions --- + function toggleRole(bundleId: string, slug: string) { + setSelected((s) => ({ + ...s, + [roleKey(bundleId, slug)]: !s[roleKey(bundleId, slug)], + })); + } + + function toggleAll(b: CatalogViewBundle) { + const imp = importable(b); + const all = imp.every((r) => selected[roleKey(b.id, r.slug)]); + setSelected((s) => { + const next = { ...s }; + imp.forEach((r) => (next[roleKey(b.id, r.slug)] = !all)); + return next; + }); + } + + function retry() { + void catalogQuery.refetch(); + bundleQueries.forEach((q) => void q.refetch()); + } + + async function installBundle(b: CatalogViewBundle) { + const slugs = selectedImportable(b).map((r) => r.slug); + if (slugs.length === 0) return; + setBusyBundle(b.id); + setResults((r) => ({ ...r, [b.id]: undefined })); + try { + const res = await importMutation.mutateAsync({ + bundleId: b.id, + language, + slugs, + conflict: "skip", + }); + // Only name conflicts get a "Rename & install"; already-installed skips + // are just informational (they can happen on a concurrent import race). + const nameConflicts = res.skippedRoles.filter( + (s) => s.reason === "name-conflict", + ); + if (nameConflicts.length > 0) { + setSkipped((prev) => { + const set = new Set(prev[b.id] ?? []); + nameConflicts.forEach((s) => set.add(s.slug)); + return { ...prev, [b.id]: set }; + }); + } + setResults((r) => ({ + ...r, + [b.id]: + res.skippedRoles.length > 0 + ? { + type: "partial", + installed: res.created, + skipped: res.skippedRoles.map((s) => ({ + slug: s.slug, + name: s.name, + reason: s.reason, + })), + } + : { + type: "success", + installed: res.created, + renamed: res.renamed || undefined, + }, + })); + } catch { + // The mutation's onError already surfaced a notification. + } finally { + setBusyBundle(null); + } + } + + async function renameInstall(bundleId: string, slug: string) { + setBusyBundle(bundleId); + try { + const res = await importMutation.mutateAsync({ + bundleId, + language, + slugs: [slug], + conflict: "rename", + }); + setSkipped((prev) => { + const set = new Set(prev[bundleId] ?? []); + set.delete(slug); + return { ...prev, [bundleId]: set }; + }); + setResults((r) => { + const cur = r[bundleId]; + if (cur?.type === "partial") { + const remaining = cur.skipped.filter((x) => x.slug !== slug); + const installed = cur.installed + res.created; + return { + ...r, + [bundleId]: + remaining.length > 0 + ? { type: "partial", installed, skipped: remaining } + : { + type: "success", + installed, + renamed: res.renamed || undefined, + }, + }; + } + return { + ...r, + [bundleId]: { + type: "success", + installed: res.created, + renamed: res.renamed || undefined, + }, + }; + }); + } catch { + // Notification already shown by the mutation. + } finally { + setBusyBundle(null); + } + } + + async function updateRole(bundleId: string, role: CatalogViewRole) { + if (!role.installedRoleId) return; + setBusyRole(roleKey(bundleId, role.slug)); + try { + await updateMutation.mutateAsync(role.installedRoleId); + } catch { + // Notification already shown by the mutation. + } finally { + setBusyRole(null); + } + } + + // Run a series of single-role update calls with progress on the button. There + // is no batch endpoint yet ([API #3]); the roles refetch after each call so the + // statuses converge as the series proceeds. + async function runUpdateSeries( + scope: string, + targets: CatalogViewRole[], + onDone: () => void, + ) { + const ids = targets + .map((r) => r.installedRoleId) + .filter((id): id is string => !!id); + if (ids.length === 0) return; + setBusyBundle(scope); + setProgress({ scope, current: 0, total: ids.length }); + try { + for (let i = 0; i < ids.length; i++) { + setProgress({ scope, current: i + 1, total: ids.length }); + await updateMutation.mutateAsync(ids[i]); + } + onDone(); + } catch { + // Notification already shown by the mutation; stop the series. + } finally { + setBusyBundle(null); + setProgress(null); + } + } + + function updateBundleAll(b: CatalogViewBundle) { + const ups = b.roles.filter((r) => r.status === "update"); + void runUpdateSeries(b.id, ups, () => + setResults((r) => ({ + ...r, + [b.id]: { type: "updated", count: ups.length }, + })), + ); + } + + function updateAllGlobal() { + const ups = viewBundles.flatMap((b) => + b.roles.filter((r) => r.status === "update"), + ); + void runUpdateSeries(GLOBAL_SCOPE, ups, () => { + // Per-bundle plaques are set for each affected bundle. + setResults((r) => { + const next = { ...r }; + for (const b of viewBundles) { + const n = b.roles.filter((x) => x.status === "update").length; + if (n > 0) next[b.id] = { type: "updated", count: n }; + } + return next; + }); + }); + } + return ( + {t("Role catalog")} + + } + styles={{ header: { alignItems: "center" } }} > - -