diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 4564ba556..6c44b3e72 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -90,9 +90,8 @@ import { workspaceSettingsRoute } from "../../../shell/workspace-routes"; import { isElectronRuntime } from "../../../../app/utils"; import { isCollectibleArtifactTarget, isLocalhostBrowserTarget, isOpenableFileTarget, type OpenTarget } from "../artifacts/open-target"; -import type { OpenTargetOptions } from "@/lib/target-provider"; -import { VoicePanel } from "../voice/voice-panel"; -import { DesignPanel } from "../design/design-panel"; +import type { OpenTargetOptions } from "@/lib/target-provider"; +import { VoicePanel } from "../voice/voice-panel"; import { designAiSelectionToken, type DesignAiSelectionContext } from "@ipollowork/design-studio"; import { useDesignAiSelectionStore } from "../design/design-ai-selection-store"; import { waitForTemplateEntrySurface } from "../templates/template-entry-route"; @@ -616,10 +615,23 @@ export function SessionPage(props: SessionPageProps) { )); const sessionPanelState = useSessionPanelState(props.selectedSessionId ?? ""); const activePanelTab = useActivePanelTab(props.selectedSessionId ?? ""); - const { workspaceApps } = useInstalledPluginContributions( + const { workspaceApps, nativeWorkspaces, loaded: pluginContributionsLoaded } = useInstalledPluginContributions( props.ipolloworkServerClient, props.runtimeWorkspaceId, ); + const designWorkspaceEnabled = !pluginContributionsLoaded + || nativeWorkspaces.some((workspace) => workspace.kind === "design"); + const videoWorkspaceEnabled = !pluginContributionsLoaded + || nativeWorkspaces.some((workspace) => workspace.kind === "video"); + useEffect(() => { + if (!pluginContributionsLoaded || !props.selectedSessionId) return; + for (const tab of sessionPanelState.tabs) { + if ((tab.type === "design" && !designWorkspaceEnabled) + || (tab.type === "video" && !videoWorkspaceEnabled)) { + closeTab(props.selectedSessionId, tab.id); + } + } + }, [closeTab, designWorkspaceEnabled, pluginContributionsLoaded, props.selectedSessionId, sessionPanelState.tabs, videoWorkspaceEnabled]); const [hiddenTargetRevision, setHiddenTargetRevision] = useState(0); const [, setExtensionStateVersion] = useState(0); const hiddenAccessibleTargetIds = useMemo( @@ -775,12 +787,19 @@ export function SessionPage(props: SessionPageProps) { : undefined, [artifactCatalogState, artifactContext, artifactScopeKey], ); - const videoOutput = useMemo(() => ( - currentVideoEntryPath - ? getArtifactsFromMessages(conversationMessages, accessibleTargets, { includeTargetFallbacks: true }) - .find((artifact) => artifactPathMatchesTarget(artifact.path, currentVideoEntryPath)) ?? null - : null - ), [accessibleTargets, conversationMessages, currentVideoEntryPath]); + const videoOutput = useMemo(() => ( + currentVideoEntryPath + ? getArtifactsFromMessages(conversationMessages, accessibleTargets, { includeTargetFallbacks: true }) + .find((artifact) => artifactPathMatchesTarget(artifact.path, currentVideoEntryPath)) ?? null + : null + ), [accessibleTargets, conversationMessages, currentVideoEntryPath]); + const availableStarterTemplateCatalog = useMemo(() => ( + pluginContributionsLoaded + ? starterTemplateCatalog.filter((template) => ( + template.manifest.surface === "design" ? designWorkspaceEnabled : videoWorkspaceEnabled + )) + : starterTemplateCatalog + ), [designWorkspaceEnabled, pluginContributionsLoaded, starterTemplateCatalog, videoWorkspaceEnabled]); const autoCollapsedSidebarRef = useRef(false); const autoCollapsedSidePanelRef = useRef(null); const lastRightPanelViewRef = useRef("launcher"); @@ -797,14 +816,15 @@ export function SessionPage(props: SessionPageProps) { const templateBriefDismissed = Boolean( props.selectedSessionId && dismissedTemplateBriefSessionIds.has(props.selectedSessionId), ); - const activateVideoStudio = useCallback((sessionId: string) => { - // Mark the agent turn as a video task so it receives the session-owned + const activateVideoStudio = useCallback((sessionId: string) => { + if (!videoWorkspaceEnabled) return; + // Mark the agent turn as a video task so it receives the session-owned // project contract. The Studio itself opens only after an output exists. setSessionType(sessionId, "video"); setSessionTypeRevision((value) => value + 1); - }, []); + }, [videoWorkspaceEnabled]); const openCurrentVideoStudio = useCallback((options?: { auto?: boolean }) => { - if (!props.selectedSessionId) return; + if (!props.selectedSessionId || !videoWorkspaceEnabled) return; if (!options?.auto) prioritizeRightPanel(); const videoTabId = `video:${props.selectedSessionId}`; openTab(props.selectedSessionId, { @@ -814,7 +834,7 @@ export function SessionPage(props: SessionPageProps) { sessionId: props.selectedSessionId, }); setSidePanelState(props.selectedSessionId, "panel"); - }, [openTab, prioritizeRightPanel, props.selectedSessionId, selectedSessionTitle, setSidePanelState]); + }, [openTab, prioritizeRightPanel, props.selectedSessionId, selectedSessionTitle, setSidePanelState, videoWorkspaceEnabled]); const refreshTemplateCatalog = useCallback(async () => { if (!props.ipolloworkServerClient || !props.runtimeWorkspaceId) return; const requestId = ++templateCatalogRequestIdRef.current; @@ -1308,8 +1328,9 @@ export function SessionPage(props: SessionPageProps) { const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); - const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { - if (panel === "design" && props.selectedSessionId) { + const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { + if (panel === "design" && props.selectedSessionId) { + if (!designWorkspaceEnabled) return; const entryPath = designTemplateEntryPath?.replaceAll("\\", "/").trim() || ""; const designTabId = entryPath ? `design:${props.selectedSessionId}:${encodeURIComponent(entryPath)}` @@ -1337,10 +1358,10 @@ export function SessionPage(props: SessionPageProps) { setSidePanelState(GLOBAL_VOICE_SIDE_PANEL_KEY, panel === "voice" ? "voice" : null); if (panel === "voice") return; setSidePanelState(props.selectedSessionId, panel); - }, [designTemplateEntryPath, openTab, props.selectedSessionId, selectTab, sessionPanelState.tabs, setSidePanelState]); - - const openDesignTab = useCallback((path?: string) => { - if (!props.selectedSessionId) return; + }, [designTemplateEntryPath, designWorkspaceEnabled, openTab, props.selectedSessionId, selectTab, sessionPanelState.tabs, setSidePanelState]); + + const openDesignTab = useCallback((path?: string) => { + if (!props.selectedSessionId || !designWorkspaceEnabled) return; const normalizedPath = path?.replaceAll("\\", "/").trim() || designTemplateEntryPath?.replaceAll("\\", "/").trim() || ""; if (!normalizedPath) { setCurrentSidePanel("panel"); @@ -1363,7 +1384,7 @@ export function SessionPage(props: SessionPageProps) { selectTab(props.selectedSessionId, designTabId); } setCurrentSidePanel("panel"); - }, [designTemplateEntryPath, openTab, props.selectedSessionId, selectTab, sessionPanelState.tabs, setCurrentSidePanel]); + }, [designTemplateEntryPath, designWorkspaceEnabled, openTab, props.selectedSessionId, selectTab, sessionPanelState.tabs, setCurrentSidePanel]); useEffect(() => { if (!props.selectedSessionId || !designTemplateEntryPath) return; @@ -2194,14 +2215,14 @@ export function SessionPage(props: SessionPageProps) { onClick: addBrowserPanelTab, disabled: !isElectronRuntime(), }, - { - id: "design", - label: "Design", - iconSrc: publicAssetUrl("sidebar-entry-code.svg"), - active: panelRailActive && activePanelTab?.type === "design", - onClick: showDesignRailPane, - disabled: !props.selectedSessionId || props.selectedWorkspaceDisplay.workspaceType === "remote", - }, + ...(designWorkspaceEnabled ? [{ + id: "design", + label: "Design", + iconSrc: publicAssetUrl("sidebar-entry-code.svg"), + active: panelRailActive && activePanelTab?.type === "design", + onClick: showDesignRailPane, + disabled: !props.selectedSessionId || props.selectedWorkspaceDisplay.workspaceType === "remote", + }] : []), { id: "files", label: t("session.side_panel.files"), @@ -2211,14 +2232,14 @@ export function SessionPage(props: SessionPageProps) { onClick: showArtifactRailPane, disabled: !hasArtifactTargets, }, - { - id: "video", - label: t("session.side_panel.video"), - iconSrc: publicAssetUrl("sidebar-entry-video.svg"), - active: videoRailActive, + ...(videoWorkspaceEnabled ? [{ + id: "video", + label: t("session.side_panel.video"), + iconSrc: publicAssetUrl("sidebar-entry-video.svg"), + active: videoRailActive, onClick: showVideoRailPane, disabled: !props.selectedSessionId || props.selectedWorkspaceDisplay.workspaceType === "remote", - }, + }] : []), ...workspaceApps.map((surface) => ({ id: `workspace-app:${surface.id}`, label: surface.label, @@ -2229,7 +2250,7 @@ export function SessionPage(props: SessionPageProps) { onClick: () => openWorkspaceApp(surface), disabled: !props.selectedSessionId, })), - ], [activePanelTab, addBrowserPanelTab, hasArtifactTargets, locale, openWorkspaceApp, panelRailActive, props.selectedSessionId, props.selectedWorkspaceDisplay.workspaceType, showArtifactRailPane, showDesignRailPane, showVideoRailPane, videoRailActive, workspaceApps]); + ], [activePanelTab, addBrowserPanelTab, designWorkspaceEnabled, hasArtifactTargets, locale, openWorkspaceApp, panelRailActive, props.selectedSessionId, props.selectedWorkspaceDisplay.workspaceType, showArtifactRailPane, showDesignRailPane, showVideoRailPane, videoRailActive, videoWorkspaceEnabled, workspaceApps]); const removeAccessibleTarget = useCallback((target: OpenTarget) => { const nextHiddenIds = new Set(hiddenAccessibleTargetIds); nextHiddenIds.add(target.id); @@ -2838,9 +2859,9 @@ export function SessionPage(props: SessionPageProps) { {mainWorkspaceView === null && !showDelayedSessionLoadingState && canRenderReactSurface ? (
- {isDesignSession && templateSessionLoading ? ( -
{t("templates.preparing")}
- ) : isDesignSession && !hasTemplateSession && props.ipolloworkServerClient && props.runtimeWorkspaceId ? ( + {isDesignSession && designWorkspaceEnabled && templateSessionLoading ? ( +
{t("templates.preparing")}
+ ) : isDesignSession && designWorkspaceEnabled && !hasTemplateSession && props.ipolloworkServerClient && props.runtimeWorkspaceId ? ( props.sidebar.onCreateTaskInWorkspace( props.selectedWorkspaceId, type, templateId, PERSONAL_WORK_CONTEXT_ID, )} - onMaterializeTemplate={async (templateId, surface) => { - if (!props.ipolloworkServerClient || !props.runtimeWorkspaceId || !props.selectedSessionId) return; + onMaterializeTemplate={async (templateId, surface) => { + if ((surface === "design" && !designWorkspaceEnabled) + || (surface === "video" && !videoWorkspaceEnabled)) return; + if (!props.ipolloworkServerClient || !props.runtimeWorkspaceId || !props.selectedSessionId) return; const result = await props.ipolloworkServerClient.materializeTemplate( props.runtimeWorkspaceId, templateId, @@ -2928,8 +2951,8 @@ export function SessionPage(props: SessionPageProps) { } openCurrentVideoStudio(); }} - onActivateVideoStudio={activateVideoStudio} - designTemplates={starterTemplateCatalog} + onActivateVideoStudio={videoWorkspaceEnabled ? activateVideoStudio : undefined} + designTemplates={availableStarterTemplateCatalog} designTemplatesLoading={starterTemplateCatalogLoading} designTemplateBusyId={templateBusyId} onInstallDesignTemplate={(templateId) => void installStarterTemplate(templateId)} @@ -3103,13 +3126,17 @@ export function SessionPage(props: SessionPageProps) { left: shellConfig.sidebar && sidebarOpen ? `${effectiveLeftSidebarWidth}px` : "0", } : undefined} > - void; onAskAi?: (context: DesignAiSelectionContext) => void; onSendWorkspaceAppMessage?: (input: { text: string; modelContext: WorkspaceAppModelContext | null }) => boolean | Promise; @@ -460,6 +462,7 @@ export function SidePanel({ workspaceRoot, isRemoteWorkspace = false, launcherItems = [], + enabledNativeWorkspaces = ["design", "video"], onAskAi, onSendWorkspaceAppMessage, onSaveAsTemplate, @@ -471,6 +474,8 @@ export function SidePanel({ }: SidePanelProps) { const { tabs } = useSessionPanelState(sessionId); const activeTab = useActivePanelTab(sessionId); + const designEnabled = enabledNativeWorkspaces.includes("design"); + const videoEnabled = enabledNativeWorkspaces.includes("video"); const isBrowserAvailable = Boolean(getElectronBrowser()); const { createTab, closeTab, selectTab, reorderTabs } = useSidePanelTabs(sessionId); @@ -715,7 +720,7 @@ export function SidePanel({ {!activeTab ? ( ) : null} - {activeTab?.type === "design" ? ( + {activeTab?.type === "design" && designEnabled ? ( - ) : activeTab?.type === "video" ? ( + ) : activeTab?.type === "video" && videoEnabled ? ( ( + contribution.type === "session-side-panel" + && contribution.location === "session-right-pane" + && contribution.ref + ? [{ + id: contribution.ref, + type: "workspace", + label: contribution.label, + description: contribution.description, + }] + : [] + )) ?? [], ]; const skillResources = item.manifest.resources.filter((resource) => resource.type === "skill"); const relatedSkillNames = item.manifest.relatedSkills ?? []; diff --git a/apps/app/src/react-app/plugin-ui/plugin-ui-contributions.ts b/apps/app/src/react-app/plugin-ui/plugin-ui-contributions.ts index 17c49c70e..36ae9705b 100644 --- a/apps/app/src/react-app/plugin-ui/plugin-ui-contributions.ts +++ b/apps/app/src/react-app/plugin-ui/plugin-ui-contributions.ts @@ -26,18 +26,33 @@ export type PluginConversationTemplate = { mode: "work" | "code" | "design" | "video"; }; +export type PluginNativeWorkspace = { + id: string; + pluginId: string; + kind: "design" | "video"; + label: string; + description: string; +}; + export type InstalledPluginContributions = { workspaceApps: PluginUiSurface[]; settingsPages: PluginUiSurface[]; conversationTemplates: PluginConversationTemplate[]; + nativeWorkspaces: PluginNativeWorkspace[]; }; const EMPTY_CONTRIBUTIONS: InstalledPluginContributions = { workspaceApps: [], settingsPages: [], conversationTemplates: [], + nativeWorkspaces: [], }; +const NATIVE_WORKSPACE_KINDS = new Map([ + ["ipollowork.design.panel", "design"], + ["ipollowork.video.panel", "video"], +]); + export const PLUGIN_UI_CONTRIBUTIONS_CHANGED = "ipollowork:plugin-ui-contributions-changed"; export function notifyPluginUiContributionsChanged() { @@ -80,10 +95,26 @@ export function resolveInstalledPluginContributions( const workspaceApps: PluginUiSurface[] = []; const settingsPages: PluginUiSurface[] = []; const conversationTemplates: PluginConversationTemplate[] = []; + const nativeWorkspaces: PluginNativeWorkspace[] = []; for (const item of items) { if (!item.enabled) continue; item.manifest.contributions?.forEach((contribution, index) => { + const nativeKind = contribution.ref ? NATIVE_WORKSPACE_KINDS.get(contribution.ref) : undefined; + if (contribution.type === "session-side-panel" + && contribution.location === "session-right-pane" + && nativeKind + && item.manifest.source.origin === "builtin" + && item.manifest.source.trusted) { + nativeWorkspaces.push({ + id: contributionId(item.pluginId, contribution, index), + pluginId: item.pluginId, + kind: nativeKind, + label: contribution.label?.trim() || item.name, + description: contribution.description?.trim() || item.manifest.description, + }); + return; + } if (contribution.type === "workspace-app" || contribution.type === "settings-page") { const surface = uiSurface(item, contribution, index); if (!surface) return; @@ -107,6 +138,7 @@ export function resolveInstalledPluginContributions( workspaceApps: workspaceApps.sort(byLabel), settingsPages: settingsPages.sort(byLabel), conversationTemplates: conversationTemplates.sort(byLabel), + nativeWorkspaces: nativeWorkspaces.sort(byLabel), }; } @@ -115,20 +147,28 @@ export function useInstalledPluginContributions( workspaceId: string | null | undefined, ) { const [contributions, setContributions] = useState(EMPTY_CONTRIBUTIONS); + const [loaded, setLoaded] = useState(false); useEffect(() => { if (!client || !workspaceId) { setContributions(EMPTY_CONTRIBUTIONS); + setLoaded(false); return; } let active = true; + setLoaded(false); const load = () => { void client.listPluginPackages(workspaceId) .then(({ items }) => { - if (active) setContributions(resolveInstalledPluginContributions(items)); + if (active) { + setContributions(resolveInstalledPluginContributions(items)); + setLoaded(true); + } }) .catch(() => { - if (active) setContributions(EMPTY_CONTRIBUTIONS); + if (active) { + setLoaded(false); + } }); }; load(); @@ -141,5 +181,5 @@ export function useInstalledPluginContributions( }; }, [client, workspaceId]); - return contributions; + return { ...contributions, loaded }; } diff --git a/apps/app/tests/plugin-ui-contributions.test.ts b/apps/app/tests/plugin-ui-contributions.test.ts index db6b81d28..546f3518b 100644 --- a/apps/app/tests/plugin-ui-contributions.test.ts +++ b/apps/app/tests/plugin-ui-contributions.test.ts @@ -27,7 +27,31 @@ describe("plugin UI contributions", () => { workspaceApps: [], settingsPages: [], conversationTemplates: [], + nativeWorkspaces: [], }); expect(resolveInstalledPluginContributions([{ ...item, disabledResourceIds: ["canvas"] }]).workspaceApps).toHaveLength(0); }); + + test("activates trusted native workspaces only through installed packages", async () => { + const manifest = parsePluginPackageManifest(await Bun.file(new URL("../../../examples/plugin-packages/design-agent/ipollowork.plugin.json", import.meta.url)).json()); + const item: iPolloWorkPluginPackageItem = { + pluginId: manifest.id, + name: manifest.name, + version: manifest.package?.version ?? "0.2.0", + enabled: true, + disabledResourceIds: [], + previousVersion: null, + manifest, + integrity: { sha256: "0".repeat(64), status: "unsigned" }, + }; + + expect(resolveInstalledPluginContributions([item]).nativeWorkspaces).toMatchObject([{ + pluginId: "design-agent", + kind: "design", + }]); + expect(resolveInstalledPluginContributions([{ + ...item, + manifest: { ...manifest, source: { ...manifest.source, origin: "local", trusted: false } }, + }]).nativeWorkspaces).toEqual([]); + }); }); diff --git a/apps/server/src/plugin-package-catalog.ts b/apps/server/src/plugin-package-catalog.ts index 40ffae1fc..9b3de1c9c 100644 --- a/apps/server/src/plugin-package-catalog.ts +++ b/apps/server/src/plugin-package-catalog.ts @@ -18,6 +18,8 @@ export const bundledPluginPackageIds = [ "deepseek-harness", ] as const; +export const defaultBundledPluginPackageIds = ["design-agent", "video-agent"] as const; + const moduleDirectory = dirname(fileURLToPath(import.meta.url)); export function bundledPluginPackageRoots(): string[] { diff --git a/apps/server/src/plugin-package-lifecycle.test.ts b/apps/server/src/plugin-package-lifecycle.test.ts index ce60e2797..dd7253b0a 100644 --- a/apps/server/src/plugin-package-lifecycle.test.ts +++ b/apps/server/src/plugin-package-lifecycle.test.ts @@ -253,9 +253,16 @@ describe("plugin package lifecycle", () => { headers: { Authorization: `Bearer ${config.token}` }, }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - items: [{ pluginId: "acme-research", manifest: { authorization: { methods: [{ connectionId: "acme-research" }] } } }], - }); + expect((await response.json()).items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + pluginId: "acme-research", + manifest: expect.objectContaining({ + authorization: expect.objectContaining({ + methods: expect.arrayContaining([expect.objectContaining({ connectionId: "acme-research" })]), + }), + }), + }), + ])); } finally { await server.stop(); } @@ -264,7 +271,7 @@ describe("plugin package lifecycle", () => { ?.manifest.authorization?.methods[0]?.connectionId).toBe("acme-research"); const lifecycleRoot = join(workspaceRoot, "plugin-packages"); expect(JSON.parse(await readFile(join(lifecycleRoot, "state.json"), "utf8"))).toMatchObject({ - schemaVersion: 2, + schemaVersion: 3, packages: { "acme-research": { versions: { "1.0.0": { manifest: { authorization: { methods: [{ connectionId: "acme-research" }] } } } } } }, }); expect(JSON.parse(await readFile(join(lifecycleRoot, "artifacts", "acme-research", "1.0.0", "ipollowork.plugin.json"), "utf8"))).toMatchObject({ @@ -750,13 +757,16 @@ describe("plugin package lifecycle", () => { headers: { authorization: `Bearer ${config.token}` }, }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ items: [{ pluginId: "acme-research", version: "1.0.0" }] }); + expect((await response.json()).items).toEqual(expect.arrayContaining([ + expect.objectContaining({ pluginId: "acme-research", version: "1.0.0" }), + ])); expect(await readFile(join(deepSeekRoot, ".dsh", "skills", "acme-research", "SKILL.md"), "utf8")) .toBe("# Acme Research\n"); await lifecycle.uninstallPluginPackage({ serverConfig: config, pluginId: "acme-research" }); await expectMissing(join(openCodeRoot, ".opencode", "skills", "acme-research", "SKILL.md")); await expectMissing(join(deepSeekRoot, ".dsh", "skills", "acme-research", "SKILL.md")); - expect(await lifecycle.listInstalledPluginPackages({ serverConfig: config })).toEqual([]); + expect((await lifecycle.listInstalledPluginPackages({ serverConfig: config })).map((item) => item.pluginId)) + .not.toContain("acme-research"); } finally { await server.stop(); } @@ -907,7 +917,8 @@ describe("plugin package lifecycle", () => { const removal = await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages/acme-research`, { method: "DELETE", headers }); expect(removal.status).toBe(200); - expect(await (await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages`, { headers })).json()).toEqual({ items: [] }); + const remaining = await (await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages`, { headers })).json(); + expect(remaining.items.map((item: { pluginId: string }) => item.pluginId)).not.toContain("acme-research"); } finally { await server.stop(); } @@ -1072,8 +1083,8 @@ describe("plugin package lifecycle", () => { { pluginId: "context7", version: "1.0.2", installedVersion: null, updateAvailable: false }, { pluginId: "github", version: "0.1.2", installedVersion: null, updateAvailable: false }, { pluginId: "wechat-official", version: "0.1.2", installedVersion: null, updateAvailable: false }, - { pluginId: "design-agent", version: "0.1.2", installedVersion: null, updateAvailable: false }, - { pluginId: "video-agent", version: "0.1.3", installedVersion: null, updateAvailable: false }, + { pluginId: "design-agent", version: "0.2.0", installedVersion: "0.2.0", updateAvailable: false }, + { pluginId: "video-agent", version: "0.2.0", installedVersion: "0.2.0", updateAvailable: false }, { pluginId: "deepseek-harness", version: "0.3.5", installedVersion: null, updateAvailable: false }, ], }); @@ -1242,7 +1253,7 @@ describe("plugin package lifecycle", () => { } }); - test("manages creative Agent skills without touching projects or related global skills", async () => { + test("installs and removes creative workspace packages without touching projects or related global skills", async () => { const workspaceRoot = await createRoot("ipollowork-creative-agent-catalog-api-"); process.env.IPOLLOWORK_RUNTIME_DB = join(workspaceRoot, "runtime.sqlite"); const designDirectory = join(workspaceRoot, "design", "existing-session"); @@ -1264,28 +1275,29 @@ describe("plugin package lifecycle", () => { const packages = [ { pluginId: "design-agent", - version: "0.1.2", + version: "0.2.0", skillPath: join(workspaceRoot, ".opencode", "skills", "ipollowork-design-studio", "SKILL.md"), heading: "# iPolloWork Design Studio", }, { pluginId: "video-agent", - version: "0.1.3", + version: "0.2.0", skillPath: join(workspaceRoot, ".opencode", "skills", "ipollowork-video-studio", "SKILL.md"), heading: "# iPolloWork Video Studio", }, ]; try { + const defaults = await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages`, { headers }); + expect(defaults.status).toBe(200); + expect((await defaults.json()).items).toEqual(expect.arrayContaining( + packages.map((item) => expect.objectContaining({ + pluginId: item.pluginId, + version: item.version, + enabled: true, + })), + )); for (const item of packages) { - const installation = await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages/catalog/${item.pluginId}/install`, { - method: "POST", - headers, - }); - expect(installation.status).toBe(200); - expect(await installation.json()).toMatchObject({ - result: { status: "installed", pluginId: item.pluginId, version: item.version }, - }); expect(await readFile(item.skillPath, "utf8")).toContain(item.heading); } @@ -1314,6 +1326,23 @@ describe("plugin package lifecycle", () => { await expectMissing(item.skillPath); } + const afterRemoval = await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages`, { headers }); + expect(afterRemoval.status).toBe(200); + expect((await afterRemoval.json()).items.map((item: { pluginId: string }) => item.pluginId)) + .not.toEqual(expect.arrayContaining(packages.map((item) => item.pluginId))); + + for (const item of packages) { + const installation = await fetch(`${base}/workspace/${WORKSPACE_ID}/plugin-packages/catalog/${item.pluginId}/install`, { + method: "POST", + headers, + }); + expect(installation.status).toBe(200); + expect(await installation.json()).toMatchObject({ + result: { status: "installed", pluginId: item.pluginId, version: item.version }, + }); + expect(await readFile(item.skillPath, "utf8")).toContain(item.heading); + } + expect(await readFile(designEntry, "utf8")).toBe("
Existing design
\n"); expect(await readFile(videoEntry, "utf8")).toBe("
Existing video
\n"); expect(await readFile(relatedSkill, "utf8")).toBe("# Existing HyperFrames CLI\n"); diff --git a/apps/server/src/plugin-package-lifecycle.ts b/apps/server/src/plugin-package-lifecycle.ts index 5fd0633b4..7db792236 100644 --- a/apps/server/src/plugin-package-lifecycle.ts +++ b/apps/server/src/plugin-package-lifecycle.ts @@ -77,14 +77,20 @@ const lifecycleStateV1Schema = z.object({ schemaVersion: z.literal(1), packages: z.record(z.string(), installedPackageSchema), }); -const lifecycleStateSchema = z.object({ +const lifecycleStateV2Schema = z.object({ schemaVersion: z.literal(2), packages: installedPackagesSchema, }); +const lifecycleStateSchema = z.object({ + schemaVersion: z.literal(3), + packages: installedPackagesSchema, + suppressedDefaultPluginIds: z.array(z.string()).default([]), +}); // Desktop installs created before portable package manifests remain user-owned data. // Keep v1 readable until an explicit artifact migration can rewrite those records safely. const persistedLifecycleStateSchema = z.discriminatedUnion("schemaVersion", [ lifecycleStateV1Schema, + lifecycleStateV2Schema, lifecycleStateSchema, ]); @@ -149,7 +155,7 @@ export type InstalledPluginUiResource = { }; function emptyState(): LifecycleState { - return { schemaVersion: 2, packages: {} }; + return { schemaVersion: 3, packages: {}, suppressedDefaultPluginIds: [] }; } function errorCode(error: unknown): string | null { @@ -372,7 +378,9 @@ function integrityForManifest( async function readState(config: ServerConfig): Promise { try { const state = persistedLifecycleStateSchema.parse(JSON.parse(await readFile(statePath(config), "utf8"))); - return state.schemaVersion === 2 ? state : { schemaVersion: 2, packages: state.packages }; + return state.schemaVersion === 3 + ? state + : { schemaVersion: 3, packages: state.packages, suppressedDefaultPluginIds: [] }; } catch (error) { if (errorCode(error) === "ENOENT") return emptyState(); throw error; @@ -420,7 +428,9 @@ function migrateInstalledManifest(value: unknown, pluginId: string): { manifest: async function readStateAt(path: string): Promise { try { const state = persistedLifecycleStateSchema.parse(JSON.parse(await readFile(path, "utf8"))); - return state.schemaVersion === 2 ? state : { schemaVersion: 2, packages: state.packages }; + return state.schemaVersion === 3 + ? state + : { schemaVersion: 3, packages: state.packages, suppressedDefaultPluginIds: [] }; } catch (error) { if (errorCode(error) === "ENOENT") return null; throw error; @@ -540,8 +550,10 @@ export async function migratePluginPackageLifecycle(config: ServerConfig): Promi if (sources.length === 0) return 0; const packages: LifecycleState["packages"] = {}; + const suppressedDefaultPluginIds = new Set(); let manifestChanged = false; for (const source of sources) { + source.state.suppressedDefaultPluginIds.forEach((pluginId) => suppressedDefaultPluginIds.add(pluginId)); for (const installed of Object.values(source.state.packages)) { const migrated = await migratedPackage(config, source.root, installed); manifestChanged ||= migrated.changed; @@ -554,7 +566,11 @@ export async function migratePluginPackageLifecycle(config: ServerConfig): Promi await writeJsonAtomic(join(artifactRoot(config, installed.pluginId, version.version), MANIFEST_FILE), version.manifest); } } - await writeState(config, lifecycleStateSchema.parse({ schemaVersion: 2, packages })); + await writeState(config, lifecycleStateSchema.parse({ + schemaVersion: 3, + packages, + suppressedDefaultPluginIds: [...suppressedDefaultPluginIds].sort(), + })); for (const source of legacySources) await rm(source.root, { recursive: true, force: true }); return legacySources.length + (manifestChanged ? 1 : 0); } @@ -1074,6 +1090,10 @@ export async function listInstalledPluginPackages(input: { serverConfig: ServerC }).sort((left, right) => left.name.localeCompare(right.name)); } +export async function listSuppressedDefaultPluginIds(input: { serverConfig: ServerConfig }): Promise { + return (await readState(input.serverConfig)).suppressedDefaultPluginIds; +} + export async function readInstalledPluginUiResource(input: { serverConfig: ServerConfig; pluginId: string; @@ -1235,6 +1255,10 @@ export async function installPluginPackage(input: { input.workspaceRoot, existing, ); + if (state.suppressedDefaultPluginIds.includes(existing.pluginId)) { + state.suppressedDefaultPluginIds = state.suppressedDefaultPluginIds.filter((pluginId) => pluginId !== existing.pluginId); + await writeState(input.serverConfig, state); + } return { status: "unchanged", pluginId: existing.pluginId, version: existing.currentVersion }; } const version = await snapshotPackage(input.serverConfig, input.packageRoot, preview); @@ -1247,6 +1271,7 @@ export async function installPluginPackage(input: { previousVersion: null, versions: { [version.version]: version }, }; + state.suppressedDefaultPluginIds = state.suppressedDefaultPluginIds.filter((pluginId) => pluginId !== preview.manifest.id); await writeState(input.serverConfig, state); return { status: "installed", pluginId: preview.manifest.id, version: version.version }; } @@ -1286,6 +1311,7 @@ export async function updatePluginPackage(input: { installed.versions[next.version] = next; installed.currentVersion = next.version; installed.previousVersion = previousVersion; + state.suppressedDefaultPluginIds = state.suppressedDefaultPluginIds.filter((pluginId) => pluginId !== installed.pluginId); await writeState(input.serverConfig, state); return { status: "updated", pluginId: installed.pluginId, previousVersion, version: next.version }; } @@ -1490,6 +1516,9 @@ export async function uninstallPluginPackage(input: { for (const path of filesByPath.keys()) await rm(resolveWithin(workspace.path, path), { force: true }); } delete state.packages[input.pluginId]; + if (manifestFromVersion(current).defaultEnabled && !state.suppressedDefaultPluginIds.includes(input.pluginId)) { + state.suppressedDefaultPluginIds.push(input.pluginId); + } await writeState(input.serverConfig, state); await rm(join(stateDirectory(input.serverConfig), "artifacts", safeSegment(input.pluginId)), { recursive: true, force: true }); return { status: "uninstalled", pluginId: input.pluginId, version: current.version }; diff --git a/apps/server/src/plugin-package-manifest.test.ts b/apps/server/src/plugin-package-manifest.test.ts index d6272aa07..eceaf1607 100644 --- a/apps/server/src/plugin-package-manifest.test.ts +++ b/apps/server/src/plugin-package-manifest.test.ts @@ -238,7 +238,7 @@ describe("plugin package manifest", () => { }]); }); - test("accepts the official Design and Video Agent packages without owning related global skills", async () => { + test("accepts the official Design and Video workspace packages without owning related global skills", async () => { const { validatePluginPackageManifest } = await import("./plugin-package-manifest.js"); const designManifest = await Bun.file(new URL("../../../examples/plugin-packages/design-agent/ipollowork.plugin.json", import.meta.url)).json(); const videoManifest = await Bun.file(new URL("../../../examples/plugin-packages/video-agent/ipollowork.plugin.json", import.meta.url)).json(); @@ -255,10 +255,32 @@ describe("plugin package manifest", () => { expect(video.manifest.relatedSkills).toContain("hyperframes-cli"); expect(video.manifest.relatedSkills).toContain("media-use"); expect(video.manifest.resources.map((resource) => resource.id)).not.toContain("hyperframes-cli"); - expect(design.manifest.contributions).toBeUndefined(); - expect(video.manifest.contributions).toBeUndefined(); + expect(design.manifest.defaultEnabled).toBe(true); + expect(video.manifest.defaultEnabled).toBe(true); + expect(design.manifest.contributions).toMatchObject([{ + type: "session-side-panel", + ref: "ipollowork.design.panel", + location: "session-right-pane", + }]); + expect(video.manifest.contributions).toMatchObject([{ + type: "session-side-panel", + ref: "ipollowork.video.panel", + location: "session-right-pane", + }]); expect(design.manifest.source).toMatchObject({ origin: "builtin", trusted: true }); expect(video.manifest.source).toMatchObject({ origin: "builtin", trusted: true }); + + const untrustedNativePanel = validatePluginPackageManifest({ + ...designManifest, + id: "third-party-design", + source: { ...designManifest.source, origin: "local", trusted: false }, + }); + expect(untrustedNativePanel.success).toBe(false); + if (untrustedNativePanel.success) throw new Error("Expected native session panel trust diagnostics"); + expect(untrustedNativePanel.issues).toContainEqual({ + path: "contributions.0.type", + message: "native session panels are restricted to trusted built-in packages", + }); }); test("accepts version 2 packages and rejects obsolete manifests", async () => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index bafd45539..ab4a7259d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,6 +50,7 @@ import { assertPluginPackageSafeForImport, installPluginPackage, listInstalledPluginPackages, + listSuppressedDefaultPluginIds, migratePluginPackageLifecycle, previewPluginPackage, readInstalledPluginUiResource, @@ -61,7 +62,7 @@ import { updatePluginPackage, } from "./plugin-package-lifecycle.js"; import { withMaterializedPluginPackageUpload } from "./plugin-package-upload.js"; -import { bundledPluginPackageIds, resolveBundledPluginPackageRoot } from "./plugin-package-catalog.js"; +import { bundledPluginPackageIds, defaultBundledPluginPackageIds, resolveBundledPluginPackageRoot } from "./plugin-package-catalog.js"; import { cancelPluginAuthorizationFlow, completePluginBrowserAuthorization, @@ -771,6 +772,70 @@ function isSessionCommandProxyRequest(method: string, proxyPath: string) { return method === "POST" && /^\/session\/[^/]+\/command$/.test(normalizeOpencodeProxyPath(proxyPath)); } +async function ensureDefaultBundledPluginPackages(config: ServerConfig): Promise { + const workspaces = config.workspaces.filter((workspace) => workspace.workspaceType === "local"); + const installWorkspace = workspaces[0]; + if (!installWorkspace) return; + + const logger = createServerLogger(config); + const installedById = new Map( + (await listInstalledPluginPackages({ serverConfig: config })).map((item) => [item.pluginId, item]), + ); + const suppressed = new Set(await listSuppressedDefaultPluginIds({ serverConfig: config })); + + for (const pluginId of defaultBundledPluginPackageIds) { + try { + const packageRoot = await resolveBundledPluginPackageRoot(pluginId); + const preview = await previewPluginPackage({ + packageRoot, + workspaceRoot: installWorkspace.path, + engineId: installWorkspace.engineId ?? DEFAULT_ENGINE_ID, + }); + if (!preview.manifest.defaultEnabled + || preview.manifest.source.origin !== "builtin" + || !preview.manifest.source.trusted) continue; + + const installed = installedById.get(pluginId); + if (!installed && suppressed.has(pluginId)) continue; + if (!installed) { + await installPluginPackage({ + serverConfig: config, + workspaceId: installWorkspace.id, + packageRoot, + workspaceRoot: installWorkspace.path, + }); + } else if (installed.version !== preview.manifest.package?.version) { + await updatePluginPackage({ + serverConfig: config, + workspaceId: installWorkspace.id, + packageRoot, + workspaceRoot: installWorkspace.path, + }); + } + } catch (error) { + logger.log("warn", `Default plugin package could not be prepared: ${pluginId}`, { + pluginId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + for (const workspace of workspaces) { + try { + await reconcilePluginPackagesForWorkspace({ + serverConfig: config, + workspaceId: workspace.id, + workspaceRoot: workspace.path, + }); + } catch (error) { + logger.log("warn", `Plugin package projection could not be reconciled: ${workspace.id}`, { + workspaceId: workspace.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + export async function startServer(config: ServerConfig): Promise { // This is a real migration, not a runtime fallback: legacy template.json // records are moved into the canonical SQLite table before routes exist. @@ -1479,6 +1544,17 @@ function createRoutes( onWorkspacesChanged: () => Promise, ): Route[] { const routes: Route[] = []; + let defaultPluginPreparation: Promise | null = null; + const prepareDefaultPlugins = () => { + if (!defaultPluginPreparation) { + defaultPluginPreparation = ensureDefaultBundledPluginPackages(config) + .catch((error) => { + defaultPluginPreparation = null; + throw error; + }); + } + return defaultPluginPreparation; + }; registerCoreRoutes({ routes, config, @@ -1904,6 +1980,7 @@ function createRoutes( addRoute(routes, "GET", "/workspace/:id/plugin-packages", "client", async (ctx) => { const workspace = await resolveWorkspace(config, ctx.params.id); + await prepareDefaultPlugins(); await reconcilePluginPackagesForWorkspace({ serverConfig: config, workspaceId: workspace.id, workspaceRoot: workspace.path }); const items = await listInstalledPluginPackages({ serverConfig: config }); return jsonResponse({ items }); @@ -1921,6 +1998,7 @@ function createRoutes( addRoute(routes, "GET", "/workspace/:id/plugin-packages/catalog", "client", async (ctx) => { const workspace = await resolveWorkspace(config, ctx.params.id); + await prepareDefaultPlugins(); const installed = await listInstalledPluginPackages({ serverConfig: config }); const installedById = new Map(installed.map((item) => [item.pluginId, item])); const items = await Promise.all(bundledPluginPackageIds.map(async (pluginId) => { diff --git a/evals/flows/official-creative-agent-packs.flow.mjs b/evals/flows/official-creative-agent-packs.flow.mjs index 229871514..fd3aee5cb 100644 --- a/evals/flows/official-creative-agent-packs.flow.mjs +++ b/evals/flows/official-creative-agent-packs.flow.mjs @@ -2,12 +2,20 @@ import { loadVoiceoverParagraphs } from "../runner/voiceover.mjs"; const vo = await loadVoiceoverParagraphs("official-creative-agent-packs"); const DESIGN_TOGGLE = '[role="switch"][aria-label*="Design Studio"]'; -const VIDEO_TOGGLE = '[role="switch"][aria-label*="Video Studio"]'; let sessionRoute = ""; let videoSessionRoute = ""; let videoProjectRoute = ""; +async function setChinese(ctx) { + await ctx.eval(`(() => { + localStorage.setItem("ipollowork.language", "zh"); + return true; + })()`); + await ctx.client.send("Page.reload", { ignoreCache: true }); + await ctx.waitFor("Boolean(window.__ipolloworkControl)", { timeoutMs: 60_000, label: "reloaded Chinese app" }); +} + async function ensureSession(ctx) { await ctx.waitFor("Boolean(window.__ipolloworkControl)", { timeoutMs: 60_000, label: "iPolloWork control API" }); await closeWorkspaceOverlay(ctx); @@ -15,12 +23,17 @@ async function ensureSession(ctx) { timeoutMs: 30_000, label: "create task action", }); + const previousHash = await ctx.eval("location.hash"); await ctx.control("session.create_task"); - await ctx.waitFor(`window.__ipolloworkControl.snapshot().route.includes("/session/")`, { + const nextHash = await ctx.waitFor(`location.hash !== ${JSON.stringify(previousHash)} && location.hash.includes('/session/') && location.hash`, { timeoutMs: 60_000, label: "active task", }); - sessionRoute = await ctx.eval("window.__ipolloworkControl.snapshot().route"); + sessionRoute = String(nextHash).replace(/^#/, ""); + await ctx.waitFor(`window.__ipolloworkControl.snapshot().route === ${JSON.stringify(sessionRoute)}`, { + timeoutMs: 60_000, + label: "active task control route", + }); } async function closeWorkspaceOverlay(ctx) { @@ -34,56 +47,74 @@ async function closeWorkspaceOverlay(ctx) { if (closed) await new Promise((resolve) => setTimeout(resolve, 300)); } -async function openCreativeEntryMenu(ctx) { +async function dismissTemplateBrief(ctx) { + const visible = await ctx.eval(`document.body.innerText.includes('开始制作视频') || document.body.innerText.includes('Start making video')`); + if (!visible) return; + const closed = await ctx.eval(`(() => { + const close = [...document.querySelectorAll('button')].find((button) => + ['关闭', 'Close'].includes(button.getAttribute('aria-label') ?? '') + ); + close?.click(); + return Boolean(close); + })()`); + ctx.assert(closed, "Video template brief could not be dismissed."); + await ctx.waitFor(`!document.body.innerText.includes('开始制作视频') && !document.body.innerText.includes('Start making video')`, { + timeoutMs: 30_000, + label: "dismissed Video brief", + }); +} + +async function ensureSidePanelVisible(ctx) { await closeWorkspaceOverlay(ctx); - await ctx.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape" }); - await ctx.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape" }); - const launcherExpression = `(() => { - const labels = [...document.querySelectorAll('[role="menuitem"], button')].map((entry) => entry.textContent?.trim() ?? ''); - return labels.includes('Design') && (labels.includes('视频') || labels.includes('Video')); - })()`; - await ctx.waitFor(`${launcherExpression} || Boolean([...document.querySelectorAll('button')].find((entry) => - ['添加侧面板入口', 'Add side panel entry', '添加面板', 'Add panel', '打开右侧面板', 'Open right panel'].includes(entry.getAttribute('aria-label') ?? '') - ))`, { timeoutMs: 30_000, label: "creative side panel launcher" }); - let launcherVisible = await ctx.eval(launcherExpression); - if (launcherVisible) return; - const hasAddEntry = await ctx.eval(`Boolean([...document.querySelectorAll('button')].find((entry) => - ['添加侧面板入口', 'Add side panel entry', '添加面板', 'Add panel'].includes(entry.getAttribute('aria-label') ?? '') - ))`); - if (!hasAddEntry) { - await ctx.eval(`(() => { - const button = [...document.querySelectorAll('button')].find((entry) => - ['打开右侧面板', 'Open right panel'].includes(entry.getAttribute('aria-label') ?? '') - ); - button?.click(); - })()`); - await ctx.waitFor(`${launcherExpression} || Boolean([...document.querySelectorAll('button')].find((entry) => - ['添加侧面板入口', 'Add side panel entry', '添加面板', 'Add panel'].includes(entry.getAttribute('aria-label') ?? '') - ))`, { timeoutMs: 30_000, label: "side panel entry control" }); - launcherVisible = await ctx.eval(launcherExpression); - if (launcherVisible) return; + const opened = await ctx.eval(`(() => { + const button = [...document.querySelectorAll('button')].find((entry) => + ['打开右侧面板', 'Open right panel'].includes(entry.getAttribute('aria-label') ?? '') + ); + button?.click(); + return Boolean(button); + })()`); + if (opened) { + await ctx.waitFor(`(() => { + const buttons = [...document.querySelectorAll('button')]; + return buttons.some((entry) => entry.textContent?.trim() === 'Design') + || buttons.some((entry) => ['添加侧面板入口', 'Add side panel entry', '添加面板', 'Add panel'].includes(entry.getAttribute('aria-label') ?? '')); + })()`, { + timeoutMs: 60_000, + label: "visible right panel", + }); } - await ctx.eval(`(() => { +} + +async function showSidePanelEntries(ctx) { + await ensureSidePanelVisible(ctx); + const directEntriesVisible = await ctx.eval(`[...document.querySelectorAll('button')].some((entry) => entry.textContent?.trim() === 'Design')`); + if (directEntriesVisible) return false; + const openedMenu = await ctx.eval(`(() => { const button = [...document.querySelectorAll('button')].find((entry) => ['添加侧面板入口', 'Add side panel entry', '添加面板', 'Add panel'].includes(entry.getAttribute('aria-label') ?? '') ); button?.click(); + return Boolean(button); })()`); - await ctx.waitFor(`(() => { - const labels = [...document.querySelectorAll('[role="menuitem"], button')].map((entry) => entry.textContent?.trim() ?? ''); - return labels.includes('Design') && (labels.includes('视频') || labels.includes('Video')); - })()`, { timeoutMs: 30_000, label: "Design and Video entries" }); + ctx.assert(openedMenu, "Side panel entry control was unavailable."); + await ctx.waitFor(`document.querySelectorAll('[role="menuitem"]').length > 0`, { + timeoutMs: 30_000, + label: "side panel entry menu", + }); + return true; } async function creativeEntriesAvailable(ctx) { - await openCreativeEntryMenu(ctx); - const available = await ctx.eval(`(() => { + const openedMenu = await showSidePanelEntries(ctx); + const available = await ctx.waitFor(`(() => { const labels = [...document.querySelectorAll('[role="menuitem"], button')].map((entry) => entry.textContent?.trim() ?? ''); return labels.includes('Design') && (labels.includes('视频') || labels.includes('Video')); - })()`); - await ctx.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape" }); - await ctx.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape" }); - return available; + })()`, { timeoutMs: 30_000, label: "Design and Video entries" }); + if (openedMenu) { + await ctx.client.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape" }); + await ctx.client.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape" }); + } + return Boolean(available); } async function selectPersonalResourceScope(ctx) { @@ -99,18 +130,18 @@ async function openPluginList(ctx) { await ctx.waitForText("偏好设置", { timeoutMs: 30_000 }); await ctx.navigateHash("/settings/extensions"); await selectPersonalResourceScope(ctx); - await ctx.waitForText("独立插件包", { timeoutMs: 30_000 }); + await ctx.waitForText("个人插件", { timeoutMs: 30_000 }); } async function clickPackageAction(ctx, packageName, labels) { - const clicked = await ctx.eval(`(() => { + await ctx.waitFor(`(() => { const labels = ${JSON.stringify(labels)}; const title = [...document.querySelectorAll('*')].find((candidate) => candidate.children.length === 0 && candidate.textContent?.trim() === ${JSON.stringify(packageName)} ); let parent = title?.parentElement; let button; - for (let depth = 0; parent && depth < 4 && !button; depth += 1, parent = parent.parentElement) { + for (let depth = 0; parent && depth < 8 && !button; depth += 1, parent = parent.parentElement) { button = [...parent.querySelectorAll('button')].find((candidate) => labels.includes(candidate.textContent?.trim() ?? '') && !candidate.disabled ); @@ -118,21 +149,14 @@ async function clickPackageAction(ctx, packageName, labels) { button?.scrollIntoView({ block: 'center' }); button?.click(); return Boolean(button); - })()`); - ctx.assert(clicked, `Could not find ${packageName} action: ${labels.join(", ")}`); + })()`, { + timeoutMs: 30_000, + label: `${packageName} action: ${labels.join(", ")}`, + }); } async function installPackage(ctx, packageName) { - const alreadyInstalled = await ctx.eval(`(() => { - const title = [...document.querySelectorAll('*')].find((candidate) => - candidate.children.length === 0 && candidate.textContent?.trim() === ${JSON.stringify(packageName)} - ); - let parent = title?.parentElement; - for (let depth = 0; parent && depth < 4; depth += 1, parent = parent.parentElement) { - if ([...parent.querySelectorAll('button')].some((candidate) => ['打开', 'Open'].includes(candidate.textContent?.trim() ?? ''))) return true; - } - return false; - })()`); + const alreadyInstalled = await packageIsInstalled(ctx, packageName); if (!alreadyInstalled) { await clickPackageAction(ctx, packageName, ["安装", "Install"]); await ctx.waitFor(`(() => { @@ -140,7 +164,7 @@ async function installPackage(ctx, packageName) { candidate.children.length === 0 && candidate.textContent?.trim() === ${JSON.stringify(packageName)} ); let parent = title?.parentElement; - for (let depth = 0; parent && depth < 4; depth += 1, parent = parent.parentElement) { + for (let depth = 0; parent && depth < 8; depth += 1, parent = parent.parentElement) { if ([...parent.querySelectorAll('button')].some((candidate) => ['打开', 'Open'].includes(candidate.textContent?.trim() ?? ''))) return true; } return false; @@ -148,6 +172,19 @@ async function installPackage(ctx, packageName) { } } +async function packageIsInstalled(ctx, packageName) { + return ctx.eval(`(() => { + const title = [...document.querySelectorAll('*')].find((candidate) => + candidate.children.length === 0 && candidate.textContent?.trim() === ${JSON.stringify(packageName)} + ); + let parent = title?.parentElement; + for (let depth = 0; parent && depth < 8; depth += 1, parent = parent.parentElement) { + if ([...parent.querySelectorAll('button')].some((candidate) => ['打开', 'Open'].includes(candidate.textContent?.trim() ?? ''))) return true; + } + return false; + })()`); +} + async function removeInstalledPackage(ctx, pluginId) { await ctx.navigateHash(`/settings/extensions/plugin/${pluginId}`); await ctx.waitFor(`document.body.innerText.includes('卸载插件') || document.body.innerText.includes('Uninstall plugin') || document.body.innerText.includes('未找到') || document.body.innerText.includes('not found')`, { @@ -167,20 +204,6 @@ async function removeInstalledPackage(ctx, pluginId) { } } -async function removePackageIfInstalled(ctx, pluginId, packageName) { - const installed = await ctx.eval(`(() => { - const title = [...document.querySelectorAll('*')].find((candidate) => - candidate.children.length === 0 && candidate.textContent?.trim() === ${JSON.stringify(packageName)} - ); - let parent = title?.parentElement; - for (let depth = 0; parent && depth < 4; depth += 1, parent = parent.parentElement) { - if ([...parent.querySelectorAll('button')].some((candidate) => ['打开', 'Open'].includes(candidate.textContent?.trim() ?? ''))) return true; - } - return false; - })()`); - if (installed) await removeInstalledPackage(ctx, pluginId); -} - async function setSkillEnabled(ctx, selector, enabled) { await ctx.waitFor(`Boolean(document.querySelector(${JSON.stringify(selector)}))`, { timeoutMs: 30_000, @@ -222,20 +245,25 @@ async function materializeVideoTemplate(ctx) { }); if (!materialized.ok) return { ok: false, stage: 'materialize', status: materialized.status }; const body = await materialized.json(); - return { ok: body?.manifest?.surface === 'video', surface: body?.manifest?.surface ?? null }; + return { + ok: body?.manifest?.surface === 'video', + surface: body?.manifest?.surface ?? null, + }; })()`, { awaitPromise: true }); ctx.assert(result.ok, `Could not materialize the bundled Video template: ${JSON.stringify(result)}`); await ctx.eval("location.reload(); true"); await new Promise((resolve) => setTimeout(resolve, 800)); await ctx.waitFor("Boolean(window.__ipolloworkControl)", { timeoutMs: 60_000, label: "reloaded Video task" }); - await ctx.waitFor(`window.__ipolloworkControl.snapshot().route === ${JSON.stringify(videoSessionRoute)}`, { + await ctx.waitFor(`window.__ipolloworkControl.snapshot().route.includes('/session/')`, { timeoutMs: 60_000, - label: "materialized Video task route", + label: "materialized Video task", }); await ctx.waitFor(`document.body.innerText.includes('开始制作视频') || document.body.innerText.includes('Start making video')`, { timeoutMs: 60_000, label: "materialized Video template", }); + await dismissTemplateBrief(ctx); + videoSessionRoute = await ctx.eval("window.__ipolloworkControl.snapshot().route"); await new Promise((resolve) => setTimeout(resolve, 800)); } @@ -244,20 +272,17 @@ async function openVideoStudio(ctx) { await new Promise((resolve) => setTimeout(resolve, 500)); const alreadyOpen = await ctx.eval(`Boolean(document.querySelector('[data-testid="video-panel"]'))`); if (!alreadyOpen) { - await openCreativeEntryMenu(ctx); - const restoredWhileOpening = await ctx.eval(`Boolean(document.querySelector('[data-testid="video-panel"]'))`); - if (!restoredWhileOpening) { - await ctx.waitFor(`(() => { - const entries = [...document.querySelectorAll('[role="menuitem"], button')]; - return entries.some((entry) => ['视频', 'Video'].includes(entry.textContent?.trim() ?? '') && !entry.disabled); - })()`, { timeoutMs: 30_000, label: "Video side panel entry" }); - await ctx.eval(`(() => { - const entries = [...document.querySelectorAll('[role="menuitem"], button')]; - const entry = entries.findLast((candidate) => ['视频', 'Video'].includes(candidate.textContent?.trim() ?? '') && !candidate.disabled); - entry?.setAttribute('data-fraimz-video-entry', 'true'); - })()`); - await ctx.trustedClick('[data-fraimz-video-entry="true"]'); - } + await showSidePanelEntries(ctx); + await ctx.waitFor(`(() => { + const labels = [...document.querySelectorAll('[role="menuitem"], button')].map((entry) => entry.textContent?.trim() ?? ''); + return labels.includes('视频') || labels.includes('Video'); + })()`, { timeoutMs: 30_000, label: "Video side panel entry" }); + await ctx.eval(`(() => { + const entries = [...document.querySelectorAll('[role="menuitem"], button')]; + const entry = entries.findLast((candidate) => ['视频', 'Video'].includes(candidate.textContent?.trim() ?? '') && !candidate.disabled); + entry?.setAttribute('data-fraimz-video-entry', 'true'); + })()`); + await ctx.eval(`document.querySelector('[data-fraimz-video-entry="true"]')?.click(); true`); } await ctx.waitFor(`document.querySelector('[data-testid="video-panel"] iframe')?.dataset.loaded === "true"`, { timeoutMs: 60_000, @@ -265,14 +290,15 @@ async function openVideoStudio(ctx) { }); const source = await ctx.eval(`document.querySelector('[data-testid="video-panel"] iframe')?.getAttribute('src') ?? ''`); const marker = source.indexOf("?"); - return marker === -1 ? source : source.slice(0, marker); + const normalized = marker === -1 ? source : source.slice(0, marker); + return new URL(normalized).hash; } export default { id: "official-creative-agent-packs", - title: "Official creative Skills stay manageable without changing Design or Video Studio", + title: "Official creative plugins install and uninstall without changing project data", kind: "user-facing", - cdpTarget: { urlIncludes: "localhost:5173" }, + cdpTarget: { urlIncludes: "localhost:" }, precondition: async (ctx) => { await ctx.waitFor("Boolean(window.__ipolloworkControl)", { timeoutMs: 60_000, label: "iPolloWork control API" }); const route = await ctx.eval("window.__ipolloworkControl.snapshot().route"); @@ -282,21 +308,23 @@ export default { }, steps: [ { - name: "Official creative packages appear without replacing task tools", + name: "Official creative plugins are installed by default", run: async (ctx) => { - await ctx.prove("Design Agent and Video Agent appear as official packages while the existing task tools remain available", { + await ctx.prove("Design Agent and Video Agent are installed through the plugin lifecycle while their familiar workspace entries remain available", { voiceover: vo[0], action: async () => { + await setChinese(ctx); await ensureSession(ctx); const toolsAvailable = await creativeEntriesAvailable(ctx); - ctx.assert(toolsAvailable, "Design or Video side panel entry was unavailable before opening settings."); - await openPluginList(ctx); - await removePackageIfInstalled(ctx, "design-agent", "iPolloWork Design Agent"); - await openPluginList(ctx); - await removePackageIfInstalled(ctx, "video-agent", "iPolloWork Video Agent"); + ctx.assert(toolsAvailable, "Design or Video side panel entry was unavailable after default plugin installation."); await openPluginList(ctx); await ctx.waitForText("iPolloWork Design Agent", { timeoutMs: 30_000 }); await ctx.waitForText("iPolloWork Video Agent", { timeoutMs: 30_000 }); + const installed = await Promise.all([ + packageIsInstalled(ctx, "iPolloWork Design Agent"), + packageIsInstalled(ctx, "iPolloWork Video Agent"), + ]); + ctx.assert(installed.every(Boolean), `Creative plugins were not installed by default: ${JSON.stringify(installed)}`); await ctx.eval(`(() => { const target = [...document.querySelectorAll('*')].find((entry) => entry.textContent?.trim() === 'iPolloWork Video Agent'); target?.scrollIntoView({ block: 'center' }); @@ -317,20 +345,24 @@ export default { }, }, { - name: "Design skills are independently manageable", + name: "Design plugin capabilities are independently manageable", run: async (ctx) => { - await ctx.prove("Design Agent groups two official Skills and lets one be disabled independently", { + await ctx.prove("Design Agent owns one workspace app and two Skills while allowing a Skill to be disabled independently", { voiceover: vo[1], action: async () => { await installPackage(ctx, "iPolloWork Design Agent"); await ctx.navigateHash("/settings/extensions/plugin/design-agent"); - await ctx.waitFor(`document.body.innerText.includes('技能 2') || document.body.innerText.includes('Skills 2')`, { + await ctx.waitFor(` + (document.body.innerText.includes('技能 2') || document.body.innerText.includes('Skills 2')) + && (document.body.innerText.includes('应用 1') || document.body.innerText.includes('Apps 1')) + `, { timeoutMs: 30_000, label: "Design Agent detail", }); await setSkillEnabled(ctx, DESIGN_TOGGLE, false); }, assert: async () => { + await ctx.expectText("Design"); await ctx.expectText("Design Studio"); await ctx.expectText("演示文稿"); const state = await ctx.eval(`document.querySelector(${JSON.stringify(DESIGN_TOGGLE)})?.getAttribute('aria-checked')`); @@ -338,7 +370,7 @@ export default { }, screenshot: { name: "design-agent-skills", - requireText: ["iPolloWork Design Agent", "技能 2", "Design Studio", "演示文稿", "卸载插件"], + requireText: ["iPolloWork Design Agent", "应用 1", "技能 2", "Design Studio", "演示文稿", "卸载插件"], rejectText: ["Something went wrong"], hashIncludes: "/settings/extensions/plugin/design-agent", }, @@ -348,7 +380,7 @@ export default { { name: "Design Studio still opens with its Skill disabled", run: async (ctx) => { - await ctx.prove("The built-in Design editor and its existing project controls do not depend on the Design Agent Skill", { + await ctx.prove("The Design workspace remains installed when only its optional Design Studio Skill is disabled", { voiceover: vo[2], action: async () => { await ctx.navigateHash(sessionRoute); @@ -390,57 +422,72 @@ export default { }, }, { - name: "Video Studio still opens after its Skill package is removed", + name: "Uninstalling Video removes its workspace entry", run: async (ctx) => { - await ctx.prove("Video Agent can be removed while the built-in HyperFrames Studio and session project remain available", { + await ctx.prove("Uninstalling Video Agent removes the Video workspace entry while preserving its session project files", { voiceover: vo[3], action: async () => { await ctx.navigateHash(sessionRoute); + await ctx.waitFor(`window.__ipolloworkControl.snapshot().route === ${JSON.stringify(sessionRoute)}`, { + timeoutMs: 60_000, + label: "source task before Video task creation", + }); await closeWorkspaceOverlay(ctx); await ctx.waitFor(`window.__ipolloworkControl.listActions().some((action) => action.id === "session.create_task" && !action.disabled)`, { timeoutMs: 30_000, label: "create Video task action", }); + const previousHash = await ctx.eval("location.hash"); await ctx.control("session.create_task"); - await ctx.waitFor(`window.__ipolloworkControl.snapshot().route.includes('/session/') && window.__ipolloworkControl.snapshot().route !== ${JSON.stringify(sessionRoute)}`, { + const nextHash = await ctx.waitFor(`location.hash !== ${JSON.stringify(previousHash)} && location.hash.includes('/session/') && location.hash`, { timeoutMs: 60_000, label: "new Video task", }); - videoSessionRoute = await ctx.eval("window.__ipolloworkControl.snapshot().route"); + videoSessionRoute = String(nextHash).replace(/^#/, ""); + await ctx.waitFor(`window.__ipolloworkControl.snapshot().route === ${JSON.stringify(videoSessionRoute)}`, { + timeoutMs: 60_000, + label: "Video task control route", + }); await materializeVideoTemplate(ctx); videoProjectRoute = await openVideoStudio(ctx); + ctx.assert( + videoProjectRoute.endsWith(videoSessionRoute.split("/session/").at(-1) ?? ""), + `Video project did not match its materialized task: ${videoSessionRoute} -> ${videoProjectRoute}`, + ); await openPluginList(ctx); - await installPackage(ctx, "iPolloWork Video Agent"); - await ctx.navigateHash("/settings/extensions/plugin/video-agent"); - await ctx.waitFor(`document.body.innerText.includes('技能 2') || document.body.innerText.includes('Skills 2')`, { - timeoutMs: 30_000, - label: "Video Agent detail", - }); - await setSkillEnabled(ctx, VIDEO_TOGGLE, false); await removeInstalledPackage(ctx, "video-agent"); - await ctx.navigateHash(videoSessionRoute); - const reopenedVideoRoute = await openVideoStudio(ctx); - ctx.assert(reopenedVideoRoute === videoProjectRoute, `Video project changed after removing its Skills: ${videoProjectRoute} -> ${reopenedVideoRoute}`); + await ctx.navigateHash(sessionRoute); + await ctx.waitFor(`window.__ipolloworkControl.snapshot().route === ${JSON.stringify(sessionRoute)}`, { + timeoutMs: 60_000, + label: "stable task after Video uninstall", + }); + await showSidePanelEntries(ctx); + await ctx.waitFor(`(() => { + const labels = [...document.querySelectorAll('[role="menuitem"], button')].map((entry) => entry.textContent?.trim() ?? ''); + return !labels.includes('视频') && !labels.includes('Video') && !document.querySelector('[data-testid="video-panel"]'); + })()`, { timeoutMs: 30_000, label: "Video workspace removed" }); }, assert: async () => { const state = await ctx.eval(`(() => ({ - iframe: Boolean(document.querySelector('[data-testid="video-panel"] iframe')), + labels: [...document.querySelectorAll('[role="menuitem"], button')].map((entry) => entry.textContent?.trim() ?? ''), + panel: Boolean(document.querySelector('[data-testid="video-panel"]')), }))()`); - ctx.assert(state.iframe, `Video Studio regressed after removing its Skills: ${JSON.stringify(state)}`); - ctx.assert(videoProjectRoute.includes("/#project/"), `Video project route is wrong: ${videoProjectRoute}`); + ctx.assert(!state.labels.includes("视频") && !state.labels.includes("Video"), `Video entry remained after uninstall: ${JSON.stringify(state.labels)}`); + ctx.assert(!state.panel, "Video panel remained mounted after uninstall."); + ctx.assert(videoProjectRoute.startsWith("#project/"), `Video project route is wrong: ${videoProjectRoute}`); }, screenshot: { - name: "video-core-unchanged", - requireText: ["视频工作室", "就绪"], + name: "video-plugin-uninstalled", + requireText: ["Design"], rejectText: ["Something went wrong"], }, }); }, }, { - name: "Reinstalling Skills restores capability without replacing projects", + name: "Reinstalling Video restores the same project", run: async (ctx) => { - await ctx.prove("Reinstalling the official Skills restores them while the same Design and Video projects remain openable", { + await ctx.prove("Reinstalling Video Agent restores its workspace entry and reopens the same preserved project", { voiceover: vo[4], action: async () => { await openPluginList(ctx); @@ -452,8 +499,14 @@ export default { }); await setSkillEnabled(ctx, DESIGN_TOGGLE, true); const restoredDesignSkill = await ctx.eval(`document.querySelector(${JSON.stringify(DESIGN_TOGGLE)})?.getAttribute('aria-checked')`); - ctx.assert(restoredDesignSkill === "true", `Design Studio Skill was not restored: ${restoredDesignSkill}`); + ctx.assert(restoredDesignSkill === "true", `Design Studio Skill was not re-enabled: ${restoredDesignSkill}`); await ctx.navigateHash(videoSessionRoute); + await ctx.client.send("Page.reload", { ignoreCache: true }); + await ctx.waitFor(`window.__ipolloworkControl.snapshot().route === ${JSON.stringify(videoSessionRoute)}`, { + timeoutMs: 60_000, + label: "preserved Video task", + }); + await dismissTemplateBrief(ctx); const reopenedVideoRoute = await openVideoStudio(ctx); ctx.assert(reopenedVideoRoute === videoProjectRoute, `Video project changed after reinstall: ${videoProjectRoute} -> ${reopenedVideoRoute}`); }, @@ -461,11 +514,11 @@ export default { const state = await ctx.eval(`(() => ({ iframe: Boolean(document.querySelector('[data-testid="video-panel"] iframe')), }))()`); - ctx.assert(state.iframe, `Creative projects were not restored: ${JSON.stringify(state)}`); + ctx.assert(state.iframe, `Video workspace was not restored: ${JSON.stringify(state)}`); }, screenshot: { name: "creative-projects-preserved", - requireText: ["视频工作室", "就绪"], + requireText: ["视频工作室"], rejectText: ["Something went wrong"], }, }); diff --git a/evals/voiceovers/official-creative-agent-packs.md b/evals/voiceovers/official-creative-agent-packs.md index b30c60c31..f3ba8d8a3 100644 --- a/evals/voiceovers/official-creative-agent-packs.md +++ b/evals/voiceovers/official-creative-agent-packs.md @@ -1,11 +1,11 @@ -# official-creative-agent-packs — 管理创作 Skills,不改变核心工作台 +# official-creative-agent-packs — 设计与视频插件安装、卸载和数据保留 -1. 我打开 iPolloWork 的扩展设置,现在可以看到两个由 iPolloWork 官方提供的能力包:Design Agent 和 Video Agent;原来的设计与视频入口完全没有变化。 +1. 我打开 iPolloWork 的扩展设置,可以看到默认安装的两个官方插件:Design Agent 和 Video Agent;原来的设计与视频入口和使用方式保持不变。 -2. 进入 Design Agent,可以集中查看和管理设计相关 Skills,支持整包安装、更新、回滚和卸载,也可以单独启用或禁用其中一个 Skill。 +2. 进入 Design Agent,可以看到它统一包含一个设计工作区和两个相关 Skills;既能整包管理,也能单独启用或禁用某个 Skill。 -3. 我打开一个已有设计模板,画布、模板内容、选区编辑、撤销、主题调整和导出功能都与升级前完全一致,不依赖 Design Agent 是否安装。 +3. 只关闭 Design Studio Skill 后,设计工作区仍然正常可用,画布、模板内容、选区编辑、撤销、主题调整和导出功能保持不变。 -4. 进入 Video Agent,可以管理 HyperFrames 和视频工作流相关 Skills;关闭或删除这些 Skills 后,已有 Video Studio、时间线、预览和视频项目仍然可以正常打开和编辑。 +4. 卸载 Video Agent 后,视频入口和工作区会从界面移除,但已有视频项目和用户文件不会被删除。 -5. 重新安装官方能力包后,对应 Skills 恢复可用;整个过程不会修改、迁移或删除任何设计模板、视频项目和用户文件。 +5. 重新安装 Video Agent 后,视频入口恢复,并重新打开卸载前的同一个项目;整个过程不会修改、迁移或删除任何用户数据。 diff --git a/examples/plugin-packages/design-agent/ipollowork.plugin.json b/examples/plugin-packages/design-agent/ipollowork.plugin.json index 250a81f5d..f8d3a4dc3 100644 --- a/examples/plugin-packages/design-agent/ipollowork.plugin.json +++ b/examples/plugin-packages/design-agent/ipollowork.plugin.json @@ -2,8 +2,9 @@ "schemaVersion": 2, "id": "design-agent", "name": "iPolloWork Design Agent", - "description": "管理 iPolloWork Design Studio 与演示文稿的官方 Agent Skills,不改变内置设计工作台、模板或项目文件。", + "description": "提供 iPolloWork Design Studio、演示文稿工作台与配套 Agent Skills。", "category": "AI Agent 与自动化", + "defaultEnabled": true, "source": { "format": "ipollowork-extension-manifest", "origin": "builtin", @@ -20,7 +21,7 @@ "defaultLocale": "zh", "translations": { "en": { - "description": "Official Agent Skills for iPolloWork Design Studio and presentations, without replacing the built-in design workspace, templates, or project files.", + "description": "iPolloWork Design Studio, presentation workspace, and official Agent Skills.", "category": "AI Agents & Automation", "composer": { "prompt": "Use iPolloWork Design Agent to create or edit content in the current session's design project." }, "resources": { @@ -38,7 +39,7 @@ } }, "package": { - "version": "0.1.2", + "version": "0.2.0", "publisher": { "id": "ipollowork", "name": "iPolloWork" @@ -58,6 +59,15 @@ "reason": "按照用户要求更新当前会话的设计或演示文稿文件。" } ], + "contributions": [ + { + "type": "session-side-panel", + "ref": "ipollowork.design.panel", + "label": "Design", + "description": "在当前会话右侧打开 Design Studio。", + "location": "session-right-pane" + } + ], "resources": [ { "type": "skill", diff --git a/examples/plugin-packages/video-agent/ipollowork.plugin.json b/examples/plugin-packages/video-agent/ipollowork.plugin.json index 9518cf7e6..7ecb4e337 100644 --- a/examples/plugin-packages/video-agent/ipollowork.plugin.json +++ b/examples/plugin-packages/video-agent/ipollowork.plugin.json @@ -2,8 +2,9 @@ "schemaVersion": 2, "id": "video-agent", "name": "iPolloWork Video Agent", - "description": "管理 iPolloWork Video Studio、HyperFrames 与旁白工作流的官方 Agent Skills,不改变内置 Studio、时间线或视频项目。", + "description": "提供 iPolloWork Video Studio、HyperFrames、旁白工作流与配套 Agent Skills。", "category": "AI Agent 与自动化", + "defaultEnabled": true, "source": { "format": "ipollowork-extension-manifest", "origin": "builtin", @@ -20,7 +21,7 @@ "defaultLocale": "zh", "translations": { "en": { - "description": "Official Agent Skills for iPolloWork Video Studio, HyperFrames, and voiceover workflows, without replacing the built-in Studio, timeline, or video project.", + "description": "iPolloWork Video Studio, HyperFrames, voiceover workflows, and official Agent Skills.", "category": "AI Agents & Automation", "composer": { "prompt": "Use iPolloWork Video Agent to create or edit video in the current session's Video Studio project." }, "resources": { @@ -39,7 +40,7 @@ } }, "package": { - "version": "0.1.3", + "version": "0.2.0", "publisher": { "id": "ipollowork", "name": "iPolloWork" @@ -63,6 +64,15 @@ "reason": "运行 HyperFrames 对当前会话项目的只读检查和验证命令。" } ], + "contributions": [ + { + "type": "session-side-panel", + "ref": "ipollowork.video.panel", + "label": "Video", + "description": "在当前会话右侧打开 Video Studio。", + "location": "session-right-pane" + } + ], "relatedSkills": [ "hyperframes", "hyperframes-animation", diff --git a/packages/types/src/plugins.ts b/packages/types/src/plugins.ts index 5038b7de2..38e5678cc 100644 --- a/packages/types/src/plugins.ts +++ b/packages/types/src/plugins.ts @@ -423,6 +423,15 @@ const manifestSchema = z.object({ }); manifest.contributions?.forEach((contribution, index) => { + if (contribution.type === "session-side-panel" + && manifest.package + && (manifest.source.origin !== "builtin" || !manifest.source.trusted)) { + context.addIssue({ + code: "custom", + path: ["contributions", index, "type"], + message: "native session panels are restricted to trusted built-in packages", + }); + } if (contribution.type === "workspace-app" || contribution.type === "settings-page") { if (!contribution.ref) { context.addIssue({ code: "custom", path: ["contributions", index, "ref"], message: "is required for UI contributions" });