diff --git a/.project-docs/30-worklog/tasks/20261014-canvas-works-drawer-7b4e92c1.md b/.project-docs/30-worklog/tasks/20261014-canvas-works-drawer-7b4e92c1.md new file mode 100644 index 00000000..418c8fc7 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-canvas-works-drawer-7b4e92c1.md @@ -0,0 +1,53 @@ +# Task: Implement approved Canvas My Works drawer + +## Identity + +- Task ID: 20261014-canvas-works-drawer-7b4e92c1 +- Mode: Feature +- Branch: codex/20261014-canvas-works-drawer-7b4e92c1-canvas-works-drawer +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-canvas-works-drawer-7b4e92c1 +- Base commit: 436f7ba1eab500e41866f890f49816ff622ef6fe +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement the approved Canvas My Works drawer based on the selected image concept. Aggregate generated image/video works across the user's Canvas projects; clear type labels/filter, preview/download, and return to source creation; coordinate open/close with the existing right-hand work pane. + +## Intent And Constraints + +- User explicitly approved implementation following the mock and clarified that the current right pane is a working area. Preserve its editing, split/focus, task/history, and generation-confirmation behavior. Temporary-app updates are coordinated separately; this worktree does not alter the trial. +- Base is the current complete trial source `436f7ba1eab500e41866f890f49816ff622ef6fe`, verified from read-only runtime metadata. Main checkout and foreign docs remain untouched; own isolated feature worktree/branch identity verified. +- Planning Gate passed: 81 readable peer records / 243 permitted sections. Canvas create recovery and existing Gallery migration are separate scopes; preserve their changes during any future integration. Do not update/restart/refresh the trial, merge main, push, deploy, or call paid models. +- New collection is a Main-owned read-only projection of existing cloud V2 Workspace Tasks/Assets; no server schema, pricing/Quote, credential, or cross-account boundary changes. No fake product works or arbitrary storage paths. +- Use approved calm light layout and existing project tokens/Lucide. Generic search suggestions that conflict with this visual target are not adopted. Generated mock is a visual target, not product artwork data. +- Delegation: server agent owns shared/Main/Host collection contract/tests; root owns UI integration/state/navigation and final checks; UI agent will own drawer rendering and focused tests after data contract is fixed. + +## Outcome + +- Implemented Canvas header “我的作品” and right drawer using existing app tokens, Sheet, icon and asset pathways. Collections read completed generated image/video outputs across account Workspaces in read-only pages. Reference/uploads/intermediate and unsuccessful results are excluded by successful Task result association. +- Added explicit image/video badges, all/image/video filtering, video play/duration, same-drawer preview, save-to-computer and return to exact source task. Collection loading, empty, unavailable pages, partial retry and truncated-history states remain distinct. Failed-page retries retain prior works for currently unavailable Workspaces while replacing successful authoritative reads, including empty results. +- Preserved existing work pane, split ratio, focus, current plan and drafts on drawer open/close. Source navigation reads and validates first, then atomically changes Workspace and opens the matching task; failed/missing/stale/changed-account/read-during-draft scenarios preserve the original Workspace and event connection. Same-project source return does not replace local drafts. No quote/confirmation/generation mutations are introduced. +- Main holds collection account binding, one pinned credential per page and epoch checks; no credential or full form/conversation data enters the collection DTO. Whole-page credential/list/detail/body reads share a deadline; bounded detail concurrency and explicit pagination keep the scan finite. Closing/unmounting/account changes abort or ignore collection responses; preview video pauses on departure. +- Updated README and project-root `design-qa.md`; selected mock vs rendered exact-size full/focused montage reviewed, desktop density P2 fixed, QA final result passed. Public fixture-only browser preview runs at `http://127.0.0.1:4179/` for isolated review. +- Verified trusted human authorization in “推代码” conversation `01a12eba-e98b-76a3-8fd5-206f2820d623` for project-task coordination. Integration task `20261014-all-thread-trial-6d72e4f1` owns combined trial refresh/main merge/push; this task will hand over one final commit and does not perform those operations. Primary/foreign files, trial profiles and runtime records remain untouched. + +## Verification + +- Scoped unit suite: **149 passed across 8 files**. First run: works-square-generated-works, image-workspace-route, works-square-design-workspace, design-my-works-drawer, image-canvas-page, image-workspace-api — 92 total. Second run: image-workspace-store (48), canvas-workspace-layout (9), 57 total. Store tests exercise actual atomic source selection failures/404/invalid target/drafts/account changes/reset/new selection/success/same project. +- Final drawer CSS change: focused component 14/14 and E2E 3/3 repeated; three screenshots per viewport **1600×1000, 1440×960, 390×844**. List/filter/image/video detail/Escape/focus return/background drafts and safe source-task navigation passed; no page errors or unexpected external requests. Responsive geometry and all six sample-card captions measured before footer. +- TypeScript `tsc --noEmit`: PASS. ESLint across all changed/new TypeScript and E2E harness files: PASS. Source review: PASS after two P2 fixes (partial retry crossed failures; failed source switch losing original Workspace). +- CUA opened the isolated browser and inspected actual drawer list, filter and video detail. Exact-size automated screenshots compared with the source in one full montage and one readable focused montage; final visual QA PASS. Media fixture uses repository posters and DOM stub, so this is not a real cloud playback or download smoke. +- Production Vite Renderer/Main/Preload/utility build PASS; existing Browserslist-data/chunk-size warnings only. Final CSS build completed successfully for all four targets; task-aware drift check and `git diff --check` PASS. No packaged app, real cloud generation or user trial interaction performed. + +## Follow-ups + +- Current cloud V2 Workspace reads expose recent assets/tasks only (100 assets / 50 tasks per Workspace). Drawer conservatively warns at either cap or missing task result. Complete older history requires server pagination or an account-scoped generated-works route; do not describe the current collection as a complete archive. +- No immutable per-work title is available in the existing API; actual collection uses current Workspace title and source caption. Fixture artwork/names are QA-only and never product fallbacks. +- Coordinated trial should confirm real account media playback/save behavior using the existing Main asset flow; local media stub cannot supply that evidence. + +## Promotion Candidates + +- Target `.project-docs/20-architecture/` Canvas/client documents and canonical current state: record account-scoped read-only collection projection (`GET /api/works/image-workspace/generated-works?offset=N`), 12 Workspace page size / lookahead / bounded detail reads, account/deadline guard, partial/truncated semantics and atomic source navigation. Evidence: source + 149 unit / 3 viewport tests. Future impact: cloud pagination may replace scan without changing user journey. Semantic conflict: none with generation/Quote or Gallery; existing right work pane is retained. Human confirmation: not required; implementation explicitly approved. +- Target accepted Canvas experience/current specification: “我的作品” consolidates generated image/video outputs with type filters, one-drawer preview/save and exact creation return; preserve work pane and local drafts. Evidence: approved mock/current user instruction, exact-size comparison in `design-qa.md`. Future impact: clarify collection vs current/history work pane and prevent navigation regressions. Semantic conflict: earlier single-project history-only presentation; add the collection while preserving its work pane. Human confirmation: already supplied for this task. +- Target canonical follow-up aggregation: cloud ability to read complete older completed outputs is still needed. Evidence: source V2 history caps and explicit `historyTruncated` cases. Future impact: complete all-history promise requires upstream capability, not hiding the existing cap in the client. Human confirmation: needed before assigning/deploying the upstream change, not for this client implementation. diff --git a/README.md b/README.md index 02b1e42a..0342bea2 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ 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` 绑定,具体用法只写在创作提示词中。 + + Canvas 顶部的“我的作品”打开右侧抽屉,按项目分批读取当前账号已完成的生成结果,提供“全部 / 图片 / 视频”筛选、明确的媒体标签、视频播放标记和时长;点开后在同一抽屉预览、保存到电脑或回到对应项目的制作记录。打开和关闭保留后台工作区的分栏、当前方案和草稿;有未发送或未保存内容时,先处理当前创作再切换项目。集合只纳入成功任务明确关联的生成资产,参考图和失败过程文件不会成为作品;账号变化清除抽屉结果。现有云端单项目详情只提供近期最多 100 个资产及 50 个任务,触及边界或读取不全时会提示较早作品暂时无法全部读取;完整历史仍需要配套云端分页能力,不能把近期集合表述为完整存档。 - `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 授权。 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..d933af2c --- /dev/null +++ b/design-qa.md @@ -0,0 +1,50 @@ +# Canvas My Works design QA + +- Task: 20261014-canvas-works-drawer-7b4e92c1 +- Source visual truth: `/Users/chillishark/.codex/generated_images/01a12e8d-643e-7453-99d8-b0187649dafc/exec-ea04cbe4-075f-4b51-864c-ca517d086861.png`. Approved by the human; preserve the existing right work pane and add clear image/video distinctions. +- Source pixels: 1586 × 992, normalized to 1600 × 1000 solely for comparison. No product image assets generated from this mock. +- Implementation: local isolated QA browser at `http://127.0.0.1:4179/`, actual Canvas/store/drawer components, repository image fixtures and non-playing video DOM stubs. No cloud, upload, mutation, paid generation or user account used. +- Viewport: 1600 × 1000 CSS pixels, deviceScaleFactor 1; responsive checks 1440 × 960 and 390 × 844. Light theme, drawer open, all filter, six sample completed works. + +## Findings + +- [P2, resolved] Initial desktop card density clips the third row captions at 1600 × 1000, where the selected mock shows all six captions above its footer. + - Evidence: combined comparison `/tmp/makelore-canvas-works-drawer-7b4e92c1-comparison.png`; initial screenshot `test-results/canvas-my-works-layout-Can-45a35-rns-to-its-source-at-1600px/canvas-my-works-1600-list.png`. + - Fixed: thumbnail ratio is now 16:9; desktop header padding is 18 px / 28 px / 12 px and filters bottom padding is 18 px. All six complete sample cards are above the footer at the reference viewport; 44 px targets and card font sizes remain intact. +- Previously found and fixed: 390 px Sheet utility specificity constrained the drawer to 75% width and overflowed its filters. Scoped drawer/title rules now supply a full-width narrow layout. Updated 390 screenshot verified and E2E measurements pass. + +## Fidelity Surfaces + +- Typography: project sans/fallback retained; 27 px drawer title, 14 px filters, 13 px card titles and 11 px origin captions establish the same hierarchy. Titles truncate in the grid and wrap in detail. No imported font or changes to application typography. +- Spacing/layout: right overlay with rounded desktop left corners and two-column cards; original editing surface remains mounted and dimmed. Desktop density P2 is resolved; narrow layout does not overflow. Six sample cards and all captions fit above the footer at all three tested sizes. +- Colors/tokens: foreground, background, borders, semantic errors and primary action use existing app tokens; subdued lavender collection accent follows the selected mock. +- Images: actual output assets are rendered with contain; image/video labels, play mark and real duration are explicit. Mock dog/fish/space art and named outputs represent user content, not static product assets; QA repository art is intentionally different. Icons use the existing Lucide library. +- Copy: collection/filter/preview/save/return controls follow the accepted behavior. Cloud read limits are stated; no claim of a complete count. Background product chrome stays with the existing app rather than replacing it with the mock. + +## Comparison History + +1. CUA inspected actual browser list, video filter, single-drawer preview, keyboard focus and background work pane. Exact-dimension E2E captures then compared with the source in one 3200 × 1000 montage. Header/filter/first-row focused comparison is `/tmp/makelore-canvas-works-drawer-7b4e92c1-focused-comparison.png`. Initial desktop density P2 found. +2. Revised source and implementation placed together in `/tmp/makelore-canvas-works-drawer-7b4e92c1-final-comparison.png` (3200 × 1000) and `/tmp/makelore-canvas-works-drawer-7b4e92c1-final-focused-comparison.png` (1402 × 500). Both compared visually after the spacing/ratio fix; the updated 1600 capture shows all six complete cards and captions. Narrow 390 capture also inspected. No actionable P0/P1/P2 differences remain. The underlying app frame uses the existing work pane and public local fixtures, rather than the speculative background/user artwork in the mock. + +## Implementation Checklist + +- [x] Real read-only collection, type filtering and same-drawer previews. +- [x] Save through the existing Main asset action; return to validated source/task. +- [x] Partial retry preserves prior successes; failures preserve original workspace. +- [x] Drawer closure/account changes ignore stale collection responses. +- [x] Keyboard Escape/focus return and editing draft/split retention. +- [x] Desktop density fix, three-size capture and repeat combined visual comparison. + +## Verification + +- E2E 3/3 passed at 1600 × 1000, 1440 × 960 and 390 × 844 using real Canvas/store components with explicit local fixtures. Measurements check two columns, no horizontal overflow and six complete cards above the footer. All page errors and unexpected external requests were empty. +- Interaction coverage: image/video filters; media labels and duration; one drawer for list/image/video; Escape and trigger focus; background split/focus/panel/draft retention; drafts block cross-project navigation; safe source switch exposes and opens the matching task record. +- Screenshots: `test-results/canvas-my-works-layout-Can-45a35-rns-to-its-source-at-1600px/canvas-my-works-1600-{list,video-preview,source}.png`; equivalent 1440 and 390 screenshots in their corresponding test result folders. +- Drawer component tests 14/14 passed, including crossover partial retry, authoritative empty, close/unmount stale result protection, same-offset retry and media lifecycle. Independent source review resolved both P2 findings and reports PASS. + +## Follow-up Polish / Limits + +- Actual cloud video playback/download cannot be demonstrated by the isolated media stub. Component coverage checks controls/no-autoplay/pause and existing asset integration; real account verification belongs to the coordinated trial. +- Current cloud V2 reads cap each project at recent 100 assets / 50 tasks; complete older history needs a cloud pagination capability. + +final result: passed diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 63de15f9..246664a2 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -476,6 +476,18 @@ export async function handleImageWorkspaceRoutes( return true; } + if (segments.length === 1 && segments[0] === 'generated-works' && req.method === 'GET') { + if (!ctx.imageWorkspace.listGeneratedWorks) { + throw new DesignWorkspaceModuleError(501, 'DESIGN_GENERATED_WORKS_UNAVAILABLE', '作品列表暂不可用'); + } + const rawOffset = url.searchParams.get('offset') ?? '0'; + if (!/^\d+$/.test(rawOffset) || !Number.isSafeInteger(Number(rawOffset))) { + throw new DesignWorkspaceModuleError(400, 'IMAGE_WORKSPACE_INVALID_OFFSET', '作品分页位置无效'); + } + sendData(res, await ctx.imageWorkspace.listGeneratedWorks(Number(rawOffset))); + return true; + } + if (segments.length === 1 && segments[0] === 'workspaces' && req.method === 'POST') { const body = await parseJsonBody>(req); const input: DesignCreateWorkspaceInput = { diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 015aab12..c5d199fc 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -6,6 +6,7 @@ import type { DesignCommandResult, DesignCreateWorkspaceInput, DesignDeleteWorkspaceResult, + DesignGeneratedWorksPage, DesignRenameWorkspaceInput, DesignWorkspace, DesignWorkspaceBootstrap, @@ -44,6 +45,7 @@ export interface DesignWorkspaceModule { deleteWorkspace(workspaceId: string): Promise; renameWorkspace(input: DesignRenameWorkspaceInput): Promise; getWorkspace(workspaceId: string): Promise; + listGeneratedWorks?(offset?: number): Promise; submitCommand(input: DesignCommandInput): Promise; uploadAsset(input: DesignAssetUploadInput): Promise; openWorkspaceEvents( diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 79ada84e..e88e2ab7 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -20,6 +20,8 @@ import { type DesignGenerationQuote, type DesignGenerationOptions, type DesignGenerationTask, + type DesignGeneratedWork, + type DesignGeneratedWorksPage, type DesignInteractionOutcome, type DesignMedium, type DesignQuoteBlockedEvent, @@ -38,7 +40,11 @@ import { type DesignWorkspaceUpdatedEvent, } from '../../shared/image-workspace'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { getValidWorksSquareAccessToken } from '../services/works-square-session'; +import { + getValidWorksSquareAccessToken, + getWorksSquareAccountBinding, + isCurrentWorksSquareAccountBinding, +} from '../services/works-square-session'; import { fetchWithDeadline, proxyAwareFetch, @@ -97,6 +103,10 @@ const RUN_MAX_POLL_MS = 4_000; const WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; const WEBSOCKET_PING_INTERVAL_MS = 20_000; const WEBSOCKET_OPEN = 1; +const GENERATED_WORKS_WORKSPACE_PAGE_SIZE = 12; +const GENERATED_WORKS_READ_CONCURRENCY = 3; +const WORKSPACE_ASSET_HISTORY_LIMIT = 100; +const WORKSPACE_TASK_HISTORY_LIMIT = 50; const PUBLIC_ASSISTANT_PROGRESS_MESSAGES: Record = { understanding: '正在听懂你刚补充的内容…', reviewing_context: '正在把它和前面的设计想法放在一起看…', @@ -886,6 +896,119 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { )); } + async listGeneratedWorks(offset = 0): Promise { + if (!Number.isSafeInteger(offset) || offset < 0 + || offset > Number.MAX_SAFE_INTEGER - GENERATED_WORKS_WORKSPACE_PAGE_SIZE) { + throw new DesignWorkspaceModuleError(400, 'IMAGE_WORKSPACE_INVALID_OFFSET', '作品分页位置无效'); + } + try { + return await runWithDeadline( + (signal) => this.scanGeneratedWorks(offset, signal), + this.requestTimeoutMs, + ); + } catch (error) { + if (error instanceof RequestDeadlineExceededError) throw designRequestTimeoutError(); + throw error; + } + } + + private async scanGeneratedWorks( + offset: number, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const account = getWorksSquareAccountBinding(); + if (!account) { + throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再查看作品'); + } + const requireCurrentAccount = () => { + signal.throwIfAborted(); + if (!isCurrentWorksSquareAccountBinding(account)) { + throw new DesignWorkspaceModuleError(409, 'DESIGN_ACCOUNT_CHANGED', '登录账号已改变,请重新打开作品'); + } + }; + const token = await getValidWorksSquareAccessToken({ + fetchImpl: this.fetchImpl, + requestTimeoutMs: this.requestTimeoutMs, + }); + requireCurrentAccount(); + if (!token) { + throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再查看作品'); + } + // Pin one account's credential for this scan; never cache or combine account pages in Main. + const summaries = arrayValue(await this.requestJson( + `/api/design/workspaces?limit=${GENERATED_WORKS_WORKSPACE_PAGE_SIZE + 1}&offset=${offset}`, + { signal }, + token, + ), 'Workspaces').slice(0, GENERATED_WORKS_WORKSPACE_PAGE_SIZE + 1).map(mapWorkspaceSummary); + requireCurrentAccount(); + const page = summaries.slice(0, GENERATED_WORKS_WORKSPACE_PAGE_SIZE); + const works: DesignGeneratedWork[] = []; + const unavailableWorkspaceIds = new Set(); + let historyTruncated = false; + let nextIndex = 0; + const readNext = async () => { + while (nextIndex < page.length) { + requireCurrentAccount(); + const summary = page[nextIndex++]; + try { + const workspace = mapWorkspace(await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(summary.workspaceId)}`, + { signal }, + token, + )); + requireCurrentAccount(); + if (workspace.workspace.workspaceId !== summary.workspaceId) { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '作品项目不匹配'); + } + historyTruncated ||= workspace.assets.length >= WORKSPACE_ASSET_HISTORY_LIMIT + || workspace.tasks.length >= WORKSPACE_TASK_HISTORY_LIMIT; + const assets = new Map(workspace.assets.map((asset) => [asset.assetId, asset])); + const includedAssetIds = new Set(); + for (const task of workspace.tasks) { + if (task.status !== 'succeeded') continue; + for (const assetId of task.resultAssetIds) { + const asset = assets.get(assetId); + if (!asset) historyTruncated = true; + if (!asset || asset.role !== 'generated' || asset.generationTaskId !== task.taskId + || includedAssetIds.has(assetId)) continue; + includedAssetIds.add(assetId); + works.push({ + asset, + workspaceTitle: workspace.workspace.title, + taskId: task.taskId, + title: workspace.workspace.title, + }); + } + } + } catch (error) { + requireCurrentAccount(); + if (error instanceof DesignWorkspaceModuleError + && (error.status === 401 || error.status === 403)) throw error; + unavailableWorkspaceIds.add(summary.workspaceId); + } + } + }; + await Promise.all(Array.from( + { length: Math.min(GENERATED_WORKS_READ_CONCURRENCY, page.length) }, + readNext, + )); + requireCurrentAccount(); + works.sort((left, right) => (Date.parse(right.asset.createdAt) || 0) + - (Date.parse(left.asset.createdAt) || 0) + || left.asset.workspaceId.localeCompare(right.asset.workspaceId) + || left.asset.assetId.localeCompare(right.asset.assetId)); + return { + works, + nextOffset: summaries.length > GENERATED_WORKS_WORKSPACE_PAGE_SIZE + ? offset + GENERATED_WORKS_WORKSPACE_PAGE_SIZE : null, + unavailableWorkspaceIds: page + .filter((summary) => unavailableWorkspaceIds.has(summary.workspaceId)) + .map((summary) => summary.workspaceId), + historyTruncated, + }; + } + async submitCommand(input: DesignCommandInput): Promise { let command: ServerAgentCommand; try { @@ -1069,10 +1192,14 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { closers.forEach((close) => close()); } - private async requestJson(path: string, init: RequestInit = {}): Promise { + private async requestJson( + path: string, + init: RequestInit = {}, + pinnedAccessToken?: string, + ): Promise { try { return await runWithDeadline(async (signal) => { - const response = await this.authorizedFetch(path, { + const requestInit: RequestInit = { ...init, signal, headers: { @@ -1082,7 +1209,10 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { : {}), ...(init.headers ?? {}), }, - }); + }; + const response = pinnedAccessToken + ? await this.fetchWithToken(path, pinnedAccessToken, requestInit) + : await this.authorizedFetch(path, requestInit); const payload = await readPayload(response); if (!response.ok) { const detail = asErrorDetail(payload); diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index da06ac12..0e046708 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -276,6 +276,22 @@ export type DesignAsset = { contentPath: string; }; +export type DesignGeneratedWork = { + asset: DesignAsset; + workspaceTitle: string; + taskId: string; + title: string; +}; + +/** One page scans Workspace summaries; nextOffset is a Workspace offset. */ +export type DesignGeneratedWorksPage = { + works: DesignGeneratedWork[]; + nextOffset: number | null; + unavailableWorkspaceIds: string[]; + /** The upstream detail API limits each Workspace's assets and task history. */ + historyTruncated: boolean; +}; + export type DesignTaskProgress = { stage: | 'queued' diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index 6d122f84..69d60725 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -16,6 +16,7 @@ import { type DesignCommandInput, type DesignCommandResult, type DesignDeleteWorkspaceResult, + type DesignGeneratedWorksPage, type DesignWorkspace, type DesignWorkspaceBootstrap, } from '../../shared/image-workspace'; @@ -109,6 +110,13 @@ export function fetchImageWorkspace(): Promise { return requestData(IMAGE_WORKSPACE_API_PATH); } +export function fetchImageGeneratedWorks( + offset = 0, + signal?: AbortSignal, +): Promise { + return requestData(`${IMAGE_WORKSPACE_API_PATH}/generated-works?offset=${offset}`, { signal }); +} + export function createImageWorkspaceProject( title: string, clientWorkspaceId = createClientId('workspace'), diff --git a/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx b/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx index 51e6d00c..bf208688 100644 --- a/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx +++ b/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx @@ -6,9 +6,10 @@ import './canvas-workspace.css'; const preferenceKey = 'makelore.canvas.conversation-ratio'; const defaultRatio = 42; -export function CanvasWorkspaceSplit({ conversation, works }: { +export function CanvasWorkspaceSplit({ conversation, works, workFocusRequest }: { conversation: ReactNode; works: ReactNode; + workFocusRequest?: number; }) { const root = useRef(null); const [ratio, setRatio] = useState(() => { @@ -18,7 +19,17 @@ export function CanvasWorkspaceSplit({ conversation, works }: { } catch { return defaultRatio; } }); const [focus, setFocus] = useState<'conversation' | 'works' | null>(null); - const [compactPanel, setCompactPanel] = useState<'conversation' | 'works'>('conversation'); + const [compactPanel, setCompactPanel] = useState<'conversation' | 'works'>( + workFocusRequest === undefined ? 'conversation' : 'works', + ); + const [appliedWorkFocus, setAppliedWorkFocus] = useState(workFocusRequest); + if (workFocusRequest !== appliedWorkFocus) { + setAppliedWorkFocus(workFocusRequest); + if (workFocusRequest !== undefined) { + setCompactPanel('works'); + setFocus(null); + } + } useEffect(() => { const node = root.current; if (!node) return; diff --git a/src/pages/ImageCanvas/DesignHistoryRail.tsx b/src/pages/ImageCanvas/DesignHistoryRail.tsx index e7cf9386..ee365de4 100644 --- a/src/pages/ImageCanvas/DesignHistoryRail.tsx +++ b/src/pages/ImageCanvas/DesignHistoryRail.tsx @@ -9,13 +9,32 @@ import { DesignPlanHistory } from './DesignPlanHistory'; import { hasActiveCreationPlan } from './plan-lifecycle'; import { youthTaskIssueMessage } from './youth-issue-copy'; -export function DesignHistoryRail({ workspace, creation }: { +export type DesignWorkFocus = { assetId: string; taskId: string; requestId: number }; + +export function DesignHistoryRail({ workspace, creation, focusedWork }: { workspace: DesignWorkspace | null; creation?: ReactNode; + focusedWork?: DesignWorkFocus | null; }) { - const [view, setView] = useState<'current' | 'history'>('current'); - const [selectedAssetId, setSelectedAssetId] = useState(null); + const [view, setView] = useState<'current' | 'history'>(focusedWork ? 'history' : 'current'); + const [selectedAssetId, setSelectedAssetId] = useState(focusedWork?.assetId ?? null); + const [appliedWorkFocus, setAppliedWorkFocus] = useState(focusedWork?.requestId); const root = useRef(null); + if (focusedWork?.requestId !== appliedWorkFocus) { + setAppliedWorkFocus(focusedWork?.requestId); + if (focusedWork) { + setSelectedAssetId(focusedWork.assetId); + setView('history'); + } + } + useEffect(() => { + if (view !== 'history' || !focusedWork) return; + const record = [...(root.current?.querySelectorAll('details[data-task-id]') ?? [])] + .find((element) => element.dataset.taskId === focusedWork.taskId); + if (!record) return; + record.open = true; + record.scrollIntoView?.({ block: 'nearest', behavior: 'auto' }); + }, [focusedWork, view]); useEffect(() => { root.current?.querySelectorAll('video').forEach((video) => { if (!video.paused && video.offsetParent === null) video.pause(); diff --git a/src/pages/ImageCanvas/DesignMyWorksDrawer.css b/src/pages/ImageCanvas/DesignMyWorksDrawer.css new file mode 100644 index 00000000..a5f9d6a9 --- /dev/null +++ b/src/pages/ImageCanvas/DesignMyWorksDrawer.css @@ -0,0 +1,69 @@ +.my-works-drawer[data-testid="design-my-works-drawer"] { display: flex; flex-direction: column; gap: 0; width: min(45vw, 760px); min-width: 490px; max-width: none; height: 100dvh; padding: 0; overflow: hidden; border-radius: 18px 0 0 18px; background: hsl(var(--background)); } +.my-works-drawer button { cursor: pointer; } +.my-works-drawer button:disabled { cursor: default; } +.my-works-drawer button:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 3px; } +.my-works-header { display: flex; flex-shrink: 0; align-items: center; gap: 14px; padding: 18px 28px 12px; } +.my-works-heading-icon { display: grid; place-items: center; width: 56px; height: 56px; flex-shrink: 0; border-radius: 16px; background: #f0e9f9; color: #7855aa; } +.my-works-heading-icon svg { width: 27px; height: 27px; } +.my-works-drawer .my-works-title { font-size: 27px; line-height: 1.3; letter-spacing: -.025em; } +.my-works-drawer .my-works-description { margin-top: 5px; font-size: 13px; line-height: 1.6; } +.my-works-close { width: 44px; height: 44px; flex-shrink: 0; align-self: flex-start; } +.my-works-filters { display: flex; flex-shrink: 0; gap: 10px; padding: 0 28px 18px; } +.my-works-filters button { min-width: 80px; min-height: 44px; padding: 8px 20px; border: 1px solid hsl(var(--border)); border-radius: 20px; color: hsl(var(--muted-foreground)); background: hsl(var(--background)); font-size: 14px; font-weight: 500; transition: background-color 150ms, border-color 150ms; } +.my-works-filters button:hover { background: hsl(var(--surface-subtle)); } +.my-works-filters button[aria-pressed="true"] { border-color: #e3d9f0; background: #f0e9f9; color: #60438e; } +.my-works-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 28px 22px; scrollbar-gutter: stable; } +.my-works-section-title { margin: 0 0 12px; font-size: 14px; font-weight: 600; } +.my-works-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.my-works-tile { min-width: 0; overflow: hidden; border: 1px solid hsl(var(--border) / .85); border-radius: 13px; background: hsl(var(--background)); text-align: left; transition: border-color 150ms, box-shadow 150ms; } +.my-works-tile:hover { border-color: #c8b8e0; box-shadow: 0 3px 12px hsl(var(--foreground) / .06); } +.my-works-thumbnail { position: relative; aspect-ratio: 16 / 9; overflow: hidden; background: hsl(var(--surface-subtle)); } +.my-works-drawer .my-works-asset { display: block; width: 100%; height: 100%; object-fit: contain; } +.my-works-kind { position: absolute; top: 8px; left: 8px; display: flex; align-items: center; gap: 4px; padding: 4px 6px; border-radius: 7px; background: hsl(var(--background) / .95); color: hsl(var(--foreground)); font-size: 10px; font-weight: 500; } +.my-works-kind svg { width: 12px; height: 12px; } +.my-works-play { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 42px; height: 42px; transform: translate(-50%, -50%); border: 2px solid white; border-radius: 50%; color: white; background: rgb(0 0 0 / .45); } +.my-works-play svg { width: 20px; height: 20px; margin-left: 2px; fill: currentColor; } +.my-works-duration { position: absolute; bottom: 8px; right: 8px; padding: 3px 6px; border-radius: 7px; color: white; background: rgb(0 0 0 / .65); font-size: 12px; font-variant-numeric: tabular-nums; } +.my-works-caption { padding: 10px 11px 11px; } +.my-works-caption h3 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 600; } +.my-works-caption p { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: hsl(var(--muted-foreground)); font-size: 11px; } +.my-works-notice { margin-bottom: 14px; padding: 10px 12px; border: 1px solid hsl(var(--brand) / .14); border-radius: 12px; background: hsl(var(--brand-soft)); color: hsl(var(--foreground)); font-size: 12px; line-height: 1.7; } +.my-works-notice button { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; color: hsl(var(--brand)); font-weight: 600; } +.my-works-notice button:disabled { opacity: .5; } +.my-works-loading { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 38px 10px; color: hsl(var(--muted-foreground)); font-size: 13px; } +.my-works-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 60px 12px; text-align: center; } +.my-works-empty > svg { color: #a68dc9; } +.my-works-empty p { margin: 4px 0 0; font-size: 14px; font-weight: 500; } +.my-works-empty span { color: hsl(var(--muted-foreground)); font-size: 12px; line-height: 1.8; } +.my-works-load-more { display: flex; min-height: 44px; margin: 20px auto 0; } +.my-works-footer { flex-shrink: 0; padding: 14px 20px 18px; border-top: 1px solid hsl(var(--border) / .5); color: hsl(var(--muted-foreground)); text-align: center; font-size: 12px; } +.my-works-detail { display: flex; flex: 1; flex-direction: column; min-height: 0; overflow-y: auto; padding: 0 28px 24px; } +.my-works-back { display: flex; align-items: center; align-self: flex-start; gap: 7px; min-height: 44px; margin-bottom: 12px; color: hsl(var(--muted-foreground)); font-size: 13px; } +.my-works-preview { position: relative; display: flex; align-items: center; justify-content: center; flex: 1; min-height: 220px; max-height: 58dvh; overflow: hidden; border-radius: 14px; background: hsl(var(--surface-subtle)); } +.my-works-preview img, .my-works-preview video { display: block; width: 100%; height: 100%; max-height: 58dvh; object-fit: contain; } +.my-works-media-message { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: hsl(var(--muted-foreground)); background: hsl(var(--surface-subtle)); font-size: 13px; } +.my-works-media-message button { gap: 7px; min-height: 44px; } +.my-works-detail-caption { padding-top: 18px; } +.my-works-inline-kind { display: inline-block; padding: 3px 7px; border-radius: 6px; background: #f0e9f9; color: #60438e; font-size: 11px; } +.my-works-detail-caption h2 { margin: 8px 0 5px; overflow-wrap: anywhere; font-size: 18px; font-weight: 600; } +.my-works-detail-caption p { color: hsl(var(--muted-foreground)); font-size: 12px; overflow-wrap: anywhere; } +.my-works-detail-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; } +.my-works-detail-actions button { min-height: 44px; flex: 1; gap: 7px; padding-inline: 12px; } +.my-works-action-message { margin-top: 14px; color: hsl(var(--muted-foreground)); font-size: 12px; line-height: 1.7; } +.my-works-action-message[role="alert"] { color: hsl(var(--destructive)); } +@media (max-width: 640px) { + .my-works-drawer[data-testid="design-my-works-drawer"] { width: 100vw; min-width: 0; border-radius: 0; } + .my-works-header { gap: 10px; padding: 20px 16px 18px; } + .my-works-heading-icon { width: 46px; height: 46px; border-radius: 13px; } + .my-works-drawer .my-works-title { font-size: 23px; } + .my-works-drawer .my-works-description { font-size: 12px; } + .my-works-filters { padding: 0 16px 20px; } + .my-works-scroll, .my-works-detail { padding-inline: 16px; } + .my-works-grid { gap: 10px; } + .my-works-caption { padding: 9px; } + .my-works-caption h3 { font-size: 12px; } +} +@media (prefers-reduced-motion: reduce) { + .my-works-drawer, .my-works-drawer button { transition: none; } + .my-works-drawer .animate-spin { animation: none; } +} diff --git a/src/pages/ImageCanvas/DesignMyWorksDrawer.tsx b/src/pages/ImageCanvas/DesignMyWorksDrawer.tsx new file mode 100644 index 00000000..b4252795 --- /dev/null +++ b/src/pages/ImageCanvas/DesignMyWorksDrawer.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { ArrowLeft, ArrowUpRight, Download, Film, ImageIcon, Loader2, Play, RefreshCw, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'; +import { fetchImageGeneratedWorks, resolveImageWorkspaceAssetUrl, saveImageWorkspaceAsset } from '@/lib/image-workspace'; +import type { DesignGeneratedWork, DesignGeneratedWorksPage } from '../../../shared/image-workspace'; +import { DesignAssetThumbnail } from './DesignAssetThumbnail'; +import './DesignMyWorksDrawer.css'; + +type Filter = 'all' | 'image' | 'video'; +type LoadedPage = { offset: number; page: DesignGeneratedWorksPage }; + +function workKey(work: DesignGeneratedWork): string { + return JSON.stringify([work.asset.workspaceId, work.asset.assetId]); +} + +function durationLabel(milliseconds: number | null): string | null { + if (milliseconds === null || !Number.isFinite(milliseconds) || milliseconds < 0) return null; + const seconds = Math.floor(milliseconds / 1000); + return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; +} + +function WorkPreview({ work }: { work: DesignGeneratedWork }) { + const [attempt, setAttempt] = useState(0); + const [source, setSource] = useState(null); + const [failed, setFailed] = useState(false); + const [loaded, setLoaded] = useState(false); + const videoRef = useRef(null); + useEffect(() => { + let active = true; + void resolveImageWorkspaceAssetUrl(work.asset.contentPath) + .then((url) => { if (active) setSource(url); }) + .catch(() => { if (active) setFailed(true); }); + return () => { active = false; }; + }, [attempt, work.asset.contentPath]); + useEffect(() => { + const video = videoRef.current; + return () => { video?.pause(); }; + }, [source]); + return ( +
+ {failed ? ( +
+

作品暂时没能打开。

+ +
+ ) : ( + <> + {!loaded &&
} + {source && (work.asset.mediaType === 'video' + ?
+ ); +} + +export function DesignMyWorksDrawer({ + open, + onOpenChange, + onReturnToCreation, + triggerRef, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onReturnToCreation: (work: DesignGeneratedWork) => Promise; + triggerRef?: RefObject; +}) { + const [pages, setPages] = useState([]); + const [filter, setFilter] = useState('all'); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(false); + const [errorOffset, setErrorOffset] = useState(null); + const [action, setAction] = useState<'download' | 'return' | null>(null); + const [actionMessage, setActionMessage] = useState(null); + const [actionFailed, setActionFailed] = useState(false); + const generation = useRef(0); + const active = useRef(false); + const request = useRef(null); + const actionBusy = useRef(false); + + const loadPage = useCallback(async (offset: number) => { + if (!active.current || request.current) return; + const controller = new AbortController(); + const currentGeneration = generation.current; + request.current = controller; + setLoading(true); + setErrorOffset(null); + try { + const page = await fetchImageGeneratedWorks(offset, controller.signal); + if (!active.current || generation.current !== currentGeneration) return; + setPages((current) => { + const previous = current.find((entry) => entry.offset === offset)?.page; + const unavailable = new Set(page.unavailableWorkspaceIds); + // Failed Workspace reads cannot invalidate its previously found works. + // Successfully re-read Workspaces use the latest authoritative results. + const preservedWorks = previous?.works.filter((work) => unavailable.has(work.asset.workspaceId)) ?? []; + const refreshedPage = { + ...page, + works: [...preservedWorks, ...page.works], + historyTruncated: Boolean(page.historyTruncated || previous?.historyTruncated), + }; + return [...current.filter((entry) => entry.offset !== offset), { offset, page: refreshedPage }] + .sort((left, right) => left.offset - right.offset); + }); + } catch { + if (active.current && generation.current === currentGeneration && !controller.signal.aborted) setErrorOffset(offset); + } finally { + if (request.current === controller) request.current = null; + if (active.current && generation.current === currentGeneration) setLoading(false); + } + }, []); + + useEffect(() => { + generation.current += 1; + active.current = open; + request.current?.abort(); + request.current = null; + setSelected(null); + setAction(null); + setActionMessage(null); + actionBusy.current = false; + if (open) { + setPages([]); + setFilter('all'); + setErrorOffset(null); + void loadPage(0); + } else setLoading(false); + return () => { + active.current = false; + generation.current += 1; + request.current?.abort(); + request.current = null; + }; + }, [open, loadPage]); + + const works = useMemo(() => { + const unique = new Map(); + for (const entry of pages) for (const work of entry.page.works) unique.set(workKey(work), work); + return [...unique.values()].sort((left, right) => Date.parse(right.asset.createdAt) - Date.parse(left.asset.createdAt)); + }, [pages]); + const shownWorks = works.filter((work) => filter === 'all' || work.asset.mediaType === filter); + const nextOffset = pages.at(-1)?.page.nextOffset ?? null; + const unavailablePages = pages.filter((entry) => entry.page.unavailableWorkspaceIds.length > 0); + const historyTruncated = pages.some((entry) => entry.page.historyTruncated); + const incomplete = nextOffset !== null || unavailablePages.length > 0 || historyTruncated; + const canLoadMore = nextOffset !== null && errorOffset === null; + + const performAction = async (kind: 'download' | 'return') => { + if (!selected || actionBusy.current) return; + const currentGeneration = generation.current; + actionBusy.current = true; + setAction(kind); + setActionMessage(null); + setActionFailed(false); + try { + if (kind === 'return') { + await onReturnToCreation(selected); + if (active.current && generation.current === currentGeneration) onOpenChange(false); + } else { + const result = await saveImageWorkspaceAsset(selected.asset); + if (active.current && generation.current === currentGeneration && result.status === 'saved') setActionMessage('作品已保存到电脑'); + } + } catch (error) { + if (active.current && generation.current === currentGeneration) { + setActionFailed(true); + setActionMessage(kind === 'return' + ? error instanceof Error && error.message.trim() ? error.message : '暂时没能回到这次创作,请再试一次。' + : '作品没有保存成功,请再试一次。'); + } + } finally { + if (active.current && generation.current === currentGeneration) { + actionBusy.current = false; + setAction(null); + } + } + }; + + return ( + + { + if (triggerRef?.current) { + event.preventDefault(); + triggerRef.current.focus(); + } + }}> +
+ +
+ 我的作品 + 你在 Canvas 创作的图片和视频,都收在这里 +
+ +
+ {open && (selected ? ( +
+ + +
{selected.asset.mediaType === 'video' ? '视频' : '图片'}

{selected.title}

来自:{selected.workspaceTitle}

+ {actionMessage &&

{actionMessage}

} +
+ + +
+
+ ) : ( + <> +
{([['all', '全部'], ['image', '图片'], ['video', '视频']] as const).map(([value, label]) => )}
+
+

最近作品

+ {historyTruncated &&

较早的作品暂时无法全部读取,当前显示已找到的作品。

} + {unavailablePages.length > 0 &&

有些创作暂时没能读到,已找到的作品可以继续查看。

} + {errorOffset !== null &&

{pages.length ? '更多作品暂时没能加载,已经找到的作品还在。' : '作品暂时没能加载,请再试一次。'}

} + {shownWorks.length > 0 &&
{shownWorks.map((work) => { + const video = work.asset.mediaType === 'video'; + const duration = durationLabel(work.asset.durationMilliseconds); + return ; + })}
} + {loading &&
} + {!loading && errorOffset === null && shownWorks.length === 0 &&
} + {canLoadMore && } +
+
点击作品,查看大图或播放视频
+ + ))} +
+
+ ); +} diff --git a/src/pages/ImageCanvas/DesignPlanHistory.tsx b/src/pages/ImageCanvas/DesignPlanHistory.tsx index bc07af3f..e8a464b4 100644 --- a/src/pages/ImageCanvas/DesignPlanHistory.tsx +++ b/src/pages/ImageCanvas/DesignPlanHistory.tsx @@ -74,6 +74,7 @@ export function DesignPlanHistory({ workspace, onSelectAsset }: { workspace: Des return (
diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index b047e377..2ca773a3 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -1,11 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { CloudOff, + Images, PanelLeft, Plus, RefreshCw, Sparkles, } from 'lucide-react'; +import type { DesignGeneratedWork } from '../../../shared/image-workspace'; import { Button } from '@/components/ui/button'; import { Sheet, @@ -24,6 +26,8 @@ import { CanvasWorkspaceSplit } from './CanvasWorkspaceSplit'; import { YouthCreationCard } from './YouthCreationCard'; import { DesignHistoryRail } from './DesignHistoryRail'; import { DesignWorksRail } from './DesignWorksRail'; +import { DesignMyWorksDrawer } from './DesignMyWorksDrawer'; +import type { DesignWorkFocus } from './DesignHistoryRail'; function useDesktopLayout(): boolean { const [desktop, setDesktop] = useState(() => ( @@ -105,7 +109,11 @@ function ErrorWorkspace({ message, retry }: { message: string; retry: () => void ); } -export function ImageCanvas() { +function ImageCanvasWorkspace({ onOpenMyWorks, worksTriggerRef, focusedWork }: { + onOpenMyWorks: () => void; + worksTriggerRef: React.RefObject; + focusedWork: (DesignWorkFocus & { workspaceId: string }) | null; +}) { const authenticated = useAuthStore((state) => state.isAuthenticated()); const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); @@ -178,6 +186,10 @@ export function ImageCanvas() { {taskStatus && {taskStatus}} + {!desktopLayout && } @@ -194,7 +206,10 @@ export function ImageCanvas() { showPlanHistory={false} showCreationCard={false} /> : } - works={ ); } + +export function ImageCanvas() { + const authenticated = useAuthStore((state) => state.isAuthenticated()); + const accountKey = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? null); + const trigger = useRef(null); + const [myWorksOpen, setMyWorksOpen] = useState(false); + const [focusedWork, setFocusedWork] = useState<(DesignWorkFocus & { workspaceId: string }) | null>(null); + const focusSequence = useRef(0); + + const [drawerOwner, setDrawerOwner] = useState({ accountKey, authenticated }); + if (drawerOwner.accountKey !== accountKey || drawerOwner.authenticated !== authenticated) { + setDrawerOwner({ accountKey, authenticated }); + setMyWorksOpen(false); + setFocusedWork(null); + } + + const openMyWorks = () => { + document.querySelectorAll('[data-testid="image-workspace"] video') + .forEach((video) => { if (!video.paused) video.pause(); }); + setMyWorksOpen(true); + }; + + const returnToCreation = async (work: DesignGeneratedWork) => { + const originalOwner = accountKey; + const current = useImageWorkspaceStore.getState(); + if (current.activeWorkspaceId !== work.asset.workspaceId + && (current.chatDraft.trim() || Object.keys(current.fieldDrafts).length > 0)) { + throw new Error('当前创作还有未发送或未保存的内容,请先回到工作区处理,再切换项目。'); + } + await current.selectProjectForWork(work.asset.workspaceId, work.taskId); + const auth = useAuthStore.getState(); + const currentOwner = auth.user?.userId ?? auth.user?.username ?? null; + if (!auth.isAuthenticated() || originalOwner !== currentOwner) { + throw new Error('登录账号已变化,请重新打开“我的作品”。'); + } + const selected = useImageWorkspaceStore.getState().workspace; + if (selected?.workspace.workspaceId !== work.asset.workspaceId) { + throw new Error('暂时没能打开这次创作,请稍后重试。'); + } + if (!selected.tasks.some((task) => task.taskId === work.taskId)) { + throw new Error('这件作品的制作记录暂时无法打开,你仍可以在这里查看和下载作品。'); + } + focusSequence.current += 1; + setFocusedWork({ + workspaceId: work.asset.workspaceId, + assetId: work.asset.assetId, + taskId: work.taskId, + requestId: focusSequence.current, + }); + }; + + return <> + + {authenticated && } + ; +} diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index 29300a76..b32cf59d 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -88,6 +88,7 @@ type ImageWorkspaceState = { renameProject: (workspaceId: string, title: string) => Promise; deleteProject: (workspaceId: string) => Promise; selectProject: (workspaceId: string) => Promise; + selectProjectForWork: (workspaceId: string, taskId: string) => Promise; refreshWorkspace: () => Promise; connectEvents: () => void; disconnectEvents: () => void; @@ -893,6 +894,70 @@ export const useImageWorkspaceStore = create((set, get) => } }, + selectProjectForWork: async (workspaceId, taskId) => { + const validateSource = (workspace: DesignWorkspace) => { + if (workspace.workspace.workspaceId !== workspaceId + || workspace.form.workspaceId !== workspaceId + || !workspace.tasks.some((task) => task.taskId === taskId)) { + throw new Error('无法在来源对话中找到这件作品,请刷新作品列表后再试'); + } + }; + const initial = get(); + if (initial.activeWorkspaceId === workspaceId && initial.workspace) { + validateSource(initial.workspace); + return; + } + const requireNoDrafts = (state: ImageWorkspaceState) => { + if (state.chatDraft.length > 0 || Object.keys(state.fieldDrafts).length > 0) { + throw new Error('当前对话还有未发送或未保存的内容,请先处理后再打开来源对话'); + } + }; + requireNoDrafts(initial); + const selectedGeneration = workspaceSelectionGeneration; + const initialWorkspaceId = initial.activeWorkspaceId; + const auth = useAuthStore.getState(); + const user = auth.user; + const userId = user?.userId; + const username = user?.username; + const authenticated = auth.isAuthenticated(); + if (!authenticated) throw new Error('请先登录后再打开来源对话'); + let workspace: DesignWorkspace; + try { + workspace = await fetchImageWorkspaceProject(workspaceId); + } catch (error) { + throw new Error(error instanceof ImageWorkspaceApiError && error.status === 404 + ? '来源对话已不存在,当前对话保持不变' + : '暂时无法打开来源对话,请稍后重试', { cause: error }); + } + const currentAuth = useAuthStore.getState(); + if (selectedGeneration !== workspaceSelectionGeneration + || get().activeWorkspaceId !== initialWorkspaceId + || currentAuth.user !== user + || currentAuth.user?.userId !== userId + || currentAuth.user?.username !== username + || currentAuth.isAuthenticated() !== authenticated) { + throw new Error('当前对话或登录账号已改变,请重新选择作品'); + } + requireNoDrafts(get()); + validateSource(workspace); + selectionGeneration += 1; + workspaceSelectionGeneration += 1; + closeEventSource(); + assistantStreamChunkIndexes.clear(); + set((state) => ({ + ...withWorkspace(state, workspace), + eventState: 'connecting', + lastEventId: null, + fieldDrafts: {}, + chatDraft: '', + assistantActivities: {}, + assistantStreams: {}, + quoteBlockers: [], + error: null, + })); + get().connectEvents(); + }, + refreshWorkspace: async () => { const workspaceId = get().activeWorkspaceId; if (!workspaceId) return null; diff --git a/tests/e2e/canvas-my-works-layout-fixture.ts b/tests/e2e/canvas-my-works-layout-fixture.ts new file mode 100644 index 00000000..9e594352 --- /dev/null +++ b/tests/e2e/canvas-my-works-layout-fixture.ts @@ -0,0 +1,50 @@ +import type { DesignAsset, DesignGeneratedWork, DesignGenerationTask, DesignWorkspace } from '../../shared/image-workspace'; +import { designCapabilitiesFixture, designFormFixture, designQuoteFixture, designSummaryFixture, designValuesFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2'; + +// Explicit local QA examples. These are never injected into product data. +const names = ['游戏小世界', '机器人伙伴', '出发去冒险']; +const titles = ['我的游戏小世界', '机器人动起来了', '出发去冒险', '机器人好朋友', '我的新关卡', '一起出发']; +export const fixtureWorks: DesignGeneratedWork[] = Array.from({ length: 6 }, (_, index) => { + const workspaceId = `qa-workspace-${Math.floor(index / 2) + 1}`; + const video = index % 2 === 1; + const asset: DesignAsset = { + assetId: `qa-asset-${index + 1}`, workspaceId, role: 'generated', mediaType: video ? 'video' : 'image', + mimeType: video ? 'video/mp4' : 'image/webp', width: 1200, height: 800, + durationMilliseconds: video ? (8 + index * 2) * 1000 : null, + generationTaskId: `qa-task-${index + 1}`, createdAt: `2026-10-14T09:${String(30 - index).padStart(2, '0')}:00Z`, + contentPath: video ? 'data:video/mp4;base64,' : `/src/assets/${index % 3 === 0 ? 'module-game' : 'module-robot'}.webp`, + }; + return { asset, taskId: asset.generationTaskId!, workspaceTitle: names[Math.floor(index / 2)], title: titles[index] }; +}); + +function taskFor(work: DesignGeneratedWork): DesignGenerationTask { + const quote = designQuoteFixture(); + return { + taskId: work.taskId, quoteId: `qa-quote-${work.taskId}`, status: 'succeeded', taskRevision: 2, + specificationRevision: 3, specificationRevisionId: 'qa-specification-3', specificationDigest: quote.specificationDigest, + compilerVersion: quote.compilerVersion, medium: work.asset.mediaType, + outputSummary: { ...quote.outputSummary, medium: work.asset.mediaType, aspectRatio: '3:4', outputCount: 1, durationSeconds: work.asset.durationMilliseconds === null ? null : work.asset.durationMilliseconds / 1000 }, + maximumCustomerChargeAtoms: 1200, + customerBilling: { quotedAtoms: 1200, heldAtoms: 0, chargedAtoms: 1200, refundedAtoms: 0, pendingResolutionAtoms: 0 }, + customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null, + progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 }, executionHealth: 'normal', + cancellation: { state: 'unavailable', outcome: null }, issues: [], resultAssetIds: [work.asset.assetId], + nextActions: ['view_result'], createdAt: work.asset.createdAt, + }; +} + +export const fixtureWorkspaces: DesignWorkspace[] = names.map((title, index) => { + const workspaceId = `qa-workspace-${index + 1}`; + const works = fixtureWorks.filter((work) => work.asset.workspaceId === workspaceId); + const values = structuredClone(designValuesFixture); + values.content.concept = '做一个充满想象力的游戏小世界'; + return designWorkspaceFixture({ + workspace: designSummaryFixture({ workspaceId, sessionId: `qa-session-${index + 1}`, title, specificationRevision: 3 }), + form: designFormFixture({ workspaceId, specificationRevision: 3, + specification: { schema_version: 1, values, field_decisions: {} } }), + turns: [{ turnId: 'qa-turn-1', rawTurnSequence: 1, userMessage: '做一个充满想象力的游戏小世界', assistantMessage: '作品已经完成啦!也可以继续告诉我你想怎么调整。' }], + tasks: works.map(taskFor), assets: works.map((work) => work.asset), + }); +}); + +export const fixtureBootstrap = { capabilities: designCapabilitiesFixture, workspaces: fixtureWorkspaces.map((workspace) => workspace.workspace) }; diff --git a/tests/e2e/canvas-my-works-layout-harness.mjs b/tests/e2e/canvas-my-works-layout-harness.mjs new file mode 100644 index 00000000..520cf48f --- /dev/null +++ b/tests/e2e/canvas-my-works-layout-harness.mjs @@ -0,0 +1,91 @@ +import { createServer, normalizePath } from 'vite'; +import { resolve } from 'node:path'; + +export async function createCanvasMyWorksServer({ port = 0 } = {}) { + const entry = normalizePath(resolve('tests/e2e/__canvas-my-works-layout-harness.tsx')); + const server = await createServer({ + configFile: false, + cacheDir: 'node_modules/.vite-canvas-my-works-layout', + resolve: { alias: { '@': resolve('src') } }, + esbuild: { jsx: 'automatic' }, + server: { host: '127.0.0.1', port, strictPort: port !== 0 }, + plugins: [{ + name: 'isolated-canvas-my-works-layout', enforce: 'pre', + resolveId(id) { if (id === '/__canvas-my-works-layout-harness.tsx') return entry; }, + load(id) { + if (id.endsWith('/src/lib/image-workspace.ts')) return ` + import { fixtureBootstrap, fixtureWorkspaces, fixtureWorks } from '/tests/e2e/canvas-my-works-layout-fixture.ts'; + export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'qa:image-workspace:create-project'; + export class ImageWorkspaceApiError extends Error {} + export const createImageWorkspaceOperationId = () => 'qa-operation'; + export const fetchImageWorkspace = async () => structuredClone(fixtureBootstrap); + export const fetchImageWorkspaceProject = async id => { + const workspace = fixtureWorkspaces.find(workspace => workspace.workspace.workspaceId === id); + if (!workspace) throw new Error('Unknown local QA Workspace'); + return structuredClone(workspace); + }; + export const fetchImageGeneratedWorks = async () => ({ works: structuredClone(fixtureWorks), nextOffset: null, unavailableWorkspaceIds: [], historyTruncated: false }); + export const openImageWorkspaceEvents = async () => ({ addEventListener() {}, close() {} }); + export const resolveImageWorkspaceAssetUrl = async path => path; + export const saveImageWorkspaceAsset = async () => ({ status: 'cancelled' }); + const forbidden = async () => { throw new Error('Local QA does not create, edit, delete, upload, or generate'); }; + export const createImageWorkspaceProject = forbidden; + export const deleteImageWorkspaceProject = forbidden; + export const renameImageWorkspaceProject = forbidden; + export const submitImageWorkspaceCommand = forbidden; + export const uploadImageWorkspaceAsset = forbidden; + `; + if (id.endsWith('/src/lib/host-api.ts')) return ` + export const setDesktopBackgroundLease = async () => undefined; + export const hostApiFetch = async () => { throw new Error('Local QA forbids Host API requests'); }; + `; + if (id === entry) return ` + import { createRoot } from 'react-dom/client'; + import { ImageCanvas } from '/src/pages/ImageCanvas/index.tsx'; + import { DesignWorksRail } from '/src/pages/ImageCanvas/DesignWorksRail.tsx'; + import { useImageWorkspaceStore } from '/src/stores/image-workspace.ts'; + import { useAuthStore } from '/src/stores/auth.ts'; + import { fixtureBootstrap, fixtureWorkspaces } from '/tests/e2e/canvas-my-works-layout-fixture.ts'; + import '/src/styles/globals.css'; + useAuthStore.setState({ initialized: true, accessToken: 'qa-only', user: { username: '本地样例', userId: 'qa-only', authorities: [], tenantId: null, deptId: null }, isAuthenticated: () => true }); + useImageWorkspaceStore.setState({ status: 'ready', bootstrap: fixtureBootstrap, activeWorkspaceId: fixtureWorkspaces[0].workspace.workspaceId, workspace: fixtureWorkspaces[0] }); + window.__canvasMyWorksQA = { + setDraft: value => useImageWorkspaceStore.getState().setChatDraft(value), + state: () => ({ draft: useImageWorkspaceStore.getState().chatDraft, workspaceId: useImageWorkspaceStore.getState().activeWorkspaceId }), + }; + // DOM-only media fixture: actual repository images are posters. No video + // bytes are played, generated, downloaded, or billed in layout tests. + document.addEventListener('error', event => { if (event.target instanceof HTMLVideoElement) event.stopImmediatePropagation(); }, true); + const videoFixture = new MutationObserver(() => document.querySelectorAll('video').forEach(video => { + if (video.dataset.qaPoster) return; + video.dataset.qaPoster = 'true'; + video.poster = '/src/assets/module-robot.webp'; + video.removeAttribute('src'); + queueMicrotask(() => video.dispatchEvent(new Event('loadedmetadata'))); + })); + videoFixture.observe(document.body, { childList: true, subtree: true }); + function CanvasFrame() { + const workspace = useImageWorkspaceStore(state => state.workspace); + return
+ + +
+
; + } + createRoot(document.getElementById('root')).render(); + `; + }, + configureServer(vite) { + vite.middlewares.use((req, res, next) => { + if (req.url !== '/' && !req.url?.startsWith('/?')) return next(); + res.setHeader('Content-Type', 'text/html'); + res.end('
'); + }); + }, + }], + }); + await server.listen(); + return server; +} diff --git a/tests/e2e/canvas-my-works-layout.spec.ts b/tests/e2e/canvas-my-works-layout.spec.ts new file mode 100644 index 00000000..53ec3895 --- /dev/null +++ b/tests/e2e/canvas-my-works-layout.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; +import type { ViteDevServer } from 'vite'; +import { createCanvasMyWorksServer } from './canvas-my-works-layout-harness.mjs'; + +let server: ViteDevServer; +let baseUrl: string; +test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); +test.beforeAll(async () => { + server = await createCanvasMyWorksServer(); + baseUrl = server.resolvedUrls!.local[0]; +}); +test.afterAll(async () => { await server?.close(); }); + +for (const viewport of [{ width: 1600, height: 1000 }, { width: 1440, height: 960 }, { width: 390, height: 844 }]) { + test(`Canvas My Works stays usable and returns to its source at ${viewport.width}px`, async ({ page }, testInfo) => { + const pageErrors: string[] = []; + const unexpectedRequests: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('request', (request) => { + const url = request.url(); + if (/^https?:/.test(url) && !url.startsWith(baseUrl)) unexpectedRequests.push(url); + }); + await page.setViewportSize(viewport); + await page.goto(baseUrl); + const split = page.getByTestId('canvas-workspace-split'); + await expect(split).toBeVisible(); + if (viewport.width >= 1024) await page.getByRole('button', { name: '展开创作对话', exact: true }).click(); + await page.getByRole('textbox', { name: '告诉 AI 你想创作什么' }).fill('这是一段尚未发送的本地草稿'); + const previousFocus = await split.getAttribute('data-focus'); + const previousPanel = await split.getAttribute('data-compact-panel'); + const trigger = page.getByRole('button', { name: '我的作品', exact: true }); + await trigger.click(); + const drawer = page.getByTestId('design-my-works-drawer'); + await expect(drawer).toBeVisible(); + const image = drawer.getByRole('button', { name: '查看图片:我的游戏小世界', exact: true }); + const video = drawer.getByRole('button', { name: '查看视频:机器人动起来了', exact: true }); + await expect(image).toBeVisible(); + await expect(video).toBeVisible(); + await expect(image.getByText('图片', { exact: true })).toBeVisible(); + await expect(video.getByText('视频', { exact: true })).toBeVisible(); + await expect(video.getByText('00:10', { exact: true })).toBeVisible(); + await expect(image.locator('img')).toBeVisible(); + const drawerBox = (await drawer.boundingBox())!; + expect(drawerBox.x).toBeGreaterThanOrEqual(-1); + expect(drawerBox.x + drawerBox.width).toBeLessThanOrEqual(viewport.width + 1); + expect(drawerBox.height).toBeLessThanOrEqual(viewport.height + 1); + const firstBox = (await image.boundingBox())!; + const secondBox = (await video.boundingBox())!; + expect(Math.abs(firstBox.y - secondBox.y)).toBeLessThanOrEqual(1); + expect(secondBox.x).toBeGreaterThan(firstBox.x + firstBox.width); + expect(await drawer.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true); + const footerBox = (await drawer.locator('.my-works-footer').boundingBox())!; + for (const tile of await drawer.getByRole('button', { name: /^查看/ }).all()) { + const tileBox = (await tile.boundingBox())!; + expect(tileBox.y + tileBox.height).toBeLessThanOrEqual(footerBox.y); + } + await page.screenshot({ path: testInfo.outputPath(`canvas-my-works-${viewport.width}-list.png`), fullPage: true }); + + const filters = drawer.getByRole('group', { name: '作品类型' }); + await filters.getByRole('button', { name: '视频', exact: true }).click(); + await expect(drawer.getByRole('button', { name: /^查看视频/ })).toHaveCount(3); + await expect(drawer.getByRole('button', { name: /^查看图片/ })).toHaveCount(0); + await video.click(); + const preview = drawer.getByTestId('my-works-preview'); + await expect(preview.locator('video')).toHaveAttribute('controls', ''); + await expect(preview.locator('video')).not.toHaveAttribute('autoplay', ''); + await expect(page.getByRole('dialog')).toHaveCount(1); + await expect(drawer.getByRole('button', { name: '保存到电脑', exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath(`canvas-my-works-${viewport.width}-video-preview.png`), fullPage: true }); + await drawer.getByRole('button', { name: '返回作品', exact: true }).click(); + await filters.getByRole('button', { name: '图片', exact: true }).click(); + await expect(drawer.getByRole('button', { name: /^查看图片/ })).toHaveCount(3); + await image.click(); + await expect(preview.locator('img')).toBeVisible(); + await drawer.getByRole('button', { name: '返回作品', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(drawer).toHaveCount(0); + await expect(trigger).toBeFocused(); + await expect(split).toHaveAttribute('data-focus', previousFocus!); + await expect(split).toHaveAttribute('data-compact-panel', previousPanel!); + await expect(page.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue('这是一段尚未发送的本地草稿'); + await expect.poll(() => page.evaluate('window.__canvasMyWorksQA.state().draft')).toBe('这是一段尚未发送的本地草稿'); + + // Real return-to-source callback guards unsent drafts and then selects the + // actual fixture Workspace, opens its task record, and exposes compact works. + await trigger.click(); + await drawer.getByRole('button', { name: '查看图片:出发去冒险', exact: true }).click(); + await drawer.getByRole('button', { name: '回到这次创作', exact: true }).click(); + await expect(drawer.getByRole('alert')).toHaveText('当前创作还有未发送或未保存的内容,请先回到工作区处理,再切换项目。'); + await expect.poll(() => page.evaluate('window.__canvasMyWorksQA.state().workspaceId')).toBe('qa-workspace-1'); + await drawer.getByRole('button', { name: '关闭我的作品', exact: true }).click(); + await expect(drawer).toHaveCount(0); + await page.getByRole('textbox', { name: '告诉 AI 你想创作什么' }).fill(''); + await trigger.click(); + await drawer.getByRole('button', { name: '查看图片:出发去冒险', exact: true }).click(); + await drawer.getByRole('button', { name: '回到这次创作', exact: true }).click(); + await expect(drawer).toHaveCount(0); + await expect.poll(() => page.evaluate('window.__canvasMyWorksQA.state().workspaceId')).toBe('qa-workspace-2'); + await expect(split).toHaveAttribute('data-focus', ''); + await expect(split).toHaveAttribute('data-compact-panel', 'works'); + const record = page.getByTestId('design-plan-history-qa-task-3'); + await expect(record).toBeVisible(); + await expect(record).toHaveAttribute('open', ''); + await page.screenshot({ path: testInfo.outputPath(`canvas-my-works-${viewport.width}-source.png`), fullPage: true }); + expect(pageErrors).toEqual([]); + expect(unexpectedRequests).toEqual([]); + }); +} diff --git a/tests/unit/design-my-works-drawer.test.tsx b/tests/unit/design-my-works-drawer.test.tsx new file mode 100644 index 00000000..f1749a44 --- /dev/null +++ b/tests/unit/design-my-works-drawer.test.tsx @@ -0,0 +1,286 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRef } from 'react'; +import { fetchImageGeneratedWorks, resolveImageWorkspaceAssetUrl, saveImageWorkspaceAsset } from '@/lib/image-workspace'; +import { DesignMyWorksDrawer } from '@/pages/ImageCanvas/DesignMyWorksDrawer'; +import type { DesignGeneratedWork, DesignGeneratedWorksPage } from '../../shared/image-workspace'; + +vi.mock('@/lib/image-workspace', () => ({ + fetchImageGeneratedWorks: vi.fn(), + resolveImageWorkspaceAssetUrl: vi.fn(), + saveImageWorkspaceAsset: vi.fn(), +})); + +function work(id: string, medium: 'image' | 'video' = 'image', workspaceId = 'workspace-1', day = 1): DesignGeneratedWork { + return { + title: `${medium === 'video' ? '视频' : '图片'}作品${id}`, + workspaceTitle: `创作${workspaceId}`, + taskId: `task-${id}`, + asset: { + assetId: id, workspaceId, role: 'generated', mediaType: medium, + mimeType: medium === 'video' ? 'video/mp4' : 'image/png', width: 800, height: 600, + durationMilliseconds: medium === 'video' ? 83000 : null, + generationTaskId: `task-${id}`, createdAt: `2026-10-${String(day).padStart(2, '0')}T09:00:00Z`, + contentPath: `/assets/${workspaceId}/${id}`, + }, + }; +} + +function page(works: DesignGeneratedWork[] = [], nextOffset: number | null = null, unavailableWorkspaceIds: string[] = [], historyTruncated = false): DesignGeneratedWorksPage { + return { works, nextOffset, unavailableWorkspaceIds, historyTruncated }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +function renderDrawer(overrides: Partial[0]> = {}) { + const props = { open: true, onOpenChange: vi.fn(), onReturnToCreation: vi.fn().mockResolvedValue(undefined), ...overrides }; + return { ...render(), props }; +} + +describe('Canvas My Works drawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page()); + vi.mocked(resolveImageWorkspaceAssetUrl).mockImplementation(async (path) => `http://localhost${path}`); + vi.mocked(saveImageWorkspaceAsset).mockResolvedValue({ status: 'saved' }); + vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('shows cross-project image/video labels, video duration, and filters without starting playback', async () => { + const image = work('image', 'image', 'dogs'); + const video = work('video', 'video', 'space', 2); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([image, video])); + renderDrawer(); + const videoTile = await screen.findByRole('button', { name: `查看视频:${video.title}` }); + const imageTile = screen.getByRole('button', { name: `查看图片:${image.title}` }); + expect(within(videoTile).getByText('视频')).toBeInTheDocument(); + expect(within(imageTile).getByText('图片')).toBeInTheDocument(); + expect(within(videoTile).getByText('01:23')).toBeInTheDocument(); + expect(within(imageTile).getByText('来自:创作dogs')).toBeInTheDocument(); + await waitFor(() => expect(videoTile.querySelector('video')).toHaveAttribute('src', `http://localhost${video.asset.contentPath}`)); + expect(videoTile.querySelector('video')).not.toHaveAttribute('autoplay'); + expect(videoTile.querySelector('video')).not.toHaveAttribute('controls'); + const filters = screen.getByRole('group', { name: '作品类型' }); + fireEvent.click(within(filters).getByRole('button', { name: '视频' })); + expect(screen.queryByRole('button', { name: `查看图片:${image.title}` })).not.toBeInTheDocument(); + fireEvent.click(within(filters).getByRole('button', { name: '图片' })); + expect(screen.queryByRole('button', { name: `查看视频:${video.title}` })).not.toBeInTheDocument(); + fireEvent.click(within(filters).getByRole('button', { name: '全部' })); + expect(screen.getAllByRole('button', { name: /^查看/ })).toHaveLength(2); + }); + + it('retries the failed Workspace offset, preserves works, deduplicates and sorts every page globally', async () => { + const first = work('same', 'image', 'old', 1); + const newer = work('new', 'video', 'new', 5); + const differentWorkspace = work('same', 'image', 'another', 3); + vi.mocked(fetchImageGeneratedWorks) + .mockResolvedValueOnce(page([first], 12)) + .mockRejectedValueOnce(new Error('network interrupted')) + .mockResolvedValueOnce(page([first, newer, differentWorkspace])); + renderDrawer(); + await screen.findByRole('button', { name: `查看图片:${first.title}` }); + fireEvent.click(screen.getByRole('button', { name: '继续加载作品' })); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: `查看图片:${first.title}` })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重新加载' })); + await screen.findByRole('button', { name: `查看视频:${newer.title}` }); + const tiles = screen.getAllByRole('button', { name: /^查看/ }); + expect(tiles).toHaveLength(3); + expect(tiles[0]).toHaveAccessibleName(`查看视频:${newer.title}`); + expect(within(tiles[1]).getByText('来自:创作another')).toBeInTheDocument(); + expect(vi.mocked(fetchImageGeneratedWorks).mock.calls.map(([offset]) => offset)).toEqual([0, 12, 12]); + expect(screen.queryByRole('button', { name: '继续加载作品' })).not.toBeInTheDocument(); + }); + + it('continues scanning when the current filter has no matches in a partial page', async () => { + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([work('image')], 12)); + renderDrawer(); + await screen.findByRole('button', { name: '查看图片:图片作品image' }); + fireEvent.click(within(screen.getByRole('group', { name: '作品类型' })).getByRole('button', { name: '视频' })); + expect(screen.getByText('这一批还没找到符合筛选的作品')).toBeInTheDocument(); + expect(screen.queryByText('还没有视频作品')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '继续加载作品' })).toBeEnabled(); + }); + + it('distinguishes initial load failure and allows a retry', async () => { + vi.mocked(fetchImageGeneratedWorks).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(page()); + renderDrawer(); + expect(screen.getByRole('status')).toHaveTextContent('正在寻找你的作品'); + await screen.findByRole('alert'); + expect(screen.queryByText('你的第一件作品会出现在这里')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重新加载' })); + await screen.findByText('你的第一件作品会出现在这里'); + expect(fetchImageGeneratedWorks).toHaveBeenCalledTimes(2); + }); + + it('retries partial Workspace failure without removing the successful page or claiming full history', async () => { + const first = work('first'); + const recovered = work('recovered', 'video'); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValueOnce(page([first], null, ['unavailable'], true)) + .mockResolvedValueOnce(page([first, recovered], null, [], true)); + renderDrawer(); + await screen.findByText('较早的作品暂时无法全部读取,当前显示已找到的作品。'); + fireEvent.click(screen.getByRole('button', { name: '重试未读到的作品' })); + await screen.findByRole('button', { name: `查看视频:${recovered.title}` }); + expect(screen.queryByRole('button', { name: '重试未读到的作品' })).not.toBeInTheDocument(); + expect(screen.getByText('较早的作品暂时无法全部读取,当前显示已找到的作品。')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /^查看/ })).toHaveLength(2); + }); + + it('does not claim there are no works when older history is truncated', async () => { + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([], null, [], true)); + renderDrawer(); + await screen.findByText('这一批还没找到符合筛选的作品'); + expect(screen.queryByText('你的第一件作品会出现在这里')).not.toBeInTheDocument(); + expect(screen.getByText('较早的作品暂时无法全部读取,当前显示已找到的作品。')).toBeInTheDocument(); + }); + + it('keeps earlier works when retries fail different Workspaces, then accepts a successful empty reread', async () => { + const a = work('a', 'image', 'workspace-a'); + const b = work('b', 'video', 'workspace-b'); + vi.mocked(fetchImageGeneratedWorks) + .mockResolvedValueOnce(page([a], null, ['workspace-b'], true)) + .mockResolvedValueOnce(page([b], null, ['workspace-a'])) + .mockResolvedValueOnce(page([b])); + renderDrawer(); + await screen.findByRole('button', { name: `查看图片:${a.title}` }); + fireEvent.click(screen.getByRole('button', { name: '重试未读到的作品' })); + await screen.findByRole('button', { name: `查看视频:${b.title}` }); + expect(screen.getByRole('button', { name: `查看图片:${a.title}` })).toBeInTheDocument(); + expect(screen.getByText('较早的作品暂时无法全部读取,当前显示已找到的作品。')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重试未读到的作品' })); + await waitFor(() => expect(screen.queryByRole('button', { name: '重试未读到的作品' })).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: `查看图片:${a.title}` })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: `查看视频:${b.title}` })).toBeInTheDocument(); + expect(screen.getByText('较早的作品暂时无法全部读取,当前显示已找到的作品。')).toBeInTheDocument(); + }); + + it('aborts on close and ignores a late old result after reopening', async () => { + const oldRequest = deferred(); + const oldWork = work('old'); + const freshWork = work('fresh'); + vi.mocked(fetchImageGeneratedWorks).mockReturnValueOnce(oldRequest.promise).mockResolvedValueOnce(page([freshWork])); + const { rerender, props } = renderDrawer(); + const signal = vi.mocked(fetchImageGeneratedWorks).mock.calls[0][1]; + rerender(); + expect(signal?.aborted).toBe(true); + rerender(); + await screen.findByRole('button', { name: `查看图片:${freshWork.title}` }); + await act(async () => { oldRequest.resolve(page([oldWork])); }); + expect(screen.queryByRole('button', { name: `查看图片:${oldWork.title}` })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: `查看图片:${freshWork.title}` })).toBeInTheDocument(); + }); + + it('aborts a fetch on unmount and ignores its late result', async () => { + const pending = deferred(); + vi.mocked(fetchImageGeneratedWorks).mockReturnValue(pending.promise); + const { unmount, props } = renderDrawer(); + const signal = vi.mocked(fetchImageGeneratedWorks).mock.calls[0][1]; + unmount(); + expect(signal?.aborted).toBe(true); + await act(async () => { pending.resolve(page([work('late')])); }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(props.onOpenChange).not.toHaveBeenCalled(); + }); + + it('previews and downloads inside the same drawer and only closes after returning succeeds', async () => { + const image = work('image'); + const returning = deferred(); + const onReturnToCreation = vi.fn().mockReturnValue(returning.promise); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([image])); + const { props } = renderDrawer({ onReturnToCreation }); + fireEvent.click(await screen.findByRole('button', { name: `查看图片:${image.title}` })); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + await screen.findByRole('img', { name: image.title }); + fireEvent.click(screen.getByRole('button', { name: '保存到电脑' })); + await screen.findByText('作品已保存到电脑'); + expect(saveImageWorkspaceAsset).toHaveBeenCalledExactlyOnceWith(image.asset); + expect(props.onOpenChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '回到这次创作' })); + expect(onReturnToCreation).toHaveBeenCalledExactlyOnceWith(image); + expect(props.onOpenChange).not.toHaveBeenCalled(); + await act(async () => { returning.resolve(); }); + expect(props.onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it('preserves the preview and shows the friendly callback failure, then permits retry', async () => { + const image = work('image'); + const onReturnToCreation = vi.fn().mockRejectedValueOnce(new Error('先发送或清空当前输入,再回到这次创作。')).mockResolvedValueOnce(undefined); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([image])); + const { props } = renderDrawer({ onReturnToCreation }); + fireEvent.click(await screen.findByRole('button', { name: `查看图片:${image.title}` })); + fireEvent.click(screen.getByRole('button', { name: '回到这次创作' })); + await screen.findByText('先发送或清空当前输入,再回到这次创作。'); + expect(screen.getByTestId('my-works-preview')).toBeInTheDocument(); + expect(props.onOpenChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '回到这次创作' })); + await waitFor(() => expect(props.onOpenChange).toHaveBeenCalledExactlyOnceWith(false)); + expect(onReturnToCreation).toHaveBeenCalledTimes(2); + }); + + it('keeps media and download failures recoverable inside the preview', async () => { + const image = work('image'); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([image])); + vi.mocked(resolveImageWorkspaceAssetUrl).mockRejectedValueOnce(new Error('thumbnail unavailable')) + .mockRejectedValueOnce(new Error('preview unavailable')) + .mockResolvedValue(`http://localhost${image.asset.contentPath}`); + vi.mocked(saveImageWorkspaceAsset).mockRejectedValueOnce(new Error('disk unavailable')).mockResolvedValueOnce({ status: 'saved' }); + renderDrawer(); + fireEvent.click(await screen.findByRole('button', { name: `查看图片:${image.title}` })); + await screen.findByText('作品暂时没能打开。'); + fireEvent.click(screen.getByRole('button', { name: '重新加载' })); + const previewImage = await screen.findByRole('img', { name: image.title }); + fireEvent.load(previewImage); + expect(screen.queryByText('作品暂时没能打开。')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '保存到电脑' })); + await screen.findByText('作品没有保存成功,请再试一次。'); + expect(screen.getByTestId('my-works-preview')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '保存到电脑' })); + await screen.findByText('作品已保存到电脑'); + expect(saveImageWorkspaceAsset).toHaveBeenCalledTimes(2); + }); + + it('stops video preview on return to the list and on close', async () => { + const video = work('video', 'video'); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([video])); + const { props, rerender, unmount } = renderDrawer(); + fireEvent.click(await screen.findByRole('button', { name: `查看视频:${video.title}` })); + await waitFor(() => expect(screen.getByTestId('my-works-preview').querySelector('video')).toHaveAttribute('controls')); + expect(screen.getByTestId('my-works-preview').querySelector('video')).not.toHaveAttribute('autoplay'); + fireEvent.click(screen.getByRole('button', { name: '返回作品' })); + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: `查看视频:${video.title}` })); + await waitFor(() => expect(screen.getByTestId('my-works-preview').querySelector('video')).toHaveAttribute('controls')); + rerender(); + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(2); + rerender(); + fireEvent.click(await screen.findByRole('button', { name: `查看视频:${video.title}` })); + await waitFor(() => expect(screen.getByTestId('my-works-preview').querySelector('video')).toHaveAttribute('controls')); + unmount(); + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(3); + }); + + it('ignores a late return callback after unmount and restores the trigger focus on close', async () => { + const image = work('image'); + const returning = deferred(); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue(page([image])); + const triggerRef = createRef(); + const props = { open: true, onOpenChange: vi.fn(), onReturnToCreation: vi.fn().mockReturnValue(returning.promise), triggerRef }; + const { rerender, unmount } = render(<>); + fireEvent.click(await screen.findByRole('button', { name: `查看图片:${image.title}` })); + fireEvent.click(screen.getByRole('button', { name: '回到这次创作' })); + rerender(<>); + await waitFor(() => expect(triggerRef.current).toHaveFocus()); + unmount(); + await act(async () => { returning.resolve(); }); + expect(props.onOpenChange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index a6719635..c94b8632 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ImageWorkspaceApiError } from '@/lib/image-workspace'; +import { fetchImageGeneratedWorks, ImageWorkspaceApiError } from '@/lib/image-workspace'; +import type { DesignGeneratedWork, DesignGenerationTask } from '../../shared/image-workspace'; import { ImageCanvas } from '@/pages/ImageCanvas'; import { useAuthStore } from '@/stores/auth'; import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum'; @@ -22,6 +23,7 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { const original = await importOriginal(); return { ...original, + fetchImageGeneratedWorks: vi.fn(), resolveImageWorkspaceAssetUrl: vi.fn().mockReturnValue(new Promise(() => undefined)), saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }), uploadImageWorkspaceAsset: vi.fn(), @@ -52,6 +54,7 @@ function prepareWorkspace(overrides = {}) { renameProject: vi.fn().mockResolvedValue(workspace), deleteProject: vi.fn().mockResolvedValue({ workspaceId: 'workspace-1', deleted: true }), selectProject: vi.fn().mockResolvedValue(undefined), + selectProjectForWork: vi.fn().mockResolvedValue(undefined), applyFieldOperations: vi.fn().mockResolvedValue(workspace), sendChat: vi.fn().mockResolvedValue(workspace), resolveDecisionPrompt: vi.fn().mockResolvedValue(workspace), @@ -81,6 +84,7 @@ function prepareWorkspace(overrides = {}) { describe('AI Design Canvas page', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue({ works: [], nextOffset: null, unavailableWorkspaceIds: [], historyTruncated: false }); setViewport(false); useAuthStore.setState({ initialized: true, @@ -100,6 +104,71 @@ describe('AI Design Canvas page', () => { useImageWorkspaceStore.getState().reset(); }); + it('opens and closes My Works without replacing the editing workspace or losing local drafts', async () => { + const { actions } = prepareWorkspace(); + render(); + fireEvent.click(screen.getByRole('button', { name: '展开作品工作区' })); + fireEvent.change(screen.getByRole('textbox', { name: '创作提示词' }), { target: { value: '还没提交的新画面' } }); + useImageWorkspaceStore.getState().setChatDraft('未发送的想法'); + fireEvent.click(screen.getByRole('button', { name: '我的作品' })); + await screen.findByRole('dialog', { name: '我的作品' }); + fireEvent.click(screen.getByRole('button', { name: '关闭我的作品' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: '我的作品' })).not.toBeInTheDocument()); + expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue('还没提交的新画面'); + expect(useImageWorkspaceStore.getState().chatDraft).toBe('未发送的想法'); + expect(screen.getByTestId('canvas-workspace-split')).toHaveAttribute('data-focus', 'works'); + expect(screen.getByRole('button', { name: '我的作品' })).toHaveFocus(); + expect(actions.selectProject).not.toHaveBeenCalled(); + expect(actions.applyFieldOperations).not.toHaveBeenCalled(); + }); + + it('clears the drawer on account changes and ignores the former account collection', async () => { + let resolve!: (value: Awaited>) => void; + vi.mocked(fetchImageGeneratedWorks).mockImplementationOnce(() => new Promise((settle) => { resolve = settle; })); + prepareWorkspace(); + render(); + fireEvent.click(screen.getByRole('button', { name: '我的作品' })); + await screen.findByRole('dialog', { name: '我的作品' }); + await act(async () => { + useAuthStore.setState({ user: { username: 'another', userId: 'another-account', tenantId: null, deptId: null, authorities: [] } }); + resolve({ works: [], nextOffset: null, unavailableWorkspaceIds: [], historyTruncated: false }); + }); + await waitFor(() => expect(screen.queryByRole('dialog', { name: '我的作品' })).not.toBeInTheDocument()); + }); + + it('returns to the source task and opens its history record in the compact work pane', async () => { + const quote = designQuoteFixture(); + const work: DesignGeneratedWork = { + title: '这一次的作品', workspaceTitle: '咖啡机新品主视觉', taskId: 'source-task', + asset: { assetId: 'source-result', workspaceId: 'workspace-1', role: 'generated', mediaType: 'image', + mimeType: 'image/png', width: 800, height: 600, durationMilliseconds: null, generationTaskId: 'source-task', + createdAt: '2026-10-14T09:00:00Z', contentPath: '/api/works/image-workspace/workspaces/workspace-1/assets/source-result/content' }, + }; + const task: DesignGenerationTask = { + taskId: work.taskId, quoteId: quote.quoteId, status: 'succeeded', taskRevision: 1, + specificationRevision: quote.specificationRevision, specificationRevisionId: quote.specificationRevisionId, + specificationDigest: quote.specificationDigest, medium: 'image', compilerVersion: quote.compilerVersion, + outputSummary: quote.outputSummary, maximumCustomerChargeAtoms: 100, + customerBilling: { quotedAtoms: 100, heldAtoms: 0, chargedAtoms: 100, refundedAtoms: 0, pendingResolutionAtoms: 0 }, + customerHoldExpiresAt: quote.expiresAt, resolutionDeadlineAt: null, + progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 }, executionHealth: 'normal', + cancellation: { state: 'unavailable', outcome: null }, issues: [], resultAssetIds: [work.asset.assetId], + nextActions: ['view_result'], createdAt: work.asset.createdAt, + }; + const { actions } = prepareWorkspace({ tasks: [task], assets: [work.asset] }); + vi.mocked(fetchImageGeneratedWorks).mockResolvedValue({ works: [work], nextOffset: null, unavailableWorkspaceIds: [], historyTruncated: false }); + render(); + fireEvent.click(screen.getByRole('button', { name: '我的作品' })); + fireEvent.click(await screen.findByRole('button', { name: '查看图片:这一次的作品' })); + fireEvent.click(screen.getByRole('button', { name: '回到这次创作' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: '我的作品' })).not.toBeInTheDocument()); + expect(actions.selectProjectForWork).toHaveBeenCalledWith('workspace-1', 'source-task'); + expect(screen.getByTestId('canvas-workspace-split')).toHaveAttribute('data-compact-panel', 'works'); + expect(screen.getByTestId('design-plan-history-source-task')).toHaveAttribute('open'); + expect(actions.confirmGeneration).not.toHaveBeenCalled(); + expect(actions.applyFieldOperations).not.toHaveBeenCalled(); + }); + it('keeps the loading shell until the first Workspace bootstrap settles', () => { const load = vi.fn(); useImageWorkspaceStore.setState({ status: 'idle', workspace: null, bootstrap: null, load }); diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index b1cbcc01..3d55dc40 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -137,6 +137,45 @@ describe('AI design V2 Main route boundary', () => { expect(response.json()).toMatchObject({ code: 'IMAGE_WORKSPACE_UNAVAILABLE' }); }); + it('reads account generated works pages without bootstrapping or changing the active project', async () => { + const page = { works: [], nextOffset: 120, unavailableWorkspaceIds: ['unavailable'], historyTruncated: true }; + const module = moduleFixture({ listGeneratedWorks: vi.fn().mockResolvedValue(page) }); + const response = createResponse(); + await handleImageWorkspaceRoutes( + createRequest('GET'), response.res, + new URL('http://127.0.0.1/api/works/image-workspace/generated-works?offset=108'), + context(module), + ); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ success: true, data: page }); + expect(module.listGeneratedWorks).toHaveBeenCalledWith(108); + expect(module.bootstrap).not.toHaveBeenCalled(); + expect(module.getWorkspace).not.toHaveBeenCalled(); + }); + + it('returns 501 when the optional generated works capability is absent', async () => { + const response = createResponse(); + await handleImageWorkspaceRoutes( + createRequest('GET'), response.res, + new URL('http://127.0.0.1/api/works/image-workspace/generated-works'), + context(moduleFixture()), + ); + expect(response.statusCode).toBe(501); + expect(response.json()).toMatchObject({ code: 'DESIGN_GENERATED_WORKS_UNAVAILABLE' }); + }); + + it.each(['-1', '1.5', 'invalid', '9007199254740992'])('rejects malformed generated works offset %s', async (offset) => { + const module = moduleFixture({ listGeneratedWorks: vi.fn() }); + const response = createResponse(); + await handleImageWorkspaceRoutes( + createRequest('GET'), response.res, + new URL(`http://127.0.0.1/api/works/image-workspace/generated-works?offset=${offset}`), + context(module), + ); + expect(response.statusCode).toBe(400); + expect(module.listGeneratedWorks).not.toHaveBeenCalled(); + }); + it('maps Workspace CRUD without exposing legacy Conversation routes', async () => { const module = moduleFixture(); const createResponseValue = createResponse(); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index f3eec307..a7b8ddb6 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -18,6 +18,12 @@ import { designSummaryFixture, designWorkspaceFixture, } from '../fixtures/design-workspace-v2'; +import type { DesignGenerationTask, DesignWorkspace } from '../../shared/image-workspace'; + +const authMock = vi.hoisted(() => ({ + user: { userId: 'user-1', username: 'account-one' } as { userId: string; username: string } | null, + authenticated: true, +})); vi.mock('@/lib/image-workspace', async (importOriginal) => { const original = await importOriginal(); @@ -41,7 +47,7 @@ vi.mock('@/lib/host-api', async (importOriginal) => { vi.mock('@/stores/auth', () => ({ useAuthStore: { - getState: () => ({ isAuthenticated: () => true }), + getState: () => ({ user: authMock.user, isAuthenticated: () => authMock.authenticated }), }, })); @@ -95,6 +101,8 @@ async function loadedStore(eventSource = new FakeEventSource()) { describe('V2 Living Form store', () => { beforeEach(() => { vi.clearAllMocks(); + authMock.user = { userId: 'user-1', username: 'account-one' }; + authMock.authenticated = true; useImageWorkspaceStore.getState().reset(); const workspace = designWorkspaceFixture(); fetchBootstrapMock.mockResolvedValue(designBootstrapFixture()); @@ -121,6 +129,152 @@ describe('V2 Living Form store', () => { expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined); }); + describe('returning to the source of a generated work', () => { + function sourceWorkspace(workspaceId = 'workspace-2'): DesignWorkspace { + const outputSummary = designQuoteFixture().outputSummary; + const task: DesignGenerationTask = { + taskId: 'source-task', quoteId: 'source-quote', status: 'succeeded', taskRevision: 1, + specificationRevision: 3, specificationRevisionId: 'spec-3', specificationDigest: 'a'.repeat(64), + medium: 'image', compilerVersion: 'design-compiler-v2', outputSummary, + maximumCustomerChargeAtoms: 100, + customerBilling: { quotedAtoms: 100, heldAtoms: 0, chargedAtoms: 100, refundedAtoms: 0, pendingResolutionAtoms: 0 }, + customerHoldExpiresAt: '2026-10-14T10:00:00Z', resolutionDeadlineAt: null, + progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 }, + executionHealth: 'normal', cancellation: { state: 'unavailable', outcome: null }, + issues: [], resultAssetIds: ['source-asset'], nextActions: ['view_result'], createdAt: '2026-10-14T10:00:00Z', + }; + return designWorkspaceFixture({ + workspace: designSummaryFixture({ workspaceId, directionId: `direction-${workspaceId}`, sessionId: `session-${workspaceId}` }), + form: designFormFixture({ workspaceId, directionId: `direction-${workspaceId}` }), + tasks: [task], + }); + } + + it.each([ + new ImageWorkspaceApiError(404, 'design_workspace_not_found', 'not found'), + new Error('read failed'), + ])('preserves the current project, drafts, and events on a failed source read', async (error) => { + const source = await loadedStore(); + const previous = useImageWorkspaceStore.getState(); + const eventCalls = openEventsMock.mock.calls.length; + fetchProjectMock.mockRejectedValueOnce(error); + await expect(previous.selectProjectForWork('workspace-2', 'source-task')).rejects.toThrow(/来源对话/); + expect(useImageWorkspaceStore.getState()).toBe(previous); + expect(source.close).not.toHaveBeenCalled(); + expect(openEventsMock).toHaveBeenCalledTimes(eventCalls); + }); + + it.each(['missing-task', 'wrong-workspace'])('keeps the original state when source validation fails: %s', async (mismatch) => { + const source = await loadedStore(); + const previous = useImageWorkspaceStore.getState(); + fetchProjectMock.mockResolvedValueOnce(mismatch === 'wrong-workspace' + ? sourceWorkspace('workspace-3') : { ...sourceWorkspace(), tasks: [] }); + await expect(previous.selectProjectForWork('workspace-2', 'source-task')).rejects.toThrow(/找到这件作品/); + expect(useImageWorkspaceStore.getState()).toBe(previous); + expect(source.close).not.toHaveBeenCalled(); + }); + + it.each(['chat', 'field'])('rejects existing %s drafts before reading the source', async (kind) => { + const source = await loadedStore(); + if (kind === 'chat') useImageWorkspaceStore.getState().setChatDraft('keep this draft'); + else useImageWorkspaceStore.getState().setFieldDraft('content.concept', 'keep this field'); + const previous = useImageWorkspaceStore.getState(); + fetchProjectMock.mockClear(); + await expect(previous.selectProjectForWork('workspace-2', 'source-task')).rejects.toThrow(/未发送或未保存/); + expect(useImageWorkspaceStore.getState()).toBe(previous); + expect(fetchProjectMock).not.toHaveBeenCalled(); + expect(source.close).not.toHaveBeenCalled(); + }); + + it.each(['chat', 'field'])('preserves a %s draft added while a source read is pending', async (kind) => { + const source = await loadedStore(); + const read = deferred(); + fetchProjectMock.mockReturnValueOnce(read.promise); + const selecting = useImageWorkspaceStore.getState().selectProjectForWork('workspace-2', 'source-task'); + if (kind === 'chat') useImageWorkspaceStore.getState().setChatDraft('new draft during read'); + else useImageWorkspaceStore.getState().setFieldDraft('content.concept', 'new field during read'); + const previous = useImageWorkspaceStore.getState(); + read.resolve(sourceWorkspace()); + await expect(selecting).rejects.toThrow(/未发送或未保存/); + expect(useImageWorkspaceStore.getState()).toBe(previous); + expect(source.close).not.toHaveBeenCalled(); + }); + + it('rejects a late source read after store reset without reviving any project', async () => { + await loadedStore(); + const read = deferred(); + fetchProjectMock.mockReturnValueOnce(read.promise); + const selecting = useImageWorkspaceStore.getState().selectProjectForWork('workspace-2', 'source-task'); + useImageWorkspaceStore.getState().reset(); + const resetState = useImageWorkspaceStore.getState(); + read.resolve(sourceWorkspace()); + await expect(selecting).rejects.toThrow(/已改变/); + expect(useImageWorkspaceStore.getState()).toBe(resetState); + }); + + it('does not replace a newer project selection with a late source read', async () => { + await loadedStore(); + const read = deferred(); + fetchProjectMock.mockReturnValueOnce(read.promise).mockResolvedValueOnce(sourceWorkspace('workspace-3')); + const selecting = useImageWorkspaceStore.getState().selectProjectForWork('workspace-2', 'source-task'); + await useImageWorkspaceStore.getState().selectProject('workspace-3'); + const newerState = useImageWorkspaceStore.getState(); + read.resolve(sourceWorkspace()); + await expect(selecting).rejects.toThrow(/已改变/); + expect(useImageWorkspaceStore.getState()).toBe(newerState); + }); + + it.each(['different-owner', 'new-reference', 'logout'])('rejects a late source read after authentication changes: %s', async (change) => { + const source = await loadedStore(); + const read = deferred(); + fetchProjectMock.mockReturnValueOnce(read.promise); + const selecting = useImageWorkspaceStore.getState().selectProjectForWork('workspace-2', 'source-task'); + if (change === 'different-owner') authMock.user = { userId: 'user-2', username: 'account-two' }; + else if (change === 'new-reference') authMock.user = { userId: 'user-1', username: 'account-one' }; + else authMock.authenticated = false; + const previous = useImageWorkspaceStore.getState(); + read.resolve(sourceWorkspace()); + await expect(selecting).rejects.toThrow(/登录账号已改变/); + expect(useImageWorkspaceStore.getState()).toBe(previous); + expect(source.close).not.toHaveBeenCalled(); + }); + + it('commits a validated source project before replacing its event stream', async () => { + const previousSource = await loadedStore(); + const read = deferred(); + const newSource = new FakeEventSource(); + const target = sourceWorkspace(); + fetchProjectMock.mockReturnValueOnce(read.promise); + openEventsMock.mockResolvedValueOnce(newSource as unknown as EventSource); + const previous = useImageWorkspaceStore.getState(); + const selecting = previous.selectProjectForWork('workspace-2', 'source-task'); + expect(useImageWorkspaceStore.getState()).toBe(previous); + expect(previousSource.close).not.toHaveBeenCalled(); + read.resolve(target); + await selecting; + expect(previousSource.close).toHaveBeenCalledOnce(); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + status: 'ready', activeWorkspaceId: 'workspace-2', workspace: target, + chatDraft: '', fieldDrafts: {}, assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], lastEventId: null, + }); + expect(openEventsMock).toHaveBeenLastCalledWith('workspace-2', 'session-workspace-2', undefined); + }); + + it('validates a same-project work without reading, reconnecting, or touching drafts', async () => { + const source = await loadedStore(); + useImageWorkspaceStore.setState({ workspace: sourceWorkspace('workspace-1'), chatDraft: 'keep draft', fieldDrafts: { 'content.concept': 'keep field' } }); + const previous = useImageWorkspaceStore.getState(); + fetchProjectMock.mockClear(); + const eventCalls = openEventsMock.mock.calls.length; + await previous.selectProjectForWork('workspace-1', 'source-task'); + expect(useImageWorkspaceStore.getState()).toBe(previous); + await expect(previous.selectProjectForWork('workspace-1', 'missing-task')).rejects.toThrow(/找到这件作品/); + expect(fetchProjectMock).not.toHaveBeenCalled(); + expect(source.close).not.toHaveBeenCalled(); + expect(openEventsMock).toHaveBeenCalledTimes(eventCalls); + }); + }); + it('closes an obsolete event source when a replacement connection wins the race', async () => { const workspace = designWorkspaceFixture(); useImageWorkspaceStore.setState({ diff --git a/tests/unit/works-square-generated-works.test.ts b/tests/unit/works-square-generated-works.test.ts new file mode 100644 index 00000000..fb8a8f93 --- /dev/null +++ b/tests/unit/works-square-generated-works.test.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace'; +import { designValuesFixture } from '../fixtures/design-workspace-v2'; + +const session = vi.hoisted(() => ({ + token: vi.fn(), + binding: vi.fn(), + current: vi.fn(), +})); + +vi.mock('@electron/services/works-square-session', () => ({ + getValidWorksSquareAccessToken: session.token, + getWorksSquareAccountBinding: session.binding, + isCurrentWorksSquareAccountBinding: session.current, +})); + +function json(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function summary(id: string) { + return { + workspace_id: id, + client_workspace_id: `client-${id}`, + title: `Title ${id}`, + direction_id: `direction-${id}`, + session_id: `session-${id}`, + direction_revision: 4, + specification_revision: 3, + workspace_view_revision: 6, + updated_at: '2026-10-14T10:00:00Z', + }; +} + +function asset( + id: string, + taskId: string | null, + overrides: Record = {}, +) { + return { + asset_id: id, + role: 'generated', + media_type: 'image', + mime_type: 'image/png', + width: 720, + height: 1280, + duration_milliseconds: null, + generation_task_id: taskId, + created_at: '2026-10-14T10:00:00Z', + ...overrides, + }; +} + +function task(id: string, resultIds: string[], status = 'succeeded') { + return { + task_id: id, + quote_id: `quote-${id}`, + status, + task_revision: 1, + specification_revision: 3, + specification_revision_id: 'spec-3', + specification_digest: 'a'.repeat(64), + medium: 'image', + compiler_version: 'design-compiler-v2', + output_summary: { + medium: 'image', + strategy: 'direct', + aspect_ratio: '9:16', + output_count: 1, + delivery_format: 'PNG', + duration_seconds: null, + required_output_roles: ['primary'], + }, + maximum_customer_charge_atoms: 100, + customer_billing: { + quoted_atoms: 100, + held_atoms: 0, + charged_atoms: 100, + refunded_atoms: 0, + pending_resolution_atoms: 0, + }, + customer_hold_expires_at: '2026-10-14T10:00:00Z', + resolution_deadline_at: null, + progress: { stage: 'completed', completed_required_steps: 1, total_required_steps: 1 }, + execution_health: 'normal', + cancellation: { state: 'unavailable', outcome: null }, + issues: [], + result_asset_ids: resultIds, + next_actions: ['view_result'], + created_at: '2026-10-14T10:00:00Z', + }; +} + +function detail( + id: string, + assets: ReturnType[] = [], + tasks: ReturnType[] = [], +) { + return { + workspace: summary(id), + form: { + workspace_id: id, + direction_id: `direction-${id}`, + direction_revision: 4, + raw_turn_sequence: 0, + specification_revision: 3, + workspace_view_revision: 6, + specification_revision_id: 'spec-3', + specification_digest: 'a'.repeat(64), + specification: { schema_version: 1, values: designValuesFixture, field_decisions: {} }, + decision_prompts: [], + active_quotes: [], + recent_change_set: { interaction_id: 'interaction-1', changes: [] }, + }, + turns: [], + assets, + tasks, + }; +} + +function adapter(fetchMock: ReturnType): WorksSquareDesignWorkspace { + return new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + }); +} + +describe('account generated works scan', () => { + beforeEach(() => { + vi.resetAllMocks(); + session.token.mockResolvedValue('fixture-credential'); + session.binding.mockReturnValue({ accountKey: 'account-a', epoch: 1 }); + session.current.mockReturnValue(true); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('includes only linked successful outputs, preserves actual media, and sorts newest first', async () => { + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + expect(init?.method ?? 'GET').toBe('GET'); + if (String(input).includes('?')) return json([summary('one'), summary('two')]); + if (String(input).endsWith('/one')) return json(detail('one', [ + asset('image', 'done'), + asset('uploaded', 'done', { role: 'uploaded' }), + asset('failed-output', 'failed'), + asset('running-output', 'running'), + asset('intermediate', 'done'), + asset('mismatched', 'other'), + ], [ + task('done', ['image', 'image', 'uploaded', 'mismatched']), + task('failed', ['failed-output'], 'failed'), + task('running', ['running-output'], 'running'), + ])); + return json(detail('two', [asset('video', 'video-task', { + media_type: 'video', + mime_type: 'video/mp4', + duration_milliseconds: 6000, + created_at: '2026-10-14T11:00:00Z', + })], [task('video-task', ['video'])])); + }); + const result = await adapter(fetchMock).listGeneratedWorks(); + expect(result.works.map((work) => [work.asset.assetId, work.asset.mediaType, work.taskId])) + .toEqual([['video', 'video', 'video-task'], ['image', 'image', 'done']]); + expect(result.works[0]).toMatchObject({ + workspaceTitle: 'Title two', + title: 'Title two', + asset: { workspaceId: 'two', contentPath: '/api/works/image-workspace/workspaces/two/assets/video/content' }, + }); + expect(result).toMatchObject({ nextOffset: null, unavailableWorkspaceIds: [], historyTruncated: false }); + expect(session.token).toHaveBeenCalledTimes(1); + for (const [, init] of fetchMock.mock.calls) { + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer fixture-credential'); + } + }); + + it('scans beyond bootstrap page 100 with twelve details and a lookahead cursor', async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + const url = new URL(String(input)); + if (url.search) { + expect(url.searchParams.get('limit')).toBe('13'); + expect(url.searchParams.get('offset')).toBe('108'); + return json(Array.from({ length: 13 }, (_, index) => summary(`workspace-${108 + index}`))); + } + const id = url.pathname.split('/').at(-1)!; + return json(detail(id)); + }); + await expect(adapter(fetchMock).listGeneratedWorks(108)).resolves.toEqual({ + works: [], nextOffset: 120, unavailableWorkspaceIds: [], historyTruncated: false, + }); + expect(fetchMock).toHaveBeenCalledTimes(13); + expect(fetchMock.mock.calls.some(([input]) => String(input).endsWith('/workspace-120'))).toBe(false); + }); + + it('caps detail reads at three concurrent requests', async () => { + let active = 0; + let maximum = 0; + const releases: Array<() => void> = []; + const fetchMock = vi.fn(async (input: string | URL) => { + const url = new URL(String(input)); + if (url.search) return json(Array.from({ length: 12 }, (_, index) => summary(String(index)))); + active += 1; + maximum = Math.max(maximum, active); + await new Promise((resolve) => releases.push(resolve)); + active -= 1; + return json(detail(url.pathname.split('/').at(-1)!)); + }); + const result = adapter(fetchMock).listGeneratedWorks(); + for (let batch = 0; batch < 4; batch += 1) { + await vi.waitFor(() => expect(releases).toHaveLength(3)); + releases.splice(0).forEach((release) => release()); + } + await result; + expect(maximum).toBe(3); + }); + + it('ends the entire page in thirty seconds even when fetch ignores abort, then stops detail fanout', async () => { + vi.useFakeTimers(); + const detailSignals: AbortSignal[] = []; + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.search) { + await new Promise((resolve) => setTimeout(resolve, 20_000)); + return json(Array.from({ length: 12 }, (_, index) => summary(String(index)))); + } + detailSignals.push(init!.signal!); + // Deliberately ignore the signal and complete after the page has expired. + await new Promise((resolve) => setTimeout(resolve, 40_000)); + return json(detail(url.pathname.split('/').at(-1)!)); + }); + const result = adapter(fetchMock).listGeneratedWorks(); + const finished = vi.fn(); + void result.then(finished, finished); + const failure = expect(result).rejects.toMatchObject({ + status: 504, code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT', + }); + await vi.advanceTimersByTimeAsync(29_999); + expect(finished).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(1); + await failure; + expect(detailSignals).toHaveLength(3); + expect(detailSignals.every((signal) => signal.aborted)).toBe(true); + await vi.advanceTimersByTimeAsync(40_000); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('includes credential acquisition in the page deadline and starts no reads after a late credential', async () => { + vi.useFakeTimers(); + session.token.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 40_000)); + return 'fixture-credential'; + }); + const fetchMock = vi.fn(); + const failure = expect(adapter(fetchMock).listGeneratedWorks()).rejects.toMatchObject({ + status: 504, code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT', + }); + await vi.advanceTimersByTimeAsync(30_000); + await failure; + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('includes reading detail response bodies in the shared page deadline', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (input: string | URL) => { + if (String(input).includes('?')) return json([summary('one'), summary('two'), summary('three'), summary('four')]); + const response = json(detail('one')); + response.text = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 40_000)); + return JSON.stringify(detail('one')); + }); + return response; + }); + const failure = expect(adapter(fetchMock).listGeneratedWorks()).rejects.toMatchObject({ + status: 504, code: 'DESIGN_WORKSPACE_REQUEST_TIMEOUT', + }); + await vi.advanceTimersByTimeAsync(30_000); + await failure; + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('returns recoverable unavailable project ids without losing other projects', async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + if (String(input).includes('?')) return json([summary('bad'), summary('good')]); + if (String(input).endsWith('/bad')) return json({ detail: { code: 'unavailable' } }, 503); + return json(detail('good', [asset('result', 'task')], [task('task', ['result'])])); + }); + const result = await adapter(fetchMock).listGeneratedWorks(); + expect(result.unavailableWorkspaceIds).toEqual(['bad']); + expect(result.works.map((work) => work.asset.assetId)).toEqual(['result']); + }); + + it.each([401, 403])('rejects whole pages on authorization status %s', async (status) => { + const fetchMock = vi.fn(async (input: string | URL) => String(input).includes('?') + ? json([summary('one')]) : json({ detail: { code: 'AUTH_EXPIRED' } }, status)); + await expect(adapter(fetchMock).listGeneratedWorks()).rejects.toMatchObject({ status }); + expect(session.token).toHaveBeenCalledTimes(1); + }); + + it('discards the entire page when the account changes during a detail request', async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + if (String(input).includes('?')) return json([summary('one')]); + session.current.mockReturnValue(false); + return json(detail('one', [asset('result', 'task')], [task('task', ['result'])])); + }); + await expect(adapter(fetchMock).listGeneratedWorks()).rejects.toMatchObject({ + status: 409, code: 'DESIGN_ACCOUNT_CHANGED', + }); + }); + + it('does not start reads if the account changes while acquiring its credential', async () => { + session.token.mockImplementation(async () => { + session.current.mockReturnValue(false); + return 'fixture-credential'; + }); + const fetchMock = vi.fn(); + await expect(adapter(fetchMock).listGeneratedWorks()).rejects.toMatchObject({ code: 'DESIGN_ACCOUNT_CHANGED' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['assets', 'tasks', 'missing-result'])('marks upstream %s history limits explicitly', async (limit) => { + const assets = limit === 'assets' + ? Array.from({ length: 100 }, (_, index) => asset(String(index), null)) : []; + const tasks = limit === 'tasks' + ? Array.from({ length: 50 }, (_, index) => task(String(index), [], 'failed')) + : limit === 'missing-result' ? [task('one', ['not-in-detail'])] : []; + const fetchMock = vi.fn(async (input: string | URL) => String(input).includes('?') + ? json([summary('one')]) : json(detail('one', assets, tasks))); + await expect(adapter(fetchMock).listGeneratedWorks()).resolves.toMatchObject({ historyTruncated: true }); + }); + + it.each([-1, 1.5, NaN, Number.MAX_SAFE_INTEGER])('rejects invalid offset %s before reading credentials', async (offset) => { + const fetchMock = vi.fn(); + await expect(adapter(fetchMock).listGeneratedWorks(offset)).rejects.toMatchObject({ status: 400 }); + expect(session.token).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});