From b8648830c198717451036ad1e6127ac8c64dd44d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 23:40:05 -0700 Subject: [PATCH 1/3] Add Plugins skill navigation regressions --- apps/web/test/plugins-page.test.tsx | 123 +++++++++++++++++++++++++-- apps/web/test/stage-top-nav.test.tsx | 2 +- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index 5460ce186..65a24d3a3 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -58,9 +58,36 @@ function json(body: unknown, status = 200): Response { }); } +function nativeValueSetter( + proto: HTMLInputElement | HTMLTextAreaElement, +): (this: HTMLInputElement | HTMLTextAreaElement, value: string) => void { + const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set; + if (setter === undefined) { + throw new Error("native value setter unavailable in this DOM"); + } + return setter; +} + +function fillField(id: string, value: string, textarea = false) { + const el = document.getElementById(id) as + | HTMLInputElement + | HTMLTextAreaElement + | null; + expect(el).not.toBeNull(); + if (el === null) return; + const setter = nativeValueSetter( + textarea + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype, + ); + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); +} + function stubFetch(): void { - globalThis.fetch = ((input: RequestInfo | URL) => { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : String(input); + const method = (init?.method ?? "GET").toUpperCase(); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); if (path.includes("/api/workbench-tenancies/kinds")) @@ -91,7 +118,25 @@ function stubFetch(): void { ); if (path.includes("/credentials/resolve/")) return Promise.resolve(json(null, 404)); - if (path.includes("/api/tenants/tnt_1/skills")) + if (path.includes("/api/tenants/tnt_1/skills")) { + if (method === "POST") { + const parsed = + init?.body === undefined + ? {} + : (JSON.parse(String(init.body)) as { name?: string }); + return Promise.resolve( + json({ + skill: { + assetId: "skill_created", + name: parsed.name ?? "summarize", + description: "Condenses.", + scope: "private", + creatorPrincipalId: "prn_1", + updatedAtIso: "2026-01-01T00:00:00.000Z", + }, + }), + ); + } return Promise.resolve( json({ skills: [ @@ -106,11 +151,14 @@ function stubFetch(): void { ], }), ); + } return Promise.resolve(json({ data: [], nextCursor: null })); }) as typeof fetch; } -async function mount() { +async function mount( + props: { readonly navigate?: (to: string) => void } = {}, +) { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -120,7 +168,10 @@ async function mount() { undefined}> - + undefined)} + /> @@ -163,7 +214,7 @@ async function mountWithDeepLink(provider: string) { - + undefined} /> @@ -303,4 +354,66 @@ describe("PluginsRoute", () => { "Couldn't find that connection — pick it below.", ); }); + + test("clicking a skill card navigates to that skill's page", async () => { + stubFetch(); + const navigated: string[] = []; + const el = await mount({ navigate: (to) => navigated.push(to) }); + + const skillsTab = [...el.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Skills") === true, + ); + act(() => { + skillsTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const card = [...el.querySelectorAll('[role="button"]')].find( + (node) => node.textContent?.includes("weekly-digest") === true, + ); + act(() => { + card?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigated).toContain("/skills/weekly-digest"); + }); + + test("Create skill posts to the registry and opens the new skill's page", async () => { + stubFetch(); + const navigated: string[] = []; + const el = await mount({ navigate: (to) => navigated.push(to) }); + + const skillsTab = [...el.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Skills") === true, + ); + act(() => { + skillsTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const newSkill = [...el.querySelectorAll("button")].find((button) => + button.textContent?.includes("New skill"), + ); + await act(async () => { + newSkill?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await act(async () => { + fillField("create-skill-name", "summarize"); + fillField("create-skill-description", "Condenses.", true); + fillField("create-skill-body", "Do it.", true); + }); + + const create = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent === "Create skill", + ); + await act(async () => { + create?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + for (let i = 0; i < 10; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + expect(navigated).toContain("/skills/summarize"); + }); }); diff --git a/apps/web/test/stage-top-nav.test.tsx b/apps/web/test/stage-top-nav.test.tsx index ab0c2cdef..afef91be5 100644 --- a/apps/web/test/stage-top-nav.test.tsx +++ b/apps/web/test/stage-top-nav.test.tsx @@ -309,7 +309,7 @@ describe("Plugins declares its nav through the top-bar contract", () => { - + From fd7acb9e630c5b72ec663df1bd7b120514b47826 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 23:40:08 -0700 Subject: [PATCH 2/3] Navigate Plugins skills to the Skills page --- .../src/pages/plugin-skill-detail-panel.tsx | 220 ------------------ apps/web/src/pages/plugins-page.tsx | 26 +-- 2 files changed, 12 insertions(+), 234 deletions(-) delete mode 100644 apps/web/src/pages/plugin-skill-detail-panel.tsx diff --git a/apps/web/src/pages/plugin-skill-detail-panel.tsx b/apps/web/src/pages/plugin-skill-detail-panel.tsx deleted file mode 100644 index 3184724f7..000000000 --- a/apps/web/src/pages/plugin-skill-detail-panel.tsx +++ /dev/null @@ -1,220 +0,0 @@ -// A skill's detail, docked on the right of the Plugins gallery (CL-6090): -// the same registry reads and mutations `SkillsPage` already -// calls (`../skills-api.ts`) — share/make private, restore an older -// version — just in the gallery's docked-panel shape instead of Settings' -// master-detail list. Nothing here forks that section; it is a second, -// smaller view over the same data. - -import { - Badge, - Button, - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - formatRelativeTime, -} from "@corbits/react-ui"; -import { WorkbenchLoadingState } from "@corbits/chat-ui"; -import { useEffect, useState } from "react"; - -import { - listSkillVersions, - loadSkill, - restoreSkillVersion, - setSkillScope, - type SkillDetail, - type SkillVersion, -} from "../skills-api"; - -type DetailState = - | { readonly status: "loading" } - | { - readonly status: "ready"; - readonly skill: SkillDetail; - readonly versions: readonly SkillVersion[]; - } - | { readonly status: "error"; readonly message: string }; - -function messageOf(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - -export function PluginSkillDetailPanel({ - tenantId, - skillName, - onClose, - onChanged, -}: { - readonly tenantId: string; - readonly skillName: string | null; - readonly onClose: () => void; - readonly onChanged: () => void; -}) { - const [state, setState] = useState({ status: "loading" }); - const [busy, setBusy] = useState(false); - const open = skillName !== null; - - useEffect(() => { - if (skillName === null) return; - let cancelled = false; - setState({ status: "loading" }); - Promise.all([ - loadSkill(tenantId, skillName), - listSkillVersions(tenantId, skillName), - ]) - .then(([detail, versions]) => { - if (!cancelled) - setState({ status: "ready", skill: detail.skill, versions }); - }) - .catch((cause: unknown) => { - if (!cancelled) - setState({ status: "error", message: messageOf(cause) }); - }); - return () => { - cancelled = true; - }; - }, [tenantId, skillName]); - - async function run(action: () => Promise) { - setBusy(true); - try { - await action(); - onChanged(); - if (skillName !== null) { - const [detail, versions] = await Promise.all([ - loadSkill(tenantId, skillName), - listSkillVersions(tenantId, skillName), - ]); - setState({ status: "ready", skill: detail.skill, versions }); - } - } catch (cause) { - setState({ status: "error", message: messageOf(cause) }); - } finally { - setBusy(false); - } - } - - return ( - { - if (!next) onClose(); - }} - > - - - - {state.status === "ready" ? state.skill.name : (skillName ?? "")} - - - {state.status === "ready" ? state.skill.description : ""} - - - {state.status === "loading" ? ( - - - - ) : state.status === "error" ? ( - -

- {state.message} -

-
- ) : ( - -
-

- {state.skill.scope === "tenant" - ? "Everyone in this workbench can use this skill." - : "Only you can use this skill."} -

- -
-
-              {state.skill.body}
-            
- - - - Version - Note - When - Action - - - - {state.versions.map((version, index) => ( - - - {`Version ${state.versions.length - index}`} - {version.current ? ( - - current - - ) : null} - - {version.message} - - {formatRelativeTime(version.committedAtIso)} - - - - - - ))} - -
-
- )} - - - -
-
- ); -} diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index de0c8a4f2..bc7bad089 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -10,9 +10,9 @@ // Connections surface. See this ticket's report for the full grounding. // // Skills gets the same gallery treatment as plugins (owner ruling): cards, -// not the Settings section's list rows — reusing `../skills-api.ts`'s data -// and mutations through `PluginSkillDetailPanel`, never forking -// `SkillsPage` itself. +// not the Skills roster's list rows. Opening a card (or creating a skill) +// navigates to `/skills/` — the same path `SkillsPage.open` already +// uses. Mutations live there; this gallery never mounts a twin editor. import { Button, PageShell, RichEmptyState } from "@corbits/react-ui"; import { WorkbenchLoadingState } from "@corbits/chat-ui"; @@ -27,6 +27,7 @@ import { Plus, SquaresFour, Warning } from "@corbits/icons"; import { useCallback, useEffect, useState } from "react"; import { useBench } from "../bench-context"; +import { SKILLS_PATH_PREFIX } from "../path-ids"; import { useClearPendingConnectProvider, usePendingConnectProvider, @@ -42,7 +43,6 @@ import { CreateSkillDialog, type SkillCreateInput, } from "./create-skill-dialog"; -import { PluginSkillDetailPanel } from "./plugin-skill-detail-panel"; type PluginsState = | { readonly status: "loading" } @@ -64,9 +64,10 @@ function canOpenPluginPanel(plugin: ResolvedPlugin): boolean { export function PluginsRoute({ path: _path, + navigate, }: { readonly path: string; - readonly navigate?: (to: string) => void; + readonly navigate: (to: string) => void; }) { const { selectedTenantId } = useBench(); const [pluginsState, setPluginsState] = useState({ @@ -76,7 +77,6 @@ export function PluginsRoute({ status: "loading", }); const [openPlugin, setOpenPlugin] = useState(null); - const [openSkillName, setOpenSkillName] = useState(null); const [createSkillOpen, setCreateSkillOpen] = useState(false); const [activeTab, setActiveTab] = useState("plugins"); const [galleryQuery, setGalleryQuery] = useState(""); @@ -143,6 +143,10 @@ export function PluginsRoute({ openPluginPanel, ]); + function openSkill(name: string) { + navigate(`${SKILLS_PATH_PREFIX}/${encodeURIComponent(name)}`); + } + async function handleCreateSkill(input: SkillCreateInput) { if (selectedTenantId === null) return; const skill = @@ -155,7 +159,7 @@ export function PluginsRoute({ }); setCreateSkillOpen(false); reloadSkills(); - setOpenSkillName(skill.name); + openSkill(skill.name); } if (selectedTenantId === null) { @@ -248,7 +252,7 @@ export function PluginsRoute({ plugins={pluginsState.plugins} skills={skillCards} onOpenPlugin={openPluginPanel} - onOpenSkill={(skill) => setOpenSkillName(skill.name)} + onOpenSkill={(skill) => openSkill(skill.name)} activeTab={activeTab} onTabChange={setActiveTab} query={galleryQuery} @@ -260,12 +264,6 @@ export function PluginsRoute({ onClose={() => setOpenPlugin(null)} onChanged={reloadPlugins} /> - setOpenSkillName(null)} - onChanged={reloadSkills} - /> Date: Mon, 24 Aug 2026 09:32:50 -0700 Subject: [PATCH 3/3] Format files changed in this PR --- apps/web/test/plugins-page.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index 65a24d3a3..1f92dc530 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -70,9 +70,7 @@ function nativeValueSetter( function fillField(id: string, value: string, textarea = false) { const el = document.getElementById(id) as - | HTMLInputElement - | HTMLTextAreaElement - | null; + HTMLInputElement | HTMLTextAreaElement | null; expect(el).not.toBeNull(); if (el === null) return; const setter = nativeValueSetter( @@ -156,9 +154,7 @@ function stubFetch(): void { }) as typeof fetch; } -async function mount( - props: { readonly navigate?: (to: string) => void } = {}, -) { +async function mount(props: { readonly navigate?: (to: string) => void } = {}) { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);