diff --git a/CHANGELOG.md b/CHANGELOG.md index 348e6c9a..62450bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable user-facing changes will be tracked here. MySkills is still prerelease software; breaking changes may happen between beta releases and will be called out in this file. +## 0.1.0-beta.17 - candidate + +Target release: `v0.1.0-beta.17`. + +- Open architecture editing in a full-page Workbench, with saved Overview, Skills, History, and Access tabs, responsive editing, draft protection, and direct links. +- Browse and manage skills in one workspace, preserving the selected skill and exact version across scopes and detail tabs. +- Save Library references through skill and release pickers. Open exact entries and source candidates from links, with reload, history, and return navigation preserved. +- Keep archived and unpublished skills available to authorized managers. Exclude archived parent skills from Library adoption choices. +- Include GitHub source authentication and retry scheduling, plus team-owned Library sourcing, tracking, and instance review. + +This release targets the regular GitHub update artifacts for operator-managed instances. Apply the GitHub integration, source cooldown, and team Library ownership migrations before starting the API; preserve the database, artifact storage, and existing configuration. GitHub authentication requires the documented instance configuration. Keep API and web on the same release. Hosted deployment and npm publication are separate actions; the published beta.16 CLI remains available. + ## 0.1.0-beta.16 - 2026-09-29 Target release: `v0.1.0-beta.16`. diff --git a/apps/api/package.json b/apps/api/package.json index eb51be37..75949b17 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@myskills-app/api", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "license": "Apache-2.0", "private": true, "type": "module", @@ -15,9 +15,9 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1137.0", - "@myskills-app/auth": "0.1.0-beta.16", - "@myskills-app/core": "0.1.0-beta.16", - "@myskills-app/skill-package": "0.1.0-beta.16", + "@myskills-app/auth": "0.1.0-beta.17", + "@myskills-app/core": "0.1.0-beta.17", + "@myskills-app/skill-package": "0.1.0-beta.17", "drizzle-orm": "^0.45.3", "fastify": "^5.12.5", "nodemailer": "^10.0.10", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 777cb761..02759012 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2162,6 +2162,23 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { }); }); + app.get("/v1/manage/skills/:slug", async (request, reply) => { + if (!options.authService) { + throw new AppError("Authentication service is not configured.", "AUTH_SERVICE_UNAVAILABLE", 503); + } + if (!options.submissionService) { + throw new AppError("Submission service is not configured.", "SUBMISSION_SERVICE_UNAVAILABLE", 503); + } + const user = await authenticateSessionUser(options.authService, requestAuthorization(request)); + if (!user) return authFailureReply(options.authService, requestAuthorization(request), reply); + const skill = await options.submissionService.getSkillManagement({ + actor: { id: user.id, roles: user.roles }, + slug: parseSlugParam(request.params), + }); + if (!skill) throw new AppError("Skill not found.", "SKILL_NOT_FOUND", 404); + return { skill }; + }); + app.get("/v1/submissions/:id", async (request, reply) => { if (!options.authService) { throw new AppError("Authentication service is not configured.", "AUTH_SERVICE_UNAVAILABLE", 503); diff --git a/apps/api/test/managed-skill-detail.test.ts b/apps/api/test/managed-skill-detail.test.ts new file mode 100644 index 00000000..387f9fd4 --- /dev/null +++ b/apps/api/test/managed-skill-detail.test.ts @@ -0,0 +1,334 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateTotpCode, hashPassword, type Role } from "@myskills-app/auth"; +import { parseSkillManifest } from "@myskills-app/skill-package"; +import { buildApp } from "../src/app.js"; +import { AuthService } from "../src/auth/service.js"; +import { MemoryAuthStore } from "../src/auth/memory-auth-store.js"; +import { apiTokenScopes } from "../src/auth/types.js"; +import { MemorySkillRepository } from "../src/repositories/memory-skill-repository.js"; +import { MemorySubmissionStore } from "../src/submissions/memory-submission-store.js"; +import { SubmissionService } from "../src/submissions/service.js"; +import type { SubmissionActor } from "../src/submissions/types.js"; + +const PASSWORD = "correct horse battery staple"; +const owner: SubmissionActor = { id: "managed-owner", roles: ["author"] }; +const maintainer: SubmissionActor = { id: "managed-maintainer", roles: ["maintainer"] }; +const ALL_SKILL_ACTIONS = ["edit", "archive", "restore", "delete"]; + +test("managed skill detail is session-only and rejects anonymous, dead and API-token credentials without details", async (t) => { + const fixture = await managedFixture(); + t.after(() => fixture.app.close()); + const { app } = fixture; + + const anonymous = await app.inject({ method: "GET", url: "/v1/manage/skills/archived-helper" }); + assert.equal(anonymous.statusCode, 401); + assert.equal(anonymous.json().error.code, "AUTHENTICATION_REQUIRED"); + + const garbage = await getDetail(app, "archived-helper", { authorization: "Bearer not-a-real-session-token" }); + assert.equal(garbage.statusCode, 401); + assert.equal(garbage.json().error.code, "AUTHENTICATION_REQUIRED"); + + const retiredSession = await login(app, "owner@example.com"); + const logout = await app.inject({ method: "POST", url: "/v1/auth/logout", headers: { authorization: `Bearer ${retiredSession}` } }); + assert.equal(logout.statusCode, 204); + const afterLogout = await getDetail(app, "archived-helper", bearer(retiredSession)); + assert.equal(afterLogout.statusCode, 401); + assert.equal(afterLogout.json().error.code, "AUTHENTICATION_REQUIRED"); + + // The owner's read-only token and the maintainer's every-scope token are + // live credentials (403 SESSION_AUTH_REQUIRED, not 401), but management + // stays session-only. + const readToken = await createApiToken(app, fixture.sessions.owner, ["skills:read"]); + const everyScopeToken = await createApiToken(app, fixture.sessions.maintainer, [...apiTokenScopes]); + for (const token of [readToken, everyScopeToken]) { + const response = await getDetail(app, "archived-helper", bearer(token)); + assert.equal(response.statusCode, 403, response.body); + assert.equal(response.json().error.code, "SESSION_AUTH_REQUIRED"); + assertNoSkillDetails(response.body); + } +}); + +test("owner and privileged maintainer sessions load archived and unpublished skills that match the managed inventory", async (t) => { + const fixture = await managedFixture(); + t.after(() => fixture.app.close()); + const { app, sessions } = fixture; + + // Fixture check through the existing inventory route. These rows are the + // source of truth the detail route must agree with. + const inventory = await app.inject({ method: "GET", url: "/v1/manage/skills", headers: bearer(sessions.owner) }); + assert.equal(inventory.statusCode, 200, inventory.body); + const rows = new Map((inventory.json().skills as Array<{ slug: string; lifecycleStatus: string }>).map((row) => [row.slug, row])); + assert.equal(rows.get("archived-helper")?.lifecycleStatus, "archived"); + assert.equal(rows.get("unpublished-helper")?.lifecycleStatus, "unpublished"); + + for (const [label, headers] of [ + ["owner bearer session", bearer(sessions.owner)], + ["owner cookie session", { cookie: `theme=dark; myskills_session=${encodeURIComponent(sessions.owner)}` }], + ["MFA-verified maintainer session", bearer(sessions.maintainer)], + ] as const) { + for (const slug of ["archived-helper", "unpublished-helper"]) { + const response = await getDetail(app, slug, headers); + assert.equal(response.statusCode, 200, `${label} ${slug}: ${response.body}`); + const body = response.json(); + assert.deepEqual(Object.keys(body), ["skill"], `${label} ${slug}: response is exactly { skill }`); + assert.deepEqual(body.skill, rows.get(slug), `${label} ${slug}: detail matches the inventory row`); + assert.equal(body.skill.slug, slug); + assert.equal(body.skill.title, `${slug} title`); + assert.deepEqual(body.skill.tags, ["workflow"]); + assert.deepEqual(body.skill.allowedActions, ALL_SKILL_ACTIONS); + } + } + + const missingForOwner = await getDetail(app, "never-submitted-helper", bearer(sessions.owner)); + assert.equal(missingForOwner.statusCode, 404, missingForOwner.body); + assert.equal(missingForOwner.json().error.code, "SKILL_NOT_FOUND"); + const missingForMaintainer = await getDetail(app, "never-submitted-helper", bearer(sessions.maintainer)); + assert.equal(missingForMaintainer.statusCode, 404, missingForMaintainer.body); + assert.equal(missingForMaintainer.json().error.code, "SKILL_NOT_FOUND"); +}); + +test("non-owner author and plain reader sessions are denied management details by the existing service authority", async (t) => { + const fixture = await managedFixture(); + t.after(() => fixture.app.close()); + const { app, sessions } = fixture; + + for (const [label, session] of [["other author", sessions.otherAuthor], ["plain reader", sessions.reader]] as const) { + const inventory = await app.inject({ method: "GET", url: "/v1/manage/skills", headers: bearer(session) }); + assert.equal(inventory.statusCode, 200); + assert.deepEqual(inventory.json().skills, [], `${label} manages nothing`); + for (const slug of ["archived-helper", "unpublished-helper", "pending-helper"]) { + const response = await getDetail(app, slug, bearer(session)); + assert.equal(response.statusCode, 403, `${label} ${slug}: ${response.body}`); + assert.equal(response.json().error.code, "SKILL_MANAGEMENT_ROLE_REQUIRED"); + assertNoSkillDetails(response.body); + } + } +}); + +test("managed skill detail validates slugs with the shared slug parser before reaching the service", async (t) => { + const fixture = await managedFixture(); + t.after(() => fixture.app.close()); + const { app, sessions } = fixture; + + for (const rawSlug of [ + "Archived-Helper", + "archived--helper", + "-archived-helper", + "..%2Fsubmissions", + "archived-helper%2F..%2F..%2Fadmin", + "archived-helper%3Fq%3Dx", + "archived%20helper", + "archived-helper%00", + ]) { + const response = await app.inject({ method: "GET", url: `/v1/manage/skills/${rawSlug}`, headers: bearer(sessions.maintainer) }); + assert.equal(response.statusCode, 400, `${rawSlug}: ${response.body}`); + assert.equal(response.json().error.code, "INVALID_SKILL_SLUG", rawSlug); + assertNoSkillDetails(response.body); + } + + // The parametric route must not shadow the inventory list route. + const inventory = await app.inject({ method: "GET", url: "/v1/manage/skills?limit=1", headers: bearer(sessions.maintainer) }); + assert.equal(inventory.statusCode, 200, inventory.body); + assert.equal(inventory.json().skills.length, 1); + assert.ok(inventory.json().nextCursor); +}); + +test("managed skill detail is read-only across allowed and denied callers", async (t) => { + const fixture = await managedFixture(); + t.after(() => fixture.app.close()); + const { app, sessions, service, store } = fixture; + const readToken = await createApiToken(app, sessions.owner, ["skills:read"]); + const before = await managementSnapshot(service, store); + assert.equal(before.reviewQueue.includes("pending-helper@1.0.0:unreviewed"), true); + assert.ok(before.audit.length > 0, "fixture setup recorded review and lifecycle audit events"); + + const allowed: number[] = []; + const denied: number[] = []; + for (let round = 0; round < 2; round += 1) { + for (const slug of ["archived-helper", "unpublished-helper", "pending-helper"]) { + allowed.push((await getDetail(app, slug, bearer(sessions.owner))).statusCode); + allowed.push((await getDetail(app, slug, bearer(sessions.maintainer))).statusCode); + denied.push((await getDetail(app, slug, bearer(sessions.otherAuthor))).statusCode); + denied.push((await getDetail(app, slug, bearer(readToken))).statusCode); + } + } + assert.deepEqual([...new Set(allowed)], [200]); + assert.deepEqual([...new Set(denied)], [403]); + + // Lifecycle, release, review-queue and audit state are identical after + // twelve allowed and twelve denied reads. + assert.deepEqual(await managementSnapshot(service, store), before); +}); + +test("managed skill detail keeps the existing missing-service responses", async (t) => { + const withoutSubmissions = buildApp({ + skillRepository: new MemorySkillRepository([]), + authService: new AuthService(new MemoryAuthStore("closed")), + }); + const withoutAuth = buildApp({ + skillRepository: new MemorySkillRepository([]), + submissionService: new SubmissionService(new MemorySubmissionStore()), + }); + t.after(async () => { + await withoutSubmissions.close(); + await withoutAuth.close(); + }); + + const noSubmissions = await withoutSubmissions.inject({ method: "GET", url: "/v1/manage/skills/archived-helper" }); + assert.equal(noSubmissions.statusCode, 503, noSubmissions.body); + assert.equal(noSubmissions.json().error.code, "SUBMISSION_SERVICE_UNAVAILABLE"); + const noAuth = await withoutAuth.inject({ method: "GET", url: "/v1/manage/skills/archived-helper" }); + assert.equal(noAuth.statusCode, 503, noAuth.body); + assert.equal(noAuth.json().error.code, "AUTH_SERVICE_UNAVAILABLE"); +}); + +async function managedFixture() { + const authStore = new MemoryAuthStore("closed"); + const store = new MemorySubmissionStore(); + const service = new SubmissionService(store); + const app = buildApp({ + skillRepository: new MemorySkillRepository([]), + authService: new AuthService(authStore), + submissionService: service, + }); + await addUser(authStore, "managed-owner", "owner@example.com", ["author"]); + await addUser(authStore, "managed-maintainer", "maintainer@example.com", ["maintainer"]); + await addUser(authStore, "other-author", "other-author@example.com", ["author"]); + await addUser(authStore, "plain-reader", "reader@example.com", ["user"]); + + // archived-helper: published, then the whole skill is archived. + // unpublished-helper: published, its only release unpublished, then the + // skill archived and restored, which recomputes it as "unpublished". + // pending-helper: submitted and still waiting in the review queue. + for (const slug of ["archived-helper", "unpublished-helper"]) { + const submission = await service.createSubmission({ actor: owner, ...packageInput(slug, "1.0.0") }); + await service.performReviewAction({ actor: maintainer, submissionId: submission.id, action: "approve", artifactSha256: submission.artifact.sha256 }); + await service.performReviewAction({ actor: maintainer, submissionId: submission.id, action: "publish" }); + } + await service.performSkillAction({ actor: owner, slug: "archived-helper", action: "archive" }); + await service.performReleaseAction({ actor: owner, slug: "unpublished-helper", version: "1.0.0", action: "unpublish" }); + await service.performSkillAction({ actor: owner, slug: "unpublished-helper", action: "archive" }); + await service.performSkillAction({ actor: owner, slug: "unpublished-helper", action: "restore" }); + await service.createSubmission({ actor: owner, ...packageInput("pending-helper", "1.0.0") }); + + return { + app, + service, + store, + sessions: { + owner: await login(app, "owner@example.com"), + maintainer: await loginWithMfa(app, "maintainer@example.com"), + otherAuthor: await login(app, "other-author@example.com"), + reader: await login(app, "reader@example.com"), + }, + }; +} + +async function managementSnapshot(service: SubmissionService, store: MemorySubmissionStore) { + const managed = await service.listManagedSkills({ actor: maintainer }); + const releases: Record = {}; + for (const slug of ["archived-helper", "unpublished-helper", "pending-helper"]) { + releases[slug] = await service.listSkillReleases({ actor: owner, slug }); + } + const reviewQueue = (await service.listReviewSubmissions(maintainer)) + .map((submission) => `${submission.slug}@${submission.version}:${submission.reviewStatus}`) + .sort(); + return { + managed: managed.skills, + releases, + reviewQueue, + audit: JSON.parse(JSON.stringify(store.auditEvents())) as unknown[], + }; +} + +function getDetail(app: ReturnType, slug: string, headers: Record) { + return app.inject({ method: "GET", url: `/v1/manage/skills/${slug}`, headers }); +} + +function bearer(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +function assertNoSkillDetails(body: string) { + for (const leaked of ["archived-helper title", "unpublished-helper title", "pending-helper title", "Managed fixture summary", "allowedActions", "lifecycleStatus"]) { + assert.equal(body.includes(leaked), false, `response leaked ${leaked}: ${body}`); + } +} + +async function addUser(authStore: MemoryAuthStore, id: string, email: string, roles: Role[]) { + authStore.addUser({ + id, + email, + status: "active", + emailVerifiedAt: new Date(), + roles, + passwordHash: await hashPassword(PASSWORD), + }); +} + +async function login(app: ReturnType, email: string): Promise { + const response = await app.inject({ method: "POST", url: "/v1/auth/login", payload: { email, password: PASSWORD } }); + assert.equal(response.statusCode, 200, response.body); + return response.json().token as string; +} + +async function loginWithMfa(app: ReturnType, email: string): Promise { + const setupSession = await login(app, email); + const enrollment = await app.inject({ + method: "POST", + url: "/v1/auth/mfa/totp/enroll", + headers: bearer(setupSession), + payload: { password: PASSWORD }, + }); + assert.equal(enrollment.statusCode, 201, enrollment.body); + const confirm = await app.inject({ + method: "POST", + url: "/v1/auth/mfa/totp/confirm", + headers: bearer(setupSession), + payload: { + factorId: enrollment.json().enrollment.factorId, + code: generateTotpCode(enrollment.json().enrollment.secret), + }, + }); + assert.equal(confirm.statusCode, 200, confirm.body); + const challenge = await app.inject({ method: "POST", url: "/v1/auth/login", payload: { email, password: PASSWORD } }); + assert.equal(challenge.statusCode, 200, challenge.body); + assert.equal(challenge.json().mfaRequired, true); + const verified = await app.inject({ + method: "POST", + url: "/v1/auth/mfa/verify", + payload: { + challengeToken: challenge.json().challengeToken, + recoveryCode: confirm.json().mfa.recoveryCodes[0], + }, + }); + assert.equal(verified.statusCode, 200, verified.body); + assert.equal(verified.json().user.mfaVerified, true); + return verified.json().token as string; +} + +async function createApiToken(app: ReturnType, session: string, scopes: string[]): Promise { + const response = await app.inject({ + method: "POST", + url: "/v1/auth/api-tokens", + headers: bearer(session), + payload: { name: `managed detail ${scopes.length}`, scopes }, + }); + assert.equal(response.statusCode, 201, response.body); + return response.json().token.token as string; +} + +function packageInput(slug: string, version: string) { + const manifest = parseSkillManifest({ + name: slug, + title: `${slug} title`, + summary: "Managed fixture summary for lifecycle detail.", + version, + license: "Apache-2.0", + visibility: "public", + platforms: [{ name: "codex", install_target: "codex-skill" }], + tags: ["workflow"], + }); + return { manifest, files: [{ path: "skill.json", content: JSON.stringify(manifest) }, { path: "README.md", content: "Document setup and use." }] }; +} diff --git a/apps/cli/README.md b/apps/cli/README.md index 2c6db32f..4c0d9bc8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -34,7 +34,7 @@ CLI tokens should be stored in the platform secret store where possible. ## Current Slice -This document describes the `0.1.0-beta.16` source candidate, including named +This document describes the `0.1.0-beta.17` source candidate, including named CLI configuration profiles and global/project inventory scopes. Source, GitHub releases, npm publication, and hosted deployment are separate states; see [release verification](../../docs/RELEASE.md) for their checks. Historical diff --git a/apps/cli/package.json b/apps/cli/package.json index 1979b2fa..f9548269 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@jarel/myskills", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "description": "Command-line client for publishing, discovering, installing, updating, and rolling back MySkills packages.", "license": "Apache-2.0", "private": false, @@ -42,8 +42,8 @@ "test": "node ../../scripts/run-node-tests.mjs --import tsx --extensions .test.ts" }, "devDependencies": { - "@myskills-app/core": "0.1.0-beta.16", - "@myskills-app/skill-package": "0.1.0-beta.16", + "@myskills-app/core": "0.1.0-beta.17", + "@myskills-app/skill-package": "0.1.0-beta.17", "esbuild": "^0.28.2", "tsx": "^4.23.15", "yaml": "2.9.1" diff --git a/apps/mcp/package.json b/apps/mcp/package.json index ee5073a2..d2446db0 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@myskills-app/mcp", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "license": "Apache-2.0", "private": true, "type": "module", @@ -19,8 +19,8 @@ "dependencies": { "@modelcontextprotocol/node": "2.1.0", "@modelcontextprotocol/server": "2.1.0", - "@myskills-app/core": "0.1.0-beta.16", - "@myskills-app/skill-package": "0.1.0-beta.16", + "@myskills-app/core": "0.1.0-beta.17", + "@myskills-app/skill-package": "0.1.0-beta.17", "yaml": "2.9.1", "zod": "^4.6.5" }, diff --git a/apps/web/package.json b/apps/web/package.json index 9eadd38d..5e8c2649 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@myskills-app/web", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "license": "Apache-2.0", "private": true, "type": "module", @@ -14,7 +14,7 @@ }, "dependencies": { "@dagrejs/dagre": "3.1.1", - "@myskills-app/core": "0.1.0-beta.16", + "@myskills-app/core": "0.1.0-beta.17", "@radix-ui/react-slot": "^1.3.3", "@xyflow/react": "12.11.6", "@tailwindcss/vite": "^4.3.3", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 77602a28..2ade15d4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -53,7 +53,6 @@ import { Settings, ShieldCheck, Shield, - SlidersHorizontal, SquareCheckBig, Trash2, Upload, @@ -66,15 +65,21 @@ import { parseSemanticVersion, type PublicSkill, type RegistryView, type SkillSh import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; -import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from "@/components/reui/frame"; +import { Frame, FramePanel } from "@/components/reui/frame"; import { ArchitecturesDashboard } from "@/components/architecture/ArchitecturesDashboard"; +import { isArchitecturePath } from "@/components/architecture/architecture-route"; +import type { ArchitectureNavigationGuard } from "@/components/architecture/useArchitectureNavigationGuard"; import { OrganizationsDashboard } from "@/components/organization/OrganizationsDashboard"; import { ArchitectureTargetsDashboard } from "@/components/target/ArchitectureTargetsDashboard"; import { LibrariesDashboard } from "@/components/library/LibrariesDashboard"; import { tileTone } from "@/components/library/library-display"; import { SystemUpdateCenter } from "@/components/update/SystemUpdateCenter"; +import { AddToLibraryButton } from "@/components/library/AddToLibraryButton"; import { PackageFileViewer } from "@/components/registry/PackageFileViewer"; -import { ManagedSkillsDashboard } from "@/components/registry/ManagedSkillsDashboard"; +import { ManagedSkillsInventory } from "@/components/registry/ManagedSkillsInventory"; +import { SkillManagePanel } from "@/components/registry/SkillManagePanel"; +import { ManagedReleaseSelect, SkillSectionTabs, SkillVersionsPanel } from "@/components/registry/SkillSections"; +import { isPublishedRelease, parseSkillScope, parseSkillTab, safeLibraryReturn, sectionPanelId, sectionTabId, type SkillScope, type SkillTab } from "@/components/registry/skill-workspace"; import { SubmissionEvidencePanel } from "@/components/registry/SubmissionEvidencePanel"; import { SkillImprovementPanel } from "@/components/registry/SkillImprovementPanel"; import { BundleWorkspace } from "@/components/registry/BundleWorkspace"; @@ -110,6 +115,7 @@ import { type ReviewActionResult, type ReviewActionName, type ReviewSubmissionSummary, + type SkillManagementSummary, type SkillReleaseSummary, type SubmitSkillResult, type TeamDashboard, @@ -127,7 +133,7 @@ interface RegistryAppProps { type LoadState = "idle" | "loading" | "ready" | "error"; type AuthState = "idle" | "loading" | "mfa"; -type AppView = "libraries" | "manage" | "landing" | "login" | "register" | "reset-password" | "verify-email" | "change-email" | "browse" | "architectures" | "organizations" | "targets" | "updates" | "admin" | "review" | "submit" | "teams" | "settings" | "not-found"; +type AppView = "libraries" | "landing" | "login" | "register" | "reset-password" | "verify-email" | "change-email" | "browse" | "architectures" | "organizations" | "targets" | "updates" | "admin" | "review" | "submit" | "teams" | "settings" | "not-found"; interface AppLocation { view: AppView; @@ -136,6 +142,7 @@ interface AppLocation { platform: string; version: string | null; catalog: CatalogLocation; + workspace: WorkspaceLocation; } /** Skills catalog state kept in the URL beside the existing skill parameters. */ @@ -144,7 +151,17 @@ interface CatalogLocation { bundle: string | null; } -type ArchitectureNavigationGuard = (action: string) => boolean; +/** Skills workspace scope, detail section and a validated Libraries return path. */ +interface WorkspaceLocation { + scope: SkillScope; + tab: SkillTab; + returnTo: string | null; +} + +// "denied" is the server's answer (401/403/404). "error" means the check did +// not complete (network, 5xx) and must be retried, not shown as a denial. +type ManagedDetail = { slug: string; status: "loading" | "ready" | "denied" | "error"; record: SkillManagementSummary | null }; + type RegistryLayout = "split" | "stack"; type MobileMenu = "more" | "account"; type RegistryFocus = { kind: "title" } | { kind: "row"; slug: string }; @@ -153,7 +170,6 @@ interface RegistryDisclosures { details: boolean; files: boolean; improvement: boolean; - owner: boolean; } type ReleaseCardState = "ready" | "loading" | "error" | "no-default"; @@ -216,6 +232,8 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const architectureNavigationGuardRef = useRef(null); const restoringPopstateRef = useRef(false); const [view, setView] = useState(initialLocation.view); + // Routes that own URL state beyond the view (the architecture section) read it from here. + const [appUrl, setAppUrl] = useState(currentBrowserUrl); const [session, setSession] = useState(() => readStoredSession()); // Bundle catalog, when the client and server provide it. A 404 from the // catalog falls back to the flat Skills list for this session. @@ -223,9 +241,19 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const [catalogView, setCatalogView] = useState(initialLocation.catalog.view); const [selectedBundleId, setSelectedBundleId] = useState(initialLocation.catalog.bundle); const catalogLocationRef = useRef(initialLocation.catalog); + const [scope, setScope] = useState(initialLocation.workspace.scope); + const [tab, setTab] = useState(initialLocation.workspace.tab); + const [returnTo, setReturnTo] = useState(initialLocation.workspace.returnTo); + const workspaceLocationRef = useRef(initialLocation.workspace); const browseUrl = (slug: string | null, nextQuery: string, nextPlatform: string, version: string | null = null) => ( - registryUrl(slug, nextQuery, nextPlatform, version, catalogLocationRef.current) + registryUrl(slug, nextQuery, nextPlatform, version, catalogLocationRef.current, workspaceLocationRef.current) ); + const updateWorkspaceLocation = (next: Partial) => { + workspaceLocationRef.current = { ...workspaceLocationRef.current, ...next }; + if (next.scope !== undefined) setScope(next.scope); + if (next.tab !== undefined) setTab(next.tab); + if (next.returnTo !== undefined) setReturnTo(next.returnTo); + }; const bundleCatalog = catalogAvailable ? registryClient.bundles : undefined; const [siteState, setSiteState] = useState<{ view: AppView; enabled?: boolean; failed?: boolean } | null>(null); const [siteRetry, setSiteRetry] = useState(0); @@ -251,7 +279,18 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const [selectedSkill, setSelectedSkill] = useState(null); const [release, setRelease] = useState(null); const [visibleReleases, setVisibleReleases] = useState([]); + // Every exact release record the server returned for the selected skill. For + // a manager this includes unpublished releases; readers only see published. + const [releaseRows, setReleaseRows] = useState([]); const [historyState, setHistoryState] = useState("idle"); + // Management authority for the selected skill comes from the server's + // management record, never from the readable skill's sharing flag. + const [managed, setManaged] = useState(null); + const [managedRetry, setManagedRetry] = useState(0); + // The slug whose readable (public) detail failed; a manager may still load it. + const [publicFailure, setPublicFailure] = useState(null); + // A saved management change reloads in place instead of clearing the detail. + const softReload = useRef({ detail: false, managed: false }); const [platform, setPlatform] = useState(initialLocation.platform); const [listState, setListState] = useState("idle"); const [nextCursor, setNextCursor] = useState(null); @@ -267,6 +306,9 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const [mfaPending, setMfaPending] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileMenu, setMobileMenu] = useState(null); + // Explicit app navigation to Libraries starts it fresh from the new URL. + // Its own library/entry URL writes (navigateFromLibraries) never bump this. + const [libraryNavigationRevision, setLibraryNavigationRevision] = useState(0); const mobileMoreButtonRef = useRef(null); const mobileMoreMenuRef = useRef(null); const mobileAccountButtonRef = useRef(null); @@ -277,11 +319,16 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const registrySurfaceRef = useRef(null); const inspectorTitleRef = useRef(null); const pendingRegistryFocus = useRef(null); - const [registryDisclosures, setRegistryDisclosures] = useState({ notes: false, details: false, files: false, improvement: false, owner: false }); + const [registryDisclosures, setRegistryDisclosures] = useState({ notes: false, details: false, files: false, improvement: false }); const setRegistryDisclosure = (key: keyof RegistryDisclosures, open: boolean) => setRegistryDisclosures((current) => current[key] === open ? current : { ...current, [key]: open }); + const sectionsId = useId(); + // Can manage needs a session and the management inventory; otherwise the + // workspace reads the catalog. + const canUseManageScope = Boolean(session && registryClient.listManagedSkills); + const workspaceScope: SkillScope = scope === "manage" && canUseManageScope ? "manage" : "all"; // A desktop split shows the first result without choosing it: the URL stays // /registry until the reader picks a skill. A stack shows the list instead. - const implicitSlug = registryLayout === "split" && selectedSlug === null ? skills[0]?.slug ?? null : null; + const implicitSlug = workspaceScope === "all" && registryLayout === "split" && selectedSlug === null ? skills[0]?.slug ?? null : null; const detailSlug = selectedSlug ?? implicitSlug; const canUseAdmin = Boolean(session && isAdminUser(session.user)); const canUseReview = Boolean(session && isReviewerUser(session.user)); @@ -289,7 +336,9 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const canUseTeams = Boolean(session); const canUseOrganizations = Boolean(session && registryClient.listOrganizations); const canUseTargets = Boolean(session && registryClient.listArchitectureTargets); - const activeView: AppView = isPublicView(view) + const activeView: AppView = view === "browse" && scope === "manage" && !session + ? "login" + : isPublicView(view) ? view : !session ? "login" @@ -309,8 +358,6 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) ? "targets" : view === "updates" && canUseTargets ? "updates" - : view === "manage" && session - ? "manage" : view === "teams" && canUseTeams ? "teams" : view === "settings" @@ -321,6 +368,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) window.history.replaceState(appHistoryState(historyIndexRef.current), "", nextUrl); currentLocationRef.current = appLocationFromWindow(); currentUrlRef.current = currentBrowserUrl(); + setAppUrl(currentUrlRef.current); }; const pushAppHistory = (nextUrl: string) => { @@ -328,6 +376,12 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) window.history.pushState(appHistoryState(historyIndexRef.current), "", nextUrl); currentLocationRef.current = appLocationFromWindow(); currentUrlRef.current = currentBrowserUrl(); + setAppUrl(currentUrlRef.current); + }; + + const navigateArchitectures = (nextUrl: string, mode: "push" | "replace") => { + if (mode === "push") pushAppHistory(nextUrl); + else replaceAppHistory(nextUrl); }; const registerArchitectureNavigationGuard = useCallback((guard: ArchitectureNavigationGuard | null) => { @@ -371,10 +425,13 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) } currentLocationRef.current = next; currentUrlRef.current = currentBrowserUrl(); + setAppUrl(currentUrlRef.current); return; } - if (previous.view === "architectures" && next.view !== "architectures") { + // The architecture guard sees every move, including moves inside the + // section; it prompts only when the destination would discard a draft. + if (previous.view === "architectures") { const guard = architectureNavigationGuardRef.current; if (guard) { const action = nextHistoryIndex !== null && nextHistoryIndex < historyIndexRef.current @@ -382,7 +439,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) : nextHistoryIndex !== null && nextHistoryIndex > historyIndexRef.current ? "go forward" : "navigate away"; - if (!guard(action)) { + if (!guard(action, currentBrowserUrl())) { const restoreDelta = nextHistoryIndex === null ? null : historyIndexRef.current - nextHistoryIndex; @@ -405,39 +462,69 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) if (nextHistoryIndex !== null) { historyIndexRef.current = nextHistoryIndex; } - currentLocationRef.current = next; - currentUrlRef.current = currentBrowserUrl(); - searchSelectionQuery.current = null; - setView(next.view); - // Clear eagerly only when the detail effects are certain to reload. An - // implicit desktop selection can become the same explicit skill, so a - // change to or from /registry is left to those effects. - const bothExplicit = next.slug !== null && previous.slug !== null; - if (bothExplicit && next.slug !== previous.slug) { - setSelectedSkill(null); - setVisibleReleases([]); - setHistoryState("idle"); - } - if (bothExplicit && (next.slug !== previous.slug || next.version !== previous.version)) { - setRelease(null); - setDetailState("loading"); - } - if (registryLayoutRef.current === "stack" && next.view === "browse" && previous.view === "browse" && next.slug !== previous.slug) { - pendingRegistryFocus.current = next.slug ? { kind: "title" } : previous.slug ? { kind: "row", slug: previous.slug } : null; - } - setSelectedSlug(next.slug); - setSelectedVersion(next.version); - setQuery(next.query); - setPlatform(next.platform); - catalogLocationRef.current = next.catalog; - setCatalogView(next.catalog.view); - setSelectedBundleId(next.catalog.bundle); - setMobileMenu(null); + applyLocation(next, previous); } window.addEventListener("popstate", syncFromBrowserHistory); return () => window.removeEventListener("popstate", syncFromBrowserHistory); }, []); + // Moves every URL-owned piece of state to a location. Uses only setters and + // refs, so the popstate listener registered once can call it safely. + function applyLocation(next: AppLocation, previous: AppLocation) { + currentLocationRef.current = next; + currentUrlRef.current = currentBrowserUrl(); + setAppUrl(currentUrlRef.current); + searchSelectionQuery.current = null; + setView(next.view); + // Clear eagerly only when the detail effects are certain to reload. An + // implicit desktop selection can become the same explicit skill, so a + // change to or from /registry is left to those effects. + const bothExplicit = next.slug !== null && previous.slug !== null; + if (bothExplicit && next.slug !== previous.slug) { + setSelectedSkill(null); + setVisibleReleases([]); + setReleaseRows([]); + setHistoryState("idle"); + } + if (bothExplicit && (next.slug !== previous.slug || next.version !== previous.version)) { + setRelease(null); + setDetailState("loading"); + } + if (registryLayoutRef.current === "stack" && next.view === "browse" && previous.view === "browse" && next.slug !== previous.slug) { + pendingRegistryFocus.current = next.slug ? { kind: "title" } : previous.slug ? { kind: "row", slug: previous.slug } : null; + } + setSelectedSlug(next.slug); + setSelectedVersion(next.version); + setQuery(next.query); + setPlatform(next.platform); + catalogLocationRef.current = next.catalog; + setCatalogView(next.catalog.view); + setSelectedBundleId(next.catalog.bundle); + workspaceLocationRef.current = next.workspace; + setScope(next.workspace.scope); + setTab(next.workspace.tab); + setReturnTo(next.workspace.returnTo); + setMobileMenu(null); + } + + /** Opens an internal app URL (for example an exact skill link from Review) as a new history entry. */ + function openAppUrl(url: string) { + const previous = currentLocationRef.current; + pushAppHistory(url); + applyLocation(currentLocationRef.current, previous); + } + + // Libraries records its own library/entry/candidate URLs through the app + // history so the history index and location refs stay accurate. A link that + // leaves Libraries (for example to an exact skill) moves the app there too. + // Stable: it uses only refs, setters and ref-backed helpers. + const navigateFromLibraries = useCallback((url: string, mode: "push" | "replace") => { + const previous = currentLocationRef.current; + if (mode === "replace") replaceAppHistory(url); + else pushAppHistory(url); + if (currentLocationRef.current.view !== "libraries") applyLocation(currentLocationRef.current, previous); + }, []); + useEffect(() => { if (!mobileMenu) { return; @@ -471,7 +558,9 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) }, [mobileMenu]); useEffect(() => { - if (!session && !isPublicView(view)) { + // Can manage is private: signed-out readers sign in first, as the old + // /manage/skills page required. + if (!session && (!isPublicView(view) || (view === "browse" && scope === "manage"))) { setView("login"); replaceAppHistory("/login"); return; @@ -485,7 +574,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) setView("browse"); replaceAppHistory("/registry"); } - }, [activeView, platform, query, selectedSlug, selectedVersion, session, view]); + }, [activeView, platform, query, scope, selectedSlug, selectedVersion, session, view]); useEffect(() => { if (activeView !== "browse") { @@ -539,8 +628,9 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) useEffect(() => { const requestEpoch = ++listEpoch.current; - // The bundle catalog owns Skills rows while it is mounted. - if (activeView !== "browse" || catalogAvailable) { + // The bundle catalog owns Skills rows while it is mounted, and the + // management inventory owns them in the Can manage scope. + if (activeView !== "browse" || catalogAvailable || workspaceScope !== "all") { setListState("idle"); return; } @@ -590,47 +680,64 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) return () => { active = false; }; - }, [activeView, catalogAvailable, registryClient, query, refreshKey]); + }, [activeView, catalogAvailable, registryClient, query, refreshKey, workspaceScope]); + + function applyReleaseRows(slug: string, rows: SkillReleaseSummary[]) { + const exact = rows.filter((row) => row.slug === slug && isExactReleaseVersion(row.version)); + setReleaseRows(exact.filter((row, index, all) => all.findIndex((other) => other.version === row.version) === index)); + setVisibleReleases(exact + .filter((row) => isPublishedRelease(row)) + .sort((a, b) => Date.parse(b.publishedAt ?? "") - Date.parse(a.publishedAt ?? "")) + .filter((row, index, all) => all.findIndex((other) => other.version === row.version) === index)); + } useEffect(() => { if (activeView !== "browse" || !detailSlug) { setSelectedSkill(null); setRelease(null); setVisibleReleases([]); + setReleaseRows([]); setHistoryState("idle"); setDetailState("idle"); setDetailMessage(null); + setPublicFailure(null); return; } let active = true; - setSelectedSkill(null); - setRelease(null); - setVisibleReleases([]); - setHistoryState("loading"); - setDetailState("loading"); - setDetailMessage(null); + // A soft reload keeps the current detail on screen until fresh data lands. + const soft = softReload.current.detail; + softReload.current.detail = false; + if (!soft) { + setSelectedSkill(null); + setRelease(null); + setVisibleReleases([]); + setReleaseRows([]); + setHistoryState("loading"); + setDetailState("loading"); + setDetailMessage(null); + setPublicFailure(null); + } registryClient.getSkill(detailSlug) .then(async (skill) => { if (!active) return; if (skill.slug !== detailSlug) { + setSelectedSkill(null); setHistoryState("error"); setDetailMessage("Skill or release not found."); setDetailState("error"); return; } setSelectedSkill(skill); + setPublicFailure(null); try { const rows = await registryClient.listSkillReleases(detailSlug); if (!active) return; - const visible = rows - .filter((row) => row.slug === detailSlug && isExactReleaseVersion(row.version) && isPublishedRelease(row)) - .sort((a, b) => Date.parse(b.publishedAt ?? "") - Date.parse(a.publishedAt ?? "")) - .filter((row, index, all) => all.findIndex((other) => other.version === row.version) === index); - setVisibleReleases(visible); + applyReleaseRows(detailSlug, rows); setHistoryState("ready"); } catch { if (!active) return; setVisibleReleases([]); + setReleaseRows([]); setHistoryState("error"); } }) @@ -638,14 +745,81 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) if (!active) return; setSelectedSkill(null); setRelease(null); - setVisibleReleases([]); - setHistoryState("idle"); + // A manager's management-only history reloads separately; keep it on a soft reload. + if (!soft) { + setVisibleReleases([]); + setReleaseRows([]); + setHistoryState("idle"); + } setDetailMessage(safeErrorMessage(error)); setDetailState("error"); + setPublicFailure(detailSlug); }); return () => { active = false; }; }, [activeView, registryClient, detailSlug, refreshKey]); + // Ask for the management record only when it can matter: in Can manage, for + // privileged roles, when the readable skill says the reader owns it, or when + // the readable detail is unavailable (archived or unpublished). The server + // decides; a denial hides every lifecycle control. + const sessionUserId = session?.user.id ?? null; + const privilegedSession = Boolean(session && isReviewerUser(session.user)); + const skillReady = selectedSkill !== null && selectedSkill.slug === detailSlug; + const ownsReadableSkill = skillReady && Boolean(session && selectedSkill.access?.canManageSharing); + const wantManaged = activeView === "browse" && Boolean(detailSlug) && Boolean(session && registryClient.getManagedSkill) + && (workspaceScope === "manage" || privilegedSession || ownsReadableSkill || publicFailure === detailSlug); + const managedForSlug = managed && managed.slug === detailSlug ? managed : null; + const managedRecord = managedForSlug?.status === "ready" ? managedForSlug.record : null; + const managedLoading = wantManaged && (!managedForSlug || managedForSlug.status === "loading"); + const managementOnly = !skillReady && publicFailure === detailSlug && managedRecord !== null; + + useEffect(() => { + const getManagedSkill = registryClient.getManagedSkill?.bind(registryClient); + if (!wantManaged || !detailSlug || !getManagedSkill) { + softReload.current.managed = false; + setManaged(null); + return; + } + let active = true; + const slug = detailSlug; + const soft = softReload.current.managed; + softReload.current.managed = false; + setManaged((current) => soft && current?.slug === slug ? current : { slug, status: "loading", record: null }); + getManagedSkill(slug) + .then((record) => { + if (!active) return; + setManaged(record && record.slug === slug ? { slug, status: "ready", record } : { slug, status: "denied", record: null }); + }) + .catch((error: unknown) => { + if (!active) return; + const status = apiErrorStatus(error); + setManaged({ slug, status: status === 401 || status === 403 || status === 404 ? "denied" : "error", record: null }); + }); + return () => { active = false; }; + }, [wantManaged, detailSlug, refreshKey, registryClient, sessionUserId, managedRetry]); + + // Readable detail failed but the manager can load the record: its release + // history comes from the same authorised releases read, never from a + // synthesized PublicSkill or release. + useEffect(() => { + if (!managementOnly || !detailSlug) return; + let active = true; + setHistoryState((current) => current === "ready" ? current : "loading"); + registryClient.listSkillReleases(detailSlug) + .then((rows) => { + if (!active) return; + applyReleaseRows(detailSlug, rows); + setHistoryState("ready"); + }) + .catch(() => { + if (!active) return; + setReleaseRows([]); + setVisibleReleases([]); + setHistoryState("error"); + }); + return () => { active = false; }; + }, [managementOnly, detailSlug, registryClient, refreshKey]); + useEffect(() => { if (activeView !== "browse" || !detailSlug || !selectedSkill || selectedSkill.slug !== detailSlug || (historyState !== "ready" && historyState !== "error")) return; @@ -712,14 +886,21 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) ), [release, selectedSkill, supportedDetailPlatform]); const latestVisibleRelease = visibleReleases.find((item) => item.version === selectedSkill?.latestVersion) ?? null; // Guards keep a stale skill or release off screen for the frame between a - // selection change and the effects that reload it. - const skillReady = selectedSkill !== null && selectedSkill.slug === detailSlug; + // selection change and the effects that reload it (skillReady is above). const expectedVersion = selectedVersion ?? selectedSkill?.latestVersion ?? null; const releaseReady = skillReady && release !== null && release.slug === detailSlug && release.version === expectedVersion; const releaseCardState: ReleaseCardState = detailMessage ? "error" : detailState === "ready" && releaseReady ? "ready" : detailState === "ready" && expectedVersion === null ? "no-default" : "loading"; + // Managers may select any release record the server returned to them (from + // Versions); readers select published releases only. + const selectableReleases = managedRecord ? releaseRows : visibleReleases; + // A readable skill defaults only to its latest stable release; with none, it + // stays unselected until an exact choice. Only a management-only record + // (no readable release at all) opens on its newest release record. + const defaultVersion = skillReady ? selectedSkill.latestVersion : managementOnly ? releaseRows[0]?.version ?? null : null; + const workspaceVersion = selectedVersion ?? defaultVersion; const registryStacked = registryLayout === "stack"; const showRegistryList = !(registryStacked && selectedSlug !== null); const showRegistryInspector = registryLayout === "split" || selectedSlug !== null; @@ -728,7 +909,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const pending = pendingRegistryFocus.current; if (!pending || activeView !== "browse") return; if (pending.kind === "title") { - if (skillReady && inspectorTitleRef.current) { + if ((skillReady || managementOnly) && inspectorTitleRef.current) { inspectorTitleRef.current.focus(); pendingRegistryFocus.current = null; } @@ -738,7 +919,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) const rows = registrySurfaceRef.current?.querySelectorAll("a[data-slug]") ?? []; Array.from(rows).find((row) => row.dataset.slug === pending.slug)?.focus(); pendingRegistryFocus.current = null; - }, [activeView, skillReady, showRegistryList, listState, skills]); + }, [activeView, skillReady, managementOnly, showRegistryList, listState, skills]); function selectSkill(slug: string) { searchSelectionQuery.current = null; @@ -752,6 +933,8 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) setSelectedSlug(slug); catalogLocationRef.current = { ...catalogLocationRef.current, bundle: null }; setSelectedBundleId(null); + // A Libraries return belongs to the skill it was opened for. + if (slug !== selectedSlug) updateWorkspaceLocation({ returnTo: null }); pushAppHistory(browseUrl(slug, query, platform, slug === selectedSlug ? selectedVersion : null)); } @@ -760,6 +943,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) searchSelectionQuery.current = null; setSelectedSlug(null); setSelectedVersion(null); + updateWorkspaceLocation({ tab: "overview", returnTo: null }); pushAppHistory(browseUrl(null, query, platform)); } @@ -773,9 +957,45 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) setRelease(null); catalogLocationRef.current = { ...catalogLocationRef.current, bundle: bundleId }; setSelectedBundleId(bundleId); + updateWorkspaceLocation({ tab: "overview", returnTo: null }); pushAppHistory(browseUrl(null, query, platform)); } + /** Scope changes keep the exact skill, version and section; only Can manage drops a bundle selection. */ + function changeScope(nextScope: SkillScope) { + if (nextScope === workspaceScope) return; + searchSelectionQuery.current = null; + setView("browse"); + catalogLocationRef.current = { ...catalogLocationRef.current, bundle: null }; + setSelectedBundleId(null); + updateWorkspaceLocation({ scope: nextScope }); + pushAppHistory(browseUrl(selectedSlug, query, platform, selectedVersion)); + } + + /** A list link keeps this skill's version and return path; another skill starts at its latest release. */ + function skillLinkUrl(slug: string): string { + const same = slug === selectedSlug; + return registryUrl(slug, query, platform, same ? selectedVersion : null, catalogLocationRef.current, { ...workspaceLocationRef.current, returnTo: same ? workspaceLocationRef.current.returnTo : null }); + } + + function scopeHref(nextScope: SkillScope): string { + return registryUrl(selectedSlug, query, platform, selectedVersion, { ...catalogLocationRef.current, bundle: null }, { ...workspaceLocationRef.current, scope: nextScope }); + } + + function selectTab(nextTab: SkillTab) { + if (!detailSlug) return; + // Choosing a section makes an implicit desktop selection explicit. + setSelectedSlug(detailSlug); + updateWorkspaceLocation({ tab: nextTab }); + pushAppHistory(browseUrl(detailSlug, query, platform, selectedVersion)); + } + + /** A saved management change reloads the detail in place so its confirmation stays visible. */ + function reloadAfterManagement() { + softReload.current = { detail: true, managed: true }; + setRefreshKey((value) => value + 1); + } + function changeCatalogView(nextView: RegistryView) { catalogLocationRef.current = { ...catalogLocationRef.current, view: nextView }; setCatalogView(nextView); @@ -783,7 +1003,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) } function selectVersion(version: string) { - const target = visibleReleases.find((item) => item.version === version); + const target = selectableReleases.find((item) => item.version === version); if (!detailSlug || !target || version === selectedVersion) return; const nextPlatform = releasePlatform(target.platforms, platform) ?? platform; // Pinning a version makes an implicit desktop selection explicit. @@ -808,7 +1028,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) setDetailState("loading"); setSelectedSlug(detailSlug); pushAppHistory(browseUrl(detailSlug, query, nextPlatform)); - if (!selectedSkill) setRefreshKey((current) => current + 1); + if (!selectedSkill && !managementOnly) setRefreshKey((current) => current + 1); } function openLanding() { @@ -941,6 +1161,7 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) function navigateTo(nextView: AppView) { setView(nextView); + if (nextView === "libraries") setLibraryNavigationRevision((value) => value + 1); if (nextView === "browse") { pushAppHistory(browseUrl(selectedSlug, query, platform, selectedVersion)); } else { @@ -1031,7 +1252,6 @@ function RegistryContent({ client: registryClient }: { client: RegistryClient }) { view: "browse" as const, label: "Skills", group: "Skills" as const, icon: