feat(#371): roles catalog modal redesign — bundle cards + per-role import results
Integrates the designer-handoff Roles Catalog modal, wired to the real API; the
parent ai-agent-roles.tsx and the { opened, onClose, roles } contract are
unchanged.
- Server importFromCatalog now returns per-role lists (createdRoles /
skippedRoles with a reason) alongside the existing counters (compat-preserving),
so the UI can name the conflicting/installed roles.
- New pure view-model (catalog-bundle-model.ts): bundlePhase (empty | allNew |
allInstalled | updates | mixed, ignoring the transient 'skipped'),
installedLangForRole (same-slug-different-language hint), mapCatalogRoleToView —
all unit-tested without mounting.
- Bundle cards with a summary status in the collapsed header (eager useQueries
fan-out over all bundles, sharing the existing per-bundle cache keys), a single
primary action per bundle, checkboxes + select/deselect-all, an inline result
plaque that keeps the modal open, per-bundle and global 'Update all' request
series with progress, and the other-language hint.
- The partial-result plaque distinguishes the skip reason: only a name-conflict
offers 'Rename & install'; an already-installed race is informational (a rename
re-import would just skip again and self-heal into a false success).
- All strings i18n'd (en/ru); mock handoff code (SEED/mockImport/delay) removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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";
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> = {},
|
||||
): 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> = {},
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
+972
-276
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user