feat: move plugin workspace into project settings

This commit is contained in:
inman
2026-09-07 10:06:37 +08:00
parent f8eee430f4
commit be1764e016
19 changed files with 363 additions and 76 deletions

View File

@@ -0,0 +1,86 @@
# Project Configuration owns Plugin navigation
## Status
Proposed for integration; implemented and verified in task
`20260906-move-plugins-project-settings-7c91a4e2`.
## Date
2026-09-06
## Context
The integrated product exposed one reviewed unified Plugin workspace at
`/plugins` through a standalone Code sidebar entry. Project Configuration no
longer contained its predecessor Plugin Services sheet. The user first directed
that Plugins belong in Project Settings, then clarified that the workspace must
live on the same project page used to configure Agents and Skills rather than
replace that page after navigation.
The navigation change must not collapse the existing independent lifecycles for
Marketplace acquisition, device installation, project enablement, Agent Skill
assignment, runtime authorization, and billing. It also must not restore the
retired duplicate sheet or move Main-owned Plugin authority into Renderer code.
## Decision
- The Agent/Skill Project Configuration page is the visible owner of the single
unified Plugin workspace.
- Its `插件` resource card opens the workspace in a wide same-page drawer. The
Project Configuration page remains mounted behind it.
- `/project-config/plugins` is the canonical deep link for the drawer-open state;
it renders Project Configuration rather than a standalone Plugin page.
- `/plugins` preserves query and navigation state while redirecting to the
canonical URL. `/plugin-marketplace`, `/my-plugins`, and `/project-plugins`
remain deterministic filtered compatibility redirects.
- The Code sidebar has no standalone Plugin entry.
- The canonical workspace remains accessible before Code Provider initialization
and when no project is active, so Marketplace and account/device views do not
incorrectly depend on local project selection.
- The existing unified controller, data sources, mutation semantics,
authentication, and lifecycle separation remain unchanged.
## Rationale
Plugin installation and activation configure resources that projects may use, so
Project Configuration is the clearer navigation owner. Reusing the unified
workspace avoids competing product surfaces and preserves one authoritative view
across Marketplace, Account Library, device packages, and project status.
Compatibility redirects protect existing deep links and authenticated return
flows while establishing an unambiguous canonical location.
## Consequences
- Project Configuration gains a Plugin card beside models, Skills, and knowledge,
with access retained in the no-active-project state.
- Closing the Plugin drawer returns to the already-mounted Project Configuration
page; Agent assignment actions close the drawer to expose that same page.
- Existing bookmarks continue to work, but tests and new product links should use
`/project-config/plugins`.
- Canonical project memory that names the Code sidebar or `/plugins` as the sole
current entry must be superseded during Integration Mode.
- No backend contract, package trust boundary, runtime permission, billing rule,
or native Web Search behavior changes.
## Supersedes
- The navigation-only claim that the Code sidebar owns the single `插件` entry and
`/plugins` is the canonical product route.
- It does not supersede the retirement of the duplicate Project Configuration
Plugin Services sheet; that implementation remains retired.
## Evidence
- User direction: “插件应该在项目设置中”, clarified as “是放在配置agent、skill那个项目页”.
- Focused unit verification: 62/62 passed.
- Electron Plugin journeys: 4/4 passed.
- Full unit, typecheck, lint, and Vite production build passed.
## Related
- `.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md`
- `.project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md`
- `src/pages/ProjectConfiguration/index.tsx`
- `src/pages/Plugins/index.tsx`
- `src/pages/Plugins/legacy-plugin-redirect.tsx`

View File

@@ -0,0 +1,100 @@
# Task: Move plugins into project settings
## Identity
- Task ID: 20260906-move-plugins-project-settings-7c91a4e2
- Mode: Feature
- Branch: codex/20260906-move-plugins-project-settings-7c91a4e2-move-plugins-project-settings
- Worktree: /Users/inmanx/Documents/makelore-task-move-plugins-project-settings-7c91a4e2
- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b
- Owner: codex
- Status: Ready for integration
## Scope
- Move the single user-visible Plugin workspace from the Code sidebar directly
into the same Project Configuration page used to configure Agents, Skills,
models, and knowledge.
- Make `/project-config/plugins` the canonical Plugin URL while retaining
deterministic compatibility redirects from `/plugins`, `/plugin-marketplace`,
`/my-plugins`, and `/project-plugins`.
- Update focused unit and Electron E2E coverage, the packaged-product marker, and
README current-state wording for the navigation change.
## Intent And Constraints
- Preserve the existing unified Plugin workspace and its projections of
Marketplace, Account Library, official-device state, Device Packages, current
project state, and retained IDs; do not restore the retired duplicate Plugin
Services sheet.
- Keep acquisition, device installation, project enablement, Agent Skill
assignment, runtime authorization, and billing as separate lifecycles.
- Preserve Main-owned Host API, authentication, package verification, and runtime
authority. Renderer navigation must not introduce direct IPC or local-runtime
HTTP access.
- Keep Project Configuration and its nested Plugin route available before Code
Provider initialization, including the no-active-project state.
- Render the nested route as a same-page Project Configuration drawer, not as a
replacement standalone Plugin page.
- Preserve legacy deep-link query/state and authenticated return paths.
- Keep the single Makelore light visual system and Chinese product copy.
## Outcome
- Added a `插件` ResourceCard alongside models, Skills, and knowledge in Project
Configuration, retained access in the no-active-project state, and removed the
standalone Code sidebar Plugin entry.
- Changed `/project-config/plugins` to render the Project Configuration page with
its wide Plugin drawer open. The Agent/Skill project page remains mounted behind
the drawer; closing it returns to the same page. Query/state-preserving
`/plugins` compatibility and existing filtered legacy redirects remain intact.
- Reused the unified Plugin controller and view inside the drawer without
restoring the retired duplicate Plugin Services implementation.
- Kept the unified Plugin controller, data sources, mutations, authentication,
and lifecycle semantics unchanged.
- Updated initialization gates, login return path, README, packaged Marketplace
artifact assertion, focused unit tests, and Electron E2E journeys.
## Verification
- Focused navigation, layout, embedded Plugin, controller, login, and artifact
verification — 6 files, 62 tests passed; the embedded-view regression failed
before implementation and passed afterward.
- `corepack pnpm run typecheck` — passed.
- Scoped ESLint across all changed source and test files — passed with no output.
- `corepack pnpm run build:vite` — Renderer, Main, Preload, and utility builds passed;
only existing Browserslist, mixed-import, and chunk-size warnings remained.
- `node ./node_modules/@playwright/test/cli.js test tests/e2e/project-plugins.spec.ts tests/e2e/plugin-marketplace.spec.ts` — 4/4 passed, including assertions that the Project Configuration page remains mounted behind the Plugin drawer.
- `corepack pnpm test` — 221 regular files (1861 passed, 3 skipped) and the pressure suite (1 passed).
- `corepack pnpm run lint:check` — passed with 0 errors and 5 pre-existing warnings.
- `git diff --check` — passed.
## Follow-ups
- Integration should reconcile the navigation wording in canonical project memory
and review overlap with planning task `20260906-remove-project-gate-7c31` before
merging. No product-semantic conflict was found in the current peer-task audit.
## Promotion Candidates
- Target: `.project-docs/30-worklog/current-state.md`,
`.project-docs/20-architecture/system-overview.md`, and
`.project-docs/40-domain/business-rules.md`.
- Proposal: record the Agent/Skill Project Configuration page as the visible
owner of the single unified Plugin workspace. Its `插件` card opens a same-page
wide drawer; `/project-config/plugins` is the canonical deep link for that
state, and old public URLs remain deterministic compatibility redirects. This
rehomes the reviewed unified workspace and does not restore the superseded
duplicate Plugin Services implementation.
- Evidence: explicit user direction in this task, focused unit coverage, 4/4
Electron E2E, full unit/typecheck/lint/build verification, and the task-owned
proposal `20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md`.
- Future impact: new Plugin navigation must originate in Project Configuration,
while Plugin lifecycle boundaries, Web Search exclusion, and Main-owned
authority remain unchanged.
- Semantic conflict: this supersedes canonical statements that the Code sidebar
owns the only Plugin entry and `/plugins` is canonical; it does not conflict
with ADR-008 or the Plugin lifecycle boundaries.
- Human confirmation: already supplied and clarified by the user: Plugins belong
on the same project page used to configure Agents and Skills, not on a separate
page. No additional product-direction approval is required during integration.

View File

@@ -28,7 +28,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工
- AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote客户端只展示媒介、画幅、数量、格式、时长、警告和设计点不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。
- AI 绘画健壮性Renderer 为每次命令生成稳定 operation id网络结果未知时保留原命令供原样重试不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。
- AI 绘画项目栏只展示 Workspace不再在项目下创建独立设计会话。删除时必须完整输入项目名称删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。
- 插件工作台Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:``local:``retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority不会自动推进下一步。任一来源失败不会清空其他来源本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。
- 项目插件设置Code 侧栏不再单列“插件”;配置 Agent、Skill、模型与知识的项目配置页内提供“插件”卡片并在同页宽抽屉中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态。深链使用 `/project-config/plugins` 打开该项目页抽屉;旧 `/plugins`Marketplace、My Plugins、Project Plugins URL 只做保留查询条件的确定性重定向。来源身份保持 `official:``local:``retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority不会自动推进下一步。任一来源失败不会清空其他来源本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。
- 官方插件运行架构Renderer 只调用 Main-owned Marketplace facadeMain 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI普通 `skill_only` 下载包不依赖运行时 Policy也不得携带可执行脚本。Project Scaffold 的 `.mjs` 是代码所有的 bundled 例外,只能从签名客户端固定资源根通过应用自带 Node 执行,不能扩展为 Marketplace 下载 artifact 的脚本权限。正式激活仍等待官方 Ed25519 公钥production key activation HOLD生产私钥只能来自部署 secret测试使用注入的临时密钥。
- 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。
- 本机 Device Packages 只由 Conversation 工具发起安装,支持 npm、Git、绝对本地 Plugin 目录和 loose `SKILL.md`。Main 在预览和独立确认后提交不可变本机 generation禁用生命周期脚本确认界面明确提示 Pi extension 以桌面当前用户权限执行。新建/空闲 parent 会同时加载所有显式安装且当前启用的 Skill以及必需 Makelore 扩展之外的全部 Pi extensionactive parent 在 turn settled 后切换child 不继承这些资源,也不会扫描 ambient 项目或用户目录Renderer 不提供安装源选择器。

View File

@@ -69,7 +69,7 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({
'/api/coding/plugin-marketplace/library',
'免费获取',
'获取、项目启用与伙伴分配彼此独立',
'/plugins',
'/project-config/plugins',
]),
});
const FORBIDDEN_PROVIDER_AUTHORITY_MARKERS = Object.freeze([

View File

@@ -26,14 +26,17 @@ import { subscribeHostEvent } from '@/lib/host-events';
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
import { resolveSupportedLanguage } from '../shared/language';
import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect';
import {
LegacyPluginRedirect,
PluginWorkspaceRedirect,
PROJECT_SETTINGS_PLUGINS_PATH,
} from './pages/Plugins/legacy-plugin-redirect';
const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component })));
const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: component })));
const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component })));
const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component })));
const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component })));
const Plugins = lazy(() => import('./pages/Plugins').then(({ Plugins: component }) => ({ default: component })));
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component })));
@@ -442,7 +445,8 @@ function App() {
)}
>
<Route path="/project-config" element={<ProjectConfiguration />} />
<Route path="/plugins" element={<Plugins />} />
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<ProjectConfiguration />} />
<Route path="/plugins" element={<PluginWorkspaceRedirect />} />
<Route path="/plugin-marketplace" element={<LegacyPluginRedirect from="/plugin-marketplace" />} />
<Route path="/my-plugins" element={<LegacyPluginRedirect from="/my-plugins" />} />
<Route path="/project-plugins" element={<LegacyPluginRedirect from="/project-plugins" />} />

View File

@@ -38,6 +38,7 @@ export function MainLayout() {
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/chat';
const isInitializationSafeRoute = location.pathname === '/project-config'
|| location.pathname.startsWith('/project-config/')
|| location.pathname === '/plugins'
|| !isProgrammingModule;

View File

@@ -12,7 +12,6 @@ import {
FolderKanban,
LogOut,
Plus,
Puzzle,
Settings as SettingsIcon,
UserCircle2,
} from 'lucide-react';
@@ -584,23 +583,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建项目'}
</button>
<div className="mt-3 grid gap-1">
<button
type="button"
data-testid="sidebar-nav-plugins"
aria-current={location.pathname === '/plugins' ? 'page' : undefined}
aria-label="插件"
onClick={() => navigate(activeProject ? '/plugins?scope=project' : '/plugins?scope=all')}
className={cn(
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
location.pathname === '/plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
sidebarCollapsed && 'justify-center px-0',
)}
>
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed ? <span></span> : null}
</button>
</div>
<div className="mt-3">
<button
type="button"

View File

@@ -90,6 +90,7 @@ export function isAiModuleAllowed(moduleId: AiModuleId, access: ModuleAccess): b
}
export function isProgrammingProviderRoute(pathname: string): boolean {
if (matchesRoute(pathname, '/project-config/plugins')) return false;
return PROGRAMMING_PROVIDER_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route));
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, type FormEvent } from 'react';
import { AlertCircle, Puzzle, RefreshCw, Search } from 'lucide-react';
import { AlertCircle, ArrowLeft, Puzzle, RefreshCw, Search } from 'lucide-react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { DataServiceInstanceState } from '../../../shared/data-service';
import { useAuthStore } from '@/stores/auth';
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
@@ -23,6 +24,7 @@ import type {
} from './plugin-workspace-model';
import { buildPluginWorkspaceProjection, isBundledOfficialPlugin } from './plugin-workspace-model';
import { resolvePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query';
import { PROJECT_SETTINGS_PLUGINS_PATH } from './legacy-plugin-redirect';
const DELIVERY_TEXT = {
system_included: '随 MakeLore 提供',
@@ -151,6 +153,8 @@ export interface PluginsViewProps {
onResetDataService(): void | Promise<void>;
onRemoveDataServiceCollection(collection: string): void | Promise<void>;
onRemoveDataServiceProject(): void | Promise<void>;
embedded?: boolean;
onBackToProjectSettings?(): void;
}
export function PluginsView(props: PluginsViewProps) {
@@ -176,21 +180,46 @@ export function PluginsView(props: PluginsViewProps) {
};
return (
<main data-testid="plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-brand">MakeLore Code</p>
<main
data-testid="plugins-page"
className={cn('w-full text-foreground', !props.embedded && 'mx-auto max-w-7xl')}
>
{props.embedded ? (
<div className="flex justify-end">
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
) : <header className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
{props.onBackToProjectSettings ? (
<Button
type="button"
variant="ghost"
size="icon"
data-testid="project-plugins-back-button"
aria-label="返回项目配置"
title="返回项目配置"
onClick={props.onBackToProjectSettings}
className="mt-0.5 h-8 w-8 shrink-0 text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<div className="min-w-0">
<p className="text-sm font-medium text-brand"></p>
<h1 className="mt-1 text-balance text-3xl font-semibold tracking-[-0.03em]"></h1>
<p className="mt-2 max-w-3xl text-pretty text-sm leading-6 text-muted-foreground">
MakeLore Skill Pi extension
MakeLore Skill Pi extension
</p>
</div>
</div>
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</header>
</header>}
<section aria-label="插件筛选" className="mt-6 rounded-2xl border border-border/70 bg-surface-subtle p-4">
<section aria-label="插件筛选" className={cn(props.embedded ? 'mt-4' : 'mt-6', 'rounded-2xl border border-border/70 bg-surface-subtle p-4')}>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-[0.8fr_0.8fr_1fr_2fr_auto]">
<label className="text-sm font-medium">
@@ -351,7 +380,7 @@ function scopeFallbackSearchFromLocationState(state: unknown): string | null {
return typeof value === 'string' ? value : null;
}
export function Plugins() {
export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -613,7 +642,7 @@ export function Plugins() {
marketplace: pluginMarketplaceStore.getState(),
device: devicePackageStore.getState(),
project: codingPluginsStore.getState(),
loginReturnPath: `/plugins?${serializePluginWorkspaceSearch(filters).toString()}`,
loginReturnPath: `${PROJECT_SETTINGS_PLUGINS_PATH}?${serializePluginWorkspaceSearch(filters).toString()}`,
navigate: (path, options) => navigate(path, options),
openSettings: () => document.getElementById('plugin-settings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
}));
@@ -641,6 +670,8 @@ export function Plugins() {
onResetDataService={() => safe(codingPluginsStore.getState().reset())}
onRemoveDataServiceCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))}
onRemoveDataServiceProject={() => safe(codingPluginsStore.getState().removeProject())}
embedded={embedded}
onBackToProjectSettings={embedded ? undefined : () => navigate('/project-config')}
/>
);
}

View File

@@ -1,13 +1,26 @@
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
export const PROJECT_SETTINGS_PLUGINS_PATH = '/project-config/plugins';
export type LegacyPluginPath = '/plugin-marketplace' | '/my-plugins' | '/project-plugins';
const LEGACY_PLUGIN_REDIRECTS: Record<LegacyPluginPath, string> = {
'/plugin-marketplace': '/plugins?scope=all&source=official',
'/my-plugins': '/plugins?scope=all&state=mine',
'/project-plugins': '/plugins?scope=project',
'/plugin-marketplace': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=official`,
'/my-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`,
'/project-plugins': `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project`,
};
export function LegacyPluginRedirect({ from }: { from: LegacyPluginPath }) {
return <Navigate to={LEGACY_PLUGIN_REDIRECTS[from]} replace />;
}
export function PluginWorkspaceRedirect() {
const location = useLocation();
return (
<Navigate
to={{ pathname: PROJECT_SETTINGS_PLUGINS_PATH, search: location.search }}
state={location.state}
replace
/>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react';
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Puzzle, Trash2, Upload, X } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -8,6 +8,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet';
import { AgentCreationDialog, type AgentCreationInput } from '@/components/coding/AgentCreationDialog';
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { Plugins } from '@/pages/Plugins';
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { abortCodingConversation } from '@/lib/coding-conversations';
@@ -70,6 +72,23 @@ function DrawerFrame({ title, desc, onClose, children, closeLabel = '关闭', cl
return <div className="flex min-h-0 flex-1 flex-col"><div className="glass-surface drawer-motion-header flex items-center justify-between gap-3 border-b border-border/70 bg-background px-5 py-4"><div className="min-w-0 flex-1"><SheetTitle className="text-lg font-semibold">{title}</SheetTitle><SheetDescription className="mt-1 text-sm font-medium text-muted-foreground">{desc}</SheetDescription></div>{closeIcon === 'back' ? closeButton : <SheetClose asChild>{closeButton}</SheetClose>}</div><div className="drawer-motion-body min-h-0 flex-1 overflow-auto p-5">{children}</div></div>;
}
function PluginWorkspaceSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
return <Sheet open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<SheetContent
data-testid="project-plugins-sheet"
className="glass-surface flex w-[min(1080px,96vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none"
>
<DrawerFrame
title="插件"
desc="在当前项目页查看插件、启用项目能力并管理伙伴分配;获取、设备安装和项目启用仍彼此独立,本机 Skill 和 Pi extension 仍仅通过对话安装。"
onClose={onClose}
>
<Plugins embedded />
</DrawerFrame>
</SheetContent>
</Sheet>;
}
function SkillDetail({ skill }: { skill: SkillInfo }) {
return <div data-testid="skill-detail-view" className="space-y-4">
<section data-testid="skill-structure" className="rounded-xl border border-foreground/15 bg-surface-subtle p-4">
@@ -198,8 +217,14 @@ export function ProjectConfiguration() {
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
const playSaveConfirmation = () => undefined;
const handleBack = () => navigate(-1);
const pluginWorkspaceOpen = location.pathname === PROJECT_SETTINGS_PLUGINS_PATH;
const openPluginWorkspace = () => navigate({
pathname: PROJECT_SETTINGS_PLUGINS_PATH,
search: `?scope=${activeProject ? 'project' : 'all'}`,
});
const closePluginWorkspace = () => navigate('/project-config', { replace: true });
if (!activeProject) return <main data-testid="project-configuration-empty-state" className="flex min-h-full items-start bg-background p-8 text-foreground"><p className="pt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p></main>;
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置智能体、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看账号与本机插件" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
@@ -455,7 +480,7 @@ export function ProjectConfiguration() {
</form>
)}
</section>
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} `} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} `} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} `} onClick={() => setDrawerMode('knowledge')} /></section>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} `} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} `} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} `} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理获取、启用与分配状态" onClick={openPluginWorkspace} /></section>
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</section>
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
@@ -492,6 +517,7 @@ export function ProjectConfiguration() {
{drawerMode === 'skills' && activeSkill ? <DrawerFrame title={`${activeSkill.name} · `} desc="查看技能目录结构和主文件 SKILL.md。" onClose={() => setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"><SkillDetail skill={activeSkill} /></DrawerFrame> : null}
{drawerMode === 'skills' && !activeSkill ? <DrawerFrame title="可用技能" desc="查看技能目录与主文件;在智能体配置中启用所需技能。" onClose={() => setDrawerMode(null)}><div className="space-y-3">{skills.length > 0 ? skills.map((skill) => <button key={skill.id} type="button" data-testid={`skill-card-${skill.id}`} aria-label={`${skill.name}`} onClick={() => setActiveSkillId(skill.id)} className="w-full rounded-lg border border-foreground/15 bg-white p-3 text-left transition hover:border-brand/40 hover:bg-brand-soft/30"><div className="flex items-start gap-3"><Blocks className="mt-0.5 h-4 w-4 shrink-0 text-brand" /><span className="min-w-0 flex-1"><span className="block font-semibold">{skill.name}</span><span className="mt-1 block text-sm text-muted-foreground">{skill.description}</span><span className="mt-2 block text-[11px] font-medium text-brand">查看目录与 SKILL.md</span></span><ArrowLeft className="mt-0.5 h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" /></div></button>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">当前没有可查看的技能。</div>}</div></DrawerFrame> : null}
</SheetContent></Sheet>
<PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} />
<ConfirmDialog
open={Boolean(archiveAgentId)}
title={`${draft.agents.find((agent) => agent.id === archiveAgentId)?.name || '未命名智能体'}`}

View File

@@ -86,10 +86,13 @@ test.describe('Unified plugin workspace', () => {
))).toBe(true);
await page.getByTestId('ai-module-option-programming').click();
await page.keyboard.press('Escape');
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-empty-state')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page).toHaveURL(/\/plugins\?scope=all/);
await expect(page).toHaveURL(/\/project-config\/plugins\?scope=all/);
await expect(page.getByTestId('plugins-page').getByText('尚未获取')).toBeVisible();
await page.getByRole('button', { name: '查看灵感笔记详情' }).click();
await expect(page.getByRole('dialog', { name: '灵感笔记' })).toContainText('详细介绍');
@@ -148,8 +151,11 @@ test.describe('Unified plugin workspace', () => {
});
});
await page.getByTestId('ai-module-option-programming').click();
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-empty-state')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page.getByRole('alert')).toContainText('官方目录刷新失败');
await expect(page.getByRole('heading', { name: 'Local Search' })).toBeVisible();
@@ -274,7 +280,10 @@ test.describe('Unified plugin workspace', () => {
))).toBe(true);
await page.getByTestId('ai-module-option-programming').click();
await page.keyboard.press('Escape');
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-empty-state')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0);
await expect(page.getByTestId('sidebar-nav-my-plugins')).toHaveCount(0);

View File

@@ -89,9 +89,13 @@ test.describe('Unified project plugin workspace', () => {
});
});
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page).toHaveURL(/\/plugins\?scope=project/);
await expect(page).toHaveURL(/\/project-config\/plugins\?scope=project/);
await expect(page.getByText('项目配置')).toBeVisible();
await page.getByRole('button', { name: '查看开发数据服务详情' }).click();
await expect(page.getByText('按平台包含,不按单次插件调用扣点。')).toBeVisible();
const localProjectId = await app.evaluate(() => (

View File

@@ -81,7 +81,7 @@ function renderLogin(initialEntries: React.ComponentProps<typeof MemoryRouter>['
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
<Route path="/plugins" element={<LocationProbe />} />
<Route path="/project-config/plugins" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
@@ -91,7 +91,7 @@ function renderPluginLoginJourney(canonicalPath: string) {
return render(
<MemoryRouter initialEntries={[canonicalPath]}>
<Routes>
<Route path="/plugins" element={<PluginSignInAction />} />
<Route path="/project-config/plugins" element={<PluginSignInAction />} />
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
@@ -245,7 +245,7 @@ describe('Login page', () => {
});
it('returns a signed-out plugin action to the exact canonical workspace query after login', async () => {
const canonicalPath = '/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
const canonicalPath = '/project-config/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});

View File

@@ -105,8 +105,8 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});
it('keeps the canonical plugin workspace outside the local project initialization gate', () => {
renderLayout('/plugins');
it('keeps the project-settings plugin workspace outside the local project initialization gate', () => {
renderLayout('/project-config/plugins');
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();

View File

@@ -36,7 +36,7 @@ const MARKETPLACE_ARTIFACT_TEXT = [
'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/',
'effectiveSkillIds', 'pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library',
'免费获取', '获取、项目启用与伙伴分配彼此独立', '/plugins',
'免费获取', '获取、项目启用与伙伴分配彼此独立', '/project-config/plugins',
].join('\n');
async function createAsarFixture(source: string, archive: string) {
@@ -389,7 +389,7 @@ describe('final Pi product artifact verification', () => {
'makelore-device-package.v1 /api/coding/device-packages device-parent-workers',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 获取、项目启用与伙伴分配彼此独立 /plugins',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 获取、项目启用与伙伴分配彼此独立 /project-config/plugins',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createAsarFixture(source, appAsar);
@@ -407,7 +407,7 @@ describe('final Pi product artifact verification', () => {
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件 /plugins',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件 /project-config/plugins',
].join('\n'));
const staleAsar = path.join(root, 'stale.asar');
await createAsarFixture(staleSource, staleAsar);

View File

@@ -1,7 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { LegacyPluginRedirect } from '@/pages/Plugins/legacy-plugin-redirect';
import {
LegacyPluginRedirect,
PluginWorkspaceRedirect,
PROJECT_SETTINGS_PLUGINS_PATH,
} from '@/pages/Plugins/legacy-plugin-redirect';
import { getGuardedAiModuleForPath, isProgrammingProviderRoute } from '@/lib/ai-modules';
function LocationProbe() {
@@ -17,15 +21,15 @@ function LocationProbe() {
describe('plugin navigation cutover', () => {
it.each([
['/plugin-marketplace', '/plugins?scope=all&source=official'],
['/my-plugins', '/plugins?scope=all&state=mine'],
['/project-plugins', '/plugins?scope=project'],
['/plugin-marketplace', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=official`],
['/my-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`],
['/project-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project`],
] as const)('replace-redirects %s to %s', async (legacy, canonical) => {
render(
<MemoryRouter initialEntries={['/previous', legacy]} initialIndex={1}>
<Routes>
<Route path={legacy} element={<LegacyPluginRedirect from={legacy} />} />
<Route path="/plugins" element={<LocationProbe />} />
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<LocationProbe />} />
<Route path="/previous" element={<output data-testid="previous">previous</output>} />
</Routes>
</MemoryRouter>,
@@ -36,8 +40,26 @@ describe('plugin navigation cutover', () => {
expect(await screen.findByTestId('previous')).toBeVisible();
});
it('classifies the canonical page as Code without making it provider-gated', () => {
expect(getGuardedAiModuleForPath('/plugins')).toBe('programming');
expect(isProgrammingProviderRoute('/plugins')).toBe(false);
it('moves the former canonical URL into project settings without losing its query', async () => {
render(
<MemoryRouter initialEntries={['/previous', '/plugins?scope=all&state=mine']} initialIndex={1}>
<Routes>
<Route path="/plugins" element={<PluginWorkspaceRedirect />} />
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<LocationProbe />} />
<Route path="/previous" element={<output data-testid="previous">previous</output>} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByTestId('location')).toHaveTextContent(
`${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`,
);
fireEvent.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByTestId('previous')).toBeVisible();
});
it('classifies the project-settings plugin page as Code without making it provider-gated', () => {
expect(getGuardedAiModuleForPath(PROJECT_SETTINGS_PLUGINS_PATH)).toBe('programming');
expect(isProgrammingProviderRoute(PROJECT_SETTINGS_PLUGINS_PATH)).toBe(false);
});
});

View File

@@ -23,7 +23,7 @@ function dependencies() {
project: {
setEnabled: vi.fn().mockResolvedValue(undefined),
},
loginReturnPath: '/plugins?scope=all&source=official&state=available',
loginReturnPath: '/project-config/plugins?scope=all&source=official&state=available',
navigate: vi.fn(),
openSettings: vi.fn(),
} satisfies PluginWorkspaceDispatchDependencies;
@@ -65,7 +65,7 @@ describe('plugin workspace controller', () => {
expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
expect(deps.device.uninstall).toHaveBeenCalledExactlyOnceWith('l');
expect(deps.navigate.mock.calls).toEqual([
['/login', { state: { from: '/plugins?scope=all&source=official&state=available' } }],
['/login', { state: { from: '/project-config/plugins?scope=all&source=official&state=available' } }],
['/project-config'],
]);
expect(deps.openSettings).toHaveBeenCalledExactlyOnceWith();

View File

@@ -208,10 +208,10 @@ function PluginsRouteHarness() {
<>
<Plugins />
<div data-testid="plugins-location">{location.pathname}{location.search}</div>
<button type="button" onClick={() => navigate('/plugins')}></button>
<button type="button" onClick={() => navigate('/project-config/plugins')}></button>
<button
type="button"
onClick={() => navigate('/plugins?scope=illegal&source=remote&state=broken')}
onClick={() => navigate('/project-config/plugins?scope=illegal&source=remote&state=broken')}
></button>
</>
);
@@ -249,16 +249,24 @@ function preparePluginsRouteState() {
}
describe('PluginsView', () => {
it('renders as project-configuration drawer content without a duplicate standalone header', () => {
render(<MemoryRouter><PluginsView {...props()} embedded /></MemoryRouter>);
expect(screen.getByTestId('plugins-page')).toBeVisible();
expect(screen.getByRole('button', { name: '刷新全部来源' })).toBeVisible();
expect(screen.queryByRole('heading', { name: '插件', level: 1 })).not.toBeInTheDocument();
});
it('keeps the production URL, scope control, and fallback notice aligned across in-place changes', async () => {
preparePluginsRouteState();
render(
<MemoryRouter initialEntries={['/plugins?scope=project']}>
<MemoryRouter initialEntries={['/project-config/plugins?scope=project']}>
<PluginsRouteHarness />
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
@@ -273,19 +281,19 @@ describe('PluginsView', () => {
act(() => codingWorkspaceStore.setState({ activeProjectId: activeProject.id, activeProject }));
fireEvent.click(screen.getByRole('button', { name: '打开裸插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=project&source=all&state=all',
'/project-config/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=project&source=all&state=all',
'/project-config/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
@@ -339,7 +347,7 @@ describe('PluginsView', () => {
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
render(<MemoryRouter initialEntries={['/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
render(<MemoryRouter initialEntries={['/project-config/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
const heading = await screen.findByRole('heading', { name });
@@ -388,7 +396,7 @@ describe('PluginsView', () => {
render(
<MemoryRouter initialEntries={[
'/plugins?scope=all&source=all&state=all&plugin=official%3Amakelore.notes',
'/project-config/plugins?scope=all&source=all&state=all&plugin=official%3Amakelore.notes',
]}>
<Plugins />
</MemoryRouter>,