fix(home): align stacked module entries with creation title
This commit is contained in:
1 parent
45e6b91715
commit
383ce71faf
11 files changed
+719
-110
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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、访问隔离、费用与不可变版本规则保持;介绍留空需要配Line truncated
|
||||
|
||||
历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。
|
||||
|
||||
高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
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 <aside aria-label="未确认的云端操作" className="mx-auto mb-6 max-w-6xl space-y-3 rounded-xl border border-amber-200 bg-amber-50/30 p-4 text-sm">
|
||||
{pending.length > 0 && <><h2 className="font-medium">有 {pending.length} 个操作的结果尚未确认</h2>
|
||||
<p className="text-muted-foreground">重试会确认原操作,不会新建一份任务。移除提醒只清除本机记录,云端已接受的任务仍会继续。</p></>}
|
||||
{pending.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><p>{retiredChannelOperations.has(item.operation) ? '旧版联系人权限操作(已移除)' : labels[item.operation] ?? '云端操作'} · {new Date(item.created_at).toLocaleString()}</p>
|
||||
if (!pending.length && !result && !error && !notice && !open) return null;
|
||||
const trigger = <DialogTrigger asChild><Button type="button" variant="ghost" className="agent-recovery-trigger" aria-label={title + (pending.length ? `(${pending.length})` : '')}>
|
||||
<History aria-hidden="true" size={16} />{title}{pending.length > 0 && <span className="agent-recovery-count" aria-hidden="true">{pending.length}</span>}
|
||||
</Button></DialogTrigger>;
|
||||
const actionsDisabled = Boolean(busy) || result?.operation === 'createKey';
|
||||
return <Dialog open={open && triggerContainer !== null} onOpenChange={next => { if (!inFlight.current) setOpen(next); }}>
|
||||
{triggerContainer === undefined ? trigger : triggerContainer && createPortal(trigger, triggerContainer)}
|
||||
<DialogContent className="agent-recovery-dialog agent-recovery-modal"
|
||||
onEscapeKeyDown={event => { if (inFlight.current) event.preventDefault(); }}
|
||||
onInteractOutside={event => { if (inFlight.current) event.preventDefault(); }}>
|
||||
<DialogHeader className="agent-recovery-heading">
|
||||
<div><DialogTitle>{title}</DialogTitle><DialogDescription>{creationOnly
|
||||
? '之前创建智能体时留下的记录,可以在这里查看和处理。'
|
||||
: '之前还没确认完成的事情,可以在这里查看和处理。'}</DialogDescription></div>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="关闭记录" disabled={Boolean(busy)} onClick={close}><X aria-hidden="true" size={18} /></Button>
|
||||
</DialogHeader>
|
||||
<div className="agent-recovery-content">
|
||||
{pending.map(item => <article key={item.id} aria-label={item.operation === 'createAgent' && typeof item.input.name === 'string' ? `创建「${item.input.name}」` : labels[item.operation] ?? '待处理记录'} className="agent-recovery-record">
|
||||
<div><h3>{item.operation === 'createAgent' && typeof item.input.name === 'string' ? `创建「${item.input.name}」`
|
||||
: retiredChannelOperations.has(item.operation) ? '旧版联系人权限操作(已移除)' : labels[item.operation] ?? '待处理记录'}</h3>
|
||||
<p className="agent-recovery-time">{new Date(item.created_at).toLocaleString()}</p>
|
||||
{item.operation === 'createAgent' && <p className="agent-recovery-record-help">还不确定有没有创建成功,先看看列表里有没有它。</p>}
|
||||
{typeof item.input.query === 'string' && <p className="mt-1 line-clamp-2 text-muted-foreground">{item.input.query}</p>}</div>
|
||||
{retiredChannelOperations.has(item.operation)
|
||||
? <Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}</Button>
|
||||
: <div className="flex gap-2"><Button size="sm" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在确认…' : '重试并确认结果'}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item, true)}>移除提醒</Button></div>}
|
||||
</div>)}
|
||||
{result && <div className="space-y-2"><p>{labels[result.operation] ?? '操作'}的结果已确认。{result.operation === 'createKey' && '请保存下面的密钥,关闭后不再显示。'}</p>
|
||||
? <Button size="sm" variant="ghost" disabled={actionsDisabled} onClick={() => void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}</Button>
|
||||
: <div className="agent-recovery-record-actions"><Button size="sm" disabled={actionsDisabled} onClick={() => void resolve(item)}>{busy === item.id ? '正在处理…' : '再试一次'}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={actionsDisabled} onClick={() => void resolve(item, true)}>不再提醒</Button></div>}
|
||||
</article>)}
|
||||
{result && <div className="agent-recovery-result space-y-2"><p>{result.operation === 'createAgent'
|
||||
? `「${result.name || '智能体'}」创建成功,可以回到列表继续设置。`
|
||||
: `${labels[result.operation] ?? '操作'}的结果已确认。`}{result.operation === 'createKey' && '请保存下面的密钥。关闭结果后将无法再次查看。'}</p>
|
||||
{result.operation !== 'createAgent' && <>
|
||||
<details open={result.operation === 'createKey'}><summary className="cursor-pointer">查看结果</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all p-2 text-xs">{JSON.stringify(result.value, null, 2)}</pre></details>
|
||||
<Button variant="ghost" size="sm" onClick={() => setResult(null)}>关闭结果</Button></div>}
|
||||
</>}
|
||||
{result.operation === 'createKey' && <p className="text-xs text-muted-foreground">先保存密钥,再点击「关闭结果」处理其他记录。</p>}
|
||||
<Button variant="ghost" size="sm" onClick={() => { awaitingKeyClose.current = false; setResult(null); }}>关闭结果</Button></div>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
{notice && <p role="status" className="text-muted-foreground">{notice}</p>}
|
||||
</aside>;
|
||||
{!pending.length && !result && !error && !notice && <p className="text-muted-foreground">记录已经处理完了。</p>}
|
||||
{pending.length > 0 && <p className="agent-recovery-footnote">不再提醒只会关闭提醒,已经保存的内容会保留。</p>}
|
||||
</div>
|
||||
<DialogFooter className="agent-recovery-footer"><Button type="button" variant="outline" disabled={Boolean(busy)} onClick={close}>返回列表</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ function AgentWorkspace() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudAgentDraft | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [recoveryTarget, setRecoveryTarget] = useState<HTMLDivElement | null>(null);
|
||||
const cancelDelete = useRef<HTMLButtonElement>(null);
|
||||
const deleteTrigger = useRef<HTMLButtonElement | null>(null);
|
||||
const createButton = useRef<HTMLButtonElement>(null);
|
||||
@@ -115,7 +116,6 @@ function AgentWorkspace() {
|
||||
void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); });
|
||||
refreshRecovery();
|
||||
};
|
||||
const recovery = <CloudRecovery pending={pending} onResolved={refreshRecovery} />;
|
||||
|
||||
const accepted = (agent: CloudAgentDraft) => {
|
||||
if (!mounted.current) return;
|
||||
@@ -156,22 +156,21 @@ function AgentWorkspace() {
|
||||
}
|
||||
};
|
||||
|
||||
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} entryView={entryView} startingPrompt={startingPrompt} recent={recent} onBack={() => {
|
||||
const renderWorkspace = () => {
|
||||
if (selected) return <div className="agent-desktop"><DraftEditor key={selected.slug} initial={selected} entryView={entryView} startingPrompt={startingPrompt} recent={recent} onBack={() => {
|
||||
setSelected(null);
|
||||
setEntryView(undefined);
|
||||
setStartingPrompt(undefined);
|
||||
clearRecent();
|
||||
}} onSaved={accepted} /></div>;
|
||||
if (shared || activity) return <div className="agent-desktop">{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
|
||||
if (shared || activity) return <div className="agent-desktop"><SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
|
||||
if (creating) return <section className="h-full overflow-auto p-6" data-testid="cloud-agents-page">
|
||||
{recovery}
|
||||
<div className="mx-auto max-w-3xl"><CreateAgentForm onCreated={agent => { setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery}
|
||||
onCancel={() => { setCreating(false); void load(); }} /></div>
|
||||
</section>;
|
||||
|
||||
return (
|
||||
<section className="agent-home" data-testid="cloud-agents-page">
|
||||
{recovery}
|
||||
<div className="agent-home-content">
|
||||
<header className="agent-home-header">
|
||||
<div className="agent-home-intro">
|
||||
@@ -184,6 +183,7 @@ function AgentWorkspace() {
|
||||
</div>}
|
||||
</div>
|
||||
<div className="agent-home-header-actions">
|
||||
<div ref={setRecoveryTarget} className="agent-recovery-slot" />
|
||||
{(section !== 'mine' || loading || error || agents.length > 0) &&
|
||||
<Button ref={createButton} className="agent-home-create" onClick={() => { setEntryView(undefined); setCreating(true); }}><Plus aria-hidden="true" />创建智能体</Button>}
|
||||
</div>
|
||||
@@ -241,6 +241,8 @@ function AgentWorkspace() {
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
return <><CloudRecovery pending={pending} onResolved={refreshRecovery} triggerContainer={recoveryTarget} />{renderWorkspace()}</>;
|
||||
}
|
||||
|
||||
function CreateAgentForm({ onCreated, onCancel, onSettled }: {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
|
||||
return (
|
||||
<main data-testid="ai-module-selection-page" className="relative h-[100dvh] overflow-auto bg-background px-4 py-6 text-foreground sm:px-8 sm:py-10">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-3rem)] max-w-6xl flex-col sm:min-h-[calc(100dvh-5rem)]">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-3rem)] max-w-7xl flex-col sm:min-h-[calc(100dvh-5rem)]">
|
||||
<header
|
||||
data-testid="module-selection-drag-region"
|
||||
className="drag-region absolute inset-x-0 top-0 flex h-16 items-start justify-end px-6 pt-6 sm:px-8 sm:pt-8"
|
||||
@@ -59,12 +59,12 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
<img src={logoWordmarkSource} alt="Makelore" className="h-8 w-40 object-cover object-center" draggable="false" />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-8 py-12 sm:gap-10">
|
||||
<section className="min-w-0 text-center">
|
||||
<p className="text-sm font-semibold tracking-[0.08em] text-muted-foreground">
|
||||
<div className="module-selection-content">
|
||||
<section className="module-selection-intro">
|
||||
<p className="module-selection-welcome text-sm font-semibold tracking-[0.08em] text-muted-foreground">
|
||||
{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}
|
||||
</p>
|
||||
<h1 className="mt-3 whitespace-nowrap text-[clamp(2rem,4vw,4rem)] font-semibold leading-[1.02] tracking-[-0.055em]">今天,想创作什么?</h1>
|
||||
<h1 className="module-selection-title whitespace-nowrap font-semibold leading-[1.02] tracking-[-0.055em]">今天,想创作什么?</h1>
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
+43
-25
@@ -407,6 +407,27 @@
|
||||
|
||||
/* Module chooser cards stay flat and opaque. The only motion allowed here is
|
||||
* a short color/border feedback on hover and focus. */
|
||||
.module-selection-content {
|
||||
display: grid;
|
||||
flex: 1 1 0%;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding-block: 3rem;
|
||||
}
|
||||
|
||||
.module-selection-intro {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.module-selection-title {
|
||||
margin-top: 0.75rem;
|
||||
font-size: clamp(2rem, 3.3vw, 3.75rem);
|
||||
}
|
||||
|
||||
.module-option-card {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
@@ -518,35 +539,32 @@
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (min-width: 1024px) {
|
||||
.module-selection-content {
|
||||
grid-template-columns: minmax(0, 0.42fr) minmax(0, 0.58fr);
|
||||
column-gap: clamp(2rem, 3vw, 3rem);
|
||||
}
|
||||
|
||||
.module-selection-intro {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.module-selection-welcome {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.75rem);
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.module-selection-title {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.module-selection-options {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
--module-option-gap: clamp(1.25rem, 2vw, 1.75rem);
|
||||
}
|
||||
|
||||
.module-selection-options .module-option-card {
|
||||
height: auto;
|
||||
min-height: clamp(17.5rem, 24vw, 21rem);
|
||||
}
|
||||
|
||||
.module-selection-options .module-option-card-surface {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.module-selection-options .module-option-card-image {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% - 0.125rem);
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 10;
|
||||
margin: 0.0625rem 0.0625rem 0;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.module-selection-options .module-option-card-content {
|
||||
justify-content: flex-start;
|
||||
padding: 1.25rem;
|
||||
height: clamp(8rem, 10vw, 10rem);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test';
|
||||
import { build } from 'vite';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudPendingOperation } from '../../shared/cloud-agents';
|
||||
|
||||
let server: Server;
|
||||
let fixtureDir: string;
|
||||
let baseUrl: string;
|
||||
const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-recovery.spec.ts'));
|
||||
const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json'));
|
||||
test.use({ headless: true, channel: undefined, video: 'off' });
|
||||
|
||||
// Exercise the real page and styles with Main IPC mocked. The static fixture
|
||||
// never starts Electron, a native window, a file picker, or a cloud service.
|
||||
test.beforeAll(async () => {
|
||||
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, '<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-recovery-harness.tsx"></script></body></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: <CloudAgents /> }], { initialEntries: ['/cloud-agents?view=mine'] });
|
||||
createRoot(document.getElementById('root')).render(<div className="h-screen min-w-0"><RouterProvider router={router} /></div>);
|
||||
`;
|
||||
if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "recovery-fixture" } }); }';
|
||||
},
|
||||
}],
|
||||
});
|
||||
const mime: Record<string, string> = { 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<void>((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<void>((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<string, unknown> | 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<string, unknown> : 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([]);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(<RouterProvider key="different-records-account" router={view.router} />);
|
||||
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();
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(yes => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it('shows only a compact counted creation entry until explicitly opened, without retrying or discarding records', async () => {
|
||||
render(<CloudRecovery pending={[first, second]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first, message]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first, second]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first, second]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[key, first]} onResolved={resolved} />);
|
||||
const dialog = await openRecords();
|
||||
fireEvent.click(within(dialog).getAllByRole('button', { name: '再试一次' })[0]);
|
||||
await screen.findByText(new RegExp(secret));
|
||||
view.rerender(<CloudRecovery pending={[first]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[key]} onResolved={resolved} />);
|
||||
await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' }));
|
||||
await screen.findByText(/last-key-fixture/);
|
||||
view.rerender(<CloudRecovery pending={[]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[verification]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[retired]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[key, verification]} onResolved={resolved} />);
|
||||
await openRecords();
|
||||
fireEvent.click(within(screen.getByRole('article', { name: '创建密钥' })).getByRole('button', { name: '再试一次' }));
|
||||
await screen.findByText(/protected-key-fixture/);
|
||||
view.rerender(<CloudRecovery pending={[verification]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[]} onResolved={resolved} />);
|
||||
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(<CloudRecovery pending={[first]} onResolved={resolved} />);
|
||||
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]]);
|
||||
});
|
||||
Reference in new issue
Block a user