merge: restore AI Design left project sidebar

This commit is contained in:
inman
2026-09-07 14:47:10 +08:00
8 changed files with 116 additions and 62 deletions

View File

@@ -0,0 +1,60 @@
# Task: Restore AI design project sidebar
## Identity
- Task ID: 20260907-restore-design-project-sidebar-6f8b2a1c
- Mode: Feature
- Branch: codex/20260907-restore-design-project-sidebar-6f8b2a1c-restore-design-sidebar
- Worktree: /Users/inmanx/Documents/makelore-restore-design-sidebar
- Base commit: 918f8f80dca61b0f561f96b168e4ef541734d414
- Owner: codex
- Status: Ready for Integration
## Scope
- Restore AI Design Workspace navigation to the left-side project rail on desktop.
- Keep the central conversation and active production plan unchanged, and avoid a duplicate desktop project list on the right.
- Preserve a compact-layout project Sheet for non-Electron/browser-sized viewports.
- Update focused layout/Canvas tests, the shared Electron Canvas fixture, and README product-state copy.
## Intent And Constraints
- Treat the user's report as explicit product-owner correction of the September 7 right-rail presentation decision.
- Preserve ADR-007 semantic authority: one Workspace, one current Direction, one Living Form/Specification, one active plan, and immutable Quote confirmation.
- Reuse the existing MainLayout Sidebar and ImageWorkspaceSidebar for desktop rather than introducing another project state owner.
- Preserve Canvas cloud-only/Main-owned transport, project create/select/rename/delete behavior, the single light visual system, and compact-layout access.
- Do not modify the concurrent Plugin drawer task or the occupied main worktree.
## Outcome
- Canvas again mounts the shared application Sidebar on `/image-canvas`, so the existing `ImageWorkspaceSidebar` provides desktop Workspace create/select/rename/delete navigation on the left and the title-bar collapse/expand control is available again.
- The shared Sidebar accepts a Canvas-only compact visibility policy: it is hidden below the desktop breakpoint so browser-sized compact layouts do not squeeze the conversation surface.
- The Canvas page no longer renders a duplicate desktop `DesignWorksRail` on the right. Its conversation and active editable plan use the full remaining content width.
- Compact layouts retain `DesignWorksRail` as a Sheet, now opening from the left. Empty-state project creation still routes to the mounted desktop Sidebar or compact Sheet through the existing event contract.
- Canvas header spacing and loading skeleton now match the restored left sidebar, while retaining macOS logo and Windows caption-control safe areas.
- README now describes the restored left project sidebar and compact left Sheet.
## Verification
- `corepack pnpm install --frozen-lockfile` — passed with pinned pnpm `10.33.4`; lockfile unchanged.
- `corepack pnpm exec vitest run tests/unit/main-layout-module-gate.test.tsx tests/unit/image-canvas-page.test.tsx --maxWorkers=4` — 2 files / 21 tests passed.
- `corepack pnpm exec eslint src/components/layout/MainLayout.tsx src/components/layout/Sidebar.tsx src/pages/ImageCanvas/index.tsx tests/unit/main-layout-module-gate.test.tsx tests/unit/image-canvas-page.test.tsx tests/e2e/image-workspace-v2.spec.ts` — passed with no output.
- `corepack pnpm run typecheck` — passed.
- `corepack pnpm run build:vite` — Renderer/Main/Preload/utility production compile passed; existing dynamic-import, chunk-size, and stale Browserslist warnings only.
- `corepack pnpm test` — 226 normal files / 1,912 tests passed with 3 skips, then pressure 1/1 passed.
- `corepack pnpm run lint:check` — 0 errors; 5 pre-existing warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`.
- `corepack pnpm exec playwright test tests/e2e/image-workspace-v2.spec.ts` — 2/2 passed. Desktop geometry proves a 256 px left Sidebar directly adjacent to the conversation with no right duplicate; compact geometry hides the shared Sidebar and opens the Works Sheet from the left.
- `git diff --check` — passed.
## Follow-ups
- Promote the recorded Canvas placement amendment into canonical project memory during a serialized Integration Gate.
## Promotion Candidates
- Target canonical documents: ADR-007, system overview, module/data-flow maps, business rules, current state, and any shared Canvas layout wording.
Proposal: amend the Canvas presentation description from a right-side Works rail to the restored desktop left project sidebar, with compact layouts retaining a project Sheet.
Evidence: direct user correction on 2026-09-07 plus the focused regression/Electron layout verification from this task.
Future impact: future Canvas redesigns must keep Workspace navigation visible on the left unless another explicit product decision changes it.
Semantic conflicts: supersedes only ADR-007's September 7 rail placement amendment; it does not change V2 Workspace/Specification/Quote authority.
Human confirmation required: no additional confirmation; the user supplied the correcting product direction in this task.

View File

@@ -5,7 +5,7 @@
Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页:
- `Makelore CodeAI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。用户在中央对话时间线中让 AI 帮助描绘想法,再直接编辑同一时间线内的制作方案;右侧全高作品栏负责新建、切换和管理 Workspace。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace用户在中央对话时间线中让 AI 帮助描绘想法,再直接编辑同一时间线内的制作方案。紧凑窗口通过左侧抽屉访问作品列表。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
@@ -29,12 +29,12 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工
- AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。对话与当前制作方案位于同一中央时间线不是两套状态聊天提取、用户修改创作提示词和制作参数都提交到同一个服务端 reducer并由方向版本、字段来源和决策状态形成权威投影。创作提示词是公开规格 `content.concept` 的可编辑投影;媒介、画幅、时长和数量始终以紧凑控件直接显示,不用折叠面板隐藏。
- AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入时间线中的可展开制作记录。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。
- AI 绘画健壮性Renderer 为每次命令生成稳定 operation id网络结果未知时保留原命令供原样重试不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。
- AI 绘画右侧全高作品栏只展示 Workspace不再在项目下创建独立设计会话。删除时必须完整输入项目名称删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。
- AI 绘画左侧项目栏只展示 Workspace不再在项目下创建独立设计会话;紧凑窗口使用同一作品列表的左侧抽屉。删除时必须完整输入项目名称删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。
- 项目插件设置Code 侧栏不再单列“插件”;配置 Agent、Skill、模型与知识的项目配置页内提供“插件”卡片并在同页宽抽屉中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态。深链使用 `/project-config/plugins` 打开该项目页抽屉;旧 `/plugins`、Marketplace、My Plugins、Project Plugins URL 只做保留查询条件的确定性重定向。来源身份保持 `official:``local:``retained:` 分离;账号获取、官方设备交付和项目启用仍分别写入既有 authority不会自动推进下一步。Data Service、Game Resource 与 Project Scaffold 三个代码所有的官方插件在项目启用后对项目内所有父 Agent 生效,不提供伙伴分配;确实采用 assignment scope 的其他 Marketplace 插件继续由伙伴分配控制。任一来源失败不会清空其他来源;本机 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 不提供安装源选择器。
- 提示词博物馆已退出当前 Canvas 产品面:右侧作品栏不提供“获取灵感”,历史 `/image-prompts` 地址统一返回 Canvas。Main-owned API、媒体校验和共享 DTO 暂作为不可见兼容/安全基础设施保留;服务端边界见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。
- 提示词博物馆已退出当前 Canvas 产品面:左侧项目栏不提供“获取灵感”,历史 `/image-prompts` 地址统一返回 Canvas。Main-owned API、媒体校验和共享 DTO 暂作为不可见兼容/安全基础设施保留;服务端边界见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。
- 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。
- 字体系统Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2按字符范围统一中英文并保留系统中文字体 fallback代码、路径和日志使用独立等宽字体。
- 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。

View File

@@ -79,20 +79,18 @@ export function MainLayout() {
integrated
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule}
showSidebarControls={!isCanvasWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
{/* Below the title bar: sidebar + content */}
<div className="relative flex min-h-0 flex-1 overflow-hidden">
{!isCanvasWorkspace ? (
<Sidebar
workspaceLayout={isChatWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
) : null}
<Sidebar
workspaceLayout={isChatWorkspace}
hideOnCompact={isCanvasWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
<main
data-testid="main-content"
className={cn(

View File

@@ -151,11 +151,17 @@ function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string {
type SidebarProps = {
workspaceLayout?: boolean;
hideOnCompact?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void;
};
export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) {
export function Sidebar({
workspaceLayout = false,
hideOnCompact = false,
sidebarPeekOpen = false,
onSidebarPeekChange,
}: SidebarProps = {}) {
const { t } = useTranslation('common');
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
@@ -544,6 +550,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
// full window height without reflowing the page underneath it.
sidebarPinnedCollapsed && 'fixed inset-y-0 left-0 z-[350]',
sidebarPeekPreview && 'sidebar-peek-surface pt-10 shadow-float',
hideOnCompact && 'hidden lg:flex',
)}
>
<div

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import {
Cloud,
CloudOff,
PanelRight,
PanelLeft,
Plus,
RefreshCw,
Sparkles,
@@ -48,18 +48,10 @@ function LoadingWorkspace() {
return (
<div className="flex h-full min-h-0 flex-col bg-background" aria-label="正在加载 AI 设计">
<div className="h-16 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-4 bg-surface-subtle/35 p-6 lg:border-r lg:border-border/60">
<div className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="ml-auto h-20 w-4/5 animate-pulse rounded-2xl bg-brand/[0.07]" />
<div className="h-48 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
</div>
<div className="hidden space-y-3 p-4 lg:block">
<div className="h-10 animate-pulse rounded-xl bg-surface-subtle" />
{[0, 1, 2, 3].map((item) => (
<div key={item} className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
))}
</div>
<div className="min-h-0 flex-1 space-y-4 bg-surface-subtle/35 p-6">
<div className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="ml-auto h-20 w-4/5 animate-pulse rounded-2xl bg-brand/[0.07]" />
<div className="h-48 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
</div>
</div>
);
@@ -166,7 +158,7 @@ export function ImageCanvas() {
: null;
const platform = window.electron?.platform;
const openCreate = () => {
setWorksOpen(true);
if (!desktopLayout) setWorksOpen(true);
globalThis.setTimeout(() => {
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
}, 0);
@@ -177,7 +169,7 @@ export function ImageCanvas() {
<header className={cn(
'flex min-h-16 shrink-0 items-center gap-4 border-b border-border/70 bg-background py-3',
platform === 'darwin'
? 'pl-[92px] pr-[116px]'
? 'pl-4 pr-[116px] sm:pl-5'
: platform === 'win32'
? 'pl-5 pr-[260px]'
: 'px-4 sm:px-5',
@@ -227,7 +219,7 @@ export function ImageCanvas() {
className="h-9 rounded-xl"
onClick={() => setWorksOpen(true)}
>
<PanelRight className="mr-1.5 h-4 w-4" />
<PanelLeft className="mr-1.5 h-4 w-4" />
</Button>
)}
@@ -246,8 +238,8 @@ export function ImageCanvas() {
</div>
)}
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_320px] xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-h-0 lg:border-r lg:border-border/70">
<main className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1">
{workspace ? (
<DesignConversationPane
workspace={workspace}
@@ -258,15 +250,11 @@ export function ImageCanvas() {
<EmptyWorkspace onCreate={openCreate} />
)}
</div>
{desktopLayout && (
<DesignWorksRail workspace={workspace} />
)}
</main>
{!desktopLayout && (
<Sheet open={worksOpen} onOpenChange={setWorksOpen}>
<SheetContent side="right" className="flex w-[min(92vw,360px)] flex-col overflow-hidden p-0 sm:max-w-[360px]">
<SheetContent side="left" className="flex w-[min(92vw,360px)] flex-col overflow-hidden p-0 sm:max-w-[360px]">
<SheetHeader className="sr-only">
<SheetTitle></SheetTitle>
<SheetDescription> AI </SheetDescription>

View File

@@ -352,7 +352,8 @@ test.describe('AI Design V2 workspace', () => {
await page.getByTestId('ai-module-option-painting').click();
await expect(page.getByTestId('image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
await expect(page.getByTestId('image-workspace-works-rail')).toBeVisible();
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '和 AI 一起完善创作' })).toBeVisible();
await expect(page.getByRole('heading', { name: '制作方案' })).toBeVisible();
await expect(page.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
@@ -365,30 +366,31 @@ test.describe('AI Design V2 workspace', () => {
const layout = await page.evaluate(() => {
const conversation = document.querySelector('[data-testid="image-workspace-conversation"]')
?.getBoundingClientRect();
const worksRail = document.querySelector('[data-testid="image-workspace-works-rail"]')
const projectSidebar = document.querySelector('[data-testid="sidebar"]')
?.getBoundingClientRect();
return conversation && worksRail
return conversation && projectSidebar
? {
conversationHeight: conversation.height,
railHeight: worksRail.height,
topGap: Math.abs(conversation.top - worksRail.top),
conversationWidth: conversation.width,
railWidth: worksRail.width,
sidebarWidth: projectSidebar.width,
horizontalGap: Math.abs(projectSidebar.right - conversation.left),
}
: null;
});
expect(layout).not.toBeNull();
expect(layout!.conversationHeight).toBeGreaterThan(300);
expect(Math.abs(layout!.conversationHeight - layout!.railHeight)).toBeLessThanOrEqual(1);
expect(layout!.topGap).toBeLessThanOrEqual(1);
expect(layout!.conversationWidth).toBeGreaterThan(layout!.railWidth);
expect(layout!.sidebarWidth).toBe(256);
expect(layout!.horizontalGap).toBeLessThanOrEqual(1);
expect(layout!.conversationWidth).toBeGreaterThan(600);
const desktopViewport = page.viewportSize();
await page.setViewportSize({ width: 375, height: 800 });
await expect(page.getByTestId('sidebar')).toBeHidden();
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
await expect(page.getByTestId('youth-creation-card')).toBeVisible();
await page.getByRole('button', { name: '作品', exact: true }).click();
await expect(page.getByTestId('image-workspace-works-rail')).toBeVisible();
await expect(page.getByRole('dialog')).toHaveClass(/left-0/);
await page.keyboard.press('Escape');
await page.setViewportSize({ width: 768, height: 800 });
@@ -396,7 +398,8 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByRole('button', { name: '作品', exact: true })).toBeVisible();
await page.setViewportSize(desktopViewport ?? { width: 1280, height: 800 });
await expect(page.getByTestId('image-workspace-works-rail')).toBeVisible();
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
await page.getByRole('combobox', { name: '画幅' }).selectOption('16:9');
await expect(page.getByRole('combobox', { name: '画幅' })).toHaveValue('16:9');

View File

@@ -125,16 +125,12 @@ describe('AI Design Canvas page', () => {
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
});
it('renders the Workspace list as a full-height right Works rail on desktop', () => {
it('leaves desktop Workspace navigation to the shared left project sidebar', () => {
setViewport(true);
prepareWorkspace();
render(<ImageCanvas />);
const rail = screen.getByTestId('image-workspace-works-rail');
expect(rail).toBeInTheDocument();
expect(within(rail).getByRole('heading', { name: '我的作品' })).toBeInTheDocument();
expect(within(rail).getByRole('button', { name: '打开作品 咖啡机新品主视觉' })).toBeInTheDocument();
expect(within(rail).getByRole('button', { name: '新建作品' })).toBeInTheDocument();
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '作品' })).not.toBeInTheDocument();
});
@@ -146,16 +142,19 @@ describe('AI Design Canvas page', () => {
render(<ImageCanvas />);
expect(screen.getByText(/制作方案已准备好,确认前仍可修改/)).toBeInTheDocument();
expect(within(screen.getByTestId('image-workspace-works-rail')).getByText('待确认')).toBeInTheDocument();
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
});
it('opens the Works rail as a right sheet on compact layouts', () => {
it('opens the Works rail as a left sheet on compact layouts', () => {
prepareWorkspace();
render(<ImageCanvas />);
fireEvent.click(screen.getByRole('button', { name: '作品' }));
expect(screen.getByTestId('image-workspace-works-rail')).toBeInTheDocument();
expect(within(screen.getByTestId('image-workspace-works-rail')).getByRole('heading', { name: '我的作品' })).toBeInTheDocument();
const rail = screen.getByTestId('image-workspace-works-rail');
expect(rail).toBeInTheDocument();
expect(within(rail).getByRole('heading', { name: '我的作品' })).toBeInTheDocument();
expect(rail.closest('[role="dialog"]')).toHaveClass('left-0');
expect(rail.closest('[role="dialog"]')).not.toHaveClass('right-0');
});
it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => {

View File

@@ -7,19 +7,18 @@ import { useSettingsStore } from '@/stores/settings';
const routeRenderMock = vi.hoisted(() => vi.fn());
vi.mock('@/components/layout/Sidebar', () => ({
Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => (
<aside data-testid="sidebar-stub" data-sidebar-collapsed={sidebarCollapsedOverride === undefined ? 'unset' : String(sidebarCollapsedOverride)} />
Sidebar: ({ hideOnCompact = false }: { hideOnCompact?: boolean }) => (
<aside data-testid="sidebar-stub" data-hide-on-compact={String(hideOnCompact)} />
),
}));
vi.mock('@/components/layout/TitleBar', () => ({
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, sidebarCollapsedOverride }: { integrated?: boolean; overlay?: boolean; showSidebarControls?: boolean; pageTitle?: string; sidebarCollapsedOverride?: boolean }) => (
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle }: { integrated?: boolean; overlay?: boolean; showSidebarControls?: boolean; pageTitle?: string }) => (
<header
data-testid="titlebar-stub"
data-integrated={integrated ? 'true' : 'false'}
data-overlay={overlay ? 'true' : 'false'}
data-sidebar-controls={showSidebarControls ? 'true' : 'false'}
data-page-title={pageTitle ?? ''}
data-sidebar-collapsed={sidebarCollapsedOverride === undefined ? 'unset' : String(sidebarCollapsedOverride)}
/>
),
}));
@@ -54,14 +53,14 @@ describe('MainLayout module isolation', () => {
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
});
it('uses the full-height workspace shell for AI painting', () => {
it('uses the full-height workspace shell with the shared left project sidebar for AI painting', () => {
renderLayout('/image-canvas');
expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'h-full', 'overflow-hidden', 'p-0');
expect(screen.getByTestId('main-content')).not.toHaveClass('p-5', 'sm:p-6');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'true');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-sidebar-controls', 'false');
expect(screen.queryByTestId('sidebar-stub')).not.toBeInTheDocument();
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-sidebar-controls', 'true');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-hide-on-compact', 'true');
});
it('does not overlay the local project initialization gate on AI hardware', () => {