fix(canvas): align fixed design sidebars

This commit is contained in:
inman
2026-09-07 16:08:17 +08:00
parent e98603435d
commit 6a05992e18
10 changed files with 74 additions and 25 deletions

View File

@@ -5,7 +5,7 @@
Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页:
- `Makelore CodeAI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace用户在中央对话时间线中让 AI 帮助描绘想法,再直接编辑同一时间线内的制作方案;桌面端右侧全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;用户在中央对话时间线中让 AI 帮助描绘想法,再直接编辑同一时间线内的制作方案;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片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,7 +29,7 @@ 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不再在项目下创建独立设计会话桌面端右侧全高历史作品栏展示当前 Workspace 的任务与生成资产紧凑窗口则把项目列表放进左侧抽屉并在中央时间线保留历史记录。删除时必须完整输入项目名称删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。
- AI 绘画左侧项目栏只展示 Workspace桌面设计模式下固定为 256px 且不提供收纳入口,不再在项目下创建独立设计会话;右侧全高历史作品栏同为 256px展示当前 Workspace 的任务与生成资产紧凑窗口则把项目列表放进左侧抽屉并在中央时间线保留历史记录。删除时必须完整输入项目名称删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。
- 项目资源设置项目配置页顶部以一横排四张资源卡提供模型、Skill、知识与插件入口并统一使用同宽右侧抽屉。模型列表完整显示具体模型名称以及服务端或已验证模型档案声明的可选思考强度未知能力不做猜测。插件抽屉直接展示能力列表不提供筛选或手动刷新控件每项提供“查看详情”和当前真实生命周期对应的添加、安装、添加到项目、启用或状态按钮。详情只展示插件介绍、主要能力、使用方式和必要设置不暴露版本、包、权限标识、运行方式或计费实现字段。深链仍使用 `/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` 回退。

View File

@@ -79,8 +79,9 @@ export function MainLayout() {
integrated
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
showSidebarControls={!isCanvasWorkspace}
sidebarPeekOpen={isCanvasWorkspace ? false : sidebarPeekOpen}
onSidebarPeekChange={isCanvasWorkspace ? undefined : handleSidebarPeekChange}
/>
{/* Below the title bar: sidebar + content */}
@@ -88,8 +89,9 @@ export function MainLayout() {
<Sidebar
workspaceLayout={isChatWorkspace}
hideOnCompact={isCanvasWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
forceExpanded={isCanvasWorkspace}
sidebarPeekOpen={isCanvasWorkspace ? false : sidebarPeekOpen}
onSidebarPeekChange={isCanvasWorkspace ? undefined : handleSidebarPeekChange}
/>
<main
data-testid="main-content"

View File

@@ -152,6 +152,7 @@ function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string {
type SidebarProps = {
workspaceLayout?: boolean;
hideOnCompact?: boolean;
forceExpanded?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void;
};
@@ -159,11 +160,13 @@ type SidebarProps = {
export function Sidebar({
workspaceLayout = false,
hideOnCompact = false,
forceExpanded = false,
sidebarPeekOpen = false,
onSidebarPeekChange,
}: SidebarProps = {}) {
const { t } = useTranslation('common');
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed;
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
const projects = useCodingWorkspaceStore((state) => state.projects);
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);

View File

@@ -234,7 +234,7 @@ function ProductTitleBar({
) : (
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 px-2">{children}</div>
)}
{integrated && overlay ? titlebarLogo : null}
{integrated && overlay && (nativeTrafficLights || showSidebarControls) ? titlebarLogo : null}
</div>
);
}

View File

@@ -11,14 +11,14 @@ export function DesignHistoryRail({ workspace }: { workspace: DesignWorkspace |
<aside
data-testid="image-workspace-history-rail"
aria-label="历史作品"
className="flex h-full min-h-0 w-[320px] shrink-0 flex-col border-l border-border/70 bg-background font-sans xl:w-[340px]"
className="flex h-full min-h-0 w-64 shrink-0 flex-col border-l border-border/70 bg-background font-sans"
>
<header
data-testid="image-workspace-history-header"
className={cn(
'flex h-12 shrink-0 items-center border-b border-border/70 px-4',
'flex h-10 shrink-0 items-center border-b border-border/70 px-4',
platform === 'darwin' && 'pr-[116px]',
platform === 'win32' && 'pr-[244px]',
platform === 'win32' && 'pr-[148px]',
)}
>
<div className="flex min-w-0 items-center gap-2">

View File

@@ -46,15 +46,15 @@ function LoadingWorkspace() {
return (
<div className="flex h-full min-h-0 bg-background" aria-label="正在加载 AI 设计">
<div className="flex min-w-0 flex-1 flex-col">
<div className="h-12 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
<div className="h-10 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
<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>
<div className="hidden h-full w-[320px] shrink-0 flex-col border-l border-border/70 bg-background lg:flex xl:w-[340px]">
<div className="h-12 animate-pulse border-b border-border/60 bg-surface-subtle/45" />
<div className="hidden h-full w-64 shrink-0 flex-col border-l border-border/70 bg-background lg:flex">
<div className="h-10 animate-pulse border-b border-border/60 bg-surface-subtle/45" />
<div className="space-y-3 p-3">
{[0, 1, 2].map((item) => (
<div key={item} className="h-16 animate-pulse rounded-xl bg-surface-subtle" />
@@ -159,7 +159,7 @@ export function ImageCanvas() {
<div data-testid="image-workspace" className="flex h-full min-h-0 overflow-hidden bg-background font-sans">
<div className="flex min-w-0 flex-1 flex-col">
<header data-testid="image-workspace-header" className={cn(
'flex h-12 shrink-0 items-center gap-3 border-b border-border/70 bg-background',
'flex h-10 shrink-0 items-center gap-3 border-b border-border/70 bg-background',
platform === 'darwin'
? 'pl-4 pr-[116px] sm:pl-5'
: platform === 'win32'

View File

@@ -321,6 +321,10 @@ test.describe('AI Design V2 workspace', () => {
await page.addInitScript(() => {
const now = Date.now();
localStorage.setItem('niancode-settings', JSON.stringify({
state: { sidebarCollapsed: true },
version: 0,
}));
localStorage.setItem('niancode-auth', JSON.stringify({
state: {
authBase: 'https://biz.nianxx.cn/auth/',
@@ -355,6 +359,7 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-history-rail')).toBeVisible();
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '和 AI 一起完善创作' })).toHaveCount(0);
await expect(page.getByText('当前设计项目的制作记录')).toHaveCount(0);
await expect(page.getByText('实时同步')).toHaveCount(0);
@@ -402,13 +407,14 @@ test.describe('AI Design V2 workspace', () => {
expect(layout!.conversationHeight).toBeGreaterThan(300);
expect(layout!.sidebarWidth).toBe(256);
expect(layout!.horizontalGap).toBeLessThanOrEqual(1);
expect(layout!.historyRailWidth).toBeGreaterThanOrEqual(320);
expect(layout!.historyRailWidth).toBe(256);
expect(layout!.historyRailWidth).toBe(layout!.sidebarWidth);
expect(layout!.historyRailHeight).toBeGreaterThan(500);
expect(layout!.historyTopGap).toBeLessThanOrEqual(1);
expect(layout!.historyBottomGap).toBeLessThanOrEqual(1);
expect(layout!.historyHorizontalGap).toBeLessThanOrEqual(1);
expect(layout!.workspaceHeaderHeight).toBe(48);
expect(layout!.historyHeaderHeight).toBe(48);
expect(layout!.workspaceHeaderHeight).toBe(40);
expect(layout!.historyHeaderHeight).toBe(40);
expect(layout!.headerTopGap).toBeLessThanOrEqual(1);
expect(layout!.headerBottomGap).toBeLessThanOrEqual(1);
expect(layout!.conversationWidth).toBeGreaterThan(600);

View File

@@ -131,9 +131,9 @@ describe('AI Design Canvas page', () => {
render(<ImageCanvas />);
const historyRail = screen.getByTestId('image-workspace-history-rail');
expect(historyRail).toHaveClass('h-full', 'border-l');
expect(screen.getByTestId('image-workspace-header')).toHaveClass('h-12');
expect(screen.getByTestId('image-workspace-history-header')).toHaveClass('h-12');
expect(historyRail).toHaveClass('h-full', 'w-64', 'border-l');
expect(screen.getByTestId('image-workspace-header')).toHaveClass('h-10');
expect(screen.getByTestId('image-workspace-history-header')).toHaveClass('h-10');
expect(within(historyRail).getByRole('heading', { name: '历史作品' })).toBeInTheDocument();
expect(within(historyRail).queryByText('当前设计项目的制作记录')).not.toBeInTheDocument();
expect(within(historyRail).getByText('还没有历史作品')).toBeInTheDocument();

View File

@@ -7,18 +7,38 @@ import { useSettingsStore } from '@/stores/settings';
const routeRenderMock = vi.hoisted(() => vi.fn());
vi.mock('@/components/layout/Sidebar', () => ({
Sidebar: ({ hideOnCompact = false }: { hideOnCompact?: boolean }) => (
<aside data-testid="sidebar-stub" data-hide-on-compact={String(hideOnCompact)} />
Sidebar: ({ hideOnCompact = false, forceExpanded = false, sidebarPeekOpen = false, onSidebarPeekChange }: {
hideOnCompact?: boolean;
forceExpanded?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
}) => (
<aside
data-testid="sidebar-stub"
data-hide-on-compact={String(hideOnCompact)}
data-force-expanded={String(forceExpanded)}
data-peek-open={String(sidebarPeekOpen)}
data-has-peek-handler={String(Boolean(onSidebarPeekChange))}
/>
),
}));
vi.mock('@/components/layout/TitleBar', () => ({
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle }: { integrated?: boolean; overlay?: boolean; showSidebarControls?: boolean; pageTitle?: string }) => (
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: {
integrated?: boolean;
overlay?: boolean;
showSidebarControls?: boolean;
pageTitle?: string;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
}) => (
<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-peek-open={String(sidebarPeekOpen)}
data-has-peek-handler={String(Boolean(onSidebarPeekChange))}
/>
),
}));
@@ -54,13 +74,19 @@ describe('MainLayout module isolation', () => {
});
it('uses the full-height workspace shell with the shared left project sidebar for AI painting', () => {
useSettingsStore.setState({ sidebarCollapsed: true });
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', 'true');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-sidebar-controls', 'false');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-peek-open', 'false');
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-has-peek-handler', 'false');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-hide-on-compact', 'true');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-force-expanded', 'true');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-peek-open', 'false');
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-has-peek-handler', 'false');
});
it('does not overlay the local project initialization gate on AI hardware', () => {

View File

@@ -132,6 +132,18 @@ describe('TitleBar platform behavior', () => {
expect(screen.getByTestId('titlebar-logo')).toBeInTheDocument();
});
it('leaves the narrow Windows Canvas rail to the native window controls', () => {
window.electron.platform = 'win32';
render(<TitleBar integrated overlay showSidebarControls={false} />);
expect(screen.queryByTestId('titlebar-sidebar-toggle')).not.toBeInTheDocument();
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
expect(screen.getByTitle('Minimize')).toBeInTheDocument();
expect(screen.getByTitle('Maximize')).toBeInTheDocument();
expect(screen.getByTitle('Close')).toBeInTheDocument();
});
it('keeps an optional page title after the macOS traffic-light safe area', () => {
window.electron.platform = 'darwin';
useSettingsStore.setState({ sidebarCollapsed: true });