Keep Canvas creation identity on retry and simplify the yellow rail
This commit is contained in:
1 parent
f34e651774
commit
ca546c987c
10 files changed
+379
-34
No files matched your search
@@ -0,0 +1,80 @@
|
||||
# Task: Remove creation copy and diagnose Canvas project creation timeouts
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20261014-canvas-create-recovery-a72d49e6
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261014-canvas-create-recovery-a72d49e6-canvas-create-recovery
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-canvas-create-recovery-a72d49e6
|
||||
- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7
|
||||
- Owner: codex-root
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Remove the exact create-dialog description, change the Canvas rail from pale lavender to pale yellow, diagnose actual Canvas project creation timeouts, and implement the smallest evidenced safe recovery/fix.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User explicitly requests the copy removal and timeout diagnosis, then steers the same task to pale-yellow background. Main remains the sole authenticated network authority; preserve real projects/drafts/accounts, recent sidebar changes, compact layout and idempotent command boundaries. Do not blindly replay unknown create requests, delete real data, trigger paid generation, deploy backend services, change runtime auth/contracts or edit other owned worktrees. Preserve primary checkout and all existing runtime features. Earlier user merge/push/temporary-app update/cleanup authorization persists, but fresh runtime state must be verified before delivery.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Removed the exact desktop creation description and disabled its absent aria description; Canvas shared rail surface is pale yellow #fff9e8, with Code recipes preserved. Both creation dialogs hold a distinct creation intent. Any failed request for unchanged intent/name reuses its original server clientWorkspaceId; a new dialog or modified name creates a new intent, and repeated concurrent submissions share one in-flight request. Account/logout/reset generations prevent old responses from changing another account or reset store. Create/rename name input is disabled during submission. README synchronized.
|
||||
|
||||
## Verification
|
||||
|
||||
- Exact runtime failure confirmed through the current authenticated local Host API: Canvas bootstrap 504/30015ms with DESIGN_WORKSPACE_REQUEST_TIMEOUT. Native telemetry also showed simultaneous ~30s Canvas, Agent and teacher cloud failures. Public Square capabilities returned actual JSON200/0.17s, so reachable upstream was verified. Fresh current pointer/Main/PPID/profile guard allowed only NetworkService PID35175 SIGTERM; Chromium rebuilt PID71263. Main35142, Renderer35176 and pointer SHA remained unchanged. Same authenticated readonly bootstrap then returned200/116ms and 5 existing workspaces; requested Autumn/秋天 was absent. No create replay, model call, paid generation, binding operation or Main restart was performed. Underlying NetworkService hang trigger is not proven; recovery establishes the affected transport path. Receipt under .trial-runtime/canvas-create-recovery-20261014-a72d49e6/network-recovery-receipt.json.
|
||||
- Standard typecheck, scoped lint, default full build:vite, whitespace and feature documentation drift passed; 81 focused unit tests in4 files passed, including API/store identities/auth/races and both UI scopes. Isolated Electron Canvas V2 acceptance passed (1 test, 7.0s), including desktop/compact split, draft/plan/production history and new creation popup; pale-yellow surface and absent exact description checked. Inspected final creation-dialog screenshot. Independent source/concurrency/auth review passed. Logs and screenshots retained outside source checkout in .trial-runtime/canvas-create-recovery-20261014-a72d49e6.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Unified update coordination chat 01a12eba-e98b-76a3-8fd5-206f2820d623 notified under direct previously verified human coordination authorization. It is currently preparing a separate Code Main diagnostic change. Current runtime pointer still says436, but actual stable alias points at WeChat name-edit Renderer f1dab; that peer file activation is awaiting native metadata finalization. Any final build/update must preserve actual f1dab product changes and freeze the real runtime pair; do not overwrite them using stale current.json alone. Primary checkout remains occupied/untouched. Native user is actively navigating, so avoid racing UI reloads or discarding work; no whole-app restart has been performed.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID: 20261014-canvas-create-recovery-a72d49e6
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261014-canvas-create-recovery-a72d49e6-canvas-create-recovery
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-canvas-create-recovery-a72d49e6
|
||||
- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 (fresh origin/main)
|
||||
- Ownership: official start/check/status match; occupied dirty primary teacher-consensus checkout remains untouched.
|
||||
- Other tasks: 74 peer records read-only Scope/Intent/Promotion inventory at /tmp/makelore-canvas-create-peer-scopes.json. Current WeChat local-remark task targets another Renderer surface and runtime436; runtime delivery must preserve it. Prior Canvas/sidebar/consolidation sources are ready and integrated; Code session diagnosis is independent. Old unrelated placeholder scope remains unknown, without a dependency.
|
||||
- Overlap/conflict: no semantic conflict in source. Runtime updates require a fresh current pointer/build-pair check; no source record edits or adoption.
|
||||
|
||||
Read:
|
||||
- AGENTS.md, existing README product state and prior same-conversation accepted context
|
||||
- .project-docs/05-agent-entry/read-before-planning.md, memory-index.md, planning-gate.md
|
||||
- Own task, positioning, current-state integrated snapshot, decision-index, system-overview
|
||||
- business-rules, success-criteria; evidence/reflection/commitment indexes in Canvas/runtime context
|
||||
- adr-007-ai-design-living-form-v2.md; peer task Scope/Intent/Promotion sections
|
||||
- maintain-project-docs skill; existing design skill remains applicable only to the requested color/copy consistency
|
||||
|
||||
Relevant understanding:
|
||||
- Goal: Chinese project-based creative client, preserving authoritative cloud Living Form, server reducer, quotes/tasks/assets.
|
||||
- Current focus: all prior Canvas/Code rail refinements are in main f34; live full trial436/35142 uses a frozen compiled snapshot and main-owned development login.
|
||||
- Active scope: exact description removal, pale-yellow rail, evidenced create timeout cause and recovery.
|
||||
- Constraints/decisions: Main owns auth/network. Unknown mutations must reuse stable identities and reconcile canonical server state; no local semantic fallback or fabricated successful form.
|
||||
- Evidence: current native Canvas shows project names but its main detail remains loading; screenshots show create timeout. Main createWorkspace performs POST then getWorkspace. Renderer currently generates a new clientWorkspaceId on every create retry. Neither observation proves which live leg timed out.
|
||||
- Files/modules: ImageWorkspaceSidebar, shared sidebar CSS, DesignWorksRail compact dialog, image-workspace client/store, WorksSquareDesignWorkspace/request deadline, focused tests and runtime evidence.
|
||||
- Stale/unknown: canonical presentation descriptions predate accepted V2 layout; preserve latest source. Live network stack may differ from source evidence, and NetworkService reset was once effective without a permanent root cause; do not assume recurrence or restart the whole app before precise read-only observations.
|
||||
|
||||
Gate result:
|
||||
- Passed
|
||||
|
||||
## Plan
|
||||
|
||||
- Remove the quoted description and use pale-yellow rail surface, keeping shared Code recipes.
|
||||
- Independently trace the Main create/load timeout path and Renderer retry identity; obtain precise read-only current UI/network evidence to identify the failed stage before changing timeout or transport behavior.
|
||||
- Fix evidenced behavior, verify timeout/retry and existing desktop/compact creation with focused tests, typecheck/lint/full build. Deliver through a fresh verified runtime tree while preserving active work; record truthful native and fixture limits, then commit/handoff or push within existing authorization.
|
||||
|
||||
## Delivery Handoff
|
||||
|
||||
- Product commit and final HEAD will be reported to the authorized coordination chat. This clean Ready source contains Renderer-only changes; Main/shared/resources are unchanged against runtime436. Source implementation and live network recovery are separately evidenced.
|
||||
- A precise read-only real-project bootstrap recovered; no real Autumn project was created. Real UI color/copy activation remains for the combined update, because actual WeChat f1dab files are active while current.json still points at436, and another Code Main update is in progress. Preserve actual newer Renderer feature source and active user work rather than blindly replacing the old pointer's tree.
|
||||
- Any different title or explicitly reopened create dialog is a new creation intent; an older transport-unknown project may still exist on the server. Never delete it or infer identity from a matching name. This patch prevents same-intent retries from making extra identities; it does not establish the underlying NetworkService hang trigger or prove real model generation.
|
||||
@@ -24,7 +24,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。绑定有效且服务正常时始终开启回复,不提供暂停或恢复开关;更换智能体保留登录与历史,完成重新登录后也开启回复。旧的关闭记录在页面可见时,重新核对登录、在线服务、可用目标、同步状态及新旧渠道操作恢复记录后串行开启;服务离线、退出待确认、目标不可用和结果未知时不自动写入。常用操作是“更换智能体”;“更多操作”内可以解除微信绑定,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。确认解绑后的卡片可选择“删除微信卡片”,只从这台设备的当前账号列表移除,刷新和重开后仍保留删除结果;其他设备及云端账号和聊天历史不受影响,同一微信重新绑定后再次显示。本机偏好按账号与租户隔离,删除前再次读取云端状态并检查原操作,保存失败时保留卡片并显示原因。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成。微信服务离线时,卡片仅保留右上角“微信服务离线”状态,不显示重复的离线长段说明;绑定或重新连接失败的窗口仍说明原因,不把扫码成功当作回复可用。未知或未同步状态显示“状态待确认”。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。微信退出仍在处理中时,禁止新的连接和绑定变更,保留刷新与原操作恢复。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。
|
||||
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部广场/账户样式,仅工作栏底色采用淡紫色(`#f5f2fa`);顶部返回箭头与 Code 对齐,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部广场/账户样式,仅工作栏底色采用淡黄色(`#fff9e8`);顶部返回箭头与 Code 对齐,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace;创建请求失败后保留名称与本次创建标识,原样重试避免重复创建项目。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 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 授权。
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import { createImageWorkspaceProjectId, IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { DesignWorkspaceSummary } from '../../../shared/image-workspace';
|
||||
@@ -48,6 +48,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
|
||||
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
|
||||
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
|
||||
const [creationIntentId, setCreationIntentId] = useState(createImageWorkspaceProjectId);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -63,6 +64,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
const openCreateDialog = () => {
|
||||
// The compact project Sheet owns this event while the desktop sidebar is hidden.
|
||||
if (typeof window.matchMedia === 'function' && !window.matchMedia('(min-width: 1024px)').matches) return;
|
||||
setCreationIntentId(createImageWorkspaceProjectId());
|
||||
setProjectName('');
|
||||
setDialogError(null);
|
||||
setDialog({ mode: 'create', project: null });
|
||||
@@ -79,6 +81,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setCreationIntentId(createImageWorkspaceProjectId());
|
||||
setProjectName('');
|
||||
setDialogError(null);
|
||||
setDialog({ mode: 'create', project: null });
|
||||
@@ -103,7 +106,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
await renameProject(dialog.project.workspaceId, title);
|
||||
toast.success('项目名称已更新');
|
||||
} else {
|
||||
await createProject(title);
|
||||
await createProject(title, creationIntentId);
|
||||
navigate('/image-canvas');
|
||||
}
|
||||
setDialog(null);
|
||||
@@ -282,12 +285,9 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
)}
|
||||
|
||||
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-md" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialog?.mode === 'rename' ? '重命名设计项目' : '新建设计项目'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建后会立即生成一份空白设计表单,可通过对话或直接编辑完善。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="design-project-name" className="text-sm font-medium text-foreground">
|
||||
@@ -295,6 +295,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
</label>
|
||||
<Input
|
||||
id="design-project-name"
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
value={projectName}
|
||||
placeholder="例如:秋季新品主视觉"
|
||||
|
||||
@@ -60,6 +60,10 @@ export function createImageWorkspaceOperationId(): string {
|
||||
return createClientId('design-operation');
|
||||
}
|
||||
|
||||
export function createImageWorkspaceProjectId(): string {
|
||||
return createClientId('workspace');
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number {
|
||||
if (error instanceof AppError && typeof error.details?.status === 'number') {
|
||||
return error.details.status;
|
||||
@@ -111,7 +115,7 @@ export function fetchImageWorkspace(): Promise<DesignWorkspaceBootstrap> {
|
||||
|
||||
export function createImageWorkspaceProject(
|
||||
title: string,
|
||||
clientWorkspaceId = createClientId('workspace'),
|
||||
clientWorkspaceId = createImageWorkspaceProjectId(),
|
||||
): Promise<DesignWorkspace> {
|
||||
return requestData(`${IMAGE_WORKSPACE_API_PATH}/workspaces`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child { min-height: 35px; gap: 7px; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child > svg { color: #7891af; }
|
||||
[data-testid="sidebar"]:is([data-classroom="true"], [data-workspace-sidebar="canvas"]) .classroom-account-footer { border-top: 1px solid #dde5ee; background: var(--workspace-sidebar-background, #edf3f9); }
|
||||
[data-testid="sidebar"][data-workspace-sidebar="canvas"] { --workspace-sidebar-background: #f5f2fa; }
|
||||
[data-testid="sidebar"][data-workspace-sidebar="canvas"] { --workspace-sidebar-background: #fff9e8; }
|
||||
[data-testid="sidebar"][data-workspace-sidebar="canvas"] .classroom-sidebar-header { padding: 0; }
|
||||
[data-testid="sidebar"][data-workspace-sidebar="canvas"] .classroom-sidebar-content { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.classroom-workspace [data-testid="coding-conversation-header"] { border-bottom-color: #e8edf3; background: #fff; color: #304968; }
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import { createImageWorkspaceProjectId, IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type { DesignWorkspace, DesignWorkspaceSummary } from '../../../shared/image-workspace';
|
||||
@@ -70,6 +70,7 @@ export function DesignWorksRail({
|
||||
const deleteProject = useImageWorkspaceStore((state) => state.deleteProject);
|
||||
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
|
||||
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
|
||||
const [creationIntentId, setCreationIntentId] = useState(createImageWorkspaceProjectId);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -92,6 +93,7 @@ export function DesignWorksRail({
|
||||
const currentStatus = activeProjectStatus(workspace);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreationIntentId(createImageWorkspaceProjectId());
|
||||
setProjectName('');
|
||||
setDialogError(null);
|
||||
setDialog({ mode: 'create', project: null });
|
||||
@@ -99,6 +101,7 @@ export function DesignWorksRail({
|
||||
|
||||
useEffect(() => {
|
||||
const handleCreateRequest = () => {
|
||||
setCreationIntentId(createImageWorkspaceProjectId());
|
||||
setProjectName('');
|
||||
setDialogError(null);
|
||||
setDialog({ mode: 'create', project: null });
|
||||
@@ -126,7 +129,7 @@ export function DesignWorksRail({
|
||||
await renameProject(dialog.project.workspaceId, title);
|
||||
toast.success('项目名称已更新');
|
||||
} else {
|
||||
await createProject(title);
|
||||
await createProject(title, creationIntentId);
|
||||
toast.success('新项目已创建');
|
||||
}
|
||||
setDialog(null);
|
||||
@@ -286,6 +289,7 @@ export function DesignWorksRail({
|
||||
<label htmlFor="canvas-work-name" className="text-sm font-medium text-foreground">项目名称</label>
|
||||
<Input
|
||||
id="canvas-work-name"
|
||||
disabled={saving}
|
||||
autoFocus
|
||||
value={projectName}
|
||||
placeholder="例如:小狗的快乐短片"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import {
|
||||
createImageWorkspaceOperationId,
|
||||
createImageWorkspaceProject,
|
||||
createImageWorkspaceProjectId,
|
||||
deleteImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
fetchImageWorkspaceProject,
|
||||
@@ -84,7 +85,7 @@ type ImageWorkspaceState = {
|
||||
quoteBlockers: DesignCompilationIssue[];
|
||||
error: string | null;
|
||||
load: () => Promise<DesignWorkspaceBootstrap | null>;
|
||||
createProject: (title: string) => Promise<DesignWorkspace>;
|
||||
createProject: (title: string, creationIntentId?: string) => Promise<DesignWorkspace>;
|
||||
renameProject: (workspaceId: string, title: string) => Promise<DesignWorkspace>;
|
||||
deleteProject: (workspaceId: string) => Promise<DesignDeleteWorkspaceResult>;
|
||||
selectProject: (workspaceId: string) => Promise<void>;
|
||||
@@ -117,6 +118,32 @@ let activeEventWorkspaceId: string | null = null;
|
||||
let eventConnectionGeneration = 0;
|
||||
let selectionGeneration = 0;
|
||||
let workspaceSelectionGeneration = 0;
|
||||
let creationGeneration = 0;
|
||||
type ProjectCreation = {
|
||||
accountKey: string;
|
||||
generation: number;
|
||||
title: string;
|
||||
clientWorkspaceId: string;
|
||||
request: Promise<DesignWorkspace> | null;
|
||||
};
|
||||
const projectCreations = new Map<string, ProjectCreation>();
|
||||
|
||||
function creationAccountKey(): string | null {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isAuthenticated()) return null;
|
||||
return JSON.stringify([
|
||||
auth.authBase,
|
||||
auth.user?.tenantId,
|
||||
auth.user?.userId,
|
||||
auth.user?.username,
|
||||
]);
|
||||
}
|
||||
|
||||
function clearProjectCreations(): void {
|
||||
creationGeneration += 1;
|
||||
projectCreations.clear();
|
||||
}
|
||||
|
||||
const assistantStreamChunkIndexes = new Map<string, number>();
|
||||
const observedCommandTerminalEvents = new Map<string, DesignCommandTerminalEvent>();
|
||||
const ASSISTANT_PROGRESS_STAGES: DesignAssistantProgressStage[] = [
|
||||
@@ -789,23 +816,68 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return inFlightLoad;
|
||||
},
|
||||
|
||||
createProject: async (title) => {
|
||||
const workspace = await createImageWorkspaceProject(title);
|
||||
selectionGeneration += 1;
|
||||
workspaceSelectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
set((state) => ({
|
||||
...withWorkspace(state, workspace),
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantActivities: {},
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
lastEventId: null,
|
||||
}));
|
||||
get().connectEvents();
|
||||
return workspace;
|
||||
createProject: (title, creationIntentId = createImageWorkspaceProjectId()) => {
|
||||
const accountKey = creationAccountKey();
|
||||
if (!accountKey) {
|
||||
return Promise.reject(new ImageWorkspaceApiError(
|
||||
401, 'design_auth_required', '请登录后再创建项目',
|
||||
));
|
||||
}
|
||||
const normalizedTitle = title.trim();
|
||||
const candidate = projectCreations.get(creationIntentId);
|
||||
const previous = candidate?.accountKey === accountKey
|
||||
&& candidate.generation === creationGeneration ? candidate : undefined;
|
||||
if (previous?.request) {
|
||||
if (previous.title === normalizedTitle) return previous.request;
|
||||
return Promise.reject(new Error('项目正在创建,请等待完成后再修改名称'));
|
||||
}
|
||||
const creation: ProjectCreation = previous?.title === normalizedTitle
|
||||
? previous
|
||||
: {
|
||||
accountKey,
|
||||
generation: creationGeneration,
|
||||
title: normalizedTitle,
|
||||
clientWorkspaceId: createImageWorkspaceProjectId(),
|
||||
request: null,
|
||||
};
|
||||
projectCreations.set(creationIntentId, creation);
|
||||
const request = (async () => {
|
||||
try {
|
||||
const workspace = await createImageWorkspaceProject(
|
||||
creation.title, creation.clientWorkspaceId,
|
||||
);
|
||||
if (creation.generation !== creationGeneration
|
||||
|| creation.accountKey !== creationAccountKey()) {
|
||||
throw new ImageWorkspaceApiError(
|
||||
401, 'design_creation_session_changed', '登录状态已变化,请重新打开新建项目',
|
||||
);
|
||||
}
|
||||
if (projectCreations.get(creationIntentId) === creation) {
|
||||
projectCreations.delete(creationIntentId);
|
||||
}
|
||||
selectionGeneration += 1;
|
||||
workspaceSelectionGeneration += 1;
|
||||
closeEventSource();
|
||||
assistantStreamChunkIndexes.clear();
|
||||
set((state) => ({
|
||||
...withWorkspace(state, workspace),
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantActivities: {},
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
lastEventId: null,
|
||||
}));
|
||||
get().connectEvents();
|
||||
return workspace;
|
||||
} finally {
|
||||
// A failed snapshot read may follow a committed create, even for 4xx errors.
|
||||
// Keep the original identity for an explicit same-payload retry.
|
||||
creation.request = null;
|
||||
}
|
||||
})();
|
||||
creation.request = request;
|
||||
return request;
|
||||
},
|
||||
|
||||
renameProject: async (workspaceId, title) => {
|
||||
@@ -1253,6 +1325,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
clearProjectCreations();
|
||||
selectionGeneration += 1;
|
||||
workspaceSelectionGeneration += 1;
|
||||
closeEventSource();
|
||||
@@ -1279,6 +1352,14 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
};
|
||||
});
|
||||
|
||||
let currentCreationAccountKey = creationAccountKey();
|
||||
useAuthStore.subscribe(() => {
|
||||
const accountKey = creationAccountKey();
|
||||
if (accountKey === currentCreationAccountKey) return;
|
||||
currentCreationAccountKey = accountKey;
|
||||
clearProjectCreations();
|
||||
});
|
||||
|
||||
export function recentDesignChanges(workspace: DesignWorkspace | null): DesignFieldChange[] {
|
||||
return workspace?.form.recentChangeSet.changes ?? [];
|
||||
}
|
||||
@@ -473,7 +473,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
await expect(page.getByText('目标与用途')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="sidebar-image-conversation-"]')).toHaveCount(0);
|
||||
|
||||
// Canvas shares Code's navigation recipe, with a lavender surface and its own top return row.
|
||||
// Canvas shares Code's navigation recipe, with a pale-yellow surface and its own top return row.
|
||||
const sidebar = page.getByTestId('sidebar');
|
||||
const createProject = sidebar.getByTestId('sidebar-create-project');
|
||||
const project = sidebar.getByTestId('sidebar-image-project-workspace-1');
|
||||
@@ -482,7 +482,7 @@ test.describe('AI Design V2 workspace', () => {
|
||||
const community = sidebar.getByRole('button', { name: '作品广场', exact: true });
|
||||
const account = sidebar.getByTestId('sidebar-member-menu-trigger');
|
||||
await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect(sidebar).toHaveCSS('background-color', 'rgb(245, 242, 250)');
|
||||
await expect(sidebar).toHaveCSS('background-color', 'rgb(255, 249, 232)');
|
||||
await expect(createProject).toHaveText('新建项目');
|
||||
await expect(projectsHeading).toHaveText('我的项目');
|
||||
await expect(project).toHaveText('咖啡机新品主视觉');
|
||||
@@ -526,6 +526,15 @@ test.describe('AI Design V2 workspace', () => {
|
||||
});
|
||||
expect(navigationStyles.community).toEqual(navigationStyles.create);
|
||||
|
||||
await createProject.click();
|
||||
const creationDialog = page.getByRole('dialog', { name: '新建设计项目', exact: true });
|
||||
await expect(creationDialog).toBeVisible();
|
||||
await expect(creationDialog).not.toContainText('创建后会立即生成一份空白设计表单');
|
||||
await expect(creationDialog.getByRole('textbox', { name: '项目名称' })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('canvas-project-create-dialog.png'), animations: 'disabled' });
|
||||
await creationDialog.getByRole('button', { name: '取消', exact: true }).click();
|
||||
await expect(creationDialog).toHaveCount(0);
|
||||
|
||||
const splitBounds = (await split.boundingBox())!;
|
||||
const conversationBounds = (await conversationPane.boundingBox())!;
|
||||
const worksBounds = (await worksPane.boundingBox())!;
|
||||
|
||||
@@ -98,12 +98,13 @@ describe('ImageWorkspaceSidebar', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计项目' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '新建设计项目' });
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
||||
expect(dialog).not.toHaveTextContent('创建后会立即生成一份空白设计表单');
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), {
|
||||
target: { value: ' 小狗的快乐短片 ' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' }));
|
||||
|
||||
await waitFor(() => expect(actions.createProject).toHaveBeenCalledExactlyOnceWith('小狗的快乐短片'));
|
||||
await waitFor(() => expect(actions.createProject).toHaveBeenCalledExactlyOnceWith('小狗的快乐短片', expect.stringMatching(/^workspace-/)));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas');
|
||||
expect(actions.selectProject).not.toHaveBeenCalled();
|
||||
@@ -111,6 +112,34 @@ describe('ImageWorkspaceSidebar', () => {
|
||||
expect(actions.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps one creation intent across a failed submission and uses a new intent after reopening', async () => {
|
||||
const { actions } = prepareProjects();
|
||||
actions.createProject.mockRejectedValueOnce(new Error('AI 设计服务响应超时,请重试'));
|
||||
renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计项目' }));
|
||||
let dialog = screen.getByRole('dialog', { name: '新建设计项目' });
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), { target: { value: '秋天' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' }));
|
||||
await within(dialog).findByText('AI 设计服务响应超时,请重试');
|
||||
const initialIntent = actions.createProject.mock.calls[0][1];
|
||||
expect(initialIntent).toMatch(/^workspace-/);
|
||||
expect(within(dialog).getByRole('textbox', { name: '项目名称' })).toHaveValue('秋天');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' }));
|
||||
await waitFor(() => expect(actions.createProject).toHaveBeenCalledTimes(2));
|
||||
expect(actions.createProject.mock.calls[1]).toEqual(['秋天', initialIntent]);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计项目' }));
|
||||
dialog = screen.getByRole('dialog', { name: '新建设计项目' });
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: '项目名称' }), { target: { value: '秋天' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '创建项目' }));
|
||||
await waitFor(() => expect(actions.createProject).toHaveBeenCalledTimes(3));
|
||||
expect(actions.createProject.mock.calls[2][1]).not.toBe(initialIntent);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('ignores compact creation events while the desktop sidebar is hidden', () => {
|
||||
const { actions } = prepareProjects();
|
||||
setViewport(false);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createImageWorkspaceOperationId,
|
||||
createImageWorkspaceProject,
|
||||
createImageWorkspaceProjectId,
|
||||
deleteImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
fetchImageWorkspaceProject,
|
||||
@@ -25,6 +26,7 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
...original,
|
||||
createImageWorkspaceOperationId: vi.fn(),
|
||||
createImageWorkspaceProject: vi.fn(),
|
||||
createImageWorkspaceProjectId: vi.fn(),
|
||||
deleteImageWorkspaceProject: vi.fn(),
|
||||
fetchImageWorkspace: vi.fn(),
|
||||
fetchImageWorkspaceProject: vi.fn(),
|
||||
@@ -39,13 +41,28 @@ vi.mock('@/lib/host-api', async (importOriginal) => {
|
||||
return { ...original, setDesktopBackgroundLease: vi.fn() };
|
||||
});
|
||||
|
||||
const authMock = vi.hoisted(() => ({
|
||||
authenticated: true,
|
||||
userId: 'account-1',
|
||||
listeners: new Set<() => void>(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: {
|
||||
getState: () => ({ isAuthenticated: () => true }),
|
||||
getState: () => ({
|
||||
isAuthenticated: () => authMock.authenticated,
|
||||
authBase: 'https://auth.example.test',
|
||||
user: { userId: authMock.userId, username: authMock.userId, tenantId: 'tenant-1' },
|
||||
}),
|
||||
subscribe: (listener: () => void) => {
|
||||
authMock.listeners.add(listener);
|
||||
return () => authMock.listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const operationIdMock = vi.mocked(createImageWorkspaceOperationId);
|
||||
const projectIdMock = vi.mocked(createImageWorkspaceProjectId);
|
||||
const createProjectMock = vi.mocked(createImageWorkspaceProject);
|
||||
const deleteProjectMock = vi.mocked(deleteImageWorkspaceProject);
|
||||
const fetchBootstrapMock = vi.mocked(fetchImageWorkspace);
|
||||
@@ -95,7 +112,12 @@ async function loadedStore(eventSource = new FakeEventSource()) {
|
||||
describe('V2 Living Form store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMock.authenticated = true;
|
||||
authMock.userId = 'account-1';
|
||||
authMock.listeners.forEach((listener) => listener());
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
let creationSequence = 0;
|
||||
projectIdMock.mockImplementation(() => `client-workspace-${++creationSequence}`);
|
||||
const workspace = designWorkspaceFixture();
|
||||
fetchBootstrapMock.mockResolvedValue(designBootstrapFixture());
|
||||
fetchProjectMock.mockResolvedValue(workspace);
|
||||
@@ -1659,12 +1681,127 @@ describe('V2 Living Form store', () => {
|
||||
await useImageWorkspaceStore.getState().renameProject('workspace-1', '新名称');
|
||||
await useImageWorkspaceStore.getState().deleteProject('workspace-1');
|
||||
|
||||
expect(createProjectMock).toHaveBeenCalledWith('新项目');
|
||||
expect(createProjectMock).toHaveBeenCalledWith('新项目', expect.any(String));
|
||||
expect(renameProjectMock).toHaveBeenCalledWith('workspace-1', '新名称');
|
||||
expect(deleteProjectMock).toHaveBeenCalledWith('workspace-1');
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
new ImageWorkspaceApiError(504, 'DESIGN_WORKSPACE_REQUEST_TIMEOUT', '响应超时'),
|
||||
new Error('连接中断'),
|
||||
new ImageWorkspaceApiError(404, 'SNAPSHOT_NOT_FOUND', '暂未读取到创建结果'),
|
||||
new ImageWorkspaceApiError(404, 'SNAPSHOT_NOT_FOUND', '暂未读取到创建结果', 'unknown'),
|
||||
])('retries an unknown project creation with its original title and Workspace identity: %s', async (error) => {
|
||||
createProjectMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().createProject(' 秋天 ', 'intent-1'))
|
||||
.rejects.toBe(error);
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
await useImageWorkspaceStore.getState().createProject('秋天', 'intent-1');
|
||||
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(1, '秋天', 'client-workspace-1');
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-1');
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe('workspace-1');
|
||||
});
|
||||
|
||||
it('does not bind a new same-name creation intent to an unknown earlier request', async () => {
|
||||
createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(502, 'NETWORK_ERROR', '连接中断'));
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow();
|
||||
await useImageWorkspaceStore.getState().createProject('秋天', 'intent-2');
|
||||
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(1, '秋天', 'client-workspace-1');
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-2');
|
||||
});
|
||||
|
||||
it('uses a new Workspace identity when the user changes the submitted project name', async () => {
|
||||
createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(504, 'TIMEOUT', '响应超时'));
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow();
|
||||
await useImageWorkspaceStore.getState().createProject('冬天', 'intent-1');
|
||||
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(1, '秋天', 'client-workspace-1');
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(2, '冬天', 'client-workspace-2');
|
||||
});
|
||||
|
||||
it('preserves the creation identity for a same-payload retry after a definitive failure', async () => {
|
||||
createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
||||
400, 'INVALID_NAME', '名称无效', 'definitive_failure',
|
||||
));
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow();
|
||||
await useImageWorkspaceStore.getState().createProject('秋天', 'intent-1');
|
||||
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-1');
|
||||
});
|
||||
|
||||
it('requires an authenticated account before creating a project', async () => {
|
||||
authMock.authenticated = false;
|
||||
authMock.listeners.forEach((listener) => listener());
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1'))
|
||||
.rejects.toMatchObject({ code: 'design_auth_required' });
|
||||
expect(createProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shares one in-flight request for repeated submits without creating two projects', async () => {
|
||||
const pending = deferred<ReturnType<typeof designWorkspaceFixture>>();
|
||||
createProjectMock.mockReturnValueOnce(pending.promise);
|
||||
const first = useImageWorkspaceStore.getState().createProject('秋天', 'intent-1');
|
||||
const repeated = useImageWorkspaceStore.getState().createProject(' 秋天 ', 'intent-1');
|
||||
|
||||
expect(repeated).toBe(first);
|
||||
expect(createProjectMock).toHaveBeenCalledTimes(1);
|
||||
await expect(useImageWorkspaceStore.getState().createProject('冬天', 'intent-1'))
|
||||
.rejects.toThrow('项目正在创建');
|
||||
pending.resolve(designWorkspaceFixture());
|
||||
await Promise.all([first, repeated]);
|
||||
expect(openEventsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['account-change', 'logout-login', 'reset'] as const)(
|
||||
'does not reuse an unknown creation after %s', async (change) => {
|
||||
createProjectMock.mockRejectedValueOnce(new ImageWorkspaceApiError(504, 'TIMEOUT', '响应超时'));
|
||||
await expect(useImageWorkspaceStore.getState().createProject('秋天', 'intent-1')).rejects.toThrow();
|
||||
|
||||
if (change === 'account-change') authMock.userId = 'account-2';
|
||||
if (change === 'logout-login') {
|
||||
authMock.authenticated = false;
|
||||
authMock.listeners.forEach((listener) => listener());
|
||||
authMock.authenticated = true;
|
||||
}
|
||||
if (change === 'reset') useImageWorkspaceStore.getState().reset();
|
||||
authMock.listeners.forEach((listener) => listener());
|
||||
await useImageWorkspaceStore.getState().createProject('秋天', 'intent-1');
|
||||
|
||||
expect(createProjectMock).toHaveBeenNthCalledWith(2, '秋天', 'client-workspace-2');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['account-change', 'logout-login', 'reset'] as const)(
|
||||
'does not install an old creation response after %s', async (change) => {
|
||||
const pending = deferred<ReturnType<typeof designWorkspaceFixture>>();
|
||||
createProjectMock.mockReturnValueOnce(pending.promise);
|
||||
const first = useImageWorkspaceStore.getState().createProject('秋天', 'intent-1');
|
||||
const rejected = expect(first).rejects.toMatchObject({ code: 'design_creation_session_changed' });
|
||||
|
||||
if (change === 'account-change') authMock.userId = 'account-2';
|
||||
if (change === 'logout-login') {
|
||||
authMock.authenticated = false;
|
||||
authMock.listeners.forEach((listener) => listener());
|
||||
authMock.authenticated = true;
|
||||
}
|
||||
if (change === 'reset') useImageWorkspaceStore.getState().reset();
|
||||
authMock.listeners.forEach((listener) => listener());
|
||||
pending.resolve(designWorkspaceFixture());
|
||||
await rejected;
|
||||
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
||||
expect(openEventsMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('renames a background Workspace without changing the active Workspace', async () => {
|
||||
await loadedStore();
|
||||
const backgroundSummary = designSummaryFixture({
|
||||
|
||||
Reference in new issue
Block a user