From 383ce71fafaa64e55509d35c3a2d8c6185afabb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 13:18:47 +0800 Subject: [PATCH] fix(home): align stacked module entries with creation title --- ...20261013-combined-trial-update-c42d19a7.md | 16 +- README.md | 4 +- src/pages/CloudAgents/CloudRecovery.tsx | 86 +++++-- src/pages/CloudAgents/agent-home.css | 20 ++ src/pages/CloudAgents/index.tsx | 12 +- src/pages/ModuleSelection/index.tsx | 10 +- src/styles/globals.css | 68 ++++-- tests/e2e/cloud-agent-recovery.spec.ts | 220 ++++++++++++++++++ tests/e2e/main-navigation.spec.ts | 58 +++-- tests/unit/cloud-agents-page.test.tsx | 126 ++++++++-- tests/unit/cloud-recovery.test.tsx | 209 +++++++++++++++++ 11 files changed, 719 insertions(+), 110 deletions(-) create mode 100644 tests/e2e/cloud-agent-recovery.spec.ts create mode 100644 tests/unit/cloud-recovery.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 1e8c4340..c77e813a 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,13 +8,27 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope - Assemble and validate completed homepage and Agent changes, then update the existing local temporary application with both Main and Renderer while retaining its profile and login. - Keep the current temporary application as the target; no production backend deployment, repository push, primary checkout change or worktree cleanup. - Follow-up: restore access to the existing account controls on the standalone module homepage and update the current trial. +- Latest follow-up: restore the desktop title-left / three-card vertical layout with the Code card centered on the title; retain Robot hiding and account controls. + +## Homepage Vertical Layout Follow-up · 2026-10-13 + +- Concurrent / Planning Gates: Passed on same-task resume; successful start and matching feature / task / branch / absolute worktree / base ownership verified. Retain unchanged startup, positioning, architecture, domain, accepted decisions, evidence/reflection/commitment context from the previous gate; reread the active record, startup, planning gate and local AGENTS. Source task records stay read-only; primary checkout remains untouched. +- Peer delta review: new completed Agent-create reminder and recovery-trial tasks affect recovery UI/runtime, not homepage layout. Canvas remains investigation-only. No conflicting homepage decision. Refresh active runtime binding and preserve any newly deployed recovery product delta before updating; do not reuse an old current pointer. +- User explicitly requests the prior vertical presentation: left welcome/title and right Agents, Code, Canvas cards. Robot remains hidden. Align the second Code card's center with the h1 center, excluding the welcome line; use equal card heights and equal vertical gaps. Keep the account footer and existing permissions/routes/drag region. +- Plan: desktop at 1024px and above uses two columns with the welcome line above the left h1 outside its alignment flow; the right cards retain image-left horizontal contents and stack vertically. Smaller widths put the title above the same card stack. Preserve current light-system assets and interaction styling, without changing Main/backend/billing/auth behavior. +- Preserve latest deployed recovery source 7d4a247b64765dfd843cb22fc18b65e1be3196fd by applying its exact seven-file product/test delta versus 5f7c851 (README combined semantically). Six source/test blobs match 7d exactly; no peer docs imported, no Main/shared/package/lock change. Preserve current recovery dialog/trigger, original operation identity, onSettled/outcome and key-result protections. +- Verification plan: focused navigation/account/wallet and recovery units, typecheck, scoped lint, complete build, Electron geometry and account-footer scenarios; inspect 1440/1024/768/375 screenshots. Refresh Renderer only after full Main/preload/resource/package comparison and live clean-state check; verify geometry and restore the original route/session/focus. +- Implementation complete: desktop two-column grid, three horizontal image-left cards stacked with equal heights/gaps; h1 is the left alignment reference and the welcome line sits above it. Narrow widths retain the title-above stack, account footer remains reachable, Robot stays hidden. No other product differences against current deployed 7d4a247 beyond ModuleSelection/globals and README. +- Verification completed before staging: 5 focused unit files / 77 tests passed; typecheck, seven-file scoped ESLint and complete build:vite passed. Four UI scenarios passed (Electron homepage geometry/account controls and browser recovery at 1440/375). Geometry at 1440/1024/768/375 checks equal card height/gap, h1/Code center error under 1px at desktop, horizontal image/text, full hit areas and no horizontal overflow. All four layout screenshots and the account menu screenshot visually inspected. No unrelated historical Code test rerun/weakening. +- Runtime update pending: refreshed current source is Renderer 7d4a247 over unchanged Main 7c51bb4; active pointer carries stale 5f effective fields from the peer overlay, so the next receipt must bind exact active Renderer/source-tree evidence and correct effective-source metadata. + ## Homepage Account Follow-up · 2026-10-13 diff --git a/README.md b/README.md index ac645270..07088222 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 + 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index 098c4b64..8a2506fd 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -1,5 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { History, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { AppError } from '@/lib/error-model'; import type { CloudPendingOperation } from '../../../shared/cloud-agents'; @@ -16,49 +19,92 @@ const labels: Record = { }; const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']); -export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) { +export function CloudRecovery({ pending, onResolved, triggerContainer }: { pending: CloudPendingOperation[]; onResolved: () => void; triggerContainer?: HTMLElement | null }) { + const [open, setOpen] = useState(false); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); - const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null); + const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null); + const inFlight = useRef(false); + const awaitingKeyClose = useRef(false); + const alive = useRef(true); + const creationOnly = pending.length > 0 ? pending.every(item => item.operation === 'createAgent') : result?.operation === 'createAgent'; + const title = creationOnly ? '创建记录' : '待处理记录'; + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + useEffect(() => { if (triggerContainer === null && !busy) setOpen(false); }, [triggerContainer, busy]); + const close = () => { if (!inFlight.current) setOpen(false); }; const resolve = async (item: CloudPendingOperation, discard = false) => { - setBusy(item.id); setError(''); setNotice(''); setResult(null); + if (inFlight.current || awaitingKeyClose.current) return; + inFlight.current = true; + setBusy(item.id); setError(''); setNotice(''); try { const response = await cloudAgentsApi.resolvePending(item.id, discard); + if (!alive.current) return; if (response.requires_input) { setResult(null); setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。'); } else if (response.retired) { setResult(null); setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。'); - } else if (!discard) setResult({ operation: item.operation, value: response.result }); + } else if (!discard) { + awaitingKeyClose.current = item.operation === 'createKey'; + setResult({ operation: item.operation, + name: typeof item.input.name === 'string' ? item.input.name : undefined, value: response.result }); + } onResolved(); } catch (e) { - const message = e instanceof Error ? e.message : '暂时无法确认结果'; + if (!alive.current) return; + const message = e instanceof Error ? e.message : '这次没能完成,请稍后再试。'; if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') { setError(`${labels[item.operation] ?? '操作'}失败:${message}`); onResolved(); } else setError(message); } - finally { setBusy(''); } + finally { inFlight.current = false; if (alive.current) setBusy(''); } }; - if (!pending.length && !result && !error && !notice) return null; - return ; + {!pending.length && !result && !error && !notice &&

记录已经处理完了。

} + {pending.length > 0 &&

不再提醒只会关闭提醒,已经保存的内容会保留。

} + + + + ; } diff --git a/src/pages/CloudAgents/agent-home.css b/src/pages/CloudAgents/agent-home.css index d29e0de5..1599c998 100644 --- a/src/pages/CloudAgents/agent-home.css +++ b/src/pages/CloudAgents/agent-home.css @@ -61,6 +61,26 @@ .agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; } .agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; } .agent-home-error p { overflow-wrap: anywhere; } +.agent-recovery-slot:empty { display: none; } +.agent-recovery-trigger { gap: 7px; color: #606c7e; font-size: 13px; } +.agent-recovery-count { display: inline-grid; min-width: 21px; height: 21px; padding-inline: 5px; place-items: center; border-radius: 8px; background: #ede9f6; color: #7161a5; font-size: 11px; } +.agent-recovery-dialog.agent-recovery-modal { display: flex; max-height: calc(100dvh - 32px); flex-direction: column; gap: 20px; border-radius: 24px; } +.agent-recovery-dialog .agent-recovery-heading { flex-shrink: 0; flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 12px; text-align: left; } +.agent-recovery-heading > div { min-width: 0; } +.agent-recovery-heading > button { flex-shrink: 0; margin-top: -8px; margin-right: -8px; } +.agent-recovery-heading p { margin-top: 8px; line-height: 1.7; } +.agent-recovery-content { display: flex; min-height: 0; flex-direction: column; gap: 14px; overflow: auto; overscroll-behavior: contain; font-size: 14px; } +.agent-recovery-record { padding: 16px; border: 1px solid #e5e0ef; border-radius: 16px; background: #f8f6fd; overflow-wrap: anywhere; } +.agent-recovery-record h3 { font-weight: 600; line-height: 1.6; } +.agent-recovery-time { margin-top: 3px; color: #788193; font-size: 12px; } +.agent-recovery-record-help { margin-top: 10px; color: #606c7e; line-height: 1.7; } +.agent-recovery-record-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.agent-recovery-record-actions button { min-height: 40px; border-radius: 10px; } +.agent-recovery-footnote { color: #788193; font-size: 12px; line-height: 1.7; } +.agent-recovery-result { padding: 16px; border: 1px solid #dbe9df; border-radius: 16px; background: #f2f8f3; line-height: 1.7; overflow-wrap: anywhere; } +.agent-recovery-result pre { overflow: auto; } +.agent-recovery-footer { flex-shrink: 0; } +.agent-recovery-footer button { min-height: 44px; } @media (max-width: 1150px) { .agent-home { padding: 32px 28px 40px; } } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index 72b217bd..474b7694 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -40,6 +40,7 @@ function AgentWorkspace() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(''); + const [recoveryTarget, setRecoveryTarget] = useState(null); const cancelDelete = useRef(null); const deleteTrigger = useRef(null); const createButton = useRef(null); @@ -115,7 +116,6 @@ function AgentWorkspace() { void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); }); refreshRecovery(); }; - const recovery = ; const accepted = (agent: CloudAgentDraft) => { if (!mounted.current) return; @@ -156,22 +156,21 @@ function AgentWorkspace() { } }; - if (selected) return
{recovery} { + const renderWorkspace = () => { + if (selected) return
{ setSelected(null); setEntryView(undefined); setStartingPrompt(undefined); clearRecent(); }} onSaved={accepted} />
; - if (shared || activity) return
{recovery} { setShared(null); setActivity(null); clearRecent(); }} />
; + if (shared || activity) return
{ setShared(null); setActivity(null); clearRecent(); }} />
; if (creating) return
- {recovery}
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery} onCancel={() => { setCreating(false); void load(); }} />
; return (
- {recovery}
@@ -184,6 +183,7 @@ function AgentWorkspace() {
}
+
{(section !== 'mine' || loading || error || agents.length > 0) && }
@@ -241,6 +241,8 @@ function AgentWorkspace() {
); + }; + return <>{renderWorkspace()}; } function CreateAgentForm({ onCreated, onCancel, onSettled }: { diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index a342ce41..e37f12a7 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -51,7 +51,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea return (
-
+
-
-
-

+

+
+

{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}

-

今天,想创作什么?

+

今天,想创作什么?

{ + fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-recovery-fixture-'))); + await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); + const html = join(fixtureDir, 'index.html'); + const output = join(fixtureDir, 'dist'); + const entry = resolve('tests/e2e/__cloud-agent-recovery-harness.tsx'); + await writeFile(html, '
'); + await build({ + configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', + resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, + css: { postcss: resolve('.') }, + build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, + plugins: [{ + name: 'isolated-cloud-agent-recovery', enforce: 'pre', + resolveId(id) { if (id === '/__cloud-agent-recovery-harness.tsx') return entry; }, + load(id) { + if (id === entry) return ` + import { createRoot } from 'react-dom/client'; + import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; + import ${JSON.stringify(resolve('src/styles/globals.css'))}; + const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); + createRoot(document.getElementById('root')).render(
); + `; + if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "recovery-fixture" } }); }'; + }, + }], + }); + const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', + png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; + server = createServer(async (req, res) => { + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); + const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } + try { res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(await readFile(file)); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); +test.afterAll(async () => { + try { + if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } + } finally { + try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } + finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } + } +}); + +const agent: CloudAgentDraft = { + slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '一起观察和记录。', system_prompt: '用简单的中文回答。', + draft_revision: 1, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' }, + published_version: 1, enabled: true, updated_at: '2026-10-13T00:00:00Z', +}; +const longName = ('观察小伙伴_' + 'SuperLongUnbrokenAgentName'.repeat(4)).slice(0, 100); +const records: CloudPendingOperation[] = Array.from({ length: 8 }, (_, index) => ({ + id: `e2e-create-record-${index + 1}`, operation: 'createAgent', + input: { operation_id: `e2e-create-record-${index + 1}`, name: index === 0 ? longName : `我的观察伙伴 ${index + 1}`, purpose: '', icon: null }, + created_at: `2026-10-13T0${8 - index}:30:00Z`, +})); +const retryError = '创建结果暂时无法确认,请稍后再试。'; +type FixtureCall = { path: string; method: string; input: Record | null }; +type RecoveryFixture = { agents: CloudAgentDraft[]; pending: CloudPendingOperation[]; retry: 'fail' | 'success'; calls: FixtureCall[] }; +type FixtureWindow = Window & { __recoveryFixture: RecoveryFixture }; + +async function installFixture(page: Page, count = 2) { + page.on('pageerror', error => { throw error; }); + await page.addInitScript(({ agent, records, retryError }) => { + const scope = window as unknown as FixtureWindow; + const state: RecoveryFixture = { agents: [agent], pending: records, retry: 'fail', calls: [] }; + scope.__recoveryFixture = state; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }); + Object.defineProperty(window, 'electron', { value: { ipcRenderer: { + invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { + if (channel === 'hostapi:token') return 'fixture'; + if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; + if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel); + const path = request?.path ?? ''; const method = request?.method ?? 'GET'; + const input = request?.body ? JSON.parse(request.body) as Record : null; + state.calls.push({ path, method, input }); + if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: state.pending }); + if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); + if (path === '/api/cloud-agents/recovery/pending') { + const record = state.pending.find(item => item.id === input?.id); + if (!record) throw new Error('Recovery must keep the original pending id'); + if (input?.discard) { state.pending = state.pending.filter(item => item.id !== record.id); return result({ discarded: true }); } + if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError } } }; + const created = { ...agent, slug: 'ml-' + 'b'.repeat(32), name: String(record.input.name), published_version: null }; + state.agents.push(created); state.pending = state.pending.filter(item => item.id !== record.id); + return result({ operation: 'createAgent', result: created }); + } + if (path.startsWith('/api/cloud-agents/agents')) { + if (method !== 'GET') throw new Error('No new Agent may be created by reminder browsing'); + return result(path === '/api/cloud-agents/agents' ? { agents: state.agents, next_cursor: null } : agent); + } + if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path ' + path); + switch (input?.operation) { + case 'access': return result({ published_version: 1, enabled: true, share_url: 'niancode://agents/' + agent.slug, + api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [{ version: 1, draft_revision: 1 }] }); + case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null }); + case 'knowledge': return result({ databases: [], models: [] }); + case 'threads': return result({ threads: [], next_offset: null }); + default: throw new Error('Unexpected fixture operation ' + String(input?.operation)); + } + }, + } } }); + }, { agent, records: records.slice(0, count), retryError }); +} + +async function recoveryCalls(page: Page) { + return page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls.filter(call => call.path === '/api/cloud-agents/recovery/pending')); +} +async function expectNoReminderOutsideDialog(page: Page) { + await expect(page.getByRole('complementary', { name: '未确认的云端操作', exact: true })).toHaveCount(0); + await expect(page.getByText('有 2 个操作的结果尚未确认', { exact: true })).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +} +async function expectModalFits(dialog: Locator) { + const geometry = await dialog.evaluate(element => { + const box = element.getBoundingClientRect(); + return { fits: box.left >= 0 && box.top >= 0 && box.right <= innerWidth && box.bottom <= innerHeight, + horizontalOverflow: element.scrollWidth > element.clientWidth }; + }); + expect(geometry).toEqual({ fits: true, horizontalOverflow: false }); +} + +for (const width of [1440, 375]) { + test(`creation records stay in the header and use a scrollable modal at ${width}px`, async ({ page }) => { + const count = width === 375 ? 8 : 2; + await page.setViewportSize({ width, height: 850 }); await installFixture(page, count); await page.goto(baseUrl); + const home = page.getByTestId('cloud-agents-page'); + const trigger = home.getByRole('button', { name: new RegExp(`^创建记录.*${count}`) }); + await expect(trigger).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await trigger.evaluate(button => Boolean(button.closest('.agent-home-header')))).toBe(true); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await expectNoReminderOutsideDialog(page); expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-home-${width}.png`), animations: 'disabled' }); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await expect(dialog).toBeVisible(); await expect(dialog).toContainText(longName); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await expect(dialog.locator('.agent-recovery-time').first()).not.toHaveText(''); + const close = dialog.getByRole('button', { name: '返回列表', exact: true }); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + await dialog.locator('.agent-recovery-record').last().scrollIntoViewIfNeeded(); + await expect(dialog.locator('.agent-recovery-record').last().getByRole('button', { name: '再试一次', exact: true })).toBeInViewport(); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-modal-${width}.png`), animations: 'disabled' }); + await close.click(); await expect(dialog).toHaveCount(0); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await close.click(); expect(await recoveryCalls(page)).toEqual([]); + }); +} + +test('retry keeps the same pending identity and errors remain inside the modal', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + for (let attempt = 0; attempt < 2; attempt++) { + await dialog.getByRole('button', { name: '再试一次', exact: true }).first().click(); + await expect(dialog.getByRole('alert')).toContainText(retryError); + await expect(dialog.getByRole('button', { name: '再试一次', exact: true }).first()).toBeEnabled(); + } + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: false }, { id: records[0].id, discard: false }]); + await page.screenshot({ path: test.info().outputPath('recovery-retry-error-375.png'), animations: 'disabled' }); + await dialog.getByRole('button', { name: '关闭记录', exact: true }).click(); + await expect(dialog).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0); + await expectNoReminderOutsideDialog(page); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(2); + expect(await recoveryCalls(page)).toHaveLength(2); +}); + +test('discard is explicit and reminders do not follow into configuration, chat or creation', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await dialog.getByRole('button', { name: '不再提醒', exact: true }).first().click(); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(1); + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: true }]); + await dialog.getByRole('button', { name: '返回列表', exact: true }).click(); + await expect(trigger).toContainText('1'); + const card = page.getByRole('article', { name: agent.name, exact: true }); + await card.getByRole('button', { name: '调整', exact: true }).click(); + await expect(page.getByTestId('cloud-agent-editor')).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '我的智能体', exact: true }).click(); + await card.getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('button', { name: '返回智能体列表', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '返回智能体列表', exact: true }).click(); + await page.getByRole('button', { name: '创建智能体', exact: true }).click(); + await expect(page.getByRole('heading', { name: '创建智能体', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '取消', exact: true }).click(); + await expect(trigger).toContainText('1'); expect(await recoveryCalls(page)).toHaveLength(1); + const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls); + expect(calls.filter(call => call.path.startsWith('/api/cloud-agents/agents') && call.method !== 'GET')).toEqual([]); +}); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index d25ca55e..74ccff8a 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -314,7 +314,7 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('lays out three module cards in a desktop row and a compact mobile stack', async ({ launchElectronApp }) => { + test('aligns the desktop home title with the middle of three stacked horizontal module cards', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -343,9 +343,7 @@ test.describe('Makelore module navigation without setup flow', () => { imageWidth: image?.width ?? 0, imageHeight: image?.height ?? 0, imageLeft: image?.left ?? 0, - imageTop: image?.top ?? 0, contentLeft: content?.left ?? 0, - contentTop: content?.top ?? 0, }; })); @@ -355,47 +353,45 @@ test.describe('Makelore module navigation without setup flow', () => { BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600); }); - for (const width of [1440, 1024, 768]) { - await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, 900), width); + for (const width of [1440, 1024, 768, 375]) { + await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, windowWidth === 375 ? 800 : 900), width); await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); const cards = await readCardMetrics(); expect(cards).toHaveLength(3); - expect(Math.max(...cards.map(card => card.top)) - Math.min(...cards.map(card => card.top))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.left)) - Math.min(...cards.map(card => card.left))).toBeLessThan(1); expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1); - const gaps = cards.slice(1).map((card, index) => card.left - cards[index]!.left - cards[index]!.width); + expect(Math.max(...cards.map(card => card.height)) - Math.min(...cards.map(card => card.height))).toBeLessThan(1); + const gaps = cards.slice(1).map((card, index) => card.top - cards[index]!.top - cards[index]!.height); expect(Math.min(...gaps)).toBeGreaterThan(0); expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1); for (const card of cards) { expect(card.left).toBeGreaterThanOrEqual(0); expect(card.left + card.width).toBeLessThanOrEqual(width); - expect(card.contentTop).toBeGreaterThanOrEqual(card.imageTop + card.imageHeight - 1); + expect(card.imageWidth).toBeLessThan(card.width / 2); + expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); + expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); } - await expect(page.getByRole('heading', { name: '今天,想创作什么?' })).toHaveCSS('text-align', 'center'); - expect(await moduleCards.evaluateAll(elements => elements.every(element => { - const bounds = element.getBoundingClientRect(); - return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => - element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), - ); - }))).toBe(true); + const heading = await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).boundingBox(); + expect(heading).not.toBeNull(); + if (width >= 1024) { + expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left); + expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1); + expect(await moduleCards.evaluateAll(elements => elements.every(element => { + const bounds = element.getBoundingClientRect(); + return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => + element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), + ); + }))).toBe(true); + } else { + expect(heading!.y + heading!.height).toBeLessThanOrEqual(cards[0]!.top); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => + elements.every(element => element.scrollWidth <= element.clientWidth), + )).toBe(true); await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) }); } - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(375, 800)); - await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); - const compactCards = await readCardMetrics(); - expect(Math.max(...compactCards.map(card => card.left)) - Math.min(...compactCards.map(card => card.left))).toBeLessThan(1); - for (const [index, card] of compactCards.entries()) { - if (index > 0) expect(card.top).toBeGreaterThan(compactCards[index - 1]!.top + compactCards[index - 1]!.height); - expect(card.imageWidth).toBeLessThan(card.width / 2); - expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); - expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); - } - expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); - expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => - elements.every(element => element.scrollWidth <= element.clientWidth), - )).toBe(true); - await page.screenshot({ path: test.info().outputPath('module-entry-375.png') }); - await moduleCards.first().hover(); await moduleCards.first().focus(); await expect.poll(async () => await moduleCards.first().evaluate((element) => { diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index bd0289b7..792e595f 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -575,9 +575,13 @@ it('restores the exact previous thread and presents an uncertain operation witho expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument(); expect(api.resolvePending).not.toHaveBeenCalled(); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + await screen.findByRole('dialog', { name: '待处理记录' }); api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); - fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); await screen.findByText('发送消息的结果已确认。'); expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false); }); @@ -605,7 +609,8 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla input: { slug: draft.slug, session_key: 'qr-session', operation_id: 'verify-operation' }, created_at: '2026-09-13T01:00:00Z' }] }); api.resolvePending.mockResolvedValue({ requires_input: true, operation: 'wechatBindVerification' }); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); await screen.findByText(/这次微信验证还需要验证码/); expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false); @@ -616,11 +621,12 @@ it('refreshes recovery after a definitive rejection without presenting a confirm api.recovery.mockResolvedValueOnce({ recent: null, pending: [pending] }).mockResolvedValue({ recent: null, pending: [] }); api.resolvePending.mockRejectedValue(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); expect(await screen.findByRole('alert')).toHaveTextContent('创建智能体失败:名称不可用'); await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); - expect(screen.queryByRole('button', { name: '重试并确认结果' })).not.toBeInTheDocument(); - expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument(); expect(screen.queryByText('查看结果')).not.toBeInTheDocument(); expect(api.resolvePending).toHaveBeenCalledWith(pending.id, false); expect(api.create).not.toHaveBeenCalled(); @@ -631,12 +637,13 @@ it('keeps an old recovery record retryable when a parameter response cannot dete api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); api.resolvePending.mockRejectedValue(new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' })); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); await screen.findByRole('alert'); - expect(screen.getByRole('button', { name: '重试并确认结果' })).toBeEnabled(); - expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '再试一次' })).toBeEnabled(); + expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument(); expect(api.recovery).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); expect(api.resolvePending.mock.calls).toEqual([[pending.id, false], [pending.id, false]]); expect(api.create).not.toHaveBeenCalled(); @@ -648,13 +655,84 @@ it('offers only explicit discard for a recovery record whose channel operation w .mockResolvedValue({ recent: null, pending: [] }); api.resolvePending.mockResolvedValue({ operation: 'channelPolicy', discarded: true, retired: true }); open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); expect(await screen.findByText(/旧版联系人权限操作(已移除)/)).toBeVisible(); - expect(screen.queryByRole('button', { name: '重试并确认结果' })).toBeNull(); + expect(screen.queryByRole('button', { name: '再试一次' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); await screen.findByText(/旧版联系人权限操作已移除/); expect(api.resolvePending).toHaveBeenCalledWith('channel-policy-old', false); }); +it('keeps historic creation records out of the new form while showing the current creation error inline', async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:old', operation: 'createAgent', + input: { operation_id: 'original-old', name: '之前的失败创建' }, created_at: '2026-10-13T03:00:00Z' }] }); + api.create.mockRejectedValue(new Error('本次创建失败,请重新检查')); + open(); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '创建智能体', exact: true })); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '创建记录' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('本次创建失败,请重新检查'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('places the records entry only in the list header and preserves it when returning from configuration or chat', async () => { + publishedAssistant(4); + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:historic', operation: 'createAgent', + input: { operation_id: 'same-historic', name: '旧记录搭档' }, created_at: '2026-10-13T03:00:00Z' }] }); + open(); + const trigger = await screen.findByRole('button', { name: /^创建记录/ }); + expect(trigger.closest('header')).not.toBeNull(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + fireEvent.click(screen.getByRole('button', { name: '和它聊聊', exact: true })); + await screen.findByLabelText('消息'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 1 个操作的结果尚未确认/)).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('retains an unclosed key through internal configuration navigation and clears it across account changes', async () => { + publishedAssistant(4); + const secret = 'configuration-navigation-key-fixture'; + const pending = { id: 'key:historical', operation: 'createKey', input: { application_id: 'fixture-application', operation_id: 'original-key' }, created_at: '2026-10-13T03:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockImplementation(async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + return { result: { secret } }; + }); + const view = open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(new RegExp(secret)); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + expect(await screen.findByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + auth.user.userId = 'different-records-account'; + view.rerender(); + await screen.findByRole('button', { name: '调整', exact: true }); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + it('freezes the uploaded avatar and creation identity when retrying a lost response', async () => { const avatar = { icon: '/api/storage/public/images/create.png', icon_url: 'https://images.test/create.png' }; api.list.mockResolvedValue({ agents: [], next_cursor: null }); @@ -730,15 +808,18 @@ it('preserves the creation identity for unknown command outcomes even when the e await screen.findByRole('button', { name: '更换头像' }); fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果'); - await screen.findByText('有 1 个操作的结果尚未确认'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument(); expect(screen.getByLabelText('名称')).toBeDisabled(); expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '重试创建' })); await screen.findByTestId('cloud-agent-editor'); expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); - await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); - expect(api.recovery).toHaveBeenCalledTimes(3); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); expect(api.uploadAvatar).toHaveBeenCalledTimes(1); }); @@ -747,11 +828,6 @@ it('keeps an uncertain creation identity after its reminder is removed and a ret let retainedPending: unknown[] = []; api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); api.uploadAvatar.mockResolvedValue(avatar); - api.resolvePending.mockImplementation(async (_id, discard) => { - expect(discard).toBe(true); - retainedPending = []; - return { discarded: true }; - }); api.create.mockImplementationOnce(async input => { retainedPending = [{ id: 'create:discarded', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); @@ -763,12 +839,18 @@ it('keeps an uncertain creation identity after its reminder is removed and a ret fireEvent.click(screen.getByRole('button', { name: '上传头像' })); await screen.findByRole('button', { name: '更换头像' }); fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); - await screen.findByText('有 1 个操作的结果尚未确认'); - fireEvent.click(screen.getByRole('button', { name: '移除提醒', exact: true })); - await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); - expect(api.resolvePending).toHaveBeenCalledWith('create:discarded', true); + await screen.findByRole('alert'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + // Clearing the journal independently must not change the still-mounted form's uncertainty. + // The records entry is now confined to the list, and explicit discard is tested there. + retainedPending = []; + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); + expect(api.resolvePending).not.toHaveBeenCalled(); expect(screen.getByLabelText('名称')).toBeDisabled(); expect(screen.getByLabelText('名称')).toHaveValue('原搭档'); expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); diff --git a/tests/unit/cloud-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx new file mode 100644 index 00000000..02a7a56b --- /dev/null +++ b/tests/unit/cloud-recovery.test.tsx @@ -0,0 +1,209 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudRecovery } from '@/pages/CloudAgents/CloudRecovery'; +import { AppError } from '@/lib/error-model'; +import type { CloudPendingOperation } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ resolvePending: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +const first: CloudPendingOperation = { id: 'create:first', operation: 'createAgent', input: { operation_id: 'original-first', name: '故事搭档' }, created_at: '2026-10-13T03:54:49Z' }; +const second: CloudPendingOperation = { id: 'create:second', operation: 'createAgent', input: { operation_id: 'original-second', name: '阅读伙伴' }, created_at: '2026-10-13T03:55:18Z' }; +const key: CloudPendingOperation = { id: 'key:original', operation: 'createKey', input: { application_id: 'application', operation_id: 'original-key', name: '应用密钥' }, created_at: '2026-10-13T04:00:00Z' }; +const resolved = vi.fn(); +beforeEach(() => { vi.resetAllMocks(); }); +afterEach(cleanup); +async function openRecords() { + fireEvent.click(screen.getByRole('button', { name: /^(创建记录|待处理记录)/ })); + return await screen.findByRole('dialog'); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +it('shows only a compact counted creation entry until explicitly opened, without retrying or discarding records', async () => { + render(); + expect(screen.getByRole('button', { name: /^创建记录/ })).toHaveTextContent('2'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + expect(screen.queryByRole('article', { name: '创建「故事搭档」' })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 2 个操作的结果尚未确认/)).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(within(dialog).getByRole('article', { name: '创建「阅读伙伴」' })).toBeVisible(); + expect(within(dialog).getAllByRole('button', { name: '再试一次', exact: true })).toHaveLength(2); + expect(within(dialog).getAllByRole('button', { name: '不再提醒', exact: true })).toHaveLength(2); + expect(api.resolvePending).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled(); +}); + +it('uses a general records entry for mixed operations and keeps the original message visible only inside the dialog', async () => { + const message = { ...second, id: 'message:original', operation: 'submit', input: { query: '我的上次问题' } }; + render(); + expect(screen.getByRole('button', { name: /^待处理记录/ })).toHaveTextContent('2'); + expect(screen.queryByText('我的上次问题')).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByText('我的上次问题')).toBeVisible(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('keeps failed records and retries the same record ID after closing and reopening the dialog', async () => { + api.resolvePending.mockRejectedValueOnce(new Error('原操作仍未确认')).mockResolvedValueOnce({ result: { slug: 'original-agent' } }); + render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' })); + await screen.findByRole('alert'); + expect(screen.getByRole('alert')).toHaveTextContent('原操作仍未确认'); + expect(resolved).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + const reopened = await openRecords(); + expect(within(reopened).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(within(reopened).getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('removes only the explicitly chosen local reminder and never implicitly retries it', async () => { + api.resolvePending.mockResolvedValue({ discarded: true }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '不再提醒' })[1]); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[second.id, true]]); + view.rerender(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(screen.queryByRole('article', { name: '创建「阅读伙伴」' })).not.toBeInTheDocument(); +}); + +it('serializes double clicks and other actions and prevents dismissal until a request finishes', async () => { + const response = deferred<{ result: { slug: string } }>(); + api.resolvePending.mockReturnValue(response.promise); + render(); + const dialog = await openRecords(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + const retry = within(dialog).getAllByRole('button', { name: '再试一次' })[0]; + act(() => { fireEvent.click(retry); fireEvent.click(retry); }); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled(); + for (const discard of within(dialog).getAllByRole('button', { name: '不再提醒' })) expect(discard).toBeDisabled(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + fireEvent.pointerDown(document.body, { button: 0, pointerType: 'mouse' }); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(resolved).not.toHaveBeenCalled(); + await act(async () => response.resolve({ result: { slug: 'original-agent' } })); + expect(resolved).toHaveBeenCalledOnce(); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeEnabled(); +}); + +it('keeps a returned key available after modal close and reopen until the result is explicitly closed', async () => { + const secret = 'fixture-key-for-unit-only'; + api.resolvePending.mockResolvedValue({ result: { secret, id: 'created-key' } }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '再试一次' })[0]); + await screen.findByText(new RegExp(secret)); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + await openRecords(); + expect(screen.getByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + +it('keeps a result entry available when the final pending record was acknowledged', async () => { + api.resolvePending.mockResolvedValue({ result: { secret: 'last-key-fixture' } }); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(/last-key-fixture/); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); expect(screen.getByText(/last-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(/last-key-fixture/)).not.toBeInTheDocument(); +}); + +it('guides verification requiring fresh input without claiming that verification completed', async () => { + const verification = { ...first, id: 'wechat:verification', operation: 'wechatBindVerification', input: { session_key: 'fixture-session' } }; + api.resolvePending.mockResolvedValue({ requires_input: true, operation: verification.operation }); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByText(/还需要验证码/)).toBeVisible(); + expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(verification.id, false); +}); + +it('offers only explicit discard for retired operations and explains the retired result without auto actions', async () => { + const retired = { ...first, id: 'retired:policy', operation: 'channelPolicy', input: {} }; + api.resolvePending.mockResolvedValue({ retired: true, discarded: true, operation: retired.operation }); + render(); + expect(api.resolvePending).not.toHaveBeenCalled(); + await openRecords(); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); + expect(await screen.findByText(/旧版联系人权限操作已移除/)).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false); +}); + +it('renders no entry or dialog when there are no pending records or retained results', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('does not let another action replace an unclosed key result, and unlocks actions after explicit result closure', async () => { + const verification = { ...second, operation: 'wechatBindVerification' }; + api.resolvePending.mockImplementation(async id => id === key.id + ? { result: { secret: 'protected-key-fixture' } } : { requires_input: true, operation: verification.operation }); + const view = render(); + await openRecords(); + fireEvent.click(within(screen.getByRole('article', { name: '创建密钥' })).getByRole('button', { name: '再试一次' })); + await screen.findByText(/protected-key-fixture/); + view.rerender(); + const row = screen.getByRole('article', { name: '微信连接验证' }); + const retry = within(row).getByRole('button', { name: '再试一次' }); + const discard = within(row).getByRole('button', { name: '不再提醒' }); + expect(retry).toBeDisabled(); expect(discard).toBeDisabled(); + fireEvent.click(retry); fireEvent.click(discard); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(screen.getByText(/protected-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(retry).toBeEnabled(); + fireEvent.click(retry); + await screen.findByText(/还需要验证码/); + expect(api.resolvePending.mock.calls).toEqual([[key.id, false], [verification.id, false]]); +}); + +it('refreshes pending records after a definite rejection while keeping the error inside the explicit dialog', async () => { + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '这条创建已被拒绝', undefined, { commandOutcome: 'definitive_failure' })); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('这条创建已被拒绝'); + expect(resolved).toHaveBeenCalledOnce(); + expect(api.resolvePending).toHaveBeenCalledWith(first.id, false); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await openRecords(); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('这条创建已被拒绝'); +}); + +it('keeps an explicitly unknown command outcome recoverable without refreshing it as a definite failure', async () => { + api.resolvePending.mockRejectedValue(new AppError('NETWORK', '请求结果仍未知', undefined, { commandOutcome: 'unknown' })); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('请求结果仍未知'); + expect(resolved).not.toHaveBeenCalled(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); +});