From 7da7265053ace151b5df409c9b07519a5408ebc9 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 22 Jul 2026 04:19:37 -0700 Subject: [PATCH 01/37] docs: specify Tools Hub maintenance refactor --- .../tools-hub-maintenance-refactor-407.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 .otto/specs/tools-hub-maintenance-refactor-407.md diff --git a/.otto/specs/tools-hub-maintenance-refactor-407.md b/.otto/specs/tools-hub-maintenance-refactor-407.md new file mode 100644 index 0000000000..03cf5e056b --- /dev/null +++ b/.otto/specs/tools-hub-maintenance-refactor-407.md @@ -0,0 +1,230 @@ +--- +id: tools-hub-maintenance-refactor-407 +name: Tools Hub Maintenance Refactor +status: approved +created_date: 2026-07-22 +description: Behavior-preserving module split of the Tools Hub collection infra, Skills registry/backend, and Tools UI into narrow, parallelizable seams. +--- + +# Tools Hub Maintenance Refactor + +Split the three oversized Tools Hub files into narrow, single-concern modules **without changing any behavior**. The refactor is pure reorganization: same rendered UI, same routes, same APIs/CLI, same wire protocol, same experiment gating. The only observable delta is smaller files and clearer module boundaries. + +Frozen behavior contract: merged **PR #845** (`337641bd07c4016b024a4a1769429f29a4a86764`) and the approved product spec [`tools-hub-resource-system-407.md`](./tools-hub-resource-system-407.md). Nothing here relaxes, extends, or reinterprets that contract. + +> [!IMPORTANT] +> This is a maintenance refactor, not a feature. If a proposed move would change a rendered pixel, a route, a payload, a CLI surface, or an experiment path, it is out of scope. When in doubt, preserve. + +## Goals / Non-Goals + +| | | +| --- | --- | +| **Goal** | Break `resource-list.tsx` (2231 lines), `SkillsView.tsx` (1621), and `ToolsView.tsx` (1101) into concern-scoped modules behind stable public import paths. | +| **Goal** | Extract the tangled Skills **registry data client** out of the `SkillsView` view file; split the 976-line server route into route + proxy + parse layers. | +| **Goal** | Define ownership/dependency rules and four disjoint work packages that run in **separate worktrees** with no shared file. | +| **Goal** | Every checkpoint stays green: typecheck, real tests, and real stories pass after each package. | +| **Non-Goal** | Any product, UX, a11y, pagination, or responsive change. Behavior is frozen by PR #845. | +| **Non-Goal** | Any wire-protocol change. `HOST_DAEMON_PROTOCOL_VERSION` stays at **63**. | +| **Non-Goal** | New public API names, renamed exports, dependency upgrades, or "while I'm here" cleanups beyond the moves defined here. | +| **Non-Goal** | Touching the `toolsHub` experiment gate logic, legacy Settings fallback, or install/provenance semantics. | + +## What Must Be Preserved + +Every item below is verified against source, not assumed. These are the invariants each work package protects. + +| Invariant | Anchor in the frozen contract | +| --- | --- | +| `toolsHub` experiment gating | `ToolsExperimentGate.tsx`, `settings-nav.tsx`, `AppLayout.tsx` route the same way when the flag is on/off. | +| Legacy Settings compatibility | Skills/Plugins/Automations still reachable via Settings when the experiment is off. | +| Rendered UX | Overview modules, focused tabs, rows, detail shell, and states render identically. | +| APIs / routes | `GET /skills-registry`, `/entry`, `/detail`, `POST /skills-registry/install` (registered by `registerSkillsRegistryRoutes` at `apps/server/src/server.ts:457`) behave identically. | +| CLI / SDK | `packages/sdk/src/areas/skills.ts` public surface (host list/delete skills) and any `bb` skills commands are byte-identical in behavior. | +| Accessibility | Hover row actions stay keyboard-focus reachable; menu/focus semantics unchanged. | +| Pagination / responsive | `RESOURCE_GRID_PAGE_SIZE = 12` (`packages/shared-ui/src/components/ui/resource-pagination.tsx`) and grid/responsive breakpoints unchanged. | +| Install / provenance | `registry-skill-provenance.ts` file format and `registry-skill-install.ts` semantics unchanged. | +| Wire compatibility | No session payload, WS message, or host RPC command/result field changes. Protocol version unchanged. | + +## Current Dependency And Coupling Map + +Three files carry too many concerns; the server route is oversized but service-backed and cleanly separable into parse/proxy/route layers. Consumers reach the shared UI through import specifiers that this refactor keeps stable. + +| File | Lines | Concerns tangled today | Public surface | +| --- | ---: | --- | --- | +| `packages/shared-ui/src/components/ui/resource-list.tsx` | 2231 | **69 exports**: atoms, toolbar/menus, rows, detail shell/sections/controls, collection/overview scaffolds | Imported by ≥10 app + plugin files via `@bb/shared-ui/resource-list` | +| `apps/app/src/views/SkillsView.tsx` | 1621 | Registry **data client** (fetch/parse/install/format) **+** provider-filter helpers **+** Skills tab UI **+** overview **+** rows **+** detail | **21 exports** (manifest below); `ToolsView` imports `SkillsLibrary` | +| `apps/app/src/views/ToolsView.tsx` | 1101 | Tools router **+** plugin capability rendering **+** plugin detail **+** automations tool view | **3 exports**: `ToolsScrollPage`, `PluginDetail`, `ToolsView` | +| `apps/server/src/routes/skills-registry.ts` | 976 | Route registration **+** HTML/JSON parsers **+** registry proxy/fetch/hydrate **+** id/url/pagination helpers | Registered via `registerSkillsRegistryRoutes` (imported at `server.ts:26`) | + +Coupling directions today (all preserved): + +``` +app + plugin consumers ── import "@bb/shared-ui/resource-list" (barrel) ──► resource/* submodules + +apps/app/src/views/ToolsView.tsx ── imports SkillsLibrary ──► apps/app/src/views/SkillsView.tsx + │ (data client extracted, re-exported) + ▼ + apps/app/src/lib/skills-registry.ts ── fetch() HTTP ─┐ + ▼ +server.ts:457 registerSkillsRegistryRoutes ─► routes/skills-registry.ts ─► registry-proxy.ts ─► registry-parse.ts + +packages/sdk/src/areas/skills.ts ── host list/delete skills (independent of the registry HTTP route) +``` + +> [!NOTE] +> The lone cross-view edge — `ToolsView.tsx` importing `SkillsLibrary` from `./SkillsView` — is the only coupling that spans two app work packages. The ownership rule below pins the full 21-name `SkillsView` surface as a frozen barrel so the two worktrees never touch the same file. The SDK skills area is **independent** of the registry HTTP route; it is fenced read-only, not part of the route split. + +## Target Modules And Narrow APIs + +The strategy is identical for each oversized file: **the original path becomes a thin re-export barrel** so every existing import specifier stays byte-identical, and concerns move into sibling submodules. Zero consumer churn is what makes the packages independently green and parallelizable. + +### Seam A — Shared Collection Infrastructure + +Convert `resource-list.tsx` into a barrel over a new `resource/` directory. Dependency layering is strictly one-way: **atoms ← toolbar ← detail-shell ← detail-sections/detail-controls ← collection**, with **row parallel to toolbar on atoms only**. `ResourcePropertyList` lives in the detail layer, not atoms. + +Exhaustive 69-export manifest by target module: + +| Target module | Depends on | Public exports it owns | +| --- | --- | --- | +| `resource/atoms.tsx` (~200) | — | `ResourceStatusTone`, `RESOURCE_ROUTE_LABEL_EVENT`, `useResourceRouteLabel`, `ResourceState`, `ResourceStatus`, `ResourceMeta`, `ResourceLocationMeta`, `ResourceCardStat` | +| `resource/toolbar.tsx` (~400) | atoms | `ResourceToolbar`, `ResourceTabDescription`, `ResourceOption`, `ResourceOptionMenu`, `ResourceMultiSelectMenu`, `ResourceSortMenu`, `ResourceToolbarAction`, `ResourceCreateTemplate`, `ResourceCreateMenuAction`, `ResourceCreateButton` | +| `resource/row.tsx` (~450) | atoms | `ResourceOverflowMenuItem`, `ResourceOverflowMenu`, `ResourceActionButton`, `ResourceRow`, `ResourceRowDetailChevron`, `ResourceListPanel`, `ResourceListState` | +| `resource/detail-shell.tsx` (~400) | toolbar, atoms | `ResourceDetailSurface`, `ResourceDetailPanel`, `ResourcePromptContextItem`, `ResourcePromptPreview`, `ResourceDetailList`, `ResourceDetailCollection`, `ResourceDetailListItem`, `ResourceDetailActionRow`, `ResourcePropertyList`, `ResourceProperty`, `ResourceDetailStack`, `ResourcePromptEditor`, `ResourceSection`, `ResourceSectionTitle`, `ResourceOverview` | +| `resource/detail-sections.tsx` (~250) | detail-shell | `ResourceDetailSectionKind`, `ResourceDetailSectionProps`, `ResourceDetailSection`, `ResourceDetailOverviewSection`, `ResourceDefinitionSection`, `ResourceDetailConfigurationSection`, `ResourceDetailReleaseSection`, `ResourceDetailIncludesSection`, `ResourceActivitySection`, `ResourceDetailPage` | +| `resource/detail-controls.tsx` (~250) | detail-shell, atoms | `ResourceDetailFacts`, `ResourceDetailFact`, `ResourceLifecycleStatus`, `ResourceInstallControl`, `ResourceInstalledControl` | +| `resource/collection.tsx` (~550, raised) | detail, toolbar, atoms | `ResourceCollectionMode`, `ResourceCollectionPage`, `ResourceCollectionViewport`, `ResourceOverviewSection`, `ResourceBrowseGrid`, `ResourceBrowseSectionItem`, `ResourceBrowseSection`, `ResourceOverviewPage`, `ResourceSourceShelf`, `ResourceShelfAction`, `ResourceShelfSeeAllAction`, `ResourceSourceItem`, `ResourceBrowseCard`, `ResourceTemplateBrowseCard` | +| `resource-list.tsx` (kept, ~15) | all submodules | **Barrel only**: `export * from "./resource/atoms"` … `./collection`. Nothing else. | + +Count: 8 + 10 + 7 + 15 + 10 + 5 + 14 = **69**, matching the current surface exactly. + +> [!WARNING] +> These internals are **not** in the 69-export surface and must **stay private**, colocated with their sole consumers — never newly exported: `targetsResourceAction` (with `ResourceRow` in `row.tsx`), `ResourceMenuTrigger` and `ResourceOptionContent` (with the menus in `toolbar.tsx`), `ResourceBrowseCardProps` (with `ResourceBrowseCard` in `collection.tsx`). + +### Seam B — Skills Registry / Backend + +Frontend data client pulled out of the view; server route split into three layers with a one-way **route → proxy → parse** dependency. No behavior crosses the server/daemon boundary. + +| Target module | Owns | Notes | +| --- | --- | --- | +| `apps/app/src/lib/skills-registry.ts` (new) | `RegistrySkill*` types, `parseRegistrySkill(s)`, `fetchRegistrySkills`, `fetchRegistrySkillDetail`, `fetchRegistrySkillEntry`, `installRegistrySkill`, `normalizeSkillName`, `resolveInstalledRegistrySkill`, `formatRegistrySource`, `formatInstallCount`, and `REGISTRY_PAGE_SIZE` (= `RESOURCE_GRID_PAGE_SIZE`) | Pure data/format layer. **No React imports.** `SkillsView` re-exports every name from here. | +| `apps/app/src/lib/skills-filters.ts` (new) | `ResourceProviderFilter`, `ResourceSortMode`, `ResourceSortDirection`, `RESOURCE_PROVIDER_FILTERS`, `providerLabel`, `skillProviderFilterId`, `providerFilterLabel`, `compareNullableProvider`, `applySortDirection` | Pure `SkillSummary` sort/filter helpers, currently internal to `SkillsView`. **No React imports.** Consumed by the Skills tab UI. | +| `apps/server/src/routes/skills-registry.ts` (slimmed, ~120) | `registerSkillsRegistryRoutes` and its four handlers + request validation only | Calls into proxy. Route stays registered from `server.ts:457`. | +| `apps/server/src/services/skills/registry-proxy.ts` (new) | Fetch/hydrate: `registryFetch`, `fetchRegistryJson`, `fetchAuthenticatedRegistryDetail`, `fetchGithubSkillMarkdown`, `fetchPublicSkillMarkdown`, `fetchGithubSkillPaths`, `fetchRegistrySkillDetail`, `fetchPublicDirectorySkills`, `hydrateDetails`, `fetchGithubStars`, `hydrateGithubStars`, `mapWithConcurrency`, `filterSkillsWithLoadableDetails`, `listRegistrySkills`, `resolveRegistrySkillById` | Depends only on `registry-parse.ts`. Server-owned product policy stays server-side. | +| `apps/server/src/services/skills/registry-parse.ts` (new) | Pure parse/id/url: `isRecord`, `decodeHtml`, `stripTags`, `renderedSkillHtmlToMarkdown`, `extractFirstDivContentsAfter`, `parsePublicSkillMarkdown`, `parsePublicHomepageSkills`, `parsePublicDetail`, `parsePublicDetailSkill`, `isApiSkill`, `parseRegistryDetailFiles`, `hasLoadableSkillContent`, `githubRepoForSource`, `parseRegistrySkillId`, `packageRefForSource`, `registrySkillUrl`, `parsePageParameter`, `parsePerPageParameter` | No I/O; deterministic. Directly unit-testable. | +| `packages/sdk/src/areas/skills.ts` (untouched) | Existing SDK skills surface | **Frozen.** Independent of the registry route; listed only to fence WP4's boundary. | + +> [!IMPORTANT] +> `registry-skill-provenance.ts`, `registry-skill-install.ts`, and `injected-skills.ts` semantics are frozen. WP4 relocates helpers into `registry-parse.ts`/`registry-proxy.ts` but must not change the provenance file format, install flow, or listing output. + +### Seam C — Tools UI + +Slim both view files to routers; move rendering clusters to concern modules under `components/tools/`. Both view files keep their **exact** public surface as barrels. + +| Target module | Owns | Notes | +| --- | --- | --- | +| `apps/app/src/views/ToolsView.tsx` (slimmed) | `ToolsView` router, `ToolsScrollPage`, `PluginDetail` re-export, plus internal `ToolsSectionBody`, `PluginsToolView`, `PluginDetailToolView`, `AutomationsToolView` wiring | Keeps its **3 exports** exactly: `ToolsScrollPage`, `PluginDetail`, `ToolsView`. | +| `apps/app/src/components/tools/PluginCapabilities.tsx` (new) | `PluginCapabilityGroup`, `PluginIncludes`, `PluginActivity`, `PluginActivityState`, `capabilityDetail`, `namedSurface`, `pluginAppSurfaceItems` | Presentational plugin-detail internals (all currently private in `ToolsView`). | +| `apps/app/src/views/SkillsView.tsx` (slimmed barrel) | The router `SkillsView`, `SkillsLibrary`, `SkillsOverview`, and re-exports of the full 21-name surface | Data client → `lib/skills-registry.ts`; provider filters → `lib/skills-filters.ts`; browse UI → `SkillsBrowse.tsx`. | +| `apps/app/src/components/tools/SkillsBrowse.tsx` (new) | `RegistrySkillsBrowsePage`, `RegistrySkillDetailView`, and browse-specific internals (`RegistrySkillSocialProof`, `RegistrySkillSourceItem`, `SkillsShAttributionLink`) | Skills discovery UI, consuming the Seam B client. | + +Exhaustive frozen 21-name `SkillsView` surface (re-exported from the barrel; no rename, no dropped name): + +| # | Name | Kind | Defined in (after refactor) | +| ---: | --- | --- | --- | +| 1–5 | `RegistrySkill`, `RegistryPagination`, `RegistrySkillsPage`, `RegistrySkillFile`, `RegistrySkillDetail` | types | `lib/skills-registry.ts` | +| 6–9 | `fetchRegistrySkills`, `fetchRegistrySkillDetail`, `fetchRegistrySkillEntry`, `installRegistrySkill` | fns | `lib/skills-registry.ts` | +| 10–13 | `normalizeSkillName`, `resolveInstalledRegistrySkill`, `formatRegistrySource`, `formatInstallCount` | fns | `lib/skills-registry.ts` | +| 14 | `ProviderLogo` | component | `SkillsView.tsx` | +| 15 | `RegistrySkillsBrowsePage` | component | `components/tools/SkillsBrowse.tsx` | +| 16–17 | `SkillsOverviewProps`, `SkillsOverview` | type + component | `SkillsView.tsx` | +| 18–19 | `SkillDetailDialogViewProps`, `SkillDetailDialogView` | type + component | `SkillsView.tsx` | +| 20–21 | `SkillsLibrary`, `SkillsView` | components | `SkillsView.tsx` | + +## Ownership And Dependency Rules + +1. **Barrels are the only public entry.** Original paths (`resource-list.tsx`, `SkillsView.tsx`, `ToolsView.tsx`) keep their exact export surface — 69, 21, and 3 names respectively. Consumers never learn the new submodule paths. +2. **Dependency direction is one-way.** Seam A: atoms ← toolbar/row ← detail-shell ← detail-sections/controls ← collection. Seam B/C: shared-ui → app data client (`lib/skills-registry.ts`, `lib/skills-filters.ts`, no React) → Skills/Tools UI. Server layers: route → proxy → parse. Server/SDK are independent of app code. +3. **No new cross-package edges.** A submodule may only import from within its own package plus the same specifiers its origin file already used. +4. **Server owns product policy; daemon owns host primitives.** Seam B keeps that boundary exactly. +5. **One concern per module; private stays private.** The four internals named in Seam A are never exported. No new exported name appears outside a barrel. + +## File-Size And Complexity Guardrails + +Pragmatic, not dogmatic — single-concern modules matter more than a number, and caps are set to be **achievable** given the current cluster sizes. + +| Guardrail | Target | Enforcement | +| --- | --- | --- | +| Module length | Per-module caps in the Seam A/B tables (detail split three ways; collection raised to ~550) | Reviewer check; no lint rule added. | +| Concerns per module | 1 cluster from the target tables | Review against this spec. | +| Barrel files | Re-exports only, no logic | `resource-list.tsx`, `SkillsView.tsx`, `ToolsView.tsx` diffs show only re-exports + kept router. | +| New public API | **None** — 69 / 21 / 3 surfaces are exact | Any new exported name outside a barrel is a defect. | +| Cyclomatic growth | None — moves only, no rewrites | Diff is relocation + import fixups. | + +> [!WARNING] +> Do not "improve" moved code. Renaming a local, tightening a type, or reordering a prop is a behavior-risk and a merge-conflict source across worktrees. Move verbatim; land improvements in a separate follow-up PR. + +## Behavior-Preserving Migration Sequence + +Each step ends on a real green checkpoint. Steps within a work package are sequential; the four packages run in parallel worktrees. + +| Step | Action | Green checkpoint | +| --- | --- | --- | +| 1 | Seam A: create `resource/` submodules per the 69-export manifest, move verbatim, reduce `resource-list.tsx` to a barrel. | `turbo run typecheck --filter=@bb/shared-ui` **and** `--filter=@bb/app` (all consumers); `ToolsResourceSystem.stories.tsx` + `ToolsStateGalleries.stories.tsx` still register and render. | +| 2 | Seam B backend: extract `registry-parse.ts`, then `registry-proxy.ts` (proxy → parse), slim route to ~120 lines. | `turbo run typecheck --filter=@bb/server`; new `registry-parse.test.ts` passes; existing `skill-listing`/`registry-skill-install` service tests still pass. | +| 3 | Seam B frontend: create `lib/skills-registry.ts` + `lib/skills-filters.ts`, repoint `SkillsView`. | `turbo run typecheck --filter=@bb/app`; new `skills-registry.test.ts` passes; `SkillsView.test.tsx` passes. | +| 4 | Seam C Skills: extract `SkillsBrowse.tsx`; reduce `SkillsView.tsx` to router + 21-name barrel. | App typecheck; Skills stories + `SkillsView.test.tsx` pass. | +| 5 | Seam C Tools: extract `PluginCapabilities.tsx`; reduce `ToolsView.tsx` to router + 3-name surface. | App typecheck; `ToolsView.plugin-detail.test.tsx` + `ToolsResourceSystem`/`ToolsStateGalleries` stories pass. | +| 6 | Integration: full `@bb/shared-ui` + `@bb/app` + `@bb/server` + `@bb/sdk` typecheck; lint; story-registry check. | All typechecks + lint green; **empty wire diff** (gate below). | + +Wire-compatibility gate at step 6: `git diff` on `packages/host-daemon-contract/` and any session/WS/RPC payload type must be **empty**. If not, the change exceeded scope — stop and bump `HOST_DAEMON_PROTOCOL_VERSION` per repo policy, or revert the offending move. + +## Test And Story Migration + +Tests and stories move with the code they cover; assertions do not change. New tests are **required** where pure functions move into fresh modules — the existing service tests do **not** cover the registry route or the moved parsers. + +| Coverage | Where | Rule | +| --- | --- | --- | +| `ToolsResourceSystem.stories.tsx`, `ToolsStateGalleries.stories.tsx` | Stay in `apps/app`; import shipped components via the `@bb/shared-ui/resource-list` barrel | Story registry must still list every current story; this is Seam A's render gate (there is no separate shared-ui resource test/story to lean on). | +| `SkillsView.test.tsx` | Stays; imports unchanged (21-name barrel preserved) | Same cases, same expectations. | +| `ToolsView.plugin-detail.test.tsx` | Stays; `PluginDetail` still exported from `ToolsView` | No change. | +| **New** `apps/app/src/lib/skills-registry.test.ts` | New | Pure tests for moved client functions: `parseRegistrySkill(s)`, `resolveInstalledRegistrySkill`, `normalizeSkillName`, `formatRegistrySource`, `formatInstallCount`, and `REGISTRY_PAGE_SIZE` wiring. | +| **New** `apps/server/test/skills/registry-parse.test.ts` | New | Pure tests for moved parse/id/url/pagination: `parseRegistrySkillId`, `registrySkillUrl`, `parsePageParameter`, `parsePerPageParameter`, `parsePublicHomepageSkills`/`parsePublicDetail` on representative fixtures. | +| Optional route HTTP test | `apps/server/test/skills/` | If a handler's request validation is non-trivially moved, add a focused HTTP test for `GET /skills-registry` + `POST /install`; do **not** claim `skill-listing`/`registry-skill-install` service tests cover the route. | +| `skill-listing.test.ts`, `registry-skill-install.test.ts`, `injected-skills.test.ts` | Stay; re-point imports where a helper moved into `registry-parse.ts` | These cover the listing/install **services**, not the registry route. | + +## Parallel Work Packages + +Four packages with **disjoint file ownership**, each suitable for its own worktree. The only inter-package contract is the frozen public surface — no two packages edit the same file. + +| WP | Owns (exclusive files/dirs) | Depends on | Worktree-safe because | +| --- | --- | --- | --- | +| **WP1 — Shared collection infra** | `packages/shared-ui/src/components/ui/resource-list.tsx` + new `resource/` (7 submodules) | none | Only package's own file touched; public barrel path unchanged. | +| **WP2 — Skills registry client + Skills UI** | `apps/app/src/views/SkillsView.tsx`, new `lib/skills-registry.ts`, `lib/skills-filters.ts`, `components/tools/SkillsBrowse.tsx`, `SkillDetailView.tsx`, `SkillsView.test.tsx`, new `skills-registry.test.ts` | WP1 barrel (path stable) | Consumes WP1 via unchanged specifier; publishes the frozen 21-name surface for WP3. | +| **WP3 — Tools UI shell + plugins** | `apps/app/src/views/ToolsView.tsx`, `components/tools/PluginDetailView.tsx`, new `PluginCapabilities.tsx`, `ToolsView.plugin-detail.test.tsx`, Tools stories | WP1 barrel; WP2 21-name surface | Never edits `SkillsView.tsx`; imports only its frozen public exports. | +| **WP4 — Skills backend + SDK fence** | `apps/server/src/routes/skills-registry.ts`, new `services/skills/registry-proxy.ts`, `registry-parse.ts`, `apps/server/test/skills/*`, (`packages/sdk/src/areas/skills.ts` read-only) | none | No app or shared-ui files; server/daemon boundary untouched. | + +> [!NOTE] +> WP2 and WP3 both live in `apps/app` but own **disjoint files**. Resolved sequencing: **WP2 lands before WP3** (or at minimum publishes the frozen 21-name surface first) so WP3's import of `SkillsLibrary` never breaks. + +## Risks And Mitigations + +| Risk | Severity | Mitigation | +| --- | :---: | --- | +| A "verbatim" move silently changes behavior (reordered effects, dropped memo) | 🔴 | Move whole functions unchanged; diff is relocation + import fixups only; existing tests/stories gate each step. | +| Accidental wire-payload change while slimming the server route | 🔴 | Step-6 gate: empty diff on host-daemon-contract and payload types; protocol version stays 63. | +| WP2/WP3 collide on `apps/app` files | 🟠 | Disjoint file ownership + frozen 21-name surface; WP2 publishes surface first. | +| Barrel re-export misses a name, breaking a consumer | 🟠 | `export *` from every submodule; typecheck all consumers (`@bb/app`) at step 1; 69/21/3 counts asserted. | +| Circular import between new submodules | 🟡 | One-way layering (atoms ← toolbar/row ← detail ← collection; route → proxy → parse). | +| Moved parsers lose coverage | 🟡 | Required new `registry-parse.test.ts` and `skills-registry.test.ts`; no reliance on service tests for the route. | + +## Acceptance Criteria + +- `resource-list.tsx`, `SkillsView.tsx`, and `ToolsView.tsx` are each reduced to a router/barrel; concerns move into the target modules named above. +- Public surfaces are **exact and unchanged**: 69 names from `@bb/shared-ui/resource-list`, the 21-name `SkillsView` surface, and the 3-name `ToolsView` surface (`ToolsScrollPage`, `PluginDetail`, `ToolsView`) all resolve identically. +- Seam A layering holds: atoms ← toolbar/row ← detail-shell ← detail-sections/detail-controls ← collection; `ResourcePropertyList` is in the detail layer; `targetsResourceAction`, `ResourceMenuTrigger`, `ResourceOptionContent`, and `ResourceBrowseCardProps` remain private with their consumers. +- The Skills registry data client lives in `lib/skills-registry.ts` (incl. `REGISTRY_PAGE_SIZE`) and provider-filter helpers in `lib/skills-filters.ts`, both with **no React imports**; `SkillsView` re-exports the client names. +- The server route is split into `registry-parse.ts` and `registry-proxy.ts` (proxy → parse) with the route file at ~120 lines, still registered from `server.ts:457`; no product policy crosses the server/daemon boundary. +- New pure tests exist and pass: `registry-parse.test.ts` (parser/id/url/pagination) and `skills-registry.test.ts` (client parse/format); no claim that `skill-listing`/`registry-skill-install` tests cover the route. +- `toolsHub` gating, legacy Settings fallback, rendered UX, routes, SDK/CLI surfaces, a11y focus behavior, `RESOURCE_GRID_PAGE_SIZE`, and responsive breakpoints are unchanged; install/provenance semantics are byte-for-behavior identical. +- Wire compatibility holds: no diff to host-daemon-contract or any session/WS/RPC payload; `HOST_DAEMON_PROTOCOL_VERSION` stays 63. +- Full `turbo run typecheck` passes for `@bb/shared-ui`, `@bb/app`, `@bb/server`, and `@bb/sdk`; lint is clean; `ToolsResourceSystem`/`ToolsStateGalleries` stories and all listed tests pass. +- Each work package is independently green at its checkpoint and buildable in an isolated worktree. From 1b2563e14761bba5ca6e41cff7a7adca2e10a4aa Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 22 Jul 2026 04:32:35 -0700 Subject: [PATCH 02/37] refactor(app): split skills registry and browse UI --- .../app/src/components/tools/SkillsBrowse.tsx | 324 +++++++++ apps/app/src/lib/skills-filters.ts | 44 ++ apps/app/src/lib/skills-registry.test.ts | 104 +++ apps/app/src/lib/skills-registry.ts | 249 +++++++ apps/app/src/views/SkillsView.tsx | 633 ++---------------- 5 files changed, 765 insertions(+), 589 deletions(-) create mode 100644 apps/app/src/components/tools/SkillsBrowse.tsx create mode 100644 apps/app/src/lib/skills-filters.ts create mode 100644 apps/app/src/lib/skills-registry.test.ts create mode 100644 apps/app/src/lib/skills-registry.ts diff --git a/apps/app/src/components/tools/SkillsBrowse.tsx b/apps/app/src/components/tools/SkillsBrowse.tsx new file mode 100644 index 0000000000..6c8f4ee30c --- /dev/null +++ b/apps/app/src/components/tools/SkillsBrowse.tsx @@ -0,0 +1,324 @@ +import { useEffect, useState } from "react"; +import type { SkillSummary } from "@bb/server-contract"; +import { Button } from "@bb/shared-ui/button"; +import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; +import { ResourcePagination } from "@bb/shared-ui/resource-pagination"; +import { + ResourceBrowseCard, + ResourceBrowseGrid, + ResourceCardStat, + ResourceCollectionViewport, + ResourceListState, + ResourceOverflowMenu, + ResourceToolbar, +} from "@bb/shared-ui/resource-list"; +import { + formatInstallCount, + formatRegistrySource, + REGISTRY_PAGE_SIZE, +} from "@/lib/skills-registry"; +import type { + RegistryPagination, + RegistrySkill, + RegistrySkillDetail, +} from "@/lib/skills-registry"; +import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; +import { + SkillBrowseInstallControl, + SkillDetailView, +} from "@/components/tools/SkillDetailView"; + +const SKILLS_SH_URL = "https://www.skills.sh/"; + +function RegistrySkillSocialProof({ skill }: { skill: RegistrySkill }) { + const installs = formatInstallCount(skill.installs); + const stars = skill.stars !== null ? formatInstallCount(skill.stars) : null; + return ( + + + {installs} + + {stars !== null ? ( + + {stars} + + ) : null} + + ); +} + +function RegistrySkillSourceItem({ + skill, + installed, + canUninstall, + onInstall, + onUninstall, + onSelect, + pending, +}: { + skill: RegistrySkill; + installed: boolean; + canUninstall: boolean; + onInstall: (skill: RegistrySkill) => void; + onUninstall: (skill: RegistrySkill) => void; + onSelect: (skill: RegistrySkill) => void; + pending: boolean; +}) { + return ( + onSelect(skill)} + headerAction={ + onInstall(skill)} + onUninstall={canUninstall ? () => onUninstall(skill) : undefined} + presentation="icon" + /> + } + footerMeta={} + /> + ); +} + +function SkillsShAttributionLink() { + return ( + + powered by + skills.sh + + ); +} + +export function RegistrySkillsBrowsePage({ + skills, + pagination, + isLoading, + hasError, + query, + pendingSkillId, + onRetry, + onQueryChange, + onPageChange, + onInstall, + onUninstall, + onSelect, + isInstalled, + canUninstall = () => false, +}: { + skills: readonly RegistrySkill[]; + pagination: RegistryPagination; + isLoading: boolean; + hasError: boolean; + query: string; + pendingSkillId: string | null; + onRetry?: () => void; + onQueryChange: (query: string) => void; + onPageChange: (page: number) => void; + onInstall: (skill: RegistrySkill) => void; + onUninstall: (skill: RegistrySkill) => void; + onSelect: (skill: RegistrySkill) => void; + isInstalled: (skill: RegistrySkill) => boolean; + canUninstall?: (skill: RegistrySkill) => boolean; +}) { + const footer = ( +
+ +
+ +
+
+ ); + return ( + + } + footer={footer} + contentClassName="space-y-4" + > + {hasError ? ( + +
+ Couldn't load skills.sh. + {onRetry ? ( + + ) : null} +
+
+ ) : isLoading ? ( + + ) : skills.length === 0 ? ( + + {query.trim().length === 0 + ? "No skills.sh resources available." + : `No skills.sh resources match "${query}"`} + + ) : ( + + {skills.map((skill) => ( + + ))} + + )} +
+ ); +} + +export function RegistrySkillDetailView({ + skill, + detail, + isLoadingDetail, + isDetailError, + installed, + installedSkill, + installedPath, + pending, + uninstallPending, + onRetry, + onInstall, + onUninstall, + onEditInstalledSkill, +}: { + skill: RegistrySkill; + detail: RegistrySkillDetail | null; + isLoadingDetail: boolean; + isDetailError: boolean; + installed: boolean; + installedSkill: SkillSummary | null; + installedPath: string | null; + pending: boolean; + uninstallPending: boolean; + onRetry: () => void; + onInstall: (skill: RegistrySkill) => void; + onUninstall?: (skill: RegistrySkill) => void; + onEditInstalledSkill: (skill: SkillSummary) => void; +}) { + const [selectedPath, setSelectedPath] = useState("SKILL.md"); + useEffect(() => setSelectedPath("SKILL.md"), [skill.id]); + const { canOpenPreferredFileTarget, openPathInPreferredFileTarget } = + useLocalOpenTargets({ enabled: installed && installedPath !== null }); + const files = detail?.files ?? []; + const selectedFile = + files.find((file) => file.path === selectedPath) ?? files[0] ?? null; + const path = installedPath ?? `skills.sh/${skill.source}/${skill.skillId}`; + return ( + onInstall(skill), + onUninstall: onUninstall ? () => onUninstall(skill) : undefined, + }} + overflowMenu={ + installedSkill !== null && installedPath !== null ? ( + { + if (installedSkill) onEditInstalledSkill(installedSkill); + }, + }, + { + label: "Open source", + icon: "ExternalLink", + disabled: installedPath === null || !canOpenPreferredFileTarget, + disabledReason: + installedPath === null + ? "Finishing installation" + : !canOpenPreferredFileTarget + ? "No editor configured" + : undefined, + onSelect: () => { + if (installedPath === null) return; + void openPathInPreferredFileTarget({ + path: installedPath, + lineNumber: null, + }); + }, + }, + ]} + /> + ) : undefined + } + files={files.map((file) => file.path)} + selectedPath={selectedFile?.path ?? selectedPath} + onSelectFile={setSelectedPath} + contentState={ + isDetailError || (!isLoadingDetail && detail === null) + ? { + kind: "error", + message: "The source SKILL.md preview is unavailable.", + onRetry, + } + : isLoadingDetail + ? { kind: "loading" } + : selectedFile + ? { kind: "ready", content: selectedFile.contents } + : { + kind: "error", + message: "The source does not include SKILL.md content.", + onRetry, + } + } + /> + ); +} diff --git a/apps/app/src/lib/skills-filters.ts b/apps/app/src/lib/skills-filters.ts new file mode 100644 index 0000000000..db2b0e76dd --- /dev/null +++ b/apps/app/src/lib/skills-filters.ts @@ -0,0 +1,44 @@ +import type { SkillProvider, SkillSummary } from "@bb/server-contract"; +import { getProviderIconInfo } from "@/lib/provider-icon"; + +export function providerLabel(providerId: SkillProvider | null): string { + if (providerId === null) { + return "bb"; + } + return getProviderIconInfo(providerId)?.ariaLabel ?? providerId; +} + +export type ResourceProviderFilter = "bb" | SkillProvider; +export type ResourceSortMode = "provider" | "alpha"; +export type ResourceSortDirection = "asc" | "desc"; + +export const RESOURCE_PROVIDER_FILTERS: readonly ResourceProviderFilter[] = [ + "bb", + "claude-code", + "codex", +]; + +export function skillProviderFilterId( + skill: SkillSummary, +): ResourceProviderFilter { + return skill.provider ?? "bb"; +} + +export function providerFilterLabel(provider: ResourceProviderFilter): string { + if (provider === "bb") return "bb"; + return providerLabel(provider); +} + +export function compareNullableProvider( + left: SkillProvider | null, + right: SkillProvider | null, +): number { + return providerLabel(left).localeCompare(providerLabel(right)); +} + +export function applySortDirection( + result: number, + direction: ResourceSortDirection, +): number { + return direction === "asc" ? result : -result; +} diff --git a/apps/app/src/lib/skills-registry.test.ts b/apps/app/src/lib/skills-registry.test.ts new file mode 100644 index 0000000000..d24b4039ca --- /dev/null +++ b/apps/app/src/lib/skills-registry.test.ts @@ -0,0 +1,104 @@ +import type { SkillSummary } from "@bb/server-contract"; +import { RESOURCE_GRID_PAGE_SIZE } from "@bb/shared-ui/resource-pagination"; +import { describe, expect, it } from "vitest"; +import { + formatInstallCount, + formatRegistrySource, + normalizeSkillName, + parseRegistrySkill, + parseRegistrySkills, + REGISTRY_PAGE_SIZE, + resolveInstalledRegistrySkill, +} from "./skills-registry"; +import type { RegistrySkill } from "./skills-registry"; + +const registrySkill: RegistrySkill = { + id: "owner/repo/useful-skill", + source: "owner/repo", + skillId: "useful-skill", + name: "Useful skill", + installs: 1_234, + stars: 56, + installUrl: null, + url: "https://skills.sh/owner/repo/useful-skill", + topic: "Development", + summary: "A useful skill.", +}; + +function installedSkill(overrides: Partial = {}): SkillSummary { + return { + id: `skill_${"a".repeat(64)}`, + name: "useful-skill", + description: "A useful skill.", + provider: null, + scope: "bb-user", + pluginId: null, + filePath: "/home/u/.bb/skills/useful-skill/SKILL.md", + manageable: true, + registrySkillId: registrySkill.id, + ...overrides, + }; +} + +describe("registry skill parsing", () => { + it("parses a complete registry skill and normalizes an omitted stars value", () => { + expect(parseRegistrySkill(registrySkill)).toEqual(registrySkill); + expect(parseRegistrySkill({ ...registrySkill, stars: undefined })).toEqual({ + ...registrySkill, + stars: null, + }); + }); + + it("rejects malformed entries and filters them from registry pages", () => { + const malformed = { ...registrySkill, installs: "1234" }; + + expect(parseRegistrySkill(malformed)).toBeNull(); + expect(parseRegistrySkill(null)).toBeNull(); + expect(parseRegistrySkills({ skills: [malformed, registrySkill] })).toEqual( + [registrySkill], + ); + expect(parseRegistrySkills({ skills: "not-an-array" })).toEqual([]); + }); +}); + +describe("registry skill matching", () => { + it("matches only manageable bb-user skills with exact registry provenance", () => { + const exactMatch = installedSkill(); + const candidates = [ + installedSkill({ + id: `skill_${"b".repeat(64)}`, + registrySkillId: "other/repo/useful-skill", + }), + exactMatch, + ]; + + expect(resolveInstalledRegistrySkill(registrySkill, candidates)).toBe( + exactMatch, + ); + expect( + resolveInstalledRegistrySkill(registrySkill, [ + installedSkill({ manageable: false }), + installedSkill({ scope: "claude-user" }), + installedSkill({ provider: "codex" }), + ]), + ).toBeNull(); + }); +}); + +describe("registry skill formatting", () => { + it("normalizes names using the existing registry slug behavior", () => { + expect(normalizeSkillName(" Ship & Review_IT ")).toBe("ship-review-it"); + }); + + it("formats sources and compact install counts at the existing thresholds", () => { + expect(formatRegistrySource("github.com/owner/repo")).toBe("owner/repo"); + expect(formatRegistrySource("owner/repo")).toBe("owner/repo"); + expect(formatInstallCount(999)).toBe("999"); + expect(formatInstallCount(1_000)).toBe("1.0K"); + expect(formatInstallCount(1_250_000)).toBe("1.3M"); + }); + + it("uses the shared resource grid page size", () => { + expect(REGISTRY_PAGE_SIZE).toBe(RESOURCE_GRID_PAGE_SIZE); + }); +}); diff --git a/apps/app/src/lib/skills-registry.ts b/apps/app/src/lib/skills-registry.ts new file mode 100644 index 0000000000..e5e7960a05 --- /dev/null +++ b/apps/app/src/lib/skills-registry.ts @@ -0,0 +1,249 @@ +import type { SkillSummary } from "@bb/server-contract"; +import { RESOURCE_GRID_PAGE_SIZE } from "@bb/shared-ui/resource-pagination"; + +export interface RegistrySkill { + id: string; + source: string; + skillId: string; + name: string; + installs: number; + stars: number | null; + installUrl: string | null; + url: string; + topic: string | null; + summary: string | null; +} + +export interface RegistryPagination { + page: number; + perPage: number; + total: number; + hasMore: boolean; +} + +export interface RegistrySkillsPage { + skills: RegistrySkill[]; + pagination: RegistryPagination; +} + +export interface RegistrySkillFile { + path: string; + contents: string; +} + +export interface RegistrySkillDetail { + id: string; + source: string; + skillId: string; + hash: string | null; + files: RegistrySkillFile[] | null; +} + +export const REGISTRY_PAGE_SIZE = RESOURCE_GRID_PAGE_SIZE; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseRegistrySkill(value: unknown): RegistrySkill | null { + if (!isRecord(value)) return null; + const { + id, + source, + skillId, + name, + installs, + stars, + installUrl, + url, + topic, + summary, + } = value; + if ( + typeof id !== "string" || + typeof source !== "string" || + typeof skillId !== "string" || + typeof name !== "string" || + typeof installs !== "number" || + (stars !== undefined && stars !== null && typeof stars !== "number") || + (installUrl !== null && typeof installUrl !== "string") || + typeof url !== "string" || + (topic !== null && typeof topic !== "string") || + (summary !== null && typeof summary !== "string") + ) { + return null; + } + return { + id, + source, + skillId, + name, + installs, + stars: typeof stars === "number" ? stars : null, + installUrl, + url, + topic, + summary, + }; +} + +export function parseRegistrySkills(value: unknown): RegistrySkill[] { + if (!isRecord(value) || !Array.isArray(value.skills)) return []; + const parsed: RegistrySkill[] = []; + for (const skill of value.skills) { + const parsedSkill = parseRegistrySkill(skill); + if (parsedSkill !== null) parsed.push(parsedSkill); + } + return parsed; +} + +export async function fetchRegistrySkills(args: { + query: string; + page: number; + perPage?: number; +}): Promise { + const params = new URLSearchParams(); + if (args.query.trim().length > 0) params.set("q", args.query.trim()); + params.set("page", String(args.page)); + params.set("perPage", String(args.perPage ?? REGISTRY_PAGE_SIZE)); + const response = await fetch(`/api/v1/skills-registry?${params.toString()}`); + if (!response.ok) throw new Error("Failed to load skills registry"); + const body = await response.json(); + if (!isRecord(body) || !isRecord(body.pagination)) { + throw new Error("Invalid skills registry response"); + } + const { page, perPage, total, hasMore } = body.pagination; + if ( + typeof page !== "number" || + !Number.isInteger(page) || + page < 0 || + typeof perPage !== "number" || + !Number.isInteger(perPage) || + perPage < 1 || + typeof total !== "number" || + !Number.isInteger(total) || + total < 0 || + typeof hasMore !== "boolean" + ) { + throw new Error("Invalid skills registry pagination"); + } + return { + skills: parseRegistrySkills(body), + pagination: { page, perPage, total, hasMore }, + }; +} + +export async function fetchRegistrySkillDetail(args: { + source: string; + skillId: string; +}): Promise { + const params = new URLSearchParams({ + source: args.source, + skillId: args.skillId, + }); + const response = await fetch( + `/api/v1/skills-registry/detail?${params.toString()}`, + ); + if (!response.ok) throw new Error("Failed to load skill files"); + const body: unknown = await response.json(); + if ( + !isRecord(body) || + typeof body.id !== "string" || + typeof body.source !== "string" || + typeof body.skillId !== "string" || + (body.hash !== null && typeof body.hash !== "string") || + (body.files !== null && !Array.isArray(body.files)) + ) { + throw new Error("Invalid skill detail response"); + } + const files: RegistrySkillFile[] | null = + body.files === null + ? null + : body.files.map((file) => { + if ( + !isRecord(file) || + typeof file.path !== "string" || + typeof file.contents !== "string" + ) { + throw new Error("Invalid skill detail file"); + } + return { path: file.path, contents: file.contents }; + }); + return { + id: body.id, + source: body.source, + skillId: body.skillId, + hash: body.hash, + files, + }; +} + +export async function fetchRegistrySkillEntry( + id: string, +): Promise { + const params = new URLSearchParams({ id }); + const response = await fetch( + `/api/v1/skills-registry/entry?${params.toString()}`, + ); + if (!response.ok) throw new Error("Failed to load registry skill"); + const skill = parseRegistrySkill(await response.json()); + if (skill === null) throw new Error("Invalid registry skill response"); + return skill; +} + +export async function installRegistrySkill(args: { skill: RegistrySkill }) { + const response = await fetch("/api/v1/skills-registry/install", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + registrySkillId: args.skill.id, + }), + }); + const body = (await response.json().catch(() => null)) as { + ok?: unknown; + message?: unknown; + filePath?: unknown; + } | null; + if (!response.ok || body?.ok !== true || typeof body.filePath !== "string") { + throw new Error( + typeof body?.message === "string" ? body.message : "Skill install failed", + ); + } + return { ok: true as const, filePath: body.filePath }; +} + +export function normalizeSkillName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-"); +} + +export function resolveInstalledRegistrySkill( + registrySkill: RegistrySkill, + installedSkills: readonly SkillSummary[], +): SkillSummary | null { + return ( + installedSkills.find((installedSkill) => { + return ( + installedSkill.scope === "bb-user" && + installedSkill.provider === null && + installedSkill.manageable && + installedSkill.registrySkillId === registrySkill.id + ); + }) ?? null + ); +} + +export function formatRegistrySource(source: string): string { + const githubPrefix = "github.com/"; + return source.startsWith(githubPrefix) + ? source.slice(githubPrefix.length) + : source; +} + +export function formatInstallCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +} diff --git a/apps/app/src/views/SkillsView.tsx b/apps/app/src/views/SkillsView.tsx index 47410eaa62..f4f830f8b0 100644 --- a/apps/app/src/views/SkillsView.tsx +++ b/apps/app/src/views/SkillsView.tsx @@ -9,28 +9,23 @@ import type { SkillProvider, SkillSummary, } from "@bb/server-contract"; -import { Button } from "@bb/shared-ui/button"; -import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { - RESOURCE_GRID_PAGE_SIZE, ResourcePagination, useResourcePagination, useResourceViewportPageSize, } from "@bb/shared-ui/resource-pagination"; import { appToast } from "@/components/ui/app-toast"; +import { SkillDetailView } from "@/components/tools/SkillDetailView"; import { - SkillBrowseInstallControl, - SkillDetailView, -} from "@/components/tools/SkillDetailView"; + RegistrySkillDetailView, + RegistrySkillsBrowsePage, +} from "@/components/tools/SkillsBrowse"; import { ProvenancePill } from "@/components/tools/ProvenancePill"; import { isSkillEditable, SKILL_SCOPE_LABELS, } from "@/components/tools/skill-taxonomy"; import { - ResourceBrowseCard, - ResourceBrowseGrid, - ResourceCardStat, ResourceCollectionPage, ResourceCollectionViewport, ResourceListPanel, @@ -54,6 +49,28 @@ import { getProviderIconColorClass, getProviderIconInfo, } from "@/lib/provider-icon"; +import { + applySortDirection, + compareNullableProvider, + providerFilterLabel, + providerLabel, + RESOURCE_PROVIDER_FILTERS, + skillProviderFilterId, +} from "@/lib/skills-filters"; +import type { + ResourceProviderFilter, + ResourceSortDirection, + ResourceSortMode, +} from "@/lib/skills-filters"; +import { + fetchRegistrySkillDetail, + fetchRegistrySkillEntry, + fetchRegistrySkills, + installRegistrySkill, + REGISTRY_PAGE_SIZE, + resolveInstalledRegistrySkill, +} from "@/lib/skills-registry"; +import type { RegistryPagination, RegistrySkill } from "@/lib/skills-registry"; import { getRegistrySkillDetailRoutePath, getRegistrySkillsRoutePath, @@ -70,301 +87,32 @@ import { } from "@/hooks/queries/skills-queries"; import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; -export interface RegistrySkill { - id: string; - source: string; - skillId: string; - name: string; - installs: number; - stars: number | null; - installUrl: string | null; - url: string; - topic: string | null; - summary: string | null; -} - -export interface RegistryPagination { - page: number; - perPage: number; - total: number; - hasMore: boolean; -} - -export interface RegistrySkillsPage { - skills: RegistrySkill[]; - pagination: RegistryPagination; -} - -export interface RegistrySkillFile { - path: string; - contents: string; -} - -export interface RegistrySkillDetail { - id: string; - source: string; - skillId: string; - hash: string | null; - files: RegistrySkillFile[] | null; -} +export type { + RegistryPagination, + RegistrySkill, + RegistrySkillDetail, + RegistrySkillFile, + RegistrySkillsPage, +} from "@/lib/skills-registry"; +export { + fetchRegistrySkillDetail, + fetchRegistrySkillEntry, + fetchRegistrySkills, + formatInstallCount, + formatRegistrySource, + installRegistrySkill, + normalizeSkillName, + resolveInstalledRegistrySkill, +} from "@/lib/skills-registry"; +export { RegistrySkillsBrowsePage } from "@/components/tools/SkillsBrowse"; const EMPTY_SKILLS: readonly SkillSummary[] = []; -const REGISTRY_PAGE_SIZE = RESOURCE_GRID_PAGE_SIZE; const EMPTY_REGISTRY_PAGINATION: RegistryPagination = { page: 0, perPage: REGISTRY_PAGE_SIZE, total: 0, hasMore: false, }; -const SKILLS_SH_URL = "https://www.skills.sh/"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function parseRegistrySkill(value: unknown): RegistrySkill | null { - if (!isRecord(value)) return null; - const { - id, - source, - skillId, - name, - installs, - stars, - installUrl, - url, - topic, - summary, - } = value; - if ( - typeof id !== "string" || - typeof source !== "string" || - typeof skillId !== "string" || - typeof name !== "string" || - typeof installs !== "number" || - (stars !== undefined && stars !== null && typeof stars !== "number") || - (installUrl !== null && typeof installUrl !== "string") || - typeof url !== "string" || - (topic !== null && typeof topic !== "string") || - (summary !== null && typeof summary !== "string") - ) { - return null; - } - return { - id, - source, - skillId, - name, - installs, - stars: typeof stars === "number" ? stars : null, - installUrl, - url, - topic, - summary, - }; -} - -function parseRegistrySkills(value: unknown): RegistrySkill[] { - if (!isRecord(value) || !Array.isArray(value.skills)) return []; - const parsed: RegistrySkill[] = []; - for (const skill of value.skills) { - const parsedSkill = parseRegistrySkill(skill); - if (parsedSkill !== null) parsed.push(parsedSkill); - } - return parsed; -} - -export async function fetchRegistrySkills(args: { - query: string; - page: number; - perPage?: number; -}): Promise { - const params = new URLSearchParams(); - if (args.query.trim().length > 0) params.set("q", args.query.trim()); - params.set("page", String(args.page)); - params.set("perPage", String(args.perPage ?? REGISTRY_PAGE_SIZE)); - const response = await fetch(`/api/v1/skills-registry?${params.toString()}`); - if (!response.ok) throw new Error("Failed to load skills registry"); - const body = await response.json(); - if (!isRecord(body) || !isRecord(body.pagination)) { - throw new Error("Invalid skills registry response"); - } - const { page, perPage, total, hasMore } = body.pagination; - if ( - typeof page !== "number" || - !Number.isInteger(page) || - page < 0 || - typeof perPage !== "number" || - !Number.isInteger(perPage) || - perPage < 1 || - typeof total !== "number" || - !Number.isInteger(total) || - total < 0 || - typeof hasMore !== "boolean" - ) { - throw new Error("Invalid skills registry pagination"); - } - return { - skills: parseRegistrySkills(body), - pagination: { page, perPage, total, hasMore }, - }; -} - -export async function fetchRegistrySkillDetail(args: { - source: string; - skillId: string; -}): Promise { - const params = new URLSearchParams({ - source: args.source, - skillId: args.skillId, - }); - const response = await fetch( - `/api/v1/skills-registry/detail?${params.toString()}`, - ); - if (!response.ok) throw new Error("Failed to load skill files"); - const body: unknown = await response.json(); - if ( - !isRecord(body) || - typeof body.id !== "string" || - typeof body.source !== "string" || - typeof body.skillId !== "string" || - (body.hash !== null && typeof body.hash !== "string") || - (body.files !== null && !Array.isArray(body.files)) - ) { - throw new Error("Invalid skill detail response"); - } - const files: RegistrySkillFile[] | null = - body.files === null - ? null - : body.files.map((file) => { - if ( - !isRecord(file) || - typeof file.path !== "string" || - typeof file.contents !== "string" - ) { - throw new Error("Invalid skill detail file"); - } - return { path: file.path, contents: file.contents }; - }); - return { - id: body.id, - source: body.source, - skillId: body.skillId, - hash: body.hash, - files, - }; -} - -export async function fetchRegistrySkillEntry( - id: string, -): Promise { - const params = new URLSearchParams({ id }); - const response = await fetch( - `/api/v1/skills-registry/entry?${params.toString()}`, - ); - if (!response.ok) throw new Error("Failed to load registry skill"); - const skill = parseRegistrySkill(await response.json()); - if (skill === null) throw new Error("Invalid registry skill response"); - return skill; -} - -export async function installRegistrySkill(args: { skill: RegistrySkill }) { - const response = await fetch("/api/v1/skills-registry/install", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - registrySkillId: args.skill.id, - }), - }); - const body = (await response.json().catch(() => null)) as { - ok?: unknown; - message?: unknown; - filePath?: unknown; - } | null; - if (!response.ok || body?.ok !== true || typeof body.filePath !== "string") { - throw new Error( - typeof body?.message === "string" ? body.message : "Skill install failed", - ); - } - return { ok: true as const, filePath: body.filePath }; -} - -export function normalizeSkillName(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/gu, "-"); -} - -export function resolveInstalledRegistrySkill( - registrySkill: RegistrySkill, - installedSkills: readonly SkillSummary[], -): SkillSummary | null { - return ( - installedSkills.find((installedSkill) => { - return ( - installedSkill.scope === "bb-user" && - installedSkill.provider === null && - installedSkill.manageable && - installedSkill.registrySkillId === registrySkill.id - ); - }) ?? null - ); -} - -export function formatRegistrySource(source: string): string { - const githubPrefix = "github.com/"; - return source.startsWith(githubPrefix) - ? source.slice(githubPrefix.length) - : source; -} - -export function formatInstallCount(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; - return String(count); -} - -function providerLabel(providerId: SkillProvider | null): string { - if (providerId === null) { - return "bb"; - } - return getProviderIconInfo(providerId)?.ariaLabel ?? providerId; -} - -type ResourceProviderFilter = "bb" | SkillProvider; -type ResourceSortMode = "provider" | "alpha"; -type ResourceSortDirection = "asc" | "desc"; - -const RESOURCE_PROVIDER_FILTERS: readonly ResourceProviderFilter[] = [ - "bb", - "claude-code", - "codex", -]; - -function skillProviderFilterId(skill: SkillSummary): ResourceProviderFilter { - return skill.provider ?? "bb"; -} - -function providerFilterLabel(provider: ResourceProviderFilter): string { - if (provider === "bb") return "bb"; - return providerLabel(provider); -} - -function compareNullableProvider( - left: SkillProvider | null, - right: SkillProvider | null, -): number { - return providerLabel(left).localeCompare(providerLabel(right)); -} - -function applySortDirection( - result: number, - direction: ResourceSortDirection, -): number { - return direction === "asc" ? result : -result; -} - export function ProviderLogo({ providerId, className, @@ -460,186 +208,6 @@ function SkillRow({ /> ); } -function RegistrySkillSocialProof({ skill }: { skill: RegistrySkill }) { - const installs = formatInstallCount(skill.installs); - const stars = skill.stars !== null ? formatInstallCount(skill.stars) : null; - return ( - - - {installs} - - {stars !== null ? ( - - {stars} - - ) : null} - - ); -} - -function RegistrySkillSourceItem({ - skill, - installed, - canUninstall, - onInstall, - onUninstall, - onSelect, - pending, -}: { - skill: RegistrySkill; - installed: boolean; - canUninstall: boolean; - onInstall: (skill: RegistrySkill) => void; - onUninstall: (skill: RegistrySkill) => void; - onSelect: (skill: RegistrySkill) => void; - pending: boolean; -}) { - return ( - onSelect(skill)} - headerAction={ - onInstall(skill)} - onUninstall={canUninstall ? () => onUninstall(skill) : undefined} - presentation="icon" - /> - } - footerMeta={} - /> - ); -} - -function SkillsShAttributionLink() { - return ( - - powered by - skills.sh - - ); -} - -export function RegistrySkillsBrowsePage({ - skills, - pagination, - isLoading, - hasError, - query, - pendingSkillId, - onRetry, - onQueryChange, - onPageChange, - onInstall, - onUninstall, - onSelect, - isInstalled, - canUninstall = () => false, -}: { - skills: readonly RegistrySkill[]; - pagination: RegistryPagination; - isLoading: boolean; - hasError: boolean; - query: string; - pendingSkillId: string | null; - onRetry?: () => void; - onQueryChange: (query: string) => void; - onPageChange: (page: number) => void; - onInstall: (skill: RegistrySkill) => void; - onUninstall: (skill: RegistrySkill) => void; - onSelect: (skill: RegistrySkill) => void; - isInstalled: (skill: RegistrySkill) => boolean; - canUninstall?: (skill: RegistrySkill) => boolean; -}) { - const footer = ( -
- -
- -
-
- ); - return ( - - } - footer={footer} - contentClassName="space-y-4" - > - {hasError ? ( - -
- Couldn't load skills.sh. - {onRetry ? ( - - ) : null} -
-
- ) : isLoading ? ( - - ) : skills.length === 0 ? ( - - {query.trim().length === 0 - ? "No skills.sh resources available." - : `No skills.sh resources match "${query}"`} - - ) : ( - - {skills.map((skill) => ( - - ))} - - )} -
- ); -} - export interface SkillsOverviewProps { skills: readonly SkillSummary[]; isLoading: boolean; @@ -876,119 +444,6 @@ export function SkillsOverview({ ); } -function RegistrySkillDetailView({ - skill, - detail, - isLoadingDetail, - isDetailError, - installed, - installedSkill, - installedPath, - pending, - uninstallPending, - onRetry, - onInstall, - onUninstall, - onEditInstalledSkill, -}: { - skill: RegistrySkill; - detail: RegistrySkillDetail | null; - isLoadingDetail: boolean; - isDetailError: boolean; - installed: boolean; - installedSkill: SkillSummary | null; - installedPath: string | null; - pending: boolean; - uninstallPending: boolean; - onRetry: () => void; - onInstall: (skill: RegistrySkill) => void; - onUninstall?: (skill: RegistrySkill) => void; - onEditInstalledSkill: (skill: SkillSummary) => void; -}) { - const [selectedPath, setSelectedPath] = useState("SKILL.md"); - useEffect(() => setSelectedPath("SKILL.md"), [skill.id]); - const { canOpenPreferredFileTarget, openPathInPreferredFileTarget } = - useLocalOpenTargets({ enabled: installed && installedPath !== null }); - const files = detail?.files ?? []; - const selectedFile = - files.find((file) => file.path === selectedPath) ?? files[0] ?? null; - const path = installedPath ?? `skills.sh/${skill.source}/${skill.skillId}`; - return ( - onInstall(skill), - onUninstall: onUninstall ? () => onUninstall(skill) : undefined, - }} - overflowMenu={ - installedSkill !== null && installedPath !== null ? ( - { - if (installedSkill) onEditInstalledSkill(installedSkill); - }, - }, - { - label: "Open source", - icon: "ExternalLink", - disabled: installedPath === null || !canOpenPreferredFileTarget, - disabledReason: - installedPath === null - ? "Finishing installation" - : !canOpenPreferredFileTarget - ? "No editor configured" - : undefined, - onSelect: () => { - if (installedPath === null) return; - void openPathInPreferredFileTarget({ - path: installedPath, - lineNumber: null, - }); - }, - }, - ]} - /> - ) : undefined - } - files={files.map((file) => file.path)} - selectedPath={selectedFile?.path ?? selectedPath} - onSelectFile={setSelectedPath} - contentState={ - isDetailError || (!isLoadingDetail && detail === null) - ? { - kind: "error", - message: "The source SKILL.md preview is unavailable.", - onRetry, - } - : isLoadingDetail - ? { kind: "loading" } - : selectedFile - ? { kind: "ready", content: selectedFile.contents } - : { - kind: "error", - message: "The source does not include SKILL.md content.", - onRetry, - } - } - /> - ); -} - export interface SkillDetailDialogViewProps { skill: SkillSummary | null; files: readonly string[]; From 568ed005a4e913b3921bed43c8f6010c518507aa Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 22 Jul 2026 04:34:08 -0700 Subject: [PATCH 03/37] refactor(shared-ui): split resource list modules --- .../src/components/ui/resource-list.tsx | 2238 +---------------- .../src/components/ui/resource/atoms.tsx | 152 ++ .../src/components/ui/resource/collection.tsx | 552 ++++ .../ui/resource/detail-controls.tsx | 272 ++ .../ui/resource/detail-sections.tsx | 145 ++ .../components/ui/resource/detail-shell.tsx | 347 +++ .../src/components/ui/resource/row.tsx | 403 +++ .../src/components/ui/resource/toolbar.tsx | 404 +++ 8 files changed, 2282 insertions(+), 2231 deletions(-) create mode 100644 packages/shared-ui/src/components/ui/resource/atoms.tsx create mode 100644 packages/shared-ui/src/components/ui/resource/collection.tsx create mode 100644 packages/shared-ui/src/components/ui/resource/detail-controls.tsx create mode 100644 packages/shared-ui/src/components/ui/resource/detail-sections.tsx create mode 100644 packages/shared-ui/src/components/ui/resource/detail-shell.tsx create mode 100644 packages/shared-ui/src/components/ui/resource/row.tsx create mode 100644 packages/shared-ui/src/components/ui/resource/toolbar.tsx diff --git a/packages/shared-ui/src/components/ui/resource-list.tsx b/packages/shared-ui/src/components/ui/resource-list.tsx index 86a1a25b24..b5f0916d89 100644 --- a/packages/shared-ui/src/components/ui/resource-list.tsx +++ b/packages/shared-ui/src/components/ui/resource-list.tsx @@ -1,2231 +1,7 @@ -import { - useEffect, - type ComponentProps, - type ReactNode, - type Ref, -} from "react"; -import { Button, type ButtonProps } from "./button"; -import { EmptyStatePanel } from "./empty-state"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "./dropdown-menu"; -import { Icon, type IconName } from "./icon"; -import { Input } from "./input"; -import { Skeleton } from "./skeleton"; -import { ScrollArea } from "./scroll-area"; -import { Textarea } from "./textarea"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./tooltip"; -import { cn } from "../../lib/utils"; - -export type ResourceStatusTone = "success" | "warning" | "error" | "muted"; - -export const RESOURCE_ROUTE_LABEL_EVENT = "bb:resource-route-label"; - -/** - * Supplies the loaded resource name to the host shell without coupling the - * shell to a particular resource API. The DOM event also crosses the frontend - * plugin boundary, where React context is not shared with the host bundle. - */ -export function useResourceRouteLabel(label: string | null | undefined) { - useEffect(() => { - if (!label || typeof window === "undefined") return; - - let active = true; - queueMicrotask(() => { - if (!active) return; - window.dispatchEvent( - new CustomEvent(RESOURCE_ROUTE_LABEL_EVENT, { detail: { label } }), - ); - }); - - return () => { - active = false; - window.dispatchEvent( - new CustomEvent(RESOURCE_ROUTE_LABEL_EVENT, { - detail: { label: null }, - }), - ); - }; - }, [label]); -} - -function targetsResourceAction(target: EventTarget): boolean { - return ( - target instanceof Element && - target.closest("a, button, [data-row-action]") !== null - ); -} - -export function ResourceState({ - tone, - showLabel = true, - showIndicator = true, - tooltip, - accessibleLabel, - children, -}: { - tone: ResourceStatusTone; - showLabel?: boolean; - showIndicator?: boolean; - tooltip?: ReactNode; - accessibleLabel?: string; - children: ReactNode; -}) { - const status = ( - - {showIndicator ? ( - - ) : null} - {showLabel ? {children} : null} - - ); - if (tooltip === undefined || tooltip === null) return status; - return ( - - - {status} - {tooltip} - - - ); -} - -export const ResourceStatus = ResourceState; - -export function ResourceMeta({ - items, -}: { - items: readonly (ReactNode | null | undefined | false)[]; -}) { - const visibleItems = items.filter(Boolean); - return ( - - {visibleItems.map((item, index) => ( - - {index > 0 ? ( - - · - - ) : null} - {item} - - ))} - - ); -} - -export function ResourceLocationMeta({ - label, - icon = "Folder", -}: { - label: string; - icon?: IconName; -}) { - return ( - - - {label} - - ); -} - -export function ResourceCardStat({ - icon, - iconClassName, - accessibleLabel, - children, -}: { - icon: IconName; - iconClassName?: string; - accessibleLabel?: string; - children: ReactNode; -}) { - return ( - - - {children} - - ); -} - -export function ResourceToolbar({ - searchValue, - searchPlaceholder, - searchLabel, - onSearchChange, - controls, - containedControls = false, - controlsClassName, - action, -}: { - searchValue: string; - searchPlaceholder: string; - searchLabel?: string; - onSearchChange: (value: string) => void; - controls?: ReactNode; - containedControls?: boolean; - controlsClassName?: string; - action?: ReactNode; -}) { - return ( -
-
- - onSearchChange(event.target.value)} - placeholder={searchPlaceholder} - aria-label={searchLabel ?? searchPlaceholder} - className="h-8 pl-8" - /> -
- {controls ? ( -
button]:size-7 [&>button]:rounded-sm", - controlsClassName, - )} - > - {controls} -
- ) : null} - {action} -
- ); -} - -export function ResourceTabDescription({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} - -export interface ResourceOption { - id: string; - label: string; - description?: string; - disabled?: boolean; -} - -function ResourceOptionContent({ option }: { option: ResourceOption }) { - return ( - - {option.label} - {option.description ? ( - - {option.description} - - ) : null} - - ); -} - -function ResourceMenuTrigger({ - label, - icon, - active = false, -}: { - label: string; - icon: IconName; - active?: boolean; -}) { - return ( - - - - - - - - {label} - - - ); -} - -export function ResourceOptionMenu({ - label, - icon, - value, - options, - onChange, -}: { - label: string; - icon: IconName; - value: string; - options: readonly ResourceOption[]; - onChange: (value: string) => void; -}) { - return ( - - - - - {label} - - {options.map((option) => { - const selected = option.id === value; - return ( - { - if (selected || option.disabled) { - event.preventDefault(); - return; - } - onChange(option.id); - }} - className="flex items-center justify-between gap-3" - > - - - - ); - })} - - - ); -} - -export function ResourceMultiSelectMenu({ - label, - icon, - selectedValues, - options, - onChange, -}: { - label: string; - icon: IconName; - selectedValues: readonly string[]; - options: readonly ResourceOption[]; - onChange: (values: string[]) => void; -}) { - const selected = new Set(selectedValues); - const activeSelectedCount = options.filter( - (option) => !option.disabled && selected.has(option.id), - ).length; - const triggerLabel = - activeSelectedCount === 0 - ? label - : `${label}: ${activeSelectedCount} selected`; - - function updateValue(option: ResourceOption, checked: boolean) { - if (option.disabled) return; - const next = new Set(selectedValues); - if (checked) { - next.add(option.id); - } else { - next.delete(option.id); - } - const enabledOptionIds = new Set( - options.filter((candidate) => !candidate.disabled).map(({ id }) => id), - ); - onChange([...next].filter((id) => enabledOptionIds.has(id))); - } - - return ( - - 0} - /> - - - {triggerLabel} - - {options.map((option) => ( - event.preventDefault()} - onCheckedChange={(checked) => updateValue(option, checked === true)} - > - - - ))} - - - ); -} - -export function ResourceSortMenu({ - value, - direction, - options, - onChange, -}: { - value: string; - direction: "asc" | "desc"; - options: readonly ResourceOption[]; - onChange: (value: string) => void; -}) { - return ( - - - - - Sort by - - {options.map((option) => { - const selected = option.id === value; - return ( - { - event.preventDefault(); - if (option.disabled) return; - onChange(option.id); - }} - className="flex items-center justify-between gap-3" - > - - - - ); - })} - - - ); -} - -export function ResourceToolbarAction({ - label, - icon = "Plus", - disabled = false, - onClick, -}: { - label: string; - icon?: IconName; - disabled?: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -export interface ResourceCreateTemplate { - label: string; - description: string; - prompt: string; - icon?: IconName; -} - -export interface ResourceCreateMenuAction { - label: string; - icon: IconName; - onSelect: () => void; -} - -export function ResourceCreateButton({ - label, - templates, - templateMenuLabel = "Examples", - menuActions = [], - onCreate, -}: { - label: string; - templates: readonly ResourceCreateTemplate[]; - templateMenuLabel?: string; - menuActions?: readonly ResourceCreateMenuAction[]; - onCreate: (prompt?: string) => void; -}) { - return ( -
- - - - - - - {menuActions.map((action) => ( - - - {action.label} - - ))} - {menuActions.length > 0 ? : null} - - {templateMenuLabel} - - {templates.map((template) => ( - onCreate(template.prompt)} - > - {template.icon ? ( - - ) : null} - - {template.label} - - : {template.description} - - ))} - - -
- ); -} - -export type ResourceOverflowMenuItem = - | { - kind?: "item"; - label: string; - icon?: IconName; - tone?: "default" | "destructive"; - disabled?: boolean; - disabledReason?: string; - onSelect: () => void; - } - | { kind: "separator" }; - -export function ResourceOverflowMenu({ - label, - disabled = false, - items, -}: { - label: string; - disabled?: boolean; - items: readonly ResourceOverflowMenuItem[]; -}) { - return ( - - - - - - {items.map((item, index) => { - if (item.kind === "separator") { - return ; - } - const menuItem = ( - { - if (item.disabled) { - event.preventDefault(); - return; - } - item.onSelect(); - }} - > - {item.icon ? ( - - ) : null} - {item.label} - - ); - if (!item.disabled || item.disabledReason === undefined) { - return menuItem; - } - return ( - - - {menuItem} - - {item.disabledReason} - - - - ); - })} - - - ); -} - -export function ResourceActionButton({ - label, - tooltipLabel, - tooltipSide, - icon, - tone = "muted", - loading = false, - disabled = false, - disabledReason, - className, - onClick, -}: { - label: string; - tooltipLabel?: string; - tooltipSide?: ComponentProps["side"]; - icon: IconName; - tone?: "muted" | "destructive"; - loading?: boolean; - disabled?: boolean; - disabledReason?: ReactNode; - className?: string; - onClick: () => void; -}) { - return ( - - - - - - - {disabled && disabledReason - ? disabledReason - : (tooltipLabel ?? label)} - - - - ); -} - -export function ResourceRow({ - leading, - title, - titleMeta, - description, - status, - state, - selected = false, - muted = false, - persistentActions, - trailingMeta, - actions, - trailingVisual, - actionsVisibility = "hover", - className, - openLabel, - onOpen, -}: { - leading?: ReactNode; - title: ReactNode; - /** Secondary identity metadata shown beside the title, such as an author. */ - titleMeta?: ReactNode; - description?: ReactNode; - status?: ReactNode; - state?: ReactNode; - selected?: boolean; - muted?: boolean; - /** Controls that communicate persistent state, such as enable switches. */ - persistentActions?: ReactNode; - /** Supporting metadata aligned with the trailing controls. */ - trailingMeta?: ReactNode; - actions?: ReactNode; - trailingVisual?: ReactNode; - actionsVisibility?: "hover" | "always"; - className?: string; - /** Accessible name for the row's primary navigation control. */ - openLabel?: string; - onOpen: () => void; -}) { - const rowState = state ?? status; - const hasLeading = - leading !== undefined && leading !== null && leading !== false; - return ( -
{ - if (targetsResourceAction(event.target)) return; - onOpen(); - }} - > - {hasLeading ? ( - - {leading} - - ) : null} - - {trailingMeta || persistentActions || actions || trailingVisual ? ( - - {trailingMeta ? ( - {trailingMeta} - ) : null} - {actions ? ( - - {actions} - - ) : null} - {persistentActions ? ( - - {persistentActions} - - ) : null} - {trailingVisual ? ( - - {trailingVisual} - - ) : null} - - ) : null} -
- ); -} - -export function ResourceRowDetailChevron() { - return ( - - ); -} - -export function ResourceListPanel({ - children, - maxHeightClassName, - className, -}: { - children: ReactNode; - maxHeightClassName?: string; - className?: string; -}) { - return ( -
-
-
{children}
-
-
- ); -} - -export function ResourceListState({ - state, - message, - onRetry, - loadingRows = 4, -}: { - state: "loading" | "empty" | "error"; - message: string; - onRetry?: () => void; - loadingRows?: number; -}) { - if (state === "loading") { - return ( - - - {message} - - - - ); - } - - return ( - -
- {message} - {state === "error" && onRetry ? ( - - ) : null} -
-
- ); -} - -export type ResourceDetailSurface = "raised" | "recessed" | "flat"; - -export function ResourceDetailPanel({ - children, - className, - surface = "raised", -}: { - children: ReactNode; - className?: string; - surface?: ResourceDetailSurface; -}) { - return ( -
- {children} -
- ); -} - -export interface ResourcePromptContextItem { - icon: IconName; - label: ReactNode; -} - -/** - * Read-only preview of the saved input to an AI composer. - * - * The instruction stays visually connected to the context it will run with, - * while the absence of input and send controls keeps this distinct from the - * real editing composer. - */ -export function ResourcePromptPreview({ - children, - className, - context = [], -}: { - children: ReactNode; - className?: string; - context?: readonly ResourcePromptContextItem[]; -}) { - return ( -
-
- {children} -
- {context.length > 0 ? ( -
- {context.map((item, index) => ( - - - {item.label} - - ))} -
- ) : null} -
- ); -} - -export function ResourceDetailList({ - children, - className, - surface, -}: { - children: ReactNode; - className?: string; - surface?: ResourceDetailSurface; -}) { - return ( - - {children} - - ); -} - -/** - * A quiet, divided group of peer resources within a detail section. - * - * Use this for files, capabilities, services, schedules, and historical - * events. The section supplies the hierarchy; the collection supplies row - * structure without introducing another card. - */ -export function ResourceDetailCollection({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( - - {children} - - ); -} - -export function ResourceDetailListItem({ - leading, - children, - trailing, - className, -}: { - leading?: ReactNode; - children: ReactNode; - trailing?: ReactNode; - className?: string; -}) { - return ( -
- {leading ? {leading} : null} -
{children}
- {trailing ? {trailing} : null} -
- ); -} - -export function ResourceDetailActionRow({ - label, - description, - action, - className, -}: { - label: ReactNode; - description?: ReactNode; - action: ReactNode; - className?: string; -}) { - return ( -
-
-
{label}
- {description ? ( -
- {description} -
- ) : null} -
-
{action}
-
- ); -} - -export function ResourcePropertyList({ - children, - className, - surface, -}: { - children: ReactNode; - className?: string; - surface?: ResourceDetailSurface; -}) { - return ( - - {children} - - ); -} - -export type ResourceDetailSectionKind = - | "overview" - | "definition" - | "configuration" - | "release" - | "includes" - | "activity"; - -export interface ResourceDetailSectionProps { - label: ReactNode; - actions?: ReactNode; - children: ReactNode; -} - -export function ResourceDetailSection({ - kind = "definition", - label, - actions, - children, -}: ResourceDetailSectionProps & { kind?: ResourceDetailSectionKind }) { - return ( -
-
-

{label}

- {actions ? ( -
{actions}
- ) : null} -
- {children} -
- ); -} - -/** - * One open hierarchy for a resource's semantic detail sections. - * - * The page is the panel. Quiet rules and shared padding separate its sections - * without turning every section into a card. Individual content can still use - * a recessed surface when its shape benefits from one, such as source code, - * settings, or an error. - */ -export function ResourceDetailStack({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( -
[data-resource-detail-section]]:py-6 [&>[data-resource-detail-section]:first-child]:pt-0 [&>[data-resource-detail-section]:last-child]:pb-0", - className, - )} - > - {children} -
- ); -} - -/** Human-readable purpose and orientation. Keep this open and lightweight. */ -export function ResourceDetailOverviewSection( - props: ResourceDetailSectionProps, -) { - return ; -} - -/** The editable or inspectable primary content that defines a resource. */ -export function ResourceDefinitionSection(props: ResourceDetailSectionProps) { - return ; -} - -/** Behavior-changing fields and settings. */ -export function ResourceDetailConfigurationSection( - props: ResourceDetailSectionProps, -) { - return ; -} - -/** Version, delivery source, compatibility, and update policy. */ -export function ResourceDetailReleaseSection( - props: ResourceDetailSectionProps, -) { - return ; -} - -/** Child resources and capabilities contributed by the resource. */ -export function ResourceDetailIncludesSection( - props: ResourceDetailSectionProps, -) { - return ; -} - -/** Current state and historical events produced by a resource. */ -export function ResourceActivitySection(props: ResourceDetailSectionProps) { - return ; -} - -export function ResourceDetailFacts({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( -
- {children} -
- ); -} - -export function ResourceDetailFact({ - label, - children, - mono = false, - action, - details, -}: { - label: ReactNode; - children: ReactNode; - mono?: boolean; - action?: ReactNode; - details?: ReactNode; -}) { - return ( -
-
- {label} -
-
-
-
- {children} -
- {action ? ( -
{action}
- ) : null} -
- {details ?
{details}
: null} -
-
- ); -} - -/** Passive lifecycle status for states that are observed, not changed here. */ -export function ResourceLifecycleStatus({ - label, - tooltip, - accessibleLabel, - icon, - appearance = "default", -}: { - label: ReactNode; - tooltip?: ReactNode; - accessibleLabel?: string; - icon?: IconName; - appearance?: "default" | "recessed"; -}) { - const status = ( - - {icon ? : null} - {label} - - ); - if (tooltip === undefined) return status; - return ( - - - {status} - {tooltip} - - - ); -} - -/** Canonical action for a resource that can be added from a browse surface. */ -export function ResourceInstallControl({ - accessibleLabel, - label = "Install", - pendingLabel = "Installing", - pending = false, - disabled = false, - presentation = "label", - tooltip, - onAction, -}: { - accessibleLabel: string; - label?: string; - pendingLabel?: string; - pending?: boolean; - disabled?: boolean; - presentation?: "label" | "icon"; - tooltip?: ReactNode; - onAction: () => void; -}) { - const control = ( - - ); - if (presentation !== "icon" || tooltip === undefined) return control; - return ( - - - {control} - {tooltip} - - - ); -} - -/** - * Canonical installed state for browse and detail surfaces. - * - * With an action it shows the installed or provenance treatment at rest, then - * reveals the destructive uninstall affordance only while hovered or focused. - */ -export function ResourceInstalledControl({ - accessibleLabel, - label = "Installed", - icon = "Check", - appearance = "installed", - actionLabel = "Uninstall", - pendingLabel = "Uninstalling", - pending = false, - presentation = "label", - tooltip, - onAction, -}: { - accessibleLabel: string; - label?: string; - icon?: IconName; - appearance?: "installed" | "provenance"; - actionLabel?: string; - pendingLabel?: string; - pending?: boolean; - presentation?: "label" | "icon"; - tooltip?: ReactNode; - onAction?: () => void; -}) { - const installedContent = ( - - - {presentation === "label" ? label : null} - - ); - - if (onAction === undefined) { - const status = ( - - {installedContent} - - ); - if (presentation !== "icon" || tooltip === undefined) return status; - return ( - - - {status} - {tooltip} - - - ); - } - - const control = ( - - ); - if (presentation !== "icon" || tooltip === undefined) return control; - return ( - - - {control} - {tooltip} - - - ); -} - -export function ResourceProperty({ - label, - children, -}: { - label: ReactNode; - children: ReactNode; -}) { - return ( -
-
{label}
-
{children}
-
- ); -} - -/** - * Prompt editing treatment for resources whose stored contract is plain text. - * It borrows the composer hierarchy without implying attachments, mentions, - * provider controls, or a send action that the resource cannot persist. - */ -export function ResourcePromptEditor({ - value, - ariaLabel, - placeholder, - hint, - onChange, -}: { - value: string; - ariaLabel: string; - placeholder?: string; - hint?: ReactNode; - onChange: (value: string) => void; -}) { - return ( -
-