fix(canvas): add full-height history rail

This commit is contained in:
inman
2026-09-07 15:09:12 +08:00
parent 4477d0da7d
commit a705253261
8 changed files with 230 additions and 122 deletions

View File

@@ -40,9 +40,10 @@
| `electron/api/routes/image-workspace.ts` | AI 绘画 Host API 与本地事件流投影 | Renderer 只通过该路由访问 Main-owned workspace adapter |
| `electron/image-workspace/works-square-workspace.ts` | Works Square V2 adapter、stable command/operation IDs 与 Direction event mapping | unknown transport result 保留精确命令Main 持有 token、ticket、WebSocket、retry classification 与 safe errors |
| `src/stores/image-workspace.ts` | Current Workspace/Direction/Living Form、local drafts、pending operations 与 Workspace resources | revision conflict 刷新 canonical stateevent order/Task progress 不成为 Specification authority |
| `src/pages/ImageCanvas/{index,DesignConversationPane,DesignProductionPanel,DesignWorksRail,YouthCreationCard}.tsx` | 中央 conversation/active 制作方案、右侧 Works rail、immutable Quote confirmation、历史 Tasks 与 Assets | active 方案留在时间线;已提交状态折叠为历史。最终 Prompt、reference binding 与直接参数编辑继续通过同一 V2 reducer确认只提交 Quote ID |
| `src/pages/ImageCanvas/{index,DesignConversationPane,DesignProductionPanel,DesignHistoryRail,DesignWorksRail,YouthCreationCard}.tsx` | 左侧 Workspace 项目导航、中央 conversation/active 制作方案、右侧全高历史作品栏、immutable Quote confirmation | active 方案留在时间线;桌面端已提交 Tasks 与 Assets 进入右侧历史栏,紧凑窗口仍在时间线内展示。最终 Prompt、reference binding 与直接参数编辑继续通过同一 V2 reducer确认只提交 Quote ID |
| `src/pages/ImageCanvas/reference-tokens.ts` and `src/pages/ImageCanvas/youth-form-projection.ts` | `@图片N` alias、缺失 binding 检测与青少年可读方案投影 | Prompt 是 reference 用途的唯一可见表达binding row 只管理真实 Asset/token identity。缺失 token 对应图片时提供定点上传并阻止 Quote新增/删除/替换保持稳定 reference ID 与连续 alias |
| `src/pages/ImageCanvas/DesignWorksRail.tsx` | 全高右侧 Workspace 选择、创建和删除 | 桌面为 320340 px 右 rail紧凑布局复用右侧 Sheet无“获取灵感”入口不创建嵌套 Conversation |
| `src/pages/ImageCanvas/DesignHistoryRail.tsx` | 当前 Workspace 的全高右侧历史作品栏 | 桌面为 320340 px 右 rail按时间展示 Tasks 与生成 Assets紧凑布局将同一历史保留在中央时间线 |
| `src/pages/ImageCanvas/DesignWorksRail.tsx` | 紧凑布局的 Workspace 选择、创建和删除 | 仅作为左侧项目 Sheet 使用;无“获取灵感”入口,不创建嵌套 Conversation |
| `shared/image-prompt-museum.ts` and `electron/api/routes/image-prompt-museum.ts` | Dormant Museum DTO 与 Main-owned 列表/详情/auth/media 安全边界 | 当前 App 不挂载 Museum Renderer 页面;历史路由回到 Canvas。仅保留固定 GET/媒体校验兼容,不代表可见模块 |
| `src/pages/AiHardware/index.tsx` | Robot 管理、现有六位 Binding以及已实现的 default-on 引导式热点配网状态机 | 只编排非敏感步骤;不接收 Wi-Fi 密码,不把 `bound` 展示为在线证明 |
| `src/lib/ai-hardware.ts` | Renderer 侧 Robot Host API 类型、安全错误映射和稳定 Binding/hotspot facade | 读取 Main-owned capability调用固定 portal-open并只传递短效 hotspot candidate ID不添加任意 URL、SSID 或 Renderer IPC |

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用户在中央对话时间线中让 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 授权。
@@ -27,9 +27,9 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工
- 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP保存时自动居中裁剪并以圆形缩略图展示未设置或加载失败时回退为姓名首字母。
- 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。
- AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。对话与当前制作方案位于同一中央时间线不是两套状态聊天提取、用户修改创作提示词和制作参数都提交到同一个服务端 reducer并由方向版本、字段来源和决策状态形成权威投影。创作提示词是公开规格 `content.concept` 的可编辑投影;媒介、画幅、时长和数量始终以紧凑控件直接显示,不用折叠面板隐藏。
- AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入时间线中的可展开制作记录。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。
- 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不再在项目下创建独立设计会话桌面端右侧全高历史作品栏展示当前 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

@@ -53,10 +53,12 @@ export function DesignConversationPane({
workspace,
quoteBlockers,
generationAvailable,
showPlanHistory = true,
}: {
workspace: DesignWorkspace;
quoteBlockers: DesignCompilationIssue[];
generationAvailable: boolean;
showPlanHistory?: boolean;
}) {
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
@@ -210,7 +212,7 @@ export function DesignConversationPane({
))}
<DesignPendingOperationsPanel />
<DesignPlanHistory workspace={workspace} />
{showPlanHistory ? <DesignPlanHistory workspace={workspace} /> : null}
{pendingChatMessages.length === 0 && (
<YouthCreationCard
workspace={workspace}

View File

@@ -0,0 +1,66 @@
import { Clock3, Images } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { DesignWorkspace } from '../../../shared/image-workspace';
import { DesignPlanHistory } from './DesignPlanHistory';
export function DesignHistoryRail({ workspace }: { workspace: DesignWorkspace | null }) {
const taskCount = workspace?.tasks.length ?? 0;
const resultCount = workspace?.assets.filter((asset) => asset.generationTaskId).length ?? 0;
const platform = window.electron?.platform;
return (
<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]"
>
<header className={cn(
'flex shrink-0 justify-between gap-3 border-b border-border/70 px-4',
platform === 'darwin' || platform === 'win32'
? 'min-h-[104px] items-end pb-3 pt-10'
: 'min-h-16 items-center',
)}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Images className="h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
<h2 className="truncate text-sm font-semibold text-foreground"></h2>
</div>
<p className="mt-1 text-[11px] tabular-nums text-muted-foreground">
{taskCount > 0 ? `${taskCount} 次制作 · ${resultCount} 个结果` : '当前设计项目的制作记录'}
</p>
</div>
{workspace?.tasks.some((task) => task.status === 'queued' || task.status === 'running') ? (
<span
role="status"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-brand/[0.06] px-2 py-1 text-[11px] font-medium text-brand"
>
<Clock3 className="h-3.5 w-3.5 animate-pulse" aria-hidden="true" />
</span>
) : null}
</header>
<div data-testid="image-workspace-history-scroll" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3">
{!workspace ? (
<div className="rounded-2xl border border-dashed border-border/80 bg-surface-subtle/35 px-4 py-7 text-center">
<Images className="mx-auto h-5 w-5 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-xs font-semibold text-foreground"></p>
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
</p>
</div>
) : taskCount === 0 ? (
<div className="rounded-2xl border border-dashed border-border/80 bg-surface-subtle/35 px-4 py-7 text-center">
<Images className="mx-auto h-5 w-5 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-xs font-semibold text-foreground"></p>
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
</p>
</div>
) : (
<DesignPlanHistory workspace={workspace} />
)}
</div>
</aside>
);
}

View File

@@ -116,7 +116,7 @@ export function DesignWorksRail({
const saveProject = async () => {
const title = projectName.trim();
if (!title) {
setDialogError('请输入作品名称');
setDialogError('请输入项目名称');
return;
}
setSaving(true);
@@ -124,15 +124,15 @@ export function DesignWorksRail({
try {
if (dialog?.mode === 'rename') {
await renameProject(dialog.project.workspaceId, title);
toast.success('作品名称已更新');
toast.success('项目名称已更新');
} else {
await createProject(title);
toast.success('新作品已创建');
toast.success('新项目已创建');
}
setDialog(null);
onProjectSelected?.();
} catch (error) {
setDialogError(error instanceof Error ? error.message : '保存作品失败');
setDialogError(error instanceof Error ? error.message : '保存项目失败');
} finally {
setSaving(false);
}
@@ -141,7 +141,7 @@ export function DesignWorksRail({
const confirmDelete = async () => {
if (!deleteTarget) return;
if (deleteConfirmation !== deleteTarget.title) {
setDeleteError('请输入完整作品名称');
setDeleteError('请输入完整项目名称');
return;
}
setDeleteError(null);
@@ -149,9 +149,9 @@ export function DesignWorksRail({
await deleteProject(deleteTarget.workspaceId);
setDeleteTarget(null);
setDeleteConfirmation('');
toast.success('作品已删除');
toast.success('项目已删除');
} catch (error) {
setDeleteError(error instanceof Error ? error.message : '删除作品失败');
setDeleteError(error instanceof Error ? error.message : '删除项目失败');
}
};
@@ -159,12 +159,12 @@ export function DesignWorksRail({
<aside
data-testid="image-workspace-works-rail"
className="flex h-full min-h-0 flex-col bg-background font-sans"
aria-label="我的作品"
aria-label="设计项目"
>
<header className="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/65 px-4">
<div>
<h2 className="text-base font-semibold tracking-tight text-foreground"></h2>
<p className="mt-0.5 text-[11px] text-muted-foreground">{projects.length} </p>
<h2 className="text-base font-semibold tracking-tight text-foreground"></h2>
<p className="mt-0.5 text-[11px] text-muted-foreground">{projects.length} </p>
</div>
<Button
type="button"
@@ -174,7 +174,7 @@ export function DesignWorksRail({
onClick={openCreate}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
</Button>
</header>
@@ -188,7 +188,7 @@ export function DesignWorksRail({
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand/[0.07] text-brand">
<Plus className="h-4 w-4" />
</span>
<span className="mt-3 block text-sm font-medium text-foreground"></span>
<span className="mt-3 block text-sm font-medium text-foreground"></span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">AI </span>
</button>
) : (
@@ -209,7 +209,7 @@ export function DesignWorksRail({
>
<button
type="button"
aria-label={`打开作品 ${project.title}`}
aria-label={`打开项目 ${project.title}`}
className="flex min-h-[68px] w-full items-center gap-3 rounded-2xl px-2.5 py-2.5 pr-16 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => {
void selectProject(project.workspaceId);
@@ -279,11 +279,11 @@ export function DesignWorksRail({
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dialog?.mode === 'rename' ? '重命名作品' : '新建作品'}</DialogTitle>
<DialogTitle>{dialog?.mode === 'rename' ? '重命名项目' : '新建项目'}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="canvas-work-name" className="text-sm font-medium text-foreground"></label>
<label htmlFor="canvas-work-name" className="text-sm font-medium text-foreground"></label>
<Input
id="canvas-work-name"
autoFocus
@@ -314,8 +314,8 @@ export function DesignWorksRail({
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="delete-canvas-work" className="text-sm font-medium text-foreground">
@@ -332,7 +332,7 @@ export function DesignWorksRail({
<Button type="button" variant="ghost" disabled={Boolean(deletingWorkspaceId)} onClick={() => setDeleteTarget(null)}></Button>
<Button type="button" variant="destructive" disabled={Boolean(deletingWorkspaceId)} onClick={() => void confirmDelete()}>
{deletingWorkspaceId && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</DialogContent>

View File

@@ -21,6 +21,7 @@ import { useAuthStore } from '@/stores/auth';
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { DesignConversationPane } from './DesignConversationPane';
import { DesignHistoryRail } from './DesignHistoryRail';
import { DesignWorksRail } from './DesignWorksRail';
import { hasActiveCreationPlan } from './plan-lifecycle';
import { projectYouthCreationCard } from './youth-form-projection';
@@ -46,12 +47,22 @@ function useDesktopLayout(): boolean {
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="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 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-16 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-16 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" />
))}
</div>
</div>
</div>
);
@@ -165,99 +176,104 @@ export function ImageCanvas() {
};
return (
<div data-testid="image-workspace" className="flex h-full min-h-0 flex-col overflow-hidden bg-background font-sans">
<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-4 pr-[116px] sm:pl-5'
: platform === 'win32'
? 'pl-5 pr-[260px]'
: 'px-4 sm:px-5',
)}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
<h1 className="truncate text-base font-semibold text-foreground">
{workspace?.workspace.title ?? 'Makelore Canvas'}
</h1>
<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 className={cn(
'flex min-h-16 shrink-0 items-center gap-4 border-b border-border/70 bg-background py-3',
platform === 'darwin'
? 'pl-4 pr-[116px] sm:pl-5'
: platform === 'win32'
? 'pl-5 pr-[260px]'
: 'px-4 sm:px-5',
)}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
<h1 className="truncate text-base font-semibold text-foreground">
{workspace?.workspace.title ?? 'Makelore Canvas'}
</h1>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{updatedAt ? `${readiness} · 更新于 ${updatedAt}` : readiness}
</p>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{updatedAt ? `${readiness} · 更新于 ${updatedAt}` : readiness}
</p>
</div>
<div
className={cn(
'hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs sm:flex',
streamHealthy ? 'bg-brand/[0.055] text-brand' : 'bg-surface-subtle text-muted-foreground',
)}
title={streamHealthy ? '已经连接' : '正在重新连接,你的内容不会丢失'}
>
{streamHealthy ? <Cloud className="h-3.5 w-3.5" /> : <CloudOff className="h-3.5 w-3.5" />}
{streamHealthy ? '实时同步' : '恢复连接'}
</div>
{workspace && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-lg"
aria-label="刷新作品"
title="刷新"
onClick={() => void refreshWorkspace()}
<div
className={cn(
'hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs sm:flex',
streamHealthy ? 'bg-brand/[0.055] text-brand' : 'bg-surface-subtle text-muted-foreground',
)}
title={streamHealthy ? '已经连接' : '正在重新连接,你的内容不会丢失'}
>
<RefreshCw className="h-4 w-4" />
</Button>
{streamHealthy ? <Cloud className="h-3.5 w-3.5" /> : <CloudOff className="h-3.5 w-3.5" />}
{streamHealthy ? '实时同步' : '恢复连接'}
</div>
{workspace && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-lg"
aria-label="刷新作品"
title="刷新"
onClick={() => void refreshWorkspace()}
>
<RefreshCw className="h-4 w-4" />
</Button>
)}
{!desktopLayout && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 rounded-xl"
onClick={() => setWorksOpen(true)}
>
<PanelLeft className="mr-1.5 h-4 w-4" />
</Button>
)}
</header>
{error && workspace && (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-amber-200 bg-amber-50/70 px-4 py-2 text-xs text-amber-950">
<span>{error}</span>
<button
type="button"
className="h-8 shrink-0 rounded-lg px-3 font-medium hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700/35"
onClick={() => void refreshWorkspace()}
>
</button>
</div>
)}
{!desktopLayout && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 rounded-xl"
onClick={() => setWorksOpen(true)}
>
<PanelLeft className="mr-1.5 h-4 w-4" />
</Button>
)}
</header>
<main className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1">
{workspace ? (
<DesignConversationPane
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
showPlanHistory={!desktopLayout}
/>
) : (
<EmptyWorkspace onCreate={openCreate} />
)}
</div>
</main>
</div>
{error && workspace && (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-amber-200 bg-amber-50/70 px-4 py-2 text-xs text-amber-950">
<span>{error}</span>
<button
type="button"
className="h-8 shrink-0 rounded-lg px-3 font-medium hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700/35"
onClick={() => void refreshWorkspace()}
>
</button>
</div>
)}
<main className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1">
{workspace ? (
<DesignConversationPane
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
/>
) : (
<EmptyWorkspace onCreate={openCreate} />
)}
</div>
</main>
{desktopLayout ? <DesignHistoryRail workspace={workspace} /> : null}
{!desktopLayout && (
<Sheet open={worksOpen} onOpenChange={setWorksOpen}>
<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>
<SheetTitle></SheetTitle>
<SheetDescription> AI </SheetDescription>
</SheetHeader>
<DesignWorksRail workspace={workspace} onProjectSelected={() => setWorksOpen(false)} />
</SheetContent>

View File

@@ -353,6 +353,7 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByTestId('image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
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.getByRole('heading', { name: '和 AI 一起完善创作' })).toBeVisible();
await expect(page.getByRole('heading', { name: '制作方案' })).toBeVisible();
@@ -368,12 +369,21 @@ test.describe('AI Design V2 workspace', () => {
?.getBoundingClientRect();
const projectSidebar = document.querySelector('[data-testid="sidebar"]')
?.getBoundingClientRect();
return conversation && projectSidebar
const workspace = document.querySelector('[data-testid="image-workspace"]')
?.getBoundingClientRect();
const historyRail = document.querySelector('[data-testid="image-workspace-history-rail"]')
?.getBoundingClientRect();
return conversation && projectSidebar && workspace && historyRail
? {
conversationHeight: conversation.height,
conversationWidth: conversation.width,
sidebarWidth: projectSidebar.width,
horizontalGap: Math.abs(projectSidebar.right - conversation.left),
historyRailWidth: historyRail.width,
historyRailHeight: historyRail.height,
historyTopGap: Math.abs(workspace.top - historyRail.top),
historyBottomGap: Math.abs(workspace.bottom - historyRail.bottom),
historyHorizontalGap: Math.abs(conversation.right - historyRail.left),
}
: null;
});
@@ -381,24 +391,31 @@ 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!.historyRailHeight).toBeGreaterThan(500);
expect(layout!.historyTopGap).toBeLessThanOrEqual(1);
expect(layout!.historyBottomGap).toBeLessThanOrEqual(1);
expect(layout!.historyHorizontalGap).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-history-rail')).toHaveCount(0);
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 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 });
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
await expect(page.getByRole('button', { name: '作品', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '项目', exact: true })).toBeVisible();
await page.setViewportSize(desktopViewport ?? { width: 1280, height: 800 });
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 page.getByRole('combobox', { name: '画幅' }).selectOption('16:9');
@@ -418,7 +435,8 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByText(/规格版本|编译器|生成方式|不可变/)).toHaveCount(0);
await page.getByRole('button', { name: '确认并开始制作' }).click();
await expect(page.getByTestId('design-plan-history-task-1')).toBeVisible();
await expect(page.getByTestId('image-workspace-history-rail').getByTestId('design-plan-history-task-1')).toBeVisible();
await expect(page.getByTestId('image-workspace-conversation').getByTestId('design-plan-history-task-1')).toHaveCount(0);
await expect(page.getByText('等待制作')).toBeVisible();
await expect(page.getByTestId('youth-creation-card')).toHaveCount(0);
await expect(page.getByText('1200', { exact: true })).toHaveCount(0);

View File

@@ -125,13 +125,17 @@ describe('AI Design Canvas page', () => {
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
});
it('leaves desktop Workspace navigation to the shared left project sidebar', () => {
it('places desktop production history in a full-height right rail', () => {
setViewport(true);
prepareWorkspace();
render(<ImageCanvas />);
const historyRail = screen.getByTestId('image-workspace-history-rail');
expect(historyRail).toHaveClass('h-full', 'border-l');
expect(within(historyRail).getByRole('heading', { name: '历史作品' })).toBeInTheDocument();
expect(within(historyRail).getByText('还没有历史作品')).toBeInTheDocument();
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '作品' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '项目' })).not.toBeInTheDocument();
});
it('prioritizes an offered current plan over older completed-work status', () => {
@@ -142,17 +146,18 @@ describe('AI Design Canvas page', () => {
render(<ImageCanvas />);
expect(screen.getByText(/制作方案已准备好,确认前仍可修改/)).toBeInTheDocument();
expect(screen.getByTestId('image-workspace-history-rail')).toBeInTheDocument();
expect(screen.queryByTestId('image-workspace-works-rail')).not.toBeInTheDocument();
});
it('opens the Works rail as a left sheet on compact layouts', () => {
it('opens project navigation as a left sheet on compact layouts', () => {
prepareWorkspace();
render(<ImageCanvas />);
fireEvent.click(screen.getByRole('button', { name: '作品' }));
fireEvent.click(screen.getByRole('button', { name: '项目' }));
const rail = screen.getByTestId('image-workspace-works-rail');
expect(rail).toBeInTheDocument();
expect(within(rail).getByRole('heading', { name: '我的作品' })).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');
});
@@ -423,6 +428,6 @@ describe('AI Design Canvas page', () => {
render(<ImageCanvas />);
fireEvent.click(screen.getByRole('button', { name: '开始第一个作品' }));
await waitFor(() => expect(screen.getByRole('heading', { name: '新建作品' })).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('heading', { name: '新建项目' })).toBeInTheDocument());
});
});