From 8cd7b5979256c4b6695f2629e6f7bdfcd113d1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 10:37:08 +0800 Subject: [PATCH 01/17] ui: move Code gallery above the account footer --- .../20261014-code-gallery-footer-7c91a2e4.md | 40 +++++++++++++++++++ README.md | 4 +- src/components/layout/Sidebar.tsx | 7 +++- src/pages/Chat/classroom-workspace.css | 5 ++- 4 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md diff --git a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md new file mode 100644 index 00000000..599c07d5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md @@ -0,0 +1,40 @@ +# Task: Move Code gallery entry above the account footer + +## Identity + +- Task ID: 20261014-code-gallery-footer-7c91a2e4 +- Mode: Feature +- Branch: codex/20261014-code-gallery-footer-7c91a2e4-code-gallery-footer +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-code-gallery-footer-7c91a2e4 +- Base commit: 4eb3efa3d1b4700a89fac4e7d1124c7a53c9ae90 +- Owner: codex +- Status: In Progress + +## Scope + +- Move only the Code sidebar Works Square entry to a fixed footer slot above the account, preserving horizontal alignment and about38px button-to-account spacing. New Project and My Projects naturally shift into the former upper gallery space. Preserve the central welcome link and existing visual/data behavior. + +## Intent And Constraints + +- User explicitly requested this sidebar positioning refinement and continuing trial update. Preserve current full-runtime Canvas/Agent changes, existing palette, handlers, real data, Main, profile and unsent input. No cloud mutations or model requests. +- Concurrent Task Gate Passed: official check/start/status match task20261014-code-gallery-footer-7c91a2e4, feature/planning, owned worktree/branch and current runtime base4eb3efa3d1b4700a89fac4e7d1124c7a53c9ae90. Prior task was returned to ready without edits. Independent read-only review read all58 peer records/174 Scope, Intent And Constraints and Promotion sections; no concrete conflict. Old child-language placeholder scope remains unknown and unrelated. +- Project Context Loaded / Planning Gate Passed: read startup memory-index, own record, positioning, integrated current-state snapshot, decision index, system overview, community collaboration, domain rules, success criteria, evidence/reflection/commitment indexes and repository development guidance. Product is a single light creative client; Main owns auth/project/shell access. Community remains an external existing URL, no course/gallery API additions. Integrated memory is older than the authoritative current trial source. UI implementation only Sidebar.tsx/classroom-workspace.css; README and owned record follow the localized behavior. +- Plan: move the existing gallery component outside the scrolling project area into a shrink-0 footer sibling, retain12px horizontal padding;27px bottom padding plus existing11px account border/top padding provides38px gap. No absolute positioning. Verify focused sidebar/layout behavior, typecheck/lint and full default build, then prepare an immutable Renderer-only overlay preserving current Main25170 and all inherited feature metadata. Fresh native saved/idle guard and actual Code screenshot are required before activation/finalization. + +## Outcome + +- Implemented the existing CommunityLink in a Code-only fixed flex footer sibling, outside project scrolling; retained its original icon/text, horizontal inset, pending/error/open behavior. Removed the former header instance so New Project/My Projects naturally shift upward. Central welcome gallery is unchanged. README now describes the new placement. +- Current runtime remains untouched while native UI is busy or unsaved state is unresolved; immutable overlay preparation and final native verification are pending. + +## Verification + +- Pinned corepack pnpm10.33.4 typecheck and scoped Sidebar ESLint passed;19 focused sidebar project/actions, main-layout peek, and community unit cases passed. Full default env -u NIANCODE_RENDERER_ONLY corepack pnpm run build:vite passed (actual log /tmp/makelore-footer-build-7c91a2e4.log). +- git diff --check and owned-document drift check passed. Exactly Sidebar.tsx and classroom-workspace.css differ under src from current runtime4eb3efa; no Main/shared/resources/package/lock/build-config delta. Final layout and native update proof pending. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 40feb998..68c5e940 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用 Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 + 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏的「作品广场」固定在底部账户上方约 38px,保持与上方入口相同的横向对齐;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、TOOL、SKILL、MCP 服务和知识库纵向呈现,资源选择按钮分别显示「选择TOOL」「选择SKILL」;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。 @@ -39,7 +39,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,并依次位于品牌下方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部账户上方约 38px;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c79271eb..dec06074 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -447,7 +447,6 @@ export function Sidebar({ 麦洛把脑洞变成作品 } - {workspaceLayout && } {!workspaceLayout && } } @@ -881,6 +880,12 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> + {workspaceLayout && isProgrammingModule && !isProjectConfigPage && ( +
+ +
+ )} +
diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index 9081d19d..85dca98a 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -8,8 +8,9 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header { padding-top: 0; padding-bottom: 0; } [data-testid="sidebar"][data-classroom="true"] [data-testid="classroom-sidebar-navigation"] { align-items: center; } [data-testid="sidebar"][data-classroom="true"] .classroom-brand { height: 46px; gap: 10px; padding: 0 2px; } -[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 48px; margin-top: 10px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; box-shadow: none; color: #26384d; font-size: 14px; font-weight: 600; line-height: 20px; white-space: normal; } -[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; } +[data-testid="sidebar"][data-classroom="true"] .classroom-community-footer { flex-shrink: 0; padding: 0 12px 27px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-community-footer .community-link[data-variant="sidebar"] { min-height: 48px; margin-top: 0; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; box-shadow: none; color: #26384d; font-size: 14px; font-weight: 600; line-height: 20px; white-space: normal; } +[data-testid="sidebar"][data-classroom="true"] .classroom-community-footer .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; } [data-testid="sidebar"][data-classroom="true"] .community-link-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; } [data-testid="sidebar"][data-classroom="true"] .community-link-icon svg { width: 20px; height: 20px; } [data-testid="sidebar"][data-classroom="true"] .community-link-arrow { margin-left: auto; color: #64758a; } From bf300031402543c89143360676a4fe1fcaef1644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 10:40:03 +0800 Subject: [PATCH 02/17] docs: preserve pending native update handoff --- .../tasks/20261014-code-gallery-footer-7c91a2e4.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md index 599c07d5..9e73c789 100644 --- a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md +++ b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md @@ -24,16 +24,18 @@ ## Outcome - Implemented the existing CommunityLink in a Code-only fixed flex footer sibling, outside project scrolling; retained its original icon/text, horizontal inset, pending/error/open behavior. Removed the former header instance so New Project/My Projects naturally shift upward. Central welcome gallery is unchanged. README now describes the new placement. -- Current runtime remains untouched while native UI is busy or unsaved state is unresolved; immutable overlay preparation and final native verification are pending. +- Product/source8cd7b5979256c4b6695f2629e6f7bdfcd113d1e7 has been frozen, built, and prepared as an immutable Renderer-only overlay at /Users/chillishark/Makelore 麦洛/.trial-runtime/code-gallery-footer-20261014-7c91a2e4/runtime. prepare.py and --verify passed with state prepared; live compatibility link and pointer remain unchanged. Main25170/source4eb3efa and inherited thirteen feature metadata/provenance are retained. +- Native update is pending saved/idle state. Initial real trial AX showed Agents Preparing/Stop; fresh full AX later showed project-config with an editable existing prompt, so root did not navigate away, save unknown values or reload. An asynchronous question asks the user to return to idle Code. Current-account alice was observed; final same-account continuity has not yet been verified. ## Verification - Pinned corepack pnpm10.33.4 typecheck and scoped Sidebar ESLint passed;19 focused sidebar project/actions, main-layout peek, and community unit cases passed. Full default env -u NIANCODE_RENDERER_ONLY corepack pnpm run build:vite passed (actual log /tmp/makelore-footer-build-7c91a2e4.log). +- Independent read-only source/layout review passed: horizontal inset remains12px, New Project/My Projects move up58px, normal gallery-to-account gap is38px, and shrink-0 footer preserves the scrolling project area. Existing update notification row can increase spacing to the account when present; no updater behavior was altered. - git diff --check and owned-document drift check passed. Exactly Sidebar.tsx and classroom-workspace.css differ under src from current runtime4eb3efa; no Main/shared/resources/package/lock/build-config delta. Final layout and native update proof pending. ## Follow-ups -- None recorded. +- After the temporary app is idle on Code with no unsaved input/configuration/dialog, obtain fresh native guard, activate prepared overlay, native View → Reload in the same window, inspect actual footer placement/central gallery/project sidebar, save AX/screenshot and finalize. Do not mark updated before actual proof. Retain worktree and current full Canvas predecessor. ## Promotion Candidates From 14fcea389dcd0c9ee62899555f28e6254d5fd368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 10:51:31 +0800 Subject: [PATCH 03/17] ui: lower the Code gallery footer by another centimetre --- .../tasks/20261014-code-gallery-footer-7c91a2e4.md | 5 +++-- README.md | 4 ++-- src/pages/Chat/classroom-workspace.css | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md index 9e73c789..688cbef4 100644 --- a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md +++ b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md @@ -12,7 +12,7 @@ ## Scope -- Move only the Code sidebar Works Square entry to a fixed footer slot above the account, preserving horizontal alignment and about38px button-to-account spacing. New Project and My Projects naturally shift into the former upper gallery space. Preserve the central welcome link and existing visual/data behavior. +- Move only the Code sidebar Works Square entry to a fixed footer slot above the account, preserving horizontal alignment; the user subsequently requested another1cm (about38px) downward shift from the initial footer placement. New Project and My Projects naturally shift into the former upper gallery space. Preserve the central welcome link and existing visual/data behavior. ## Intent And Constraints @@ -35,7 +35,8 @@ ## Follow-ups -- After the temporary app is idle on Code with no unsaved input/configuration/dialog, obtain fresh native guard, activate prepared overlay, native View → Reload in the same window, inspect actual footer placement/central gallery/project sidebar, save AX/screenshot and finalize. Do not mark updated before actual proof. Retain worktree and current full Canvas predecessor. +- User confirmed idle Code; root saved a fresh actual native guard, activated the immutable overlay and performed same-window View → Reload. Native AX confirmed gallery below the long project list and before Alice, with actual projects, models and teacher avatars retained. A transient runtime preparation message cleared without Retry/model requests. DevTools was opened only for read-only loading diagnostics and closed. Final clean Code screenshot/metadata finalization remained pending while UI navigation changed. +- User then explicitly clarified that the LEFT BOTTOM entry should move another1cm downward. Source follow-up removes27px gallery bottom space plus the adjacent account footer11px top inset/border, lowering only that entry38px while keeping Alice and upper projects anchored. New build/immutable follow-up/native proof required; never edit the frozen first stage or falsely mark it active-verified. Retain current full Canvas predecessor and worktree. ## Promotion Candidates diff --git a/README.md b/README.md index 68c5e940..9b57fc0a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用 Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏的「作品广场」固定在底部账户上方约 38px,保持与上方入口相同的横向对齐;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 + 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏的「作品广场」固定在底部、紧邻账户按钮上方,保持与上方入口相同的横向对齐;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、TOOL、SKILL、MCP 服务和知识库纵向呈现,资源选择按钮分别显示「选择TOOL」「选择SKILL」;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。 @@ -39,7 +39,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部账户上方约 38px;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index 85dca98a..b2d1bb5c 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -8,7 +8,8 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header { padding-top: 0; padding-bottom: 0; } [data-testid="sidebar"][data-classroom="true"] [data-testid="classroom-sidebar-navigation"] { align-items: center; } [data-testid="sidebar"][data-classroom="true"] .classroom-brand { height: 46px; gap: 10px; padding: 0 2px; } -[data-testid="sidebar"][data-classroom="true"] .classroom-community-footer { flex-shrink: 0; padding: 0 12px 27px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-community-footer { flex-shrink: 0; padding: 0 12px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-community-footer + .classroom-account-footer { padding-top: 0; border-top: 0; } [data-testid="sidebar"][data-classroom="true"] .classroom-community-footer .community-link[data-variant="sidebar"] { min-height: 48px; margin-top: 0; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; box-shadow: none; color: #26384d; font-size: 14px; font-weight: 600; line-height: 20px; white-space: normal; } [data-testid="sidebar"][data-classroom="true"] .classroom-community-footer .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; } [data-testid="sidebar"][data-classroom="true"] .community-link-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; } From 805cf47ff86b591c271d373ffda2f6f9fc9e210f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:00:34 +0800 Subject: [PATCH 04/17] docs: record verified footer baseline and CSS follow-up --- .../30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md index 688cbef4..8cd71c83 100644 --- a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md +++ b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md @@ -36,7 +36,9 @@ ## Follow-ups - User confirmed idle Code; root saved a fresh actual native guard, activated the immutable overlay and performed same-window View → Reload. Native AX confirmed gallery below the long project list and before Alice, with actual projects, models and teacher avatars retained. A transient runtime preparation message cleared without Retry/model requests. DevTools was opened only for read-only loading diagnostics and closed. Final clean Code screenshot/metadata finalization remained pending while UI navigation changed. -- User then explicitly clarified that the LEFT BOTTOM entry should move another1cm downward. Source follow-up removes27px gallery bottom space plus the adjacent account footer11px top inset/border, lowering only that entry38px while keeping Alice and upper projects anchored. New build/immutable follow-up/native proof required; never edit the frozen first stage or falsely mark it active-verified. Retain current full Canvas predecessor and worktree. +- V1 first placement is now honestly active-verified: root saved actual Code screenshot/AX (native-code-v1-proof files), inspected the footer above Alice and real project list, finalized the receipt and reverified Main25170 and immutable bytes. +- User then explicitly clarified that the LEFT BOTTOM entry should move another1cm downward. Source14fcea389dcd0c9ee62899555f28e6254d5fd368 removes27px gallery bottom space plus adjacent account footer11px top inset/border, lowering only the entry38px while keeping Alice and upper projects anchored. Its full default build passed and independent layout review found no overlap. Root proved the new compiled route CSS equals exactly those two requested rule edits and has identical URL references. +- Computer-use observations continued returning project configuration, active reply and later a WeChat binding modal despite the user confirming idle Code. Root stopped further GUI actions rather than use that inconsistent state. V2 will use an immutable CSS-only overlay: retain V1 HTML/JS/all other artifacts byte-for-byte, replace only assets/index-BpmN0xyW.css with exact new-build assets/index-RzIMVSVW.css contents. A file-only switch preserves the already loaded document; manual user refresh and explicit user visual confirmation will be recorded honestly instead of fabricating native proof. V2 activation/confirmation is pending; frozen V1 remains intact. ## Promotion Candidates From 8e81cb93357d2facc19a105be8eb93af2ec3b434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:05:26 +0800 Subject: [PATCH 05/17] docs: record CSS-only update awaiting user refresh --- .../20261014-code-gallery-footer-7c91a2e4.md | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md index 8cd71c83..5e06a8b1 100644 --- a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md +++ b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md @@ -19,26 +19,28 @@ - User explicitly requested this sidebar positioning refinement and continuing trial update. Preserve current full-runtime Canvas/Agent changes, existing palette, handlers, real data, Main, profile and unsent input. No cloud mutations or model requests. - Concurrent Task Gate Passed: official check/start/status match task20261014-code-gallery-footer-7c91a2e4, feature/planning, owned worktree/branch and current runtime base4eb3efa3d1b4700a89fac4e7d1124c7a53c9ae90. Prior task was returned to ready without edits. Independent read-only review read all58 peer records/174 Scope, Intent And Constraints and Promotion sections; no concrete conflict. Old child-language placeholder scope remains unknown and unrelated. - Project Context Loaded / Planning Gate Passed: read startup memory-index, own record, positioning, integrated current-state snapshot, decision index, system overview, community collaboration, domain rules, success criteria, evidence/reflection/commitment indexes and repository development guidance. Product is a single light creative client; Main owns auth/project/shell access. Community remains an external existing URL, no course/gallery API additions. Integrated memory is older than the authoritative current trial source. UI implementation only Sidebar.tsx/classroom-workspace.css; README and owned record follow the localized behavior. -- Plan: move the existing gallery component outside the scrolling project area into a shrink-0 footer sibling, retain12px horizontal padding;27px bottom padding plus existing11px account border/top padding provides38px gap. No absolute positioning. Verify focused sidebar/layout behavior, typecheck/lint and full default build, then prepare an immutable Renderer-only overlay preserving current Main25170 and all inherited feature metadata. Fresh native saved/idle guard and actual Code screenshot are required before activation/finalization. +- Implementation uses normal flex flow with a shrink-0 gallery slot outside project scrolling, retaining12px horizontal padding. Initial38px account gap was subsequently removed at the user’s explicit request to lower the entry another1cm. Final CSS removes27px gallery padding and11px account top border/inset; Alice and upper project positions stay anchored. No absolute positioning or overlapping targets. +- Initial full Renderer update required fresh saved/idle native guard and actual native evidence. The CSS-only follow-up uses exact whole-artifact byte proof for a file-only switch with zero GUI effects; completion requires real user manual-refresh and visual confirmation. It does not claim an unobserved native clean state. ## Outcome -- Implemented the existing CommunityLink in a Code-only fixed flex footer sibling, outside project scrolling; retained its original icon/text, horizontal inset, pending/error/open behavior. Removed the former header instance so New Project/My Projects naturally shift upward. Central welcome gallery is unchanged. README now describes the new placement. -- Product/source8cd7b5979256c4b6695f2629e6f7bdfcd113d1e7 has been frozen, built, and prepared as an immutable Renderer-only overlay at /Users/chillishark/Makelore 麦洛/.trial-runtime/code-gallery-footer-20261014-7c91a2e4/runtime. prepare.py and --verify passed with state prepared; live compatibility link and pointer remain unchanged. Main25170/source4eb3efa and inherited thirteen feature metadata/provenance are retained. -- Native update is pending saved/idle state. Initial real trial AX showed Agents Preparing/Stop; fresh full AX later showed project-config with an editable existing prompt, so root did not navigate away, save unknown values or reload. An asynchronous question asks the user to return to idle Code. Current-account alice was observed; final same-account continuity has not yet been verified. +- Existing CommunityLink now occupies a Code-only fixed flex footer slot. New Project/My Projects moved up58px into the previous upper gallery space. Icon/text,12px horizontal inset, existing open/pending/error behavior, central welcome entry, palette, real projects and handlers are retained. README describes the final placement. +- Initial source8cd7b5979256c4b6695f2629e6f7bdfcd113d1e7 was default-built and updated through root native View → Reload. Actual native Code AX/screenshot in .trial-runtime/code-gallery-footer-20261014-7c91a2e4/evidence/native-code-v1-proof.* verified the first footer placement, real projects and Alice; finalize/verify passed active-verified. Main25170/source4eb3efa and inherited features remained intact. The transient preparation message cleared without Retry or model calls; diagnostics were closed. +- Final source14fcea389dcd0c9ee62899555f28e6254d5fd368 lowers the LEFT BOTTOM gallery another38px, as explicitly clarified by the user. Alice remains bottom anchored and upper project positions do not change further. V2 stage is .trial-runtime/code-gallery-footer-20261014-7c91a2e4-v2. Its immutable artifact changes only dist/assets/index-BpmN0xyW.css to the exact default-built index-RzIMVSVW.css contents; all HTML/JS/Main/other resource bytes remain V1-identical. +- V2 preparation, pure file activation and --verify passed; compatibility link now selects V2, while current.json remains V1 pending actual refresh/confirmation. No live document reload, input discard, cloud mutation, profile/credential read or Main restart occurred in this step. Original fourteen feature metadata and recursive predecessor proofs are retained. Final user manual-refresh/visual confirmation is pending. +- Native tooling returned inconsistent pages (project configuration, active teacher reply and later a WeChat binding modal) despite user-confirmed idle Code. Root stopped GUI actions and used the verified CSS-only file switch. User has been asked once after file activation to press Cmd+R in Code and confirm the lower gallery and unchanged Alice position; no human answer or native observation will be fabricated. ## Verification -- Pinned corepack pnpm10.33.4 typecheck and scoped Sidebar ESLint passed;19 focused sidebar project/actions, main-layout peek, and community unit cases passed. Full default env -u NIANCODE_RENDERER_ONLY corepack pnpm run build:vite passed (actual log /tmp/makelore-footer-build-7c91a2e4.log). -- Independent read-only source/layout review passed: horizontal inset remains12px, New Project/My Projects move up58px, normal gallery-to-account gap is38px, and shrink-0 footer preserves the scrolling project area. Existing update notification row can increase spacing to the account when present; no updater behavior was altered. -- git diff --check and owned-document drift check passed. Exactly Sidebar.tsx and classroom-workspace.css differ under src from current runtime4eb3efa; no Main/shared/resources/package/lock/build-config delta. Final layout and native update proof pending. +- Pinned corepack pnpm10.33.4 typecheck, scoped Sidebar ESLint and19 focused sidebar/actions/layout-peek/community unit cases passed. Initial and final default full build:vite (NIANCODE_RENDERER_ONLY unset) passed; actual final log is retained in V2 evidence/default-build.log. +- Independent reviews confirmed the initial58px upward project move and final38px downward gallery move, unchanged Alice anchor, responsive scrolling and no overlap. Existing updater row behavior is retained. +- Root and immutable helper checks prove the new route CSS equals exactly the two requested rule changes; URL/import references are unchanged. V2 evidence/artifact-transition-proof.json records the sole changed compiled CSS and preserved HTML/JS/Main/other bytes. No affected-feature tests are invented or represented as native observations. +- Source product delta from task base is exactly Sidebar.tsx/classroom-workspace.css; final follow-up delta from V1 is only classroom-workspace.css. Main/shared/resources/package/lock/build configuration are unchanged. Diff and owned-document drift checks passed. Final V2 visual proof is pending user confirmation. ## Follow-ups -- User confirmed idle Code; root saved a fresh actual native guard, activated the immutable overlay and performed same-window View → Reload. Native AX confirmed gallery below the long project list and before Alice, with actual projects, models and teacher avatars retained. A transient runtime preparation message cleared without Retry/model requests. DevTools was opened only for read-only loading diagnostics and closed. Final clean Code screenshot/metadata finalization remained pending while UI navigation changed. -- V1 first placement is now honestly active-verified: root saved actual Code screenshot/AX (native-code-v1-proof files), inspected the footer above Alice and real project list, finalized the receipt and reverified Main25170 and immutable bytes. -- User then explicitly clarified that the LEFT BOTTOM entry should move another1cm downward. Source14fcea389dcd0c9ee62899555f28e6254d5fd368 removes27px gallery bottom space plus adjacent account footer11px top inset/border, lowering only the entry38px while keeping Alice and upper projects anchored. Its full default build passed and independent layout review found no overlap. Root proved the new compiled route CSS equals exactly those two requested rule edits and has identical URL references. -- Computer-use observations continued returning project configuration, active reply and later a WeChat binding modal despite the user confirming idle Code. Root stopped further GUI actions rather than use that inconsistent state. V2 will use an immutable CSS-only overlay: retain V1 HTML/JS/all other artifacts byte-for-byte, replace only assets/index-BpmN0xyW.css with exact new-build assets/index-RzIMVSVW.css contents. A file-only switch preserves the already loaded document; manual user refresh and explicit user visual confirmation will be recorded honestly instead of fabricating native proof. V2 activation/confirmation is pending; frozen V1 remains intact. +- After the user actually confirms manual refresh, lower gallery placement and unchanged Alice, save their exact trusted question/answer/provenance in V2 evidence; finalize.py accepts source:user-message-confirmation, ui_refresh_performed_by:user, user_confirmed_refresh/visual_result:true, user_confirmation:{question,answer,received_at}, fresh inspected_at and refresh_evidence. New footer evidence is human-confirmed; retained gallery/project evidence uses inherited-byte-identity with predecessor8cd7b59 and artifact-transition-proof.json. Never label it root-native proof. +- Run V2 finalize.py with the saved receipt, prepare.py --verify, update this record Ready for Integration, drift check/commit and official task_context complete. Retain worktree/branch and reversible V1/full Canvas predecessors. No merge/push requested. ## Promotion Candidates From b7f50be87bac49bb321c28264279c7a9c379aee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:07:10 +0800 Subject: [PATCH 06/17] docs: confirm final footer placement and trial update --- .../tasks/20261014-code-gallery-footer-7c91a2e4.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md index 5e06a8b1..cb6a38a8 100644 --- a/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md +++ b/.project-docs/30-worklog/tasks/20261014-code-gallery-footer-7c91a2e4.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-code-gallery-footer-7c91a2e4 - Base commit: 4eb3efa3d1b4700a89fac4e7d1124c7a53c9ae90 - Owner: codex -- Status: In Progress +- Status: Ready for Integration ## Scope @@ -27,20 +27,19 @@ - Existing CommunityLink now occupies a Code-only fixed flex footer slot. New Project/My Projects moved up58px into the previous upper gallery space. Icon/text,12px horizontal inset, existing open/pending/error behavior, central welcome entry, palette, real projects and handlers are retained. README describes the final placement. - Initial source8cd7b5979256c4b6695f2629e6f7bdfcd113d1e7 was default-built and updated through root native View → Reload. Actual native Code AX/screenshot in .trial-runtime/code-gallery-footer-20261014-7c91a2e4/evidence/native-code-v1-proof.* verified the first footer placement, real projects and Alice; finalize/verify passed active-verified. Main25170/source4eb3efa and inherited features remained intact. The transient preparation message cleared without Retry or model calls; diagnostics were closed. - Final source14fcea389dcd0c9ee62899555f28e6254d5fd368 lowers the LEFT BOTTOM gallery another38px, as explicitly clarified by the user. Alice remains bottom anchored and upper project positions do not change further. V2 stage is .trial-runtime/code-gallery-footer-20261014-7c91a2e4-v2. Its immutable artifact changes only dist/assets/index-BpmN0xyW.css to the exact default-built index-RzIMVSVW.css contents; all HTML/JS/Main/other resource bytes remain V1-identical. -- V2 preparation, pure file activation and --verify passed; compatibility link now selects V2, while current.json remains V1 pending actual refresh/confirmation. No live document reload, input discard, cloud mutation, profile/credential read or Main restart occurred in this step. Original fourteen feature metadata and recursive predecessor proofs are retained. Final user manual-refresh/visual confirmation is pending. -- Native tooling returned inconsistent pages (project configuration, active teacher reply and later a WeChat binding modal) despite user-confirmed idle Code. Root stopped GUI actions and used the verified CSS-only file switch. User has been asked once after file activation to press Cmd+R in Code and confirm the lower gallery and unchanged Alice position; no human answer or native observation will be fabricated. +- V2 preparation and pure file activation passed without reloading the live document or discarding input. The user then explicitly confirmed manual Code refresh, correct lower placement and unchanged Alice position. Root independently captured and inspected the refreshed actual Code AX/screenshot: gallery is directly above Alice; upper New Project/My Projects and real project/conversation data remain in place. V2 finalize and --verify passed active-verified; current.json now identifies CSS source14fcea, retained JS source8cd7b59, Main25170/source4eb3efa and all fourteen inherited feature IDs. No cloud mutation, profile/credential read or Main restart occurred. +- Native tooling returned inconsistent pages (project configuration, active teacher reply and later a WeChat binding modal) despite user-confirmed idle Code. Root stopped GUI actions and used the verified CSS-only file switch. After file activation, the user manually refreshed and confirmed the result. Their exact trusted reply and the later consistent native AX/screenshot are preserved as separate evidence sources; earlier inconsistent reads were not treated as visual proof. ## Verification - Pinned corepack pnpm10.33.4 typecheck, scoped Sidebar ESLint and19 focused sidebar/actions/layout-peek/community unit cases passed. Initial and final default full build:vite (NIANCODE_RENDERER_ONLY unset) passed; actual final log is retained in V2 evidence/default-build.log. - Independent reviews confirmed the initial58px upward project move and final38px downward gallery move, unchanged Alice anchor, responsive scrolling and no overlap. Existing updater row behavior is retained. - Root and immutable helper checks prove the new route CSS equals exactly the two requested rule changes; URL/import references are unchanged. V2 evidence/artifact-transition-proof.json records the sole changed compiled CSS and preserved HTML/JS/Main/other bytes. No affected-feature tests are invented or represented as native observations. -- Source product delta from task base is exactly Sidebar.tsx/classroom-workspace.css; final follow-up delta from V1 is only classroom-workspace.css. Main/shared/resources/package/lock/build configuration are unchanged. Diff and owned-document drift checks passed. Final V2 visual proof is pending user confirmation. +- Source product delta from task base is exactly Sidebar.tsx/classroom-workspace.css; final follow-up delta from V1 is only classroom-workspace.css. Main/shared/resources/package/lock/build configuration are unchanged. Diff and owned-document drift checks passed. Final V2 visual proof passed: evidence/user-refresh-confirmation.json preserves the real manual-refresh/visual confirmation; evidence/native-code-final.ax.txt and evidence/native-code-final.png preserve root’s actual independent inspection. evidence/root-verification-receipt.json separates user confirmation from inherited byte-identity proof. Metadata finalization and active-verified checks passed. ## Follow-ups -- After the user actually confirms manual refresh, lower gallery placement and unchanged Alice, save their exact trusted question/answer/provenance in V2 evidence; finalize.py accepts source:user-message-confirmation, ui_refresh_performed_by:user, user_confirmed_refresh/visual_result:true, user_confirmation:{question,answer,received_at}, fresh inspected_at and refresh_evidence. New footer evidence is human-confirmed; retained gallery/project evidence uses inherited-byte-identity with predecessor8cd7b59 and artifact-transition-proof.json. Never label it root-native proof. -- Run V2 finalize.py with the saved receipt, prepare.py --verify, update this record Ready for Integration, drift check/commit and official task_context complete. Retain worktree/branch and reversible V1/full Canvas predecessors. No merge/push requested. +- Requested layout changes and temporary-app update are complete, including the user’s additional1cm downward correction. Retain the owned worktree/branch and immutable V1/V2/full Canvas rollback evidence. No merge/push or cleanup requested; no required implementation work remains. ## Promotion Candidates From 1c39471156e4230d8efe110fd8695176cf80ae3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:14:43 +0800 Subject: [PATCH 07/17] Expand left sidebar on home module entry --- .../20261014-home-sidebar-default-31a87f9c.md | 41 +++++++++++++++++ README.md | 2 +- src/pages/ModuleSelection/index.tsx | 3 ++ tests/unit/module-navigation.test.tsx | 44 ++++++++++++++++++- 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md diff --git a/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md b/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md new file mode 100644 index 00000000..03959c6c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md @@ -0,0 +1,41 @@ +# Task: Expand module sidebars when entering from home + +## Identity + +- Task ID: 20261014-home-sidebar-default-31a87f9c +- Mode: Feature +- Branch: codex/20261014-home-sidebar-default-31a87f9c-home-sidebar-default +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-home-sidebar-default-31a87f9c +- Base commit: b7f50be87bac49bb321c28264279c7a9c379aee4 +- Owner: codex +- Status: In Progress + +## Scope + +- Expand the shared left sidebar before an authorized homepage module-card navigation for Code, Agents, and Canvas. Cover prior-collapse/re-entry and preserve manual toggling. + +## Intent And Constraints + +- Scope is homepage entry behavior only; retain module access/auth guards, Agent view=mine entry, manual collapse, and accepted gallery placement. No new API or content values. Preserve current combined trial Main and inherited feature bytes during Renderer update. + +## Outcome + +- Implemented guarded homepage-entry expansion through the existing shared sidebar setter; already-expanded entries avoid redundant preference writes. Agent entry remains `?view=mine`; auth/module policy guards remain before changes. Manual toggling and Canvas desktop/compact rules are unchanged. +- README current behavior updated. Source review found no actionable issue. +- Combined-trial Renderer update and focused Electron verification pending. + +## Verification + +- Planning gate passed after official task identity/status and all 63 peer records (189 sections) were checked read-only; no blocking semantic conflict. +- Typecheck and targeted ESLint passed. +- 37 focused tests passed across module-navigation, cloud-agent-entry-navigation, main-layout-sidebar-peek, and image-workspace-sidebar. +- Default full `build:vite` passed with Renderer-only mode unset; existing Browserslist/chunk warnings only. +- Focused Electron and actual trial checks pending. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 9b57fc0a..d7feb64d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。每次从首页进入 Code、Agents 或 Canvas 时,默认展开左侧工作栏;进入后沿用各模块现有的手动收起与紧凑布局规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏的「作品广场」固定在底部、紧邻账户按钮上方,保持与上方入口相同的横向对齐;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 1efc7209..6ac9fd46 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -12,6 +12,7 @@ import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules' import { getAuthUserDisplayName } from '@/lib/auth-user-display'; import { cn } from '@/lib/utils'; import { useAuthStore } from '@/stores/auth'; +import { useSettingsStore } from '@/stores/settings'; import { getUserProfileDisplayName } from '@/stores/user-profile'; const moduleSelectionContent: Record = { @@ -23,6 +24,7 @@ const moduleSelectionContent: Record state.setSidebarCollapsed); const authUser = useAuthStore((state) => state.user); const moduleAccess = useAuthStore((state) => state.moduleAccess); const authenticated = useAuthStore((state) => state.isAuthenticated()); @@ -46,6 +48,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea navigate('/login'); return; } + if (useSettingsStore.getState().sidebarCollapsed) setSidebarCollapsed(false); navigate(route === '/cloud-agents' ? `${route}?view=mine` : route); }; diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index 3128e9b2..433ca5a4 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -1,11 +1,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { ModuleSwitcher } from '@/components/layout/ModuleSwitcher'; import { ModuleSelection } from '@/pages/ModuleSelection'; import { useAuthStore } from '@/stores/auth'; import { codingWorkspaceStore } from '@/stores/coding-workspace'; import { useUserProfileStore } from '@/stores/user-profile'; +import { useSettingsStore } from '@/stores/settings'; const { hostApiFetchMock } = vi.hoisted(() => ({ hostApiFetchMock: vi.fn(), @@ -20,9 +21,22 @@ function LocationProbe() { return {location.pathname}; } +function SidebarEntryProbe() { + const collapsed = useSettingsStore((state) => state.sidebarCollapsed); + const setCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); + const navigate = useNavigate(); + return <> + + {collapsed ? 'collapsed' : 'expanded'} + + + ; +} + describe('AI module navigation', () => { beforeEach(() => { vi.clearAllMocks(); + useSettingsStore.setState({ sidebarCollapsed: true }); useAuthStore.setState({ initialized: true, loading: false, @@ -52,6 +66,32 @@ describe('AI module navigation', () => { }); }); + it.each([ + ['programming', '/chat'], + ['cloud_agents', '/cloud-agents'], + ['painting', '/image-canvas'], + ])('expands %s on every home entry while allowing manual collapse', async (moduleId, route) => { + render( + + + } /> + } /> + + , + ); + + fireEvent.click(screen.getByTestId(`ai-module-option-${moduleId}`)); + expect(await screen.findByTestId('location-path')).toHaveTextContent(route); + expect(screen.getByTestId('sidebar-state')).toHaveTextContent('expanded'); + fireEvent.click(screen.getByRole('button', { name: 'Toggle sidebar' })); + expect(screen.getByTestId('sidebar-state')).toHaveTextContent('collapsed'); + + fireEvent.click(screen.getByRole('button', { name: 'Home', exact: true })); + expect(useSettingsStore.getState().sidebarCollapsed).toBe(true); + fireEvent.click(screen.getByTestId(`ai-module-option-${moduleId}`)); + expect(await screen.findByTestId('sidebar-state')).toHaveTextContent('expanded'); + }); + it('shows Agents, Code and Canvas on the home page and routes Canvas from the chooser', async () => { render( @@ -144,6 +184,7 @@ describe('AI module navigation', () => { fireEvent.click(canvasOption); expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.queryByTestId('location-path')).not.toBeInTheDocument(); + expect(useSettingsStore.getState().sidebarCollapsed).toBe(true); }); it('routes AI programming from the chooser to the project conversation entry', async () => { @@ -184,6 +225,7 @@ describe('AI module navigation', () => { fireEvent.click(screen.getByTestId('ai-module-option-programming')); expect(await screen.findByTestId('location-path')).toHaveTextContent('/login'); + expect(useSettingsStore.getState().sidebarCollapsed).toBe(true); }); it('returns to the module chooser from the Sidebar entry', async () => { From cca23df79c73b05459bbf8c447e44dd82771b023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:15:22 +0800 Subject: [PATCH 08/17] Cover expanded home module entries in Electron --- tests/e2e/main-navigation.spec.ts | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index bb181987..9fa24c7e 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -51,6 +51,96 @@ async function seedInactiveCodingProjects(resources: { rootDir: string; userData } test.describe('Makelore module navigation without setup flow', () => { + test('opens each home module with the sidebar expanded after a saved collapse', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await app.evaluate(({ ipcMain, BrowserWindow }, summary) => { + const window = BrowserWindow.getAllWindows()[0]; + window.setSize(1440, 900); + const session = () => ({ accessToken: 'home-sidebar-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false }); + const send = window.webContents.send.bind(window.webContents); + window.webContents.send = (channel: string, ...args: unknown[]) => send(channel, ...(channel === 'auth:session-changed' ? [session()] : args)); + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => { + const path = request.path ?? ''; + if (path === '/api/auth/session/sync') return result({ success: true, session: session() }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'sidebar-child', userId: 'sidebar-user', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (['/api/provider-accounts', '/api/provider-accounts/key-info', '/api/provider-vendors', '/api/providers'].includes(path)) return result([]); + if (path === '/api/provider-accounts/default') return result({ accountId: null }); + if (path === '/api/coding/projects') return result({ projects: [], activeProjectId: null }); + if (path === '/api/settings') return result({ classroomSidebarWidth: 171, sidebarCollapsed: true, setupComplete: true }); + if (path === '/api/user-sync/bootstrap') return result({ success: true, bootstrap: { preferences: {}, providerAccounts: [], partners: [], usageAggregates: [] } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-14T00:00:00Z' } }); + if (path === '/api/works/billing/me') return result({ success: true, data: summary }); + if (path === '/api/works/image-workspace') return result({ success: true, data: { + capabilities: { conversation: true, generation: false, image: false, video: false, generationOptions: { + image: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: false, supportedReferenceRoles: [] }, + video: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: true, supportedReferenceRoles: [] }, + } }, workspaces: [], + } }); + if (path.startsWith('/api/cloud-agents/')) { + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null }); + } + return result({ success: true }); + }); + }, billingSummary()); + await page.addInitScript(() => { + const now = Date.now(); + localStorage.setItem('niancode-settings', JSON.stringify({ version: 0, state: { classroomSidebarWidth: 171, sidebarCollapsed: true } })); + localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: { + authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'home-sidebar-fixture', tokenType: 'Bearer', + expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null, + user: { username: 'sidebar-child', userId: 'sidebar-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + } })); + localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: { + profilesByUserId: { 'sidebar-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null, + shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true, + pendingSync: false, version: 1, updatedAt: '2026-10-14T00:00:00Z' } }, + } })); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + const savedCollapsed = () => page.evaluate(() => JSON.parse(localStorage.getItem('niancode-settings')!).state.sidebarCollapsed as boolean); + const checkExpanded = async () => { + await expect(page.getByTestId('sidebar')).toBeVisible(); + await expect(page.getByTestId('sidebar')).toHaveAttribute('aria-hidden', 'false'); + await expect.poll(async () => (await page.getByTestId('sidebar').boundingBox())?.width ?? 0).toBeGreaterThan(150); + await expect.poll(savedCollapsed).toBe(false); + }; + const collapseAndReturnHome = async () => { + await page.getByTestId('titlebar-sidebar-toggle').click(); + await page.mouse.click(1000, 500); + await expect.poll(savedCollapsed).toBe(true); + await expect(page.getByTestId('sidebar')).toHaveAttribute('aria-hidden', 'true'); + await page.getByTestId('sidebar-module-switcher-trigger').click(); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await expect.poll(savedCollapsed).toBe(true); + }; + for (const [moduleId, route] of [['programming', /\/chat$/], ['cloud_agents', /\/cloud-agents\?view=mine$/]] as const) { + await expect.poll(savedCollapsed).toBe(true); + await page.getByTestId(`ai-module-option-${moduleId}`).click(); + await expect(page).toHaveURL(route); + await checkExpanded(); + await collapseAndReturnHome(); + await page.getByTestId(`ai-module-option-${moduleId}`).click(); + await expect(page).toHaveURL(route); + await checkExpanded(); + await collapseAndReturnHome(); + } + await page.getByTestId('ai-module-option-painting').click(); + await expect(page).toHaveURL(/\/image-canvas$/); + await checkExpanded(); + await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveCount(0); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } + }); + test('keeps the home account wallet, profile, settings and logout available without creating a payment', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); try { From d0451e9a9dc85288149376a55ecf2153ba3facb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:22:51 +0800 Subject: [PATCH 09/17] build(trial): preserve homepage sidebar while adding WeChat prompts Implementation: a837c4fd7e52fc1288f1f9a6737bd9775bfee56c Only product delta: src/pages/CloudAgents/ChannelAccounts.tsx --- src/pages/CloudAgents/ChannelAccounts.tsx | 46 ++++++++++++++++++----- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 70091acf..42480f58 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -65,9 +65,18 @@ const qrLabels: Record = { const blockerText: Record = { target_unavailable: '当前智能体暂时不能使用,可以更换一个智能体。', publish_required: '请先让智能体开始使用。', - worker_offline: '微信连接暂时不可用,请稍后刷新。', - channel_sync_degraded: '正在确认微信的使用状态。', + worker_offline: '智能体的微信服务暂时离线,暂时无法自动回复。', + channel_sync_degraded: '微信使用状态尚未同步,请刷新状态后再继续。', }; +function blockerMessage(blocker: string, account: CloudUserChannelAccount) { + const text = blockerText[blocker]; + return blocker === 'worker_offline' && + account.health === 'connected' && + account.target_agent_slug && + !account.blockers.includes('wechat_disconnect_pending') + ? text + '微信登录和智能体选择已保留,服务恢复后请刷新状态。' + : text; +} const unsettled = (account: CloudUserChannelAccount) => account.blockers.includes('wechat_disconnect_pending') || Boolean(account.sync_state && account.sync_state !== 'ready') || @@ -75,17 +84,28 @@ const unsettled = (account: CloudUserChannelAccount) => (['paused', 'disabled'].includes(account.desired_state ?? '') && account.enabled) || (account.desired_state === 'enabled' && !account.enabled); function accountState(account: CloudUserChannelAccount, uncertain: boolean, targetDisabled = false) { - if (uncertain || unsettled(account)) return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' }; + if (uncertain || unsettled(account)) return { text: '状态待确认', tone: 'bg-[#fff0d8] text-[#876033]' }; if (!account.target_agent_slug) return { text: '未绑定', tone: 'bg-[#f0edf8] text-[#75618e]' }; if (['login_required', 'verification_required'].includes(account.health)) return { text: '需要重新扫码', tone: 'bg-[#fff0d8] text-[#876033]' }; if (targetDisabled) return { text: '智能体已停用', tone: 'bg-[#fff0d8] text-[#876033]' }; + if (account.health === 'connected' && account.blockers.includes('worker_offline')) + return { text: '微信服务离线', tone: 'bg-[#fff0d8] text-[#876033]' }; if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) - return { text: '正在确认', tone: 'bg-[#fff0d8] text-[#876033]' }; + return { text: '状态待确认', tone: 'bg-[#fff0d8] text-[#876033]' }; return account.enabled ? { text: '使用中', tone: 'bg-[#e7f4ec] text-[#3f7458]' } : { text: '已暂停', tone: 'bg-[#f0edf8] text-[#75618e]' }; } +function bindingReadinessMessage(account: CloudUserChannelAccount) { + if (unsettled(account)) return '微信使用状态尚未确认,请刷新状态后再继续。'; + if (account.health !== 'connected') + return '微信登录状态尚未确认,请先完成微信登录,再检查连接。'; + const blocker = usableBlockers(account).find((item) => blockerText[item]); + return blocker + ? blockerMessage(blocker, account) + : '暂时无法完成微信绑定,请检查连接后再继续。'; +} async function loadAgents(): Promise { const items: CloudAgentDraft[] = []; let cursor: string | null = null; @@ -347,7 +367,7 @@ export function ChannelAccounts() { if (!account || account.target_agent_slug !== target) throw new Error('绑定的智能体已发生变化,请返回上一步确认。'); if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) - throw new Error('微信还没有准备好,请稍后重试完成绑定。'); + throw new Error(bindingReadinessMessage(account)); if (ownerPending.current) throw new Error('微信退出还在确认中,请先处理原来的操作。'); if (agentsRef.current.some((agent) => agent.slug === target && agent.enabled === false)) throw new Error('当前智能体已停用,请更换智能体后再使用。'); @@ -361,7 +381,7 @@ export function ChannelAccounts() { account = await verifyFinal(id, { target, enabled: true }); } if (account.health !== 'connected' || unsettled(account) || usableBlockers(account).length) - throw new Error('微信还没有准备好,请稍后重试完成绑定。'); + throw new Error(bindingReadinessMessage(account)); intents.current.delete('enable'); return account; }); @@ -785,7 +805,7 @@ export function ChannelAccounts() { .filter((item) => blockerText[item]) .map((item) => (

- {blockerText[item]} + {blockerMessage(item, account)}

))}
@@ -990,10 +1010,16 @@ export function ChannelAccounts() { {qr?.status === 'confirmed' || selected?.health === 'connected' ? (
-

{busy ? '正在完成微信绑定…' : '微信登录已确认'}

+

+ {busy + ? '正在检查连接并完成绑定…' + : selected?.health === 'connected' + ? '微信登录已确认' + : '扫码已确认'} +

{connectionFailed - ? '还没有完成绑定,请查看上方提示。' + ? '绑定尚未完成,请按上方提示处理,再检查连接。' : '正在确认智能体和自动回复状态。'}

@@ -1052,7 +1078,7 @@ export function ChannelAccounts() { disabled={disabled || checkingIds.includes(flow.accountId)} onClick={() => void finishBinding(flow.accountId, flow.target)} > - 重试完成绑定 + 检查连接并完成绑定 )} {checkingIds.includes(flow.accountId) && ( From 5e3afa841a86b005a6f19462e1634d4bf92cd881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:23:48 +0800 Subject: [PATCH 10/17] Record home sidebar entry verification --- .../tasks/20261014-home-sidebar-default-31a87f9c.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md b/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md index 03959c6c..a4b4860a 100644 --- a/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md +++ b/.project-docs/30-worklog/tasks/20261014-home-sidebar-default-31a87f9c.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-home-sidebar-default-31a87f9c - Base commit: b7f50be87bac49bb321c28264279c7a9c379aee4 - Owner: codex -- Status: In Progress +- Status: Ready for Integration ## Scope @@ -22,7 +22,11 @@ - Implemented guarded homepage-entry expansion through the existing shared sidebar setter; already-expanded entries avoid redundant preference writes. Agent entry remains `?view=mine`; auth/module policy guards remain before changes. Manual toggling and Canvas desktop/compact rules are unchanged. - README current behavior updated. Source review found no actionable issue. -- Combined-trial Renderer update and focused Electron verification pending. +- Product commit `1c39471156e4230d8efe110fd8695176cf80ae3a`; Electron coverage commit `cca23df79c73b05459bbf8c447e44dd82771b023`. +- Immutable Renderer snapshot prepared in `.trial-runtime/home-sidebar-default-20261014-31a87f9c`; verifies all 14 inherited features and byte-identical accepted footer CSS, Main source `4eb3efa` / PID 25170 unchanged. +- After the user confirmed an idle homepage, captured the fresh guard, activated the immutable Renderer files and reloaded the same native window through View > Reload. Native DevTools showed the exact new compiled entry `index-StjdrODv.js`; closed DevTools. Final native homepage AX/screenshot retained Alice; Code AX after reload retained sidebar width 256 and expected project/gallery/account elements. +- Finalize and verify passed `active-verified`; current pointer now Renderer `cca23df79c73b05459bbf8c447e44dd82771b023`, same Main `4eb3efa` / PID 25170, all 14 inherited features plus `home-sidebar-default`. No Main restart or business/config/channel writes. Requested layout preference synchronization uses the existing setter. +- Native automation exhibited inconsistent AX/paint/action timing, including an elementHasNoFrame result. Three-module behavior verification is explicitly from the passing isolated Electron fixture; no claim of three fresh native flows. No input discarded; final screen is the homepage. Earlier guard was invalidated when a user dialog was observed; activation waited for user confirmation and a fresh homepage guard. ## Verification @@ -30,7 +34,9 @@ - Typecheck and targeted ESLint passed. - 37 focused tests passed across module-navigation, cloud-agent-entry-navigation, main-layout-sidebar-peek, and image-workspace-sidebar. - Default full `build:vite` passed with Renderer-only mode unset; existing Browserslist/chunk warnings only. -- Focused Electron and actual trial checks pending. +- Focused Electron test `opens each home module with the sidebar expanded after a saved collapse`: 1 passed (3.2s), isolated fake auth/Main boundary, no actual user profile or cloud operation. Verified three home card entries, Code/Agents manual collapse and home re-entry, existing Canvas desktop behavior. +- ESLint across all three changed code/test files and diff whitespace check passed. +- Trial helper prepare, safe file activation, root native reload receipt, finalize, and final verification all passed. Evidence and operations are in `.trial-runtime/home-sidebar-default-20261014-31a87f9c`; `overlay-manifest.json` is active-verified. Artifact hashes retain Main/bootstrap and accepted footer stylesheet exactly; recursive inherited coverage is verified separately from the new focused fixture. ## Follow-ups From 6bb15aec1fafc7435f14957716127fa3c01f49e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:33:59 +0800 Subject: [PATCH 11/17] Share Code sidebar with conversation programming settings --- ...20261014-code-settings-sidebar-8f4b2c71.md | 40 +++++++++++++++++++ README.md | 2 +- src/components/layout/MainLayout.tsx | 7 +++- src/components/layout/Sidebar.tsx | 5 +-- src/components/layout/TitleBar.tsx | 38 ++++++++++-------- src/pages/Chat/classroom-workspace.css | 6 +-- src/pages/Chat/index.tsx | 1 - 7 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md diff --git a/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md b/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md new file mode 100644 index 00000000..52783b85 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md @@ -0,0 +1,40 @@ +# Task: Use Code sidebar throughout conversation programming settings + +## Identity + +- Task ID: 20261014-code-settings-sidebar-8f4b2c71 +- Mode: Feature +- Branch: codex/20261014-code-settings-sidebar-8f4b2c71-code-settings-sidebar +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-code-settings-sidebar-8f4b2c71 +- Base commit: 5e3afa841a86b005a6f19462e1634d4bf92cd881 +- Owner: codex +- Status: In Progress + +## Scope + +- Reuse the approved Code left rail for conversation programming settings (`/project-config?agent=…`) and its plugin resource route, including navigation/brand, shared resize/collapse, project rows, footer gallery and account placement. + +## Intent And Constraints + +- User explicitly supersedes the old config-specific header/gallery omission. Keep project configuration content, APIs, back/unsaved guards, session/project identities, current home-entry behavior and all trial inherited features unchanged. Renderer-only update preserves actual Main process/bytes. Load the Code skin from shared layout for direct route/reload. + +## Outcome + +- Implemented a shared Code sidebar route scope for `/chat`, `/project-config` and descendants. TitleBar uses a sidebar-specific layout flag so configuration title/project context/logo and main content padding retain their existing behavior. +- Removed configuration-only suppression of brand/header and gallery footer; project/session/account state and handlers are shared. Moved Code CSS loading to MainLayout so direct configuration entry and reload include the approved skin. Only two CSS scoping rules changed; footer rules and accepted placement remain exact. +- README behavior updated; focused Electron and trial update pending. + +## Verification + +- Official Concurrent/Planning Gates passed; 64 peer records/192 sections inspected read-only, no blocking semantic conflict. +- Typecheck, scoped ESLint, default full `build:vite` passed. +- 40 focused unit tests passed across layout peek, sidebar project actions, community, module navigation and Canvas layout. +- Focused Electron flow and native trial refresh pending. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index d7feb64d..0c10824c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;进入“会话编程设置”后的项目配置页及插件资源页复用相同的左侧工作栏,品牌、返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 6e34ad2e..cc3b718e 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -12,6 +12,7 @@ import { getAiModuleForPath } from '@/lib/ai-modules'; import { useSettingsStore } from '@/stores/settings'; import { cn } from '@/lib/utils'; import type { SidebarPeekSource } from './sidebar-peek'; +import '@/pages/Chat/classroom-workspace.css'; const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180; @@ -31,6 +32,7 @@ export function MainLayout() { const isPaintingModule = activeModule === 'painting'; const isCanvasWorkspace = location.pathname === '/image-canvas'; const isChatWorkspace = location.pathname === '/chat'; + const isCodeSidebar = isChatWorkspace || location.pathname === '/project-config' || location.pathname.startsWith('/project-config/'); const isCloudAgents = activeModule === 'cloud_agents'; const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => { @@ -80,13 +82,14 @@ export function MainLayout() { if (location.pathname === '/settings' || location.pathname.startsWith('/settings/')) return ; return ( -
+
{/* Title bar: drag region on macOS, icon + controls on Windows */} {workspaceLayout && !sidebarPinnedCollapsed && } - {!isProjectConfigPage && activeModule !== 'cloud_agents' &&
- {workspaceLayout && isProgrammingModule && !isProjectConfigPage && ( + {workspaceLayout && isProgrammingModule && (
diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 043df9b1..b9cf996a 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -24,6 +24,7 @@ type SidebarPeekChange = (open: boolean, source: SidebarPeekSource) => void; type TitleBarProps = { integrated?: boolean; workspaceLayout?: boolean; + workspaceSidebar?: boolean; overlay?: boolean; showSidebarControls?: boolean; pageTitle?: string; @@ -32,11 +33,11 @@ type TitleBarProps = { onSidebarPeekChange?: SidebarPeekChange; }; -export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, showSidebarControls = true, pageTitle, showModuleReturn = false, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) { +export function TitleBar({ integrated = false, workspaceLayout = false, workspaceSidebar = workspaceLayout, overlay = false, showSidebarControls = true, pageTitle, showModuleReturn = false, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) { const platform = window.electron?.platform; if (platform === 'darwin') { - return ; + return ; } // Linux keeps its native window frame. Agents still needs an application @@ -45,12 +46,13 @@ export function TitleBar({ integrated = false, workspaceLayout = false, overlay return showModuleReturn ? : null; } - return ; + return ; } function ProductTitleBar({ integrated, workspaceLayout = false, + workspaceSidebar = workspaceLayout, overlay = false, showSidebarControls = true, pageTitle, @@ -63,6 +65,7 @@ function ProductTitleBar({ }: { integrated: boolean; workspaceLayout?: boolean; + workspaceSidebar?: boolean; overlay?: boolean; showSidebarControls?: boolean; pageTitle?: string; @@ -93,10 +96,10 @@ function ProductTitleBar({ // The project sidebar preview is rendered below the title bar, so mirror // its full width in the title bar for ordinary pages. The chat workspace // keeps project conversations in the sidebar and must preserve its title rail. - const sidebarPreviewTitlebarVisible = sidebarCollapsed && sidebarPeekOpen && !workspaceLayout && !overlay; + const sidebarPreviewTitlebarVisible = sidebarCollapsed && sidebarPeekOpen && !workspaceSidebar && !overlay; const sidebarTitlebarHidden = sidebarCollapsed && !sidebarPreviewTitlebarVisible; const agentNavigationWidth = sidebarTitlebarHidden ? (nativeTrafficLights && !nativeFullscreen ? 168 : 88) : 256; - const navigationWidth = workspaceLayout ? classroomTitlebarWidth : showModuleReturn ? agentNavigationWidth : undefined; + const navigationWidth = workspaceSidebar ? classroomTitlebarWidth : showModuleReturn ? agentNavigationWidth : undefined; const sidebarToggle = ( @@ -123,18 +126,18 @@ function ProductTitleBar({ data-testid="titlebar-sidebar-toggle-layer" className={cn( 'no-drag pointer-events-auto fixed left-0 top-0 z-[400] h-10', - !workspaceLayout && !showModuleReturn && (nativeTrafficLights ? 'w-[132px]' : 'w-[52px]'), + !workspaceSidebar && !showModuleReturn && (nativeTrafficLights ? 'w-[132px]' : 'w-[52px]'), )} style={navigationWidth !== undefined ? { width: navigationWidth } : undefined} - onPointerEnter={workspaceLayout || showModuleReturn ? () => handleSidebarPeekEnter('titlebar') : undefined} - onPointerLeave={workspaceLayout || showModuleReturn ? () => handleSidebarPeekLeave('titlebar') : undefined} - onFocusCapture={workspaceLayout || showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined} - onBlurCapture={workspaceLayout || showModuleReturn ? (event) => { + onPointerEnter={workspaceSidebar || showModuleReturn ? () => handleSidebarPeekEnter('titlebar') : undefined} + onPointerLeave={workspaceSidebar || showModuleReturn ? () => handleSidebarPeekLeave('titlebar') : undefined} + onFocusCapture={workspaceSidebar || showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined} + onBlurCapture={workspaceSidebar || showModuleReturn ? (event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) handleSidebarPeekLeave('focus'); } : undefined} > - {workspaceLayout || showModuleReturn ?
@@ -184,7 +187,7 @@ function ProductTitleBar({ style={navigationWidth !== undefined ? { width: navigationWidth, minWidth: navigationWidth, flexBasis: navigationWidth } : undefined} className={cn( 'flex h-full shrink-0 items-center gap-1 px-2', - workspaceLayout + workspaceSidebar ? '' : sidebarTitlebarHidden ? nativeTrafficLights @@ -258,7 +261,7 @@ function ProductTitleBar({ ); } -function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarControls, pageTitle, showModuleReturn, sidebarPeekOpen, onSidebarPeekChange }: Omit & { integrated: boolean }) { +function WindowsTitleBar({ integrated, workspaceLayout, workspaceSidebar, overlay, showSidebarControls, pageTitle, showModuleReturn, sidebarPeekOpen, onSidebarPeekChange }: Omit & { integrated: boolean }) { const [maximized, setMaximized] = useState(false); useEffect(() => { @@ -288,6 +291,7 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarCont Date: Wed, 14 Oct 2026 11:37:11 +0800 Subject: [PATCH 12/17] Verify shared settings sidebar through conversation menus --- tests/e2e/main-navigation.spec.ts | 89 ++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 9fa24c7e..2ee0f717 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -376,7 +376,7 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('opens remembered Code projects and keeps the module return entry in chat instead of project settings', async ({ + test('opens remembered Code projects and keeps shared Code sidebar chrome in settings', async ({ electronTestResources, launchElectronApp, }) => { @@ -435,10 +435,11 @@ test.describe('Makelore module navigation without setup flow', () => { const communityBox = await community.boundingBox(); const createBox = await sidebar.getByTestId('sidebar-create-project').boundingBox(); const headingBox = await sidebar.locator('.classroom-projects-heading').boundingBox(); - expect(communityBox!.y).toBeGreaterThanOrEqual(brandBox!.y + brandBox!.height); - expect(communityBox!.y - brandBox!.y - brandBox!.height).toBe(10); - expect(createBox!.y - communityBox!.y - communityBox!.height).toBe(10); - expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(communityBox!.height + 20); + const accountBox = await sidebar.getByTestId('sidebar-member-menu-trigger').boundingBox(); + expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(10); + expect(communityBox!.x).toBe(createBox!.x); + expect(communityBox!.y).toBeGreaterThan(headingBox!.y + headingBox!.height); + expect(accountBox!.y - communityBox!.y - communityBox!.height).toBeCloseTo(0, 0); expect(communityBox!.height).toBe(createBox!.height); await expect(community).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); const buttonStyles = await page.evaluate(() => { @@ -484,14 +485,76 @@ test.describe('Makelore module navigation without setup flow', () => { await sidebarResizer.dblclick(); await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '171'); - await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click(); - await page.getByRole('menuitem', { name: '项目设置', exact: true }).click(); - await expect(page).toHaveURL(/\/project-config$/); + const readCodeChrome = () => page.evaluate(() => { + const selectors = { + sidebar: '[data-testid="sidebar"]', + titlebar: '[data-testid="titlebar-sidebar-surface"]', + navigation: '[data-testid="classroom-titlebar-navigation"]', + home: '[data-testid="sidebar-module-switcher-trigger"]', + toggle: '[data-testid="titlebar-sidebar-toggle"]', + brand: '[data-testid="sidebar"] .classroom-brand', + create: '[data-testid="sidebar-create-project"]', + heading: '[data-testid="sidebar"] .classroom-projects-heading', + community: '[data-testid="sidebar"] .community-link[data-variant="sidebar"]', + communityIcon: '[data-testid="sidebar"] .community-link-icon', + account: '[data-testid="sidebar-member-menu-trigger"]', + }; + return Object.fromEntries(Object.entries(selectors).map(([name, selector]) => { + const element = document.querySelector(selector); + if (!element) throw new Error('Code chrome element missing: ' + name); + const bounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const svg = element.querySelector('svg'); + return [name, { + x: Number(bounds.x.toFixed(1)), y: Number(bounds.y.toFixed(1)), + width: Number(bounds.width.toFixed(1)), height: Number(bounds.height.toFixed(1)), + // The titlebar surface is empty chrome; its inherited text color + // belongs to the route's content theme and paints no text or icons. + color: name === 'titlebar' ? null : style.color, + background: style.backgroundColor, border: style.borderTopColor, + radius: style.borderRadius, padding: style.padding, + iconWidth: svg ? Number(svg.getBoundingClientRect().width.toFixed(1)) : null, + iconStroke: svg?.getAttribute('stroke-width') ?? null, + }]; + })); + }); + // Create local metadata only; no prompt is sent and no model runs. + await sidebar.getByRole('button', { name: '新建对话', exact: true }).click(); + const conversationActions = sidebar.getByRole('button', { name: '对话操作:新对话', exact: true }).first(); + await expect(conversationActions).toBeAttached(); + await page.mouse.move(1000, 500); + const chatChrome = await readCodeChrome(); + await conversationActions.hover(); + await conversationActions.click(); + await page.getByRole('menuitem', { name: '会话编程设置', exact: true }).click(); + await expect(page).toHaveURL(/\/project-config\?agent=/); await expect(page.getByTestId('project-configuration-page')).toBeVisible(); - await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0); + await expect(moduleReturn).toBeVisible(); const projectBack = page.getByTestId('project-configuration-back-button'); await expect(projectBack).toBeVisible(); - await page.screenshot({ path: test.info().outputPath('project-config-without-module-return.png') }); + await page.mouse.move(1000, 500); + await expect.poll(readCodeChrome).toEqual(chatChrome); + await expect(sidebar).toHaveCSS('background-color', 'rgb(243, 247, 251)'); + const prompt = page.getByLabel('项目提示词', { exact: true }); + await expect(prompt).toBeVisible(); + const originalPrompt = await prompt.inputValue(); + await page.screenshot({ path: test.info().outputPath('project-config-shared-code-sidebar.png') }); + + // Reload directly on the settings URL, without mounting /chat first. + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page).toHaveURL(/\/project-config\?agent=/); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect.poll(readCodeChrome).toEqual(chatChrome); + const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle'); + await sidebarToggle.click(); + await page.getByTestId('project-configuration-page').click({ position: { x: 500, y: 20 } }); + await expect(sidebar).toHaveAttribute('aria-hidden', 'true'); + await expect(moduleReturn).toBeVisible(); + await sidebarToggle.click(); + await page.mouse.move(1000, 500); + await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); + await expect.poll(readCodeChrome).toEqual(chatChrome); + await expect(prompt).toHaveValue(originalPrompt); await projectBack.click(); await expect(page).toHaveURL(/\/chat$/); await expect(moduleReturn).toBeVisible(); @@ -502,8 +565,9 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(page).toHaveURL(/\/project-config\/plugins\?/); const plugins = page.getByTestId('project-plugins-sheet'); await expect(plugins).toBeVisible(); - await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0); - await page.screenshot({ path: test.info().outputPath('project-config-plugins-without-module-return.png') }); + await expect(moduleReturn).toBeVisible(); + await expect.poll(readCodeChrome).toEqual(chatChrome); + await page.screenshot({ path: test.info().outputPath('project-config-plugins-shared-code-sidebar.png') }); await plugins.getByRole('button', { name: '关闭', exact: true }).click(); await expect(page).toHaveURL(/\/project-config(?:\?|$)/); await expect(plugins).toHaveCount(0); @@ -512,7 +576,6 @@ test.describe('Makelore module navigation without setup flow', () => { await sidebar.getByTestId('sidebar-course-project-project-storybook').click(); await expect(page).toHaveURL(/\/chat$/); - const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle'); await sidebarToggle.click(); await page.getByTestId('coding-chat-panel').click({ position: { x: 500, y: 200 } }); await expect(sidebar).toHaveAttribute('aria-hidden', 'true'); From ec07c67ef4a9218dbadcb73c9e6e79fd0da2c172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:44:20 +0800 Subject: [PATCH 13/17] Retain active work entry restoration in sidebar preview --- README.md | 2 + electron/api/routes/agent-browser.ts | 11 +- .../coding-runtime/conversation-service.ts | 7 + .../coding-runtime/work-entry-contracts.ts | 14 + electron/coding-runtime/work-entry-store.ts | 233 ++++++++ electron/coding-runtime/work-launcher.ts | 551 ++++++++++++++++++ electron/coding-runtime/work-preview.ts | 203 ++++++- shared/coding-recovery.ts | 14 +- src/lib/coding-work-preview.ts | 5 +- src/pages/Chat/AgentBrowserPanel.tsx | 4 +- src/pages/Chat/use-work-preview.ts | 21 +- tests/e2e/work-entry-restoration.spec.ts | 74 +++ tests/unit/coding-work-preview.test.ts | 247 ++++++-- tests/unit/use-work-preview.test.tsx | 20 + tests/unit/work-entry-store.test.ts | 256 ++++++++ tests/unit/work-launcher.test.ts | 325 +++++++++++ 16 files changed, 1924 insertions(+), 63 deletions(-) create mode 100644 electron/coding-runtime/work-entry-contracts.ts create mode 100644 electron/coding-runtime/work-entry-store.ts create mode 100644 electron/coding-runtime/work-launcher.ts create mode 100644 tests/e2e/work-entry-restoration.spec.ts create mode 100644 tests/unit/work-entry-store.test.ts create mode 100644 tests/unit/work-launcher.test.ts diff --git a/README.md b/README.md index 0c10824c..4d84e8fe 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +作品入口按项目记住已确认的本机页面和受限的打开方式。应用重启后先检查并复用原服务;服务停止时,可直接重新启动已有的 Vite 工程或普通静态网页,再显示实际可访问的页面,不要求当前对话选择模型,也不自动新建对话。旧项目可从已成功打开页面的工具回执恢复入口。空项目仍显示原有空状态;无法确定打开方式、缺少运行组件或入口改变时说明原因,由用户明确选择「让助手检查」后才调用编程助手。后台恢复只访问本机地址,保存的打开方式按项目真实目录校验,不保存或重放任意终端命令。 + 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index 0a1e6abc..2c9eb575 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -9,6 +9,7 @@ import { parseJsonBody, sendJson } from '../route-utils'; type AgentBrowserBody = { conversation_id?: unknown; + allow_assistant?: unknown; request_id?: unknown; project_id?: unknown; project_path?: unknown; @@ -42,6 +43,7 @@ function workPreviewService(ctx: HostApiContext): CodingWorkPreviewService | und conversations: ctx.codingProducts.conversations, runtime: ctx.codingProducts.runtime, ensureActive: (target) => ensureProjectStillActive(ctx, target), + storedSnapshot: (id) => ctx.codingProducts!.conversations.getStoredSnapshot(id), }); workPreviewServices.set(ctx, preview); } @@ -264,11 +266,16 @@ export async function handleAgentBrowserRoutes( if (!requestId || !/^[a-zA-Z0-9-]{1,80}$/.test(requestId)) { throw new AgentBrowserRouteError('INVALID_REQUEST', '作品请求无效。'); } + if (body.allow_assistant !== undefined && typeof body.allow_assistant !== 'boolean') { + throw new AgentBrowserRouteError('INVALID_REQUEST', '作品请求无效。'); + } if (!ctx.codingProducts) throw new AgentBrowserRouteError('CLOSED', '本地编程服务暂时不可用。', 503); const preview = workPreviewService(ctx)!; const release = ctx.lifecycle?.acquireLease({ id: `work-preview:${project.id}:${requestId}`, kind: 'work-preview' }); try { - const result = await preview.ensure(project, requestId, nonEmptyString(body.conversation_id)); + const result = body.allow_assistant === true + ? await preview.ensure(project, requestId, nonEmptyString(body.conversation_id), true) + : await preview.ensure(project, requestId, nonEmptyString(body.conversation_id)); await ensureProjectStillActive(ctx, project); sendJson(res, 200, { success: true, ...result }); } finally { @@ -313,7 +320,7 @@ export async function handleAgentBrowserRoutes( ...(body.inject_project_data === true ? { injectProjectData: true } : {}), }); await ensureProjectStillActive(ctx, project); - workPreviewService(ctx)?.remember(project, browser); + await workPreviewService(ctx)?.remember(project, browser).catch(() => undefined); emitState(ctx, 'agent-browser:show', browser); sendJson(res, 200, { success: true, browser }); return true; diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 2c42d482..e505abad 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -411,6 +411,13 @@ export class CodingConversationService { } } + /** Read existing facts for work restoration without opening a model session. */ + async getStoredSnapshot(conversationId: string): Promise { + const { project, conversation } = await this.projects.findActiveConversation(conversationId); + if (!conversation.sessionKey || !this.options.readHistory) return null; + return await this.options.readHistory(project.id, conversation); + } + async acceptPrompt(input: { conversationId: string; clientRequestId: unknown; diff --git a/electron/coding-runtime/work-entry-contracts.ts b/electron/coding-runtime/work-entry-contracts.ts new file mode 100644 index 00000000..6edc29bb --- /dev/null +++ b/electron/coding-runtime/work-entry-contracts.ts @@ -0,0 +1,14 @@ +/** Project-relative, app-owned launch recipes. Never persisted shell commands. */ +export type WorkLaunchSpec = + | { kind: 'vite'; directory: string; args: string[] } + | { kind: 'static'; directory: string; entry: string }; + +export type WorkEntryProject = { id: string; path: string }; +export type WorkEntry = { + version: 1; + projectId: string; + projectPath: string; + url: string; + documentHash?: string; + launch?: WorkLaunchSpec; +}; diff --git a/electron/coding-runtime/work-entry-store.ts b/electron/coding-runtime/work-entry-store.ts new file mode 100644 index 00000000..aab046ab --- /dev/null +++ b/electron/coding-runtime/work-entry-store.ts @@ -0,0 +1,233 @@ +import { randomUUID } from 'node:crypto'; +import { constants, type Stats } from 'node:fs'; +import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; +import type { WorkEntry, WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts'; + +const ENTRY_FILENAME = 'work-entry.json'; +const MAX_ENTRY_BYTES = 16 * 1024; +const MAX_URL_LENGTH = 2_048; +const MAX_PATH_LENGTH = 1_024; +const NO_FOLLOW = constants.O_NOFOLLOW ?? 0; + +type ProjectRoot = { root: string; rootStat: Stats }; +type Locations = ProjectRoot & { directory: string; file: string; directoryStat: Stats }; + +function invalid(): never { + throw new Error('Invalid project work entry'); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function hasOnlyKeys(value: Record, keys: string[]): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +function samePath(left: string, right: string): boolean { + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function sameNode(left: Stats, right: Stats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function containsControl(value: string, includeSpace = false): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < (includeSpace ? 33 : 32) || code === 127) return true; + } + return false; +} + +async function projectRoot(project: WorkEntryProject): Promise { + if (typeof project.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(project.id) + || typeof project.path !== 'string' || !path.isAbsolute(project.path)) invalid(); + const root = await realpath(project.path); + const rootStat = await lstat(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) invalid(); + return { root, rootStat }; +} + +async function assertRoot(target: ProjectRoot): Promise { + const stat = await lstat(target.root); + if (!stat.isDirectory() || stat.isSymbolicLink() || !sameNode(stat, target.rootStat) + || !samePath(await realpath(target.root), target.root)) invalid(); +} + +async function locations(project: WorkEntryProject, createMetadata = false, verifiedRoot?: ProjectRoot): Promise { + const { root, rootStat } = verifiedRoot ?? await projectRoot(project); + const directory = path.join(root, '.makelore'); + const directoryStat = await lstat(directory).catch(async (error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT' || !createMetadata) throw error; + await assertRoot({ root, rootStat }); + // Imported folders may not have metadata yet. Create only this direct child; + // a concurrent creator must still pass the same directory/link checks below. + await mkdir(directory, { mode: 0o700 }).catch((mkdirError: NodeJS.ErrnoException) => { + if (mkdirError.code !== 'EEXIST') throw mkdirError; + }); + return await lstat(directory); + }); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() + || !samePath(await realpath(directory), directory)) invalid(); + await assertRoot({ root, rootStat }); + return { root, directory, file: path.join(directory, ENTRY_FILENAME), rootStat, directoryStat }; +} + +async function assertLocations(target: Locations): Promise { + await assertRoot(target); + const directoryStat = await lstat(target.directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || !sameNode(directoryStat, target.directoryStat) + || !samePath(await realpath(target.directory), target.directory)) invalid(); +} + +function relativePath(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > MAX_PATH_LENGTH + || containsControl(value) || /[\\:]/u.test(value) || value.trim() !== value + || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) invalid(); + const pieces = value.split('/'); + if (pieces.length > 32 || pieces.includes('..')) invalid(); + return path.posix.normalize(value); +} + +async function relativeTarget(root: string, value: unknown, kind: 'directory' | 'file'): Promise { + const relative = relativePath(value); + let current = root; + for (const piece of relative.split('/').filter((piece) => piece !== '.')) { + current = path.join(current, piece); + if ((await lstat(current)).isSymbolicLink()) invalid(); + } + const stat = await lstat(current); + if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile()) + || !samePath(await realpath(current), current)) invalid(); + return relative; +} + +function localUrl(value: unknown): string { + if (typeof value !== 'string' || value.length > MAX_URL_LENGTH + || containsControl(value, true)) invalid(); + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) + || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + || url.username || url.password) invalid(); + return value; +} + +/** Only structured Vite options are persisted; the launcher supplies the loopback host. */ +function viteArgs(value: unknown): string[] { + if (!Array.isArray(value) || value.length > 16) invalid(); + const result: string[] = []; + const seen = new Set(); + for (let index = 0; index < value.length; index += 1) { + const flag: unknown = value[index]; + if (typeof flag !== 'string' || seen.has(flag)) invalid(); + seen.add(flag); + if (flag === '--strictPort' || flag === '--force') { + result.push(flag); + continue; + } + const parameter: unknown = value[++index]; + if (typeof parameter !== 'string') invalid(); + if (flag === '--port') { + if (!/^[1-9][0-9]{0,4}$/u.test(parameter) || Number(parameter) > 65_535) invalid(); + } else if (flag === '--mode') { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(parameter)) invalid(); + } else if (flag === '--base') { + if (parameter.length > MAX_PATH_LENGTH || !/^(?:\/(?!\/)|\.\/)/u.test(parameter) + || containsControl(parameter, true) || /[\\?#:]/u.test(parameter) + || parameter.split('/').includes('..')) invalid(); + } else invalid(); + result.push(flag, parameter); + } + return result; +} + +async function launchSpec(root: string, value: unknown): Promise { + if (!isRecord(value)) invalid(); + if (value.kind === 'vite' && hasOnlyKeys(value, ['kind', 'directory', 'args'])) { + return { kind: 'vite', directory: await relativeTarget(root, value.directory, 'directory'), args: viteArgs(value.args) }; + } + if (value.kind === 'static' && hasOnlyKeys(value, ['kind', 'directory', 'entry'])) { + const directory = await relativeTarget(root, value.directory, 'directory'); + const entry = await relativeTarget(path.join(root, directory), value.entry, 'file'); + return { kind: 'static', directory, entry }; + } + return invalid(); +} + +async function validatedEntry(project: WorkEntryProject, root: string, value: unknown, saving = false): Promise { + if (!isRecord(value) || !hasOnlyKeys(value, ['version', 'projectId', 'projectPath', 'url', 'launch', 'documentHash']) + || value.version !== 1 || value.projectId !== project.id || typeof value.projectPath !== 'string' + || !path.isAbsolute(value.projectPath)) invalid(); + const boundPath = saving ? await realpath(value.projectPath) : value.projectPath; + if (!samePath(boundPath, root)) invalid(); + if (value.documentHash !== undefined + && (typeof value.documentHash !== 'string' || !/^[a-f0-9]{64}$/u.test(value.documentHash))) invalid(); + return { + version: 1, projectId: project.id, projectPath: root, url: localUrl(value.url), + ...(value.launch === undefined ? {} : { launch: await launchSpec(root, value.launch) }), + ...(value.documentHash === undefined ? {} : { documentHash: value.documentHash }), + }; +} + +/** Reads bounded project-owned metadata without following links or starting any work. */ +export async function loadWorkEntry(project: WorkEntryProject): Promise { + try { + const target = await locations(project); + const fileStat = await lstat(target.file); + if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > MAX_ENTRY_BYTES) return null; + const handle = await open(target.file, constants.O_RDONLY | NO_FOLLOW); + try { + const openedStat = await handle.stat(); + if (!openedStat.isFile() || openedStat.size > MAX_ENTRY_BYTES || !sameNode(fileStat, openedStat)) return null; + const bytes = Buffer.alloc(MAX_ENTRY_BYTES + 1); + let size = 0; + while (size < bytes.length) { + const chunk = await handle.read(bytes, size, bytes.length - size, size); + if (!chunk.bytesRead) break; + size += chunk.bytesRead; + } + if (size > MAX_ENTRY_BYTES) return null; + await assertLocations(target); + const currentStat = await lstat(target.file); + if (currentStat.isSymbolicLink() || !sameNode(openedStat, currentStat)) return null; + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size)); + return await validatedEntry(project, target.root, JSON.parse(source)); + } finally { + await handle.close(); + } + } catch { + // Missing, copied, changed or corrupt metadata is never launch authority. + return null; + } +} + +/** Publishes one complete entry in project metadata, using an exclusive temp file. */ +export async function saveWorkEntry(project: WorkEntryProject, entry: WorkEntry): Promise { + const root = await projectRoot(project); + const value = await validatedEntry(project, root.root, entry, true); + const source = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(source, 'utf8') > MAX_ENTRY_BYTES) invalid(); + const target = await locations(project, true, root); + await assertLocations(target); + const temporary = path.join(target.directory, `.${ENTRY_FILENAME}.${randomUUID()}.tmp`); + try { + const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, 0o600); + try { + await handle.writeFile(source, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await assertLocations(target); + const existing = await lstat(target.file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + return null; + }); + if (existing && (!existing.isFile() || existing.isSymbolicLink())) invalid(); + await rename(temporary, target.file); + } finally { + await assertLocations(target).then(() => rm(temporary, { force: true })).catch(() => undefined); + } +} diff --git a/electron/coding-runtime/work-launcher.ts b/electron/coding-runtime/work-launcher.ts new file mode 100644 index 00000000..3ea0d291 --- /dev/null +++ b/electron/coding-runtime/work-launcher.ts @@ -0,0 +1,551 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readFile, readdir, realpath } from 'node:fs/promises'; +import { get, type IncomingHttpHeaders } from 'node:http'; +import { createServer } from 'node:net'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import type { WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts'; + +export type WorkLaunchErrorCode = + | 'WORK_PREVIEW_DEPENDENCIES_MISSING' + | 'WORK_PREVIEW_START_FAILED' + | 'WORK_PREVIEW_ENTRY_MISSING' + | 'WORK_PREVIEW_NEEDS_HELP'; + +export class WorkLaunchError extends Error { + constructor(readonly code: WorkLaunchErrorCode, message: string) { + super(message); + this.name = 'WorkLaunchError'; + } +} + +const MAX_DEPTH = 2; +const MAX_DIRECTORIES = 160; +const START_TIMEOUT_MS = 15_000; +const ownedLaunches = new Map(); +const SKIP_DIRECTORIES = new Set(['node_modules', '.git', '.makelore', '.codex']); +const LOCAL_BASE = /^(?:\/(?!\/)[A-Za-z0-9_./-]*|\.\/?[A-Za-z0-9_./-]*)$/; + +/** Only a small Vite CLI vocabulary is replayable; package scripts never enter a shell. */ +function viteArgs(script: unknown): string[] | null { + if (typeof script !== 'string' || script.length > 1_000) return null; + const tokens = script.trim().split(/\s+/); + if (tokens.shift() !== 'vite') return null; + const args: string[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + const equal = token.indexOf('='); + const flag = equal < 0 ? token : token.slice(0, equal); + const inline = equal < 0 ? undefined : token.slice(equal + 1); + if (flag === '--strictPort' || flag === '--force') { + if (inline !== undefined) return null; + args.push(flag); + continue; + } + if (flag === '--host') { + const host = inline ?? (!tokens[index + 1]?.startsWith('--') ? tokens[++index] : undefined); + if (host !== undefined && !['127.0.0.1', 'localhost', '0.0.0.0'].includes(host)) return null; + continue; // The app supplies the loopback binding. + } + if (!['--port', '--base', '--mode'].includes(flag)) return null; + const value = inline ?? tokens[++index]; + if (!value) return null; + if (flag === '--port' && (!/^\d{1,5}$/.test(value) || Number(value) < 1 || Number(value) > 65_535)) return null; + if (flag === '--mode' && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) return null; + if (flag === '--base' && (!LOCAL_BASE.test(value) || value.split('/').includes('..'))) return null; + if (args.includes(flag)) return null; + args.push(flag, flag === '--port' ? String(Number(value)) : value); + } + return args; +} + +function within(root: string, target: string): boolean { + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function relativeParts(value: string): string[] { + if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || path.isAbsolute(value)) { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置已改变,请重新检查作品。'); + } + if (value === '.') return []; + const parts = value.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置不在项目中。'); + } + return parts; +} + +async function safeExistingPath(root: string, relative: string, kind: 'directory' | 'file'): Promise { + const parts = relativeParts(relative); + let current = root; + try { + for (const part of parts) { + current = path.join(current, part); + if ((await lstat(current)).isSymbolicLink()) throw new Error('symlink'); + } + const stat = await lstat(current); + const resolved = await realpath(current); + if (!within(root, resolved) || (kind === 'file' ? !stat.isFile() : !stat.isDirectory())) throw new Error('outside project'); + return resolved; + } catch { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的文件或启动位置找不到了。'); + } +} + +async function projectRoot(projectPath: string): Promise { + try { + const root = await realpath(projectPath); + if (!(await lstat(root)).isDirectory()) throw new Error('not a directory'); + return root; + } catch { + throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '项目文件夹找不到了。'); + } +} + +async function packageRecord(directory: string): Promise | null> { + const file = path.join(directory, 'package.json'); + const stat = await lstat(file).catch(() => null); + if (!stat) return null; + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_000_000) { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); + } + try { + const value: unknown = JSON.parse(await readFile(file, 'utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid package'); + return value as Record; + } catch { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); + } +} + +function devArgs(pkg: Record): string[] | null { + const scripts = pkg.scripts; + return viteArgs(scripts && typeof scripts === 'object' && !Array.isArray(scripts) + ? (scripts as Record).dev + : undefined); +} + +export async function discoverWorkLaunch(projectPath: string): Promise { + const root = await projectRoot(projectPath); + const candidates: WorkLaunchSpec[] = []; + let inspected = 0; + let needsHelp = false; + const visit = async (directory: string, depth: number): Promise => { + if (++inspected > MAX_DIRECTORIES) { + needsHelp = true; + return; + } + const relative = path.relative(root, directory).split(path.sep).join('/') || '.'; + let pkg: Record | null; + try { pkg = await packageRecord(directory); } catch { needsHelp = true; return; } + if (pkg) { + const args = devArgs(pkg); + if (args) candidates.push({ kind: 'vite', directory: relative, args }); + else needsHelp = true; + // Do not mistake a framework's public/dist/template files for a plain website. + return; + } + const index = await lstat(path.join(directory, 'index.html')).catch(() => null); + if (index?.isFile() && !index.isSymbolicLink()) { + candidates.push({ kind: 'static', directory: relative, entry: 'index.html' }); + return; + } + if (depth >= MAX_DEPTH || candidates.length > 1) return; + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || SKIP_DIRECTORIES.has(entry.name)) continue; + await visit(path.join(directory, entry.name), depth + 1); + if (candidates.length > 1 || inspected > MAX_DIRECTORIES) break; + } + }; + await visit(root, 0); + if (candidates.length > 1 || needsHelp) { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '需要帮忙确认打开哪个作品,以及怎样启动它。'); + } + return candidates[0] ?? null; +} + +async function viteCli(root: string, directory: string): Promise { + for (let current = directory; within(root, current); current = path.dirname(current)) { + const packagePath = path.join(current, 'node_modules', 'vite', 'package.json'); + try { + const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as { name?: string }; + const cli = await realpath(path.join(current, 'node_modules', 'vite', 'bin', 'vite.js')); + const api = path.resolve(path.dirname(cli), '../dist/node/index.js'); + if (pkg.name === 'vite' && (await lstat(cli)).isFile() && (await lstat(api)).isFile()) return cli; + } catch { /* Try installed dependencies at a containing project directory. */ } + if (current === root) break; + } + throw new WorkLaunchError('WORK_PREVIEW_DEPENDENCIES_MISSING', '作品的启动工具还没准备好,需要帮忙检查。'); +} + +function argumentValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index < 0 ? undefined : args[index + 1]; +} + +function previousPort(previousUrl: string | undefined): number | undefined { + try { + const url = new URL(previousUrl ?? ''); + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + || url.username || url.password || !url.port) return undefined; + return Number(url.port); + } catch { return undefined; } +} + +async function availablePort(preferred?: number): Promise { + const reserve = (port: number) => new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { server.close(); reject(new Error('port unavailable')); return; } + server.close((error) => error ? reject(error) : resolve(address.port)); + }); + }); + if (preferred) { + try { return await reserve(preferred); } catch { /* Never kill whoever already owns the old port. */ } + } + return await reserve(0); +} + +// App-owned code, passed as a Node argument. No project script or shell is evaluated. +const STATIC_RUNNER = String.raw` +const http = require('node:http'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const [root, entry, port, token, identity] = process.argv.slice(1); +const mime = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.mjs':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif','.ico':'image/x-icon','.mp3':'audio/mpeg','.wav':'audio/wav','.ogg':'audio/ogg','.mp4':'video/mp4','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.wasm':'application/wasm','.txt':'text/plain; charset=utf-8'}; +async function safeFile(parts) { + let file = root; + for (const part of parts) { + file = path.join(file, part); + if ((await fs.lstat(file)).isSymbolicLink()) throw new Error('forbidden'); + } + const resolved = await fs.realpath(file); + const relative = path.relative(root, resolved); + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('forbidden'); + if (!(await fs.stat(resolved)).isFile()) throw new Error('missing'); + return resolved; +} +http.createServer(async (req, res) => { + try { + if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return; } + const pathname = decodeURIComponent((req.url || '/').split('?')[0]); + if (!pathname.startsWith('/') || pathname.includes('\\') || pathname.includes('\0')) throw new Error('forbidden'); + let parts = pathname.slice(1).split('/').filter(Boolean); + if (parts.some(part => part === '..' || part.startsWith('.'))) throw new Error('forbidden'); + if (parts.length === 0) parts = entry.split('/'); + else if (pathname.endsWith('/')) parts.push('index.html'); + const file = await safeFile(parts); + const bytes = await fs.readFile(file); + res.writeHead(200, {'content-type':mime[path.extname(file).toLowerCase()] || 'application/octet-stream','content-length':bytes.length,'cache-control':'no-store','x-makelore-work':token,'x-makelore-work-project':identity,'x-content-type-options':'nosniff'}); + res.end(req.method === 'HEAD' ? undefined : bytes); + } catch (error) { + res.writeHead(error.message === 'forbidden' ? 403 : 404); res.end(); + } +}).listen(Number(port), '127.0.0.1', () => console.log('Makelore preview pid=' + process.pid)); +`; + +// Use the installed Vite runtime/config with app-owned options and report its resolved base. +const VITE_RUNNER = String.raw` +const http = require('node:http'); +const {pathToFileURL} = require('node:url'); +const path = require('node:path'); +const fs = require('node:fs/promises'); +const [token, identity, cli, projectRoot, ...args] = process.argv.slice(1); +const emit = http.Server.prototype.emit; +http.Server.prototype.emit = function(event, ...values) { + if (event === 'request') { + values[1].setHeader('x-makelore-work', token); + values[1].setHeader('x-makelore-work-project', identity); + } + return emit.call(this, event, ...values); +}; +const value = flag => args[args.indexOf(flag) + 1]; +(async () => { + const vite = await import(pathToFileURL(path.resolve(path.dirname(cli), '../dist/node/index.js')).href); + const options = {clearScreen:false,server:{host:'127.0.0.1',port:Number(value('--port')),strictPort:true,open:false},optimizeDeps:{force:args.includes('--force')}}; + if (args.includes('--base')) options.base = value('--base'); + if (args.includes('--mode')) options.mode = value('--mode'); + const server = await vite.createServer(options); + const root = await fs.realpath(server.config.root); + const relative = path.relative(projectRoot, root); + if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('Vite root is outside this project'); + await server.listen(); + const origin = 'http://127.0.0.1:' + value('--port'); + const url = new URL(server.config.base || '/', origin + '/'); + if (url.origin !== origin || url.username || url.password) throw new Error('Vite base is outside this local server'); + console.log('Makelore ready pid=' + process.pid + ' url=' + url.href); + if (!process.send) throw new Error('Missing launch readiness channel'); + process.send({token,url:url.href}, () => process.disconnect()); +})().catch(error => { console.error(error); process.exit(1); }); +`; + +async function previewLog(root: string, kind: WorkLaunchSpec['kind']) { + let directory = root; + for (const part of ['.makelore', 'logs']) { + directory = path.join(directory, part); + await mkdir(directory).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error; }); + const stat = await lstat(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe log directory'); + } + const logPath = path.join(directory, `${kind}-preview.log`); + const previous = await lstat(logPath).catch(() => null); + if (previous && (previous.isSymbolicLink() || !previous.isFile())) throw new Error('unsafe log file'); + return await open(logPath, + constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600); +} + +function childEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', NO_COLOR: '1' }; + for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'COMSPEC', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA']) { + if (process.env[key] !== undefined) env[key] = process.env[key]; + } + return env; +} + +type PreviewPage = { status?: number; headers: IncomingHttpHeaders; body: string }; + +function readPage(url: string, maxBytes = 256 * 1024): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (page: PreviewPage | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(page); + }; + const timer = setTimeout(() => { request.destroy(); finish(null); }, 800); + const request = get(url, (response) => { + let body = ''; + let receivedBytes = 0; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + receivedBytes += Buffer.byteLength(chunk, 'utf8'); + if (receivedBytes > maxBytes) { request.destroy(); finish(null); return; } + body += chunk; + }); + response.once('error', () => finish(null)); + response.once('end', () => finish({ status: response.statusCode, headers: response.headers, body })); + }); + request.once('error', () => finish(null)); + }); +} + +function workIdentity(root: string, directory: string): string { + return createHash('sha256').update(`${root}\0${directory}`).digest('hex'); +} + +function hasExactModuleSource(body: string, source: string): boolean { + const fullSource = source.trim(); + if (!fullSource) return false; + if (body.includes(fullSource)) return true; + const maps = [...body.matchAll(/\/\/[#@]\s*sourceMappingURL=data:application\/json(?:;charset=[\w-]+)?;base64,([A-Za-z0-9+/=]+)/g)]; + const lastMap = maps.at(-1)?.[1]; + if (!lastMap || lastMap.length > 2 * 1024 * 1024) return false; + try { + const map = JSON.parse(Buffer.from(lastMap, 'base64').toString('utf8')) as { sourcesContent?: unknown }; + return Array.isArray(map.sourcesContent) && map.sourcesContent.some((content) => content === source); + } catch { return false; } +} + +function exactRawModule(body: string, source: string): boolean { + const exported = body.match(/^\s*export\s+default\s+("(?:[^"\\]|\\.)*")\s*;?\s*(?:\/\/[#@]\s*sourceMappingURL=[^\r\n]*\s*)?$/); + if (!exported) return false; + try { return JSON.parse(exported[1]) === source; } catch { return false; } +} + +const TEXT_MODULE_EXTENSION = /\.(?:[cm]?js|jsx|[cm]?ts|tsx|css|json|svg|glsl|html?|txt)$/i; + +async function matchesLocalModule( + directory: string, + relative: string, + address: URL, + visited: Set, + depth: number, +): Promise { + if (visited.has(relative)) return true; + if (depth > 4 || visited.size >= 20) return false; + visited.add(relative); + try { + const file = await safeExistingPath(directory, relative, 'file'); + if ((await lstat(file)).size > 512 * 1024) return false; + const source = await readFile(file, 'utf8'); + const rawAddress = new URL(address); + rawAddress.search = '?raw'; + const raw = await readPage(rawAddress.href, 2 * 1024 * 1024); + if (!raw || raw.status !== 200 || !exactRawModule(raw.body, source)) { + const response = await readPage(address.href, 2 * 1024 * 1024); + if (!response || response.status !== 200 || !hasExactModuleSource(response.body, source)) return false; + } + // Exact entry text alone is insufficient when another local module contains the game. + const specifiers = [...source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["']([^"']+)["']/g)].map((match) => match[1]); + if (specifiers.some((specifier) => /^(?:@\/|~\/|\/)/.test(specifier)) + || /\bimport\s*\(\s*([^'"\s])/.test(source)) return false; + const imports = specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier)); + const matches = await Promise.all(imports.map(async (specifier) => { + const local = decodeURIComponent(specifier.split(/[?#]/)[0]); + let imported = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local)); + const extension = path.posix.extname(imported); + if (extension && !TEXT_MODULE_EXTENSION.test(imported)) return true; + if (!extension) { + const candidates = [imported, ...['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'].flatMap((suffix) => [imported + suffix, `${imported}/index${suffix}`])]; + const present: string[] = []; + for (const candidate of candidates) { + try { await safeExistingPath(directory, candidate, 'file'); present.push(candidate); } catch { /* Unknown imports cannot establish identity. */ } + } + if (present.length !== 1) return false; + imported = present[0]; + } + const importedAddress = new URL(local, address); + if (!extension) { + const original = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local)); + importedAddress.pathname += imported.slice(original.length); + } + return await matchesLocalModule(directory, imported, importedAddress, visited, depth + 1); + })); + return matches.every(Boolean); + } catch { return false; } +} + +async function matchesEntryModule(directory: string, module: string, pageUrl: string, spec: WorkLaunchSpec, visited: Set): Promise { + try { + const address = new URL(module, pageUrl); + if (address.origin !== new URL(pageUrl).origin || address.username || address.password) return false; + let relative = decodeURIComponent(module.split(/[?#]/)[0]); + if (relative.startsWith('/')) { + const base = spec.kind === 'vite' ? argumentValue(spec.args, '--base') : undefined; + if (base?.startsWith('/') && relative.startsWith(base)) relative = relative.slice(base.length); + else relative = relative.slice(1); + } + if (relative.startsWith('./')) relative = relative.slice(2); + if (!/\.(?:[cm]?js|jsx|ts|tsx)$/.test(relative)) return false; + return await matchesLocalModule(directory, relative, address, visited, 0); + } catch { return false; } +} + +/** Check a saved address against this project's files before reusing its local service. */ +export async function workLaunchMatches(project: WorkEntryProject, spec: WorkLaunchSpec, url: string): Promise { + if (previousPort(url) === undefined) return false; + try { + const root = await projectRoot(project.path); + const directory = await safeExistingPath(root, spec.directory, 'directory'); + const entry = await safeExistingPath(directory, spec.kind === 'static' ? spec.entry : 'index.html', 'file'); + const page = await readPage(url); + if (!page || page.status !== 200 || !String(page.headers['content-type']).includes('text/html')) return false; + if (page.headers['x-makelore-work-project'] === workIdentity(root, directory)) return true; + const source = await readFile(entry, 'utf8'); + if (spec.kind === 'static') return page.body === source; + // Older, manually opened Vite services do not yet have the app's ownership header. + const title = source.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); + const actualTitle = page.body.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); + const modules = [...source.matchAll(/]*)>/gi)].flatMap((match) => { + if (!/\btype\s*=\s*["']module["']/i.test(match[1])) return []; + const module = match[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1]; + return module ? [module] : []; + }); + if (!title || title !== actualTitle || modules.length === 0 || modules.length > 8 || !page.body.includes('/@vite/client')) return false; + const visited = new Set(); + const matches = await Promise.all(modules.map((module) => matchesEntryModule(directory, module, url, spec, visited))); + return matches.every(Boolean); + } catch { return false; } +} + +function terminateOwnedProcess(child: ChildProcess): void { + if (!child.pid || child.exitCode !== null) return; + if (process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGTERM'); return; } catch { /* The child may already have exited. */ } + } + child.kill(); +} + +/** Cancel only a launch owned by this Main process, never an arbitrary saved address. */ +export async function stopWorkLaunch(project: WorkEntryProject, url: string): Promise { + const root = await projectRoot(project.path).catch(() => null); + if (!root) return; + const key = `${root}\0${url}`; + const child = ownedLaunches.get(key); + if (!child) return; + ownedLaunches.delete(key); + terminateOwnedProcess(child); +} + +export async function startWorkLaunch(project: WorkEntryProject, spec: WorkLaunchSpec, previousUrl?: string): Promise { + const root = await projectRoot(project.path); + const directory = await safeExistingPath(root, spec.directory, 'directory'); + let args: string[]; + let base = '/'; + const token = randomUUID(); + const identity = workIdentity(root, directory); + if (spec.kind === 'vite') { + const pkg = await packageRecord(directory); + const recognized = pkg && devArgs(pkg); + if (!recognized || JSON.stringify(recognized) !== JSON.stringify(spec.args)) { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '作品的启动方式变了,需要帮忙检查。'); + } + await safeExistingPath(directory, 'index.html', 'file'); + const cli = await viteCli(root, directory); + args = ['-e', VITE_RUNNER, token, identity, cli, root, + ...spec.args.filter((value, index) => value !== '--port' && spec.args[index - 1] !== '--port' && value !== '--strictPort')]; + const configuredBase = argumentValue(spec.args, '--base'); + if (configuredBase?.startsWith('/')) base = configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`; + } else if (spec.kind === 'static') { + if (await packageRecord(directory)) throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品需要先确认启动方式。'); + await safeExistingPath(directory, spec.entry, 'file'); + if (!/\.html?$/i.test(spec.entry)) throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的网页入口找不到了。'); + args = ['-e', STATIC_RUNNER, directory, spec.entry]; + } else { + throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。'); + } + let child: ChildProcess | undefined; + try { + const port = await availablePort(previousPort(previousUrl) ?? (spec.kind === 'vite' ? Number(argumentValue(spec.args, '--port')) || undefined : undefined)); + let url = `http://127.0.0.1:${port}${base}`; + let readyUrl = spec.kind === 'static' ? url : undefined; + args.push(...(spec.kind === 'vite' ? ['--host', '127.0.0.1', '--port', String(port), '--strictPort'] : [String(port), token, identity])); + const log = await previewLog(root, spec.kind); + try { + child = spawn(process.execPath, args, { + cwd: directory, env: childEnvironment(), shell: false, detached: true, + windowsHide: true, stdio: spec.kind === 'vite' ? ['ignore', log.fd, log.fd, 'ipc'] : ['ignore', log.fd, log.fd], + }); + child.on('message', (message: unknown) => { + if (!message || typeof message !== 'object') return; + const value = message as { token?: unknown; url?: unknown }; + if (value.token !== token || typeof value.url !== 'string' || value.url.length > 4_096) return; + try { + const address = new URL(value.url); + if (address.origin === `http://127.0.0.1:${port}` && !address.username && !address.password) readyUrl = address.href; + } catch { /* Ignore untrusted or malformed child messages. */ } + }); + await new Promise((resolve, reject) => { child!.once('spawn', resolve); child!.once('error', reject); }); + await log.write(`\nMakelore launch pid=${child.pid} url=${url}\n`); + } finally { await log.close(); } + let failed = false; + child.once('error', () => { failed = true; }); + const deadline = Date.now() + START_TIMEOUT_MS; + while (Date.now() < deadline) { + if (failed || child.exitCode !== null || child.signalCode !== null) break; + if (readyUrl) url = readyUrl; + const page = readyUrl ? await readPage(url) : null; + if (page?.status === 200 && String(page.headers['content-type']).includes('text/html') + && page.headers['x-makelore-work'] === token) { + const key = `${root}\0${url}`; + const ownedChild = child; + ownedLaunches.set(key, ownedChild); + ownedChild.once('exit', () => { if (ownedLaunches.get(key) === ownedChild) ownedLaunches.delete(key); }); + child.unref(); + return url; + } + await new Promise((resolve) => setTimeout(resolve, 80)); + } + throw new Error('preview did not become ready'); + } catch { + if (child) terminateOwnedProcess(child); + throw new WorkLaunchError('WORK_PREVIEW_START_FAILED', '作品没有成功启动,可以帮忙检查启动记录。'); + } +} diff --git a/electron/coding-runtime/work-preview.ts b/electron/coding-runtime/work-preview.ts index a674e1b1..85d616c4 100644 --- a/electron/coding-runtime/work-preview.ts +++ b/electron/coding-runtime/work-preview.ts @@ -5,6 +5,10 @@ import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-wo import { codingRecovery } from '../../shared/coding-recovery'; import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import { inspectWorkPresence, type WorkPresence } from './work-presence'; +import { loadWorkEntry, saveWorkEntry } from './work-entry-store'; +import { discoverWorkLaunch, startWorkLaunch, stopWorkLaunch, workLaunchMatches } from './work-launcher'; +import type { WorkEntry, WorkLaunchSpec } from './work-entry-contracts'; +import { createHash } from 'node:crypto'; type Project = { id: string; path: string }; type Attempt = { @@ -16,6 +20,15 @@ type Attempt = { result?: WorkPreviewResult; }; +function sameWorkServer(left: string, right: string): boolean { + try { + const a = new URL(left); + const b = new URL(right); + const local = (url: URL) => ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + return local(a) && local(b) && a.protocol === b.protocol && a.port === b.port; + } catch { return false; } +} + /** Only probe a known loopback work address; never scan ports or follow redirects. */ export async function workPageResponds(address: string): Promise { try { @@ -31,12 +44,42 @@ export async function workPageResponds(address: string): Promise { } } +/** Identity for pages whose launch method is unknown; only bounded local HTML. */ +export async function workPageSignature(address: string): Promise { + try { + const url = new URL(address); + if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + || url.username || url.password) return null; + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); + if (!response.ok || !response.headers.get('content-type')?.includes('text/html') || !response.body) { + await response.body?.cancel(); + return null; + } + const reader = response.body.getReader(); + const hash = createHash('sha256'); + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return hash.digest('hex'); + bytes += value.byteLength; + if (bytes > 256 * 1024) return null; + hash.update(value); + } + } finally { await reader.cancel().catch(() => undefined); } + } catch { return null; } +} + export class CodingWorkPreviewService { private readonly attempts = new Map(); private readonly flights = new Map>(); + private readonly flightHelp = new Map(); // Native views are disposable during background sleep; the running project // server and its verified address have a separate lifetime. private readonly pages = new Map(); + private readonly pageRevisions = new Map(); + private readonly rememberedWrites = new Map>(); + private readonly loaded = new Map(); constructor(private readonly deps: { browser: Pick; @@ -45,55 +88,106 @@ export class CodingWorkPreviewService { ensureActive(project: Project): Promise; responds?(url: string): Promise; inspectWork?(projectPath: string): Promise; + entries?: { load: typeof loadWorkEntry; save: typeof saveWorkEntry }; + launcher?: { discover: typeof discoverWorkLaunch; start: typeof startWorkLaunch; matches?: typeof workLaunchMatches; stop?: typeof stopWorkLaunch }; + storedSnapshot?: CodingConversationService['getStoredSnapshot']; + signature?(url: string): Promise; }) {} - remember(project: Project, snapshot: AgentBrowserSnapshot): void { + remember(project: Project, snapshot: AgentBrowserSnapshot, launch?: WorkLaunchSpec): Promise { if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId - || snapshot.state !== 'attached' || snapshot.error) return; + || snapshot.state !== 'attached' || snapshot.error) return Promise.resolve(); this.pages.delete(project.id); if (this.pages.size >= 100) this.pages.delete(this.pages.keys().next().value!); this.pages.set(project.id, { path: project.path, url: snapshot.url }); + this.pageRevisions.set(project.id, (this.pageRevisions.get(project.id) ?? 0) + 1); this.attempts.delete(project.id); // A successful manual/Agent open supersedes a slow older startup check. this.flights.delete(project.id); + const previous = this.rememberedWrites.get(project.id); + const write = (async () => { + await previous?.catch(() => undefined); + const recipe = launch ?? await (this.deps.launcher?.discover ?? discoverWorkLaunch)(project.path).catch(() => null); + const documentHash = await (this.deps.signature ?? workPageSignature)(snapshot.url); + const entry: WorkEntry = { version: 1, projectId: project.id, projectPath: project.path, url: snapshot.url, + ...(documentHash ? { documentHash } : {}), + ...(recipe ? { launch: recipe } : {}) }; + this.loaded.set(project.id, entry); + await (this.deps.entries?.save ?? saveWorkEntry)(project, entry); + })(); + this.rememberedWrites.set(project.id, write); + // Manual opens remain usable if project metadata temporarily cannot be saved. + void write.catch(() => undefined); + return write; } - async ensure(project: Project, requestId: string, conversationId?: string): Promise { + async ensure(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise { await this.deps.ensureActive(project); // Serialize browser checks as well as prompt submission for each project. const existing = this.flights.get(project.id); - if (existing) return existing; - const flight = this.check(project, requestId, conversationId); + if (existing) { + if (allowAssistant && !this.flightHelp.get(project.id)) { + await existing; + return this.ensure(project, requestId, conversationId, true); + } + return existing; + } + const flight = this.check(project, requestId, conversationId, allowAssistant).catch((error: unknown) => { + const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code : 'WORK_PREVIEW_START_FAILED'; + if (['PROJECT_NOT_ACTIVE', 'PROJECT_MISMATCH'].includes(code)) throw error; + const result = { status: 'failed' as const, errorCode: code, message: codingRecovery(code, true).message }; + const attempt = this.attempts.get(project.id); + if (attempt?.requestId === requestId) attempt.result = result; + return result; + }); this.flights.set(project.id, flight); + this.flightHelp.set(project.id, allowAssistant); try { return await flight; } - finally { if (this.flights.get(project.id) === flight) this.flights.delete(project.id); } + finally { if (this.flights.get(project.id) === flight) { this.flights.delete(project.id); this.flightHelp.delete(project.id); } } } - private async check(project: Project, requestId: string, conversationId?: string): Promise { + private async check(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise { const { browser, conversations } = this.deps; + const pageRevision = this.pageRevisions.get(project.id) ?? 0; + const superseded = () => (this.pageRevisions.get(project.id) ?? 0) !== pageRevision; + await this.rememberedWrites.get(project.id)?.catch(() => undefined); + if (!this.loaded.has(project.id) || this.loaded.get(project.id)?.projectPath !== project.path) { + const loaded = await (this.deps.entries?.load ?? loadWorkEntry)(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + this.loaded.set(project.id, loaded); + } + const saved = this.loaded.get(project.id); const snapshot = await browser.getSnapshot(project.path); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const responds = this.deps.responds ?? workPageResponds; const remembered = this.pages.get(project.id); const address = snapshot.projectId === project.id && snapshot.url ? snapshot.url - : remembered?.path === project.path ? remembered.url : undefined; - if (address && await responds(address)) { + : remembered?.path === project.path ? remembered.url : saved?.url; + const matches = this.deps.launcher?.matches ?? workLaunchMatches; + const addressMatches = async () => saved?.launch ? await matches(project, saved.launch, address!) + : saved?.documentHash ? await (this.deps.signature ?? workPageSignature)(address!) === saved.documentHash + : Boolean(remembered?.path === project.path || (snapshot.projectId === project.id && snapshot.url)); + if (address && await responds(address) && await addressMatches()) { await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const ready = snapshot.projectId === project.id && snapshot.url === address && snapshot.browserId && snapshot.state === 'attached' && !snapshot.error ? snapshot : await browser.open({ projectId: project.id, projectPath: project.path, url: address, visible: false }); await this.deps.ensureActive(project); - this.remember(project, ready); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + await this.remember(project, ready, saved?.launch).catch(() => undefined); return { status: 'ready', browser: ready }; } - if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId, allowAssistant); this.pages.delete(project.id); await this.deps.ensureActive(project); let attempt = this.attempts.get(project.id); if (attempt?.submitted && attempt.conversationId) { const current = await conversations.getSnapshot(attempt.conversationId); - if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (!['idle', 'error'].includes(current.run.status)) { return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; } @@ -120,13 +214,94 @@ export class CodingWorkPreviewService { if (busy()) return { status: 'waiting' }; const presence = await (this.deps.inspectWork ?? inspectWorkPresence)(project.path); await this.deps.ensureActive(project); - if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (busy()) return { status: 'waiting' }; if (presence === 'empty') { // No cached terminal attempt: the same tab/request can discover newly made work. this.attempts.delete(project.id); return { status: 'empty' }; } + // Upgrade existing projects from successful browser tool receipts, never + // assistant prose or an arbitrary link, and never start Pi to read them. + let discoveryError: unknown; + const launch = saved?.launch ?? await (this.deps.launcher?.discover ?? discoverWorkLaunch)(project.path).catch((error: unknown) => { + discoveryError = error; + return null; + }); + if (!saved && this.deps.storedSnapshot) { + for (const item of list.slice(0, 20)) { + const history = await this.deps.storedSnapshot(item.id).catch(() => null); + await this.deps.ensureActive(project); + if (!history || history.conversation.projectId !== project.id) continue; + for (const node of [...history.nodes].reverse()) { + if (node.kind !== 'tool' || node.status !== 'complete' || node.toolName !== 'agent_browser' + || node.details?.schema !== 'agent-browser.v1' || node.details.action !== 'open') continue; + const text = node.output.filter((block) => block.kind === 'text').map((block) => block.text).join(''); + if (text.length > 16_384) continue; + let known: AgentBrowserSnapshot; + try { known = JSON.parse(text); } catch { continue; } + if (!known || typeof known !== 'object') continue; + if (known.projectId !== project.id || known.state !== 'attached' || !known.browserId || !known.url + || !launch || !(await responds(known.url)) || !(await matches(project, launch, known.url))) continue; + await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + const restored = await browser.open({ projectId: project.id, projectPath: project.path, url: known.url, visible: false }); + await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + await this.remember(project, restored, launch ?? undefined).catch(() => undefined); + return { status: 'ready', browser: restored }; + } + } + } + if (discoveryError && !allowAssistant) throw discoveryError; + await this.deps.ensureActive(project); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); + if (busy()) return { status: 'waiting' }; + if (launch) { + try { + const url = await (this.deps.launcher?.start ?? startWorkLaunch)(project, launch, address); + const stopUnused = async () => { + const newer = this.pages.get(project.id); + if (!newer || !sameWorkServer(newer.url, url)) await (this.deps.launcher?.stop ?? stopWorkLaunch)(project, url); + }; + if (superseded()) { + await stopUnused(); + return this.check(project, requestId, conversationId, allowAssistant); + } + // Keep the verified server associated with its own project even if the + // student switches projects while it starts. Only UI attachment is gated. + const startedEntry: WorkEntry = { version: 1, projectId: project.id, projectPath: project.path, url, launch }; + this.loaded.set(project.id, startedEntry); + const previousWrite = this.rememberedWrites.get(project.id); + const startedWrite = (async () => { + await previousWrite?.catch(() => undefined); + await (this.deps.entries?.save ?? saveWorkEntry)(project, startedEntry); + })(); + this.rememberedWrites.set(project.id, startedWrite); + await startedWrite.catch(() => undefined); + if (superseded()) { + await stopUnused(); + return this.check(project, requestId, conversationId, allowAssistant); + } + await this.deps.ensureActive(project); + if (superseded()) { + await stopUnused(); + return this.check(project, requestId, conversationId, allowAssistant); + } + const opened = await browser.open({ projectId: project.id, projectPath: project.path, url, visible: false }); + await this.deps.ensureActive(project); + if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); + await this.remember(project, opened, launch).catch(() => undefined); + return { status: 'ready', browser: opened }; + } catch (error) { + if (!allowAssistant || (error && typeof error === 'object' && 'code' in error + && ['PROJECT_NOT_ACTIVE', 'PROJECT_MISMATCH'].includes(String(error.code)))) throw error; + // The user chose assistance for a known launch failure. The assistant + // may inspect it; merely reopening the tab never takes this path. + } + } + if (!allowAssistant) return { status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP', + message: codingRecovery('WORK_PREVIEW_NEEDS_HELP', true).message }; let conversation = attempt.conversationId ? await conversations.getConversation(attempt.conversationId) : list.find((item) => !item.archivedAt); @@ -134,7 +309,7 @@ export class CodingWorkPreviewService { conversation ??= await conversations.createConversation({ projectId: project.id, title: '新对话' }); attempt.conversationId = conversation.id; const current = await conversations.getSnapshot(conversation.id); - if (this.pages.has(project.id)) return this.check(project, requestId, conversationId); + if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (!['idle', 'error'].includes(current.run.status)) return { status: 'waiting', conversation }; await this.deps.ensureActive(project); // Mark before dispatch: uncertain acceptance must never cause automatic resubmission. diff --git a/shared/coding-recovery.ts b/shared/coding-recovery.ts index d5a3d6a8..2d19b0c7 100644 --- a/shared/coding-recovery.ts +++ b/shared/coding-recovery.ts @@ -1,7 +1,19 @@ -export type CodingRecoveryAction = 'login' | 'continue' | 'check-work' | 'refresh' | 'settings' | 'account'; +export type CodingRecoveryAction = 'login' | 'continue' | 'check-work' | 'assist-work' | 'refresh' | 'settings' | 'account'; /** Product copy is intentionally independent of raw provider/tool diagnostics. */ export function codingRecovery(errorCode?: string, work = false) { + if (work && errorCode === 'WORK_PREVIEW_NEEDS_HELP') { + return { action: 'assist-work' as const, label: '让助手检查', message: '还没找到这个作品的打开方式,可以让助手检查已有文件和启动情况。' }; + } + if (work && errorCode === 'WORK_PREVIEW_DEPENDENCIES_MISSING') { + return { action: 'assist-work' as const, label: '让助手检查', message: '这个作品缺少运行组件,可以让助手检查并帮你准备。' }; + } + if (work && errorCode === 'WORK_PREVIEW_ENTRY_MISSING') { + return { action: 'assist-work' as const, label: '让助手检查', message: '之前的作品入口找不到了,可能移动或修改过文件,可以让助手重新检查。' }; + } + if (work && errorCode === 'WORK_PREVIEW_START_FAILED') { + return { action: 'check-work' as const, label: '重新打开作品', message: '作品没能启动。可以重新打开,或让助手检查原因。' }; + } if (errorCode === 'CODING_PROVIDER_AUTH_REQUIRED' || errorCode === 'AUTH_INVALID') { return { action: 'login' as const, label: '登录并继续', message: '登录已失效,重新登录后就能接着完成这一步。' }; } diff --git a/src/lib/coding-work-preview.ts b/src/lib/coding-work-preview.ts index da5ed50c..4f962365 100644 --- a/src/lib/coding-work-preview.ts +++ b/src/lib/coding-work-preview.ts @@ -1,9 +1,10 @@ import { hostApiFetch } from './host-api'; import type { WorkPreviewResult } from '../../shared/coding-work-preview'; -export function ensureWorkPreview(projectId: string, requestId: string, conversationId?: string | null): Promise { +export function ensureWorkPreview(projectId: string, requestId: string, conversationId?: string | null, allowAssistant = false): Promise { return hostApiFetch('/api/agent-browser/ensure-work', { method: 'POST', - body: JSON.stringify({ project_id: projectId, request_id: requestId, conversation_id: conversationId }), + body: JSON.stringify({ project_id: projectId, request_id: requestId, conversation_id: conversationId, + ...(allowAssistant ? { allow_assistant: true } : {}) }), }); } diff --git a/src/pages/Chat/AgentBrowserPanel.tsx b/src/pages/Chat/AgentBrowserPanel.tsx index f2384c77..d4996ffa 100644 --- a/src/pages/Chat/AgentBrowserPanel.tsx +++ b/src/pages/Chat/AgentBrowserPanel.tsx @@ -567,10 +567,12 @@ export function AgentBrowserPanel({ : '输入 URL,或等待智能体打开调试页面'}

} {embedded &&
{!workEmpty && (presentationFailed ? : work.result?.status === 'failed' && )} + {!workEmpty && work.result?.errorCode === 'WORK_PREVIEW_START_FAILED' && }
}
diff --git a/src/pages/Chat/use-work-preview.ts b/src/pages/Chat/use-work-preview.ts index d6b352e4..44faccf2 100644 --- a/src/pages/Chat/use-work-preview.ts +++ b/src/pages/Chat/use-work-preview.ts @@ -17,6 +17,7 @@ export function useWorkPreview(input: { const onResult = useRef(input.onResult); const conversationId = useRef(input.conversationId); const manualRevision = useRef(0); + const helpAttempt = useRef<{ projectId: string | null; version: string } | null>(null); const checkRef = useRef<(() => Promise) | null>(null); useEffect(() => { onResult.current = input.onResult; @@ -26,6 +27,7 @@ export function useWorkPreview(input: { if (!input.enabled || !input.projectId) return; const projectId = input.projectId; const version = `${revision}:${input.revision ?? 0}`; + const allowAssistant = helpAttempt.current?.projectId === projectId && helpAttempt.current.version === version; if (attempt.current?.projectId !== projectId || attempt.current.revision !== version) { attempt.current = { projectId, revision: version, requestId: crypto.randomUUID(), transportFailures: 0 }; } @@ -43,7 +45,9 @@ export function useWorkPreview(input: { const requestedAtRevision = manualRevision.current; inFlightRevision = requestedAtRevision; try { - const next = await ensureWorkPreview(projectId, requestId, conversationId.current); + const next = allowAssistant + ? await ensureWorkPreview(projectId, requestId, conversationId.current, true) + : await ensureWorkPreview(projectId, requestId, conversationId.current); if (cancelled || manualRevision.current !== requestedAtRevision) return; attempt.current!.transportFailures = 0; setResult(next); @@ -52,15 +56,18 @@ export function useWorkPreview(input: { } catch (error) { if (cancelled || manualRevision.current !== requestedAtRevision) return; const failure = normalizeAppError(error); - const transient = failure.code === 'NETWORK' || failure.code === 'TIMEOUT'; + const backendCode = failure.details?.backendCode; + const errorCode = typeof backendCode === 'string' && /^(CODING_|WORK_PREVIEW_)/.test(backendCode) + ? backendCode : failure.code; + const transient = errorCode === 'NETWORK' || errorCode === 'TIMEOUT'; const failures = ++attempt.current!.transportFailures; if (transient && failures <= 2) { setResult({ status: 'starting', message: '连接暂时断开,正在重新连接…' }); timer = setTimeout(check, failures * 2_000); } else { - setResult({ status: 'failed', errorCode: failure.code, message: transient + setResult({ status: 'failed', errorCode, message: transient ? '连接还没有恢复。检查网络后,点“再试一次”即可。' - : codingRecovery(failure.code, true).message }); + : codingRecovery(errorCode, true).message }); } } finally { if (inFlightRevision === requestedAtRevision) inFlightRevision = null; @@ -95,5 +102,9 @@ export function useWorkPreview(input: { void checkRef.current?.(); } else setRevision((value) => value + 1); }; - return { result, showBrowser, restore, retry }; + const requestHelp = () => { + helpAttempt.current = { projectId: input.projectId, version: `${revision + 1}:${input.revision ?? 0}` }; + setRevision((value) => value + 1); + }; + return { result, showBrowser, restore, retry, requestHelp }; } diff --git a/tests/e2e/work-entry-restoration.spec.ts b/tests/e2e/work-entry-restoration.spec.ts new file mode 100644 index 00000000..0f6faeb4 --- /dev/null +++ b/tests/e2e/work-entry-restoration.spec.ts @@ -0,0 +1,74 @@ +import type { Page } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { expect, getStableWindow, test } from './fixtures/electron'; +import type { WorkPreviewResult } from '../../shared/coding-work-preview'; + +async function hostRequest(page: Page, path: string, body?: unknown): Promise { + const response = await page.evaluate(async (request) => await window.electron.ipcRenderer.invoke('hostapi:fetch', request) as { + ok: boolean; data?: { ok: boolean; json?: unknown }; + }, { path, method: body === undefined ? 'GET' : 'POST', body }); + expect(response, `${path}: ${JSON.stringify(response)}`).toMatchObject({ ok: true, data: { ok: true } }); + return response.data!.json as T; +} + +test('existing work survives a full app restart and restarts a stopped service without choosing a model', async ({ launchElectronApp, electronTestResources }) => { + const projectPath = join(electronTestResources.rootDir, 'remembered-work'); + await mkdir(projectPath); + const html = 'Remembered project work

My existing game

'; + await writeFile(join(projectPath, 'index.html'), html); + const server = createServer((_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.end(html); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing test port'); + const url = `http://127.0.0.1:${address.port}/`; + let serverClosed = false; + try { + const first = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(first); + const { project } = await hostRequest<{ project: { id: string } }>(page, '/api/coding/projects/open', { projectPath }); + const listPath = `/api/coding/projects/conversations?project_id=${project.id}`; + const before = await hostRequest<{ conversations: unknown[] }>(page, listPath); + await page.evaluate(() => window.electron.ipcRenderer.invoke('lifecycle:activity', { visible: true, module: 'programming' })); + await hostRequest(page, '/api/agent-browser/open', { project_id: project.id, url, visible: false, + bounds: { x: 20, y: 70, width: 420, height: 300 } }); + const record = JSON.parse(await readFile(join(projectPath, '.makelore/work-entry.json'), 'utf8')); + expect(record).toMatchObject({ projectId: project.id, url, launch: { kind: 'static', directory: '.', entry: 'index.html' } }); + await first.close(); + + const restarted = await launchElectronApp({ skipSetup: true }); + page = await getStableWindow(restarted); + await hostRequest(page, '/api/coding/projects/open', { projectPath }); + const restored = await hostRequest(page, '/api/agent-browser/ensure-work', { project_id: project.id, request_id: 'app-restarted' }); + expect(restored).toMatchObject({ status: 'ready', browser: { url, state: 'attached' } }); + expect(await hostRequest(page, listPath)).toEqual(before); + expect(await restarted.evaluate(({ webContents }, expectedUrl) => webContents.getAllWebContents().some((contents) => contents.getURL() === expectedUrl && contents.getTitle() === 'Remembered project work'), url)).toBe(true); + + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + serverClosed = true; + const started = await hostRequest(page, '/api/agent-browser/ensure-work', { project_id: project.id, request_id: 'service-stopped' }); + expect(started).toMatchObject({ status: 'ready', browser: { state: 'attached' } }); + expect(await (await fetch(started.browser!.url)).text()).toBe(html); + expect(await hostRequest(page, listPath)).toEqual(before); + // Use the real native presentation route; this fixture has no authenticated + // cloud account and must not fabricate one to enter the guarded Code UI. + await hostRequest(page, '/api/agent-browser/present', { project_id: project.id, visible: true, + bounds: { x: 20, y: 70, width: 420, height: 300 } }); + expect(await restarted.evaluate(async ({ webContents }, expectedUrl) => { + const contents = webContents.getAllWebContents().find((item) => item.getURL() === expectedUrl); + return await contents?.executeJavaScript('document.querySelector("h1")?.textContent'); + }, started.browser!.url)).toBe('My existing game'); + await page.screenshot({ path: test.info().outputPath('restored-work-without-model.png') }); + } finally { + if (!serverClosed) await new Promise((resolve) => server.close(() => resolve())); + const log = await readFile(join(projectPath, '.makelore/logs/static-preview.log'), 'utf8').catch(() => ''); + for (const match of log.matchAll(/Makelore launch pid=(\d+) /g)) { + const pid = Number(match[1]); + try { process.kill(process.platform === 'win32' ? pid : -pid, 'SIGTERM'); } catch { /* Test child has already exited. */ } + } + } +}); diff --git a/tests/unit/coding-work-preview.test.ts b/tests/unit/coding-work-preview.test.ts index f9e0d31d..eaea6f7f 100644 --- a/tests/unit/coding-work-preview.test.ts +++ b/tests/unit/coding-work-preview.test.ts @@ -9,6 +9,7 @@ import { firstMessageTitle } from '../../electron/coding-runtime/conversation-ti import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts'; import type { CodingConversationV2 } from '../../electron/coding-projects/conversation-store'; +import type { WorkEntry, WorkLaunchSpec } from '../../electron/coding-runtime/work-entry-contracts'; const project = { id: 'p1', path: '/project' }; const conversation = { id: 'c1', archivedAt: null } as CodingConversationV2; @@ -26,8 +27,11 @@ function setup() { const ensureActive = vi.fn(async () => {}); const responds = vi.fn(async () => false); const inspectWork = vi.fn(async (_projectPath: string): Promise => 'possible'); - const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork }); - return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot }; + const entries = { load: vi.fn(async (): Promise => null), save: vi.fn(async () => {}) }; + const launcher = { discover: vi.fn(async (): Promise => null), start: vi.fn(async () => 'http://127.0.0.1:5173/'), matches: vi.fn(async () => true), stop: vi.fn(async () => {}) }; + const signature = vi.fn(async () => 'a'.repeat(64)); + const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork, entries, launcher, signature }); + return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot, entries, launcher, signature }; } const directories: string[] = []; @@ -37,6 +41,173 @@ afterEach(async () => { }); describe('opening a work preview', () => { + it('restores a persisted project page in a new service before any model or conversation access', async () => { + const s = setup(); + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', documentHash: 'a'.repeat(64) }); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'restored', state: 'attached', url: 'http://localhost:3000' }); + s.conversations.getSnapshot.mockRejectedValue(Object.assign(new Error('no model'), { code: 'CODING_MIGRATION_MODEL_REQUIRED' })); + expect(await s.service.ensure(project, 'after-restart')).toMatchObject({ status: 'ready' }); + expect(s.conversations.listConversations).not.toHaveBeenCalled(); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.createConversation).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('restarts saved existing work locally once across simultaneous clicks without a model', async () => { + const s = setup(); + const launch: WorkLaunchSpec = { kind: 'vite', directory: 'game', args: [] }; + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', launch }); + s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'new', state: 'attached', url: 'http://127.0.0.1:5173/' }); + const results = await Promise.all([s.service.ensure(project, 'one'), s.service.ensure(project, 'two')]); + expect(results.every((result) => result.status === 'ready')).toBe(true); + expect(s.launcher.start).toHaveBeenCalledOnce(); + expect(s.launcher.start).toHaveBeenCalledWith(project, launch, 'http://localhost:3000'); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ launch })); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.createConversation).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('offers explicit help for an unknown entry and preserves the model-required error when help is chosen', async () => { + const s = setup(); + expect(await s.service.ensure(project, 'automatic', 'c1')).toMatchObject({ status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + expect(s.conversations.createConversation).not.toHaveBeenCalled(); + s.conversations.getSnapshot.mockRejectedValue(Object.assign(new Error('no model'), { code: 'CODING_MIGRATION_MODEL_REQUIRED' })); + expect(await s.service.ensure(project, 'help', 'c1', true)).toMatchObject({ status: 'failed', errorCode: 'CODING_MIGRATION_MODEL_REQUIRED', message: expect.stringContaining('选择一个可用模型') }); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('upgrades a legacy project from a successful browser receipt without starting its Agent', async () => { + const s = setup(); + s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' }); + const receipt = { ...s.snapshot, browserId: 'previous', state: 'attached', url: 'http://localhost:3000' }; + const storedSnapshot = vi.fn(async () => ({ conversation: { projectId: project.id }, nodes: [{ kind: 'tool', status: 'complete', toolName: 'agent_browser', details: { schema: 'agent-browser.v1', action: 'open' }, output: [{ kind: 'text', text: JSON.stringify(receipt) }] }] }) as ConversationSnapshot); + const service = new CodingWorkPreviewService({ ...s, entries: s.entries, launcher: s.launcher, storedSnapshot }); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue(receipt as AgentBrowserSnapshot); + expect(await service.ensure(project, 'upgrade')).toMatchObject({ status: 'ready' }); + expect(storedSnapshot).toHaveBeenCalledWith('c1'); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: receipt.url })); + expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('reports a local startup failure without dispatching a model request', async () => { + const s = setup(); + s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' }); + s.launcher.start.mockRejectedValue(Object.assign(new Error('missing entry'), { code: 'WORK_PREVIEW_ENTRY_MISSING' })); + expect(await s.service.ensure(project, 'open')).toMatchObject({ status: 'failed', errorCode: 'WORK_PREVIEW_ENTRY_MISSING', message: expect.stringContaining('入口找不到') }); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('saves a verified address even when its launch method cannot be inferred', async () => { + const s = setup(); + s.launcher.discover.mockRejectedValue(Object.assign(new Error('two works'), { code: 'WORK_PREVIEW_NEEDS_HELP' })); + await s.service.remember(project, { ...s.snapshot, browserId: 'open', state: 'attached', url: 'http://localhost:3000' }); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: 'http://localhost:3000' })); + }); + + it('lets explicit assistant help inspect an ambiguous or failed local launch', async () => { + const s = setup(); + s.launcher.discover.mockRejectedValue(Object.assign(new Error('two works'), { code: 'WORK_PREVIEW_NEEDS_HELP' })); + expect(await s.service.ensure(project, 'automatic', 'c1')).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + expect(await s.service.ensure(project, 'help', 'c1', true)).toMatchObject({ status: 'starting' }); + expect(s.conversations.acceptPrompt).toHaveBeenCalledOnce(); + }); + + it('keeps a newly started address for its own project when the student switches away', async () => { + const s = setup(); + s.launcher.discover.mockResolvedValue({ kind: 'static', directory: '.', entry: 'index.html' }); + s.launcher.start.mockImplementation(async () => { + s.ensureActive.mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' })); + return 'http://127.0.0.1:5173/'; + }); + await expect(s.service.ensure(project, 'open')).rejects.toThrow('project changed'); + expect(s.entries.save).toHaveBeenCalledWith(project, expect.objectContaining({ url: 'http://127.0.0.1:5173/' })); + expect(s.browser.open).not.toHaveBeenCalled(); + s.ensureActive.mockResolvedValue(); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue({ ...s.snapshot, browserId: 'restored', state: 'attached', url: 'http://127.0.0.1:5173/' }); + expect(await s.service.ensure(project, 'return')).toMatchObject({ status: 'ready' }); + expect(s.launcher.start).toHaveBeenCalledOnce(); + }); + + it('preserves a manual open that supersedes a slow local startup', async () => { + const s = setup(); + const launch: WorkLaunchSpec = { kind: 'static', directory: '.', entry: 'index.html' }; + s.launcher.discover.mockResolvedValue(launch); + let finish!: (url: string) => void; + s.launcher.start.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const pending = s.service.ensure(project, 'open'); + await vi.waitFor(() => expect(s.launcher.start).toHaveBeenCalled()); + const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' }; + await s.service.remember(project, manual, launch); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue(manual); + finish('http://127.0.0.1:5173/'); + expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } }); + expect(s.launcher.stop).toHaveBeenCalledWith(project, 'http://127.0.0.1:5173/'); + expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: manual.url })); + }); + + it('queues an explicit helper choice behind an already pending automatic check', async () => { + const s = setup(); + let finish!: () => void; + s.inspectWork.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve('possible'); })); + const automatic = s.service.ensure(project, 'automatic', 'c1'); + await vi.waitFor(() => expect(s.inspectWork).toHaveBeenCalled()); + const help = s.service.ensure(project, 'help', 'c1', true); + finish(); + expect(await automatic).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(await help).toMatchObject({ status: 'starting' }); + expect(s.conversations.acceptPrompt).toHaveBeenCalledOnce(); + }); + + it('does not restore an unknown saved page whose content no longer matches', async () => { + const s = setup(); + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000', documentHash: 'b'.repeat(64) }); + s.responds.mockResolvedValue(true); + expect(await s.service.ensure(project, 'open')).toMatchObject({ errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(s.browser.open).not.toHaveBeenCalled(); + expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); + }); + + it('keeps a newer manual page when a slow saved-address probe completes later', async () => { + const s = setup(); + s.entries.load.mockResolvedValue({ version: 1, projectId: project.id, projectPath: project.path, url: 'http://localhost:3000/old', documentHash: 'a'.repeat(64) }); + let finish!: (value: boolean) => void; + s.responds.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(true); + const pending = s.service.ensure(project, 'restore'); + await vi.waitFor(() => expect(s.responds).toHaveBeenCalled()); + const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000/new' }; + await s.service.remember(project, manual); + s.browser.open.mockResolvedValue(manual); + finish(true); + expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } }); + expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: manual.url })); + expect(s.browser.open).not.toHaveBeenCalledWith(expect.objectContaining({ url: 'http://localhost:3000/old' })); + }); + + it('does not stop the new server when a manual page uses another path on that same server', async () => { + const s = setup(); + const launch: WorkLaunchSpec = { kind: 'static', directory: '.', entry: 'index.html' }; + s.launcher.discover.mockResolvedValue(launch); + let finish!: (url: string) => void; + s.launcher.start.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const pending = s.service.ensure(project, 'open'); + await vi.waitFor(() => expect(s.launcher.start).toHaveBeenCalled()); + const manual = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://127.0.0.1:5173/play?level=2' }; + await s.service.remember(project, manual, launch); + s.responds.mockResolvedValue(true); + s.browser.open.mockResolvedValue(manual); + finish('http://127.0.0.1:5173/'); + expect(await pending).toMatchObject({ status: 'ready', browser: { url: manual.url } }); + expect(s.launcher.stop).not.toHaveBeenCalled(); + }); it('keeps repeated empty-project clicks out of conversations and discovers work made later', async () => { const s = setup(); const path = await mkdtemp(join(tmpdir(), 'makelore-empty-preview-')); @@ -59,7 +230,7 @@ describe('opening a work preview', () => { await mkdir(join(path, 'game')); await writeFile(join(path, 'game/play.html'), '

Game

'); - expect(await s.service.ensure(emptyProject, 'new-click')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(emptyProject, 'new-click', undefined, true)).toMatchObject({ status: 'starting' }); expect(s.conversations.createConversation).toHaveBeenCalledTimes(1); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); @@ -67,7 +238,7 @@ describe('opening a work preview', () => { it('preserves startup when a local check cannot establish absence', async () => { const s = setup(); s.inspectWork.mockResolvedValue('unknown'); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); @@ -75,19 +246,19 @@ describe('opening a work preview', () => { const s = setup(); s.inspectWork.mockResolvedValue('empty'); s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' }); expect(s.inspectWork).not.toHaveBeenCalled(); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'empty' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'empty' }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); it('revalidates the active project after an asynchronous presence check', async () => { const s = setup(); s.inspectWork.mockImplementation(async () => { - s.ensureActive.mockRejectedValue(new Error('project changed')); + s.ensureActive.mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' })); return 'empty'; }); - await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed'); + await expect(s.service.ensure(project, 'req', 'c1', true)).rejects.toThrow('project changed'); expect(s.conversations.createConversation).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -98,7 +269,7 @@ describe('opening a work preview', () => { s.runtime.getDiagnostics.mockReturnValue({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never); return 'empty'; }); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' }); expect(s.conversations.createConversation).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -107,13 +278,13 @@ describe('opening a work preview', () => { const s = setup(); let finish!: (value: ConversationSnapshot) => void; s.conversations.getSnapshot.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); - const pending = s.service.ensure(project, 'req', 'c1'); + const pending = s.service.ensure(project, 'req', 'c1', true); await vi.waitFor(() => expect(s.conversations.getSnapshot).toHaveBeenCalled()); const healthy = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' }; s.service.remember(project, healthy); s.responds.mockResolvedValue(true); s.browser.open.mockResolvedValue(healthy); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' }); finish({ run: { status: 'idle' } } as ConversationSnapshot); expect(await pending).toMatchObject({ status: 'ready' }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); @@ -126,7 +297,7 @@ describe('opening a work preview', () => { s.browser.open.mockResolvedValue({ ...healthy, generation: 2 }); s.responds.mockResolvedValue(true); s.conversations.getSnapshot.mockRejectedValue(new Error('login required')); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' }); expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -142,19 +313,19 @@ describe('opening a work preview', () => { it('returns the login recovery cause after an accepted action fails, without automatically resending', async () => { const s = setup(); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'error', runId: 'work-run', error: { code: 'CODING_PROVIDER_AUTH_REQUIRED' } } } as ConversationSnapshot); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') }); - await s.service.ensure(project, 'req', 'c1'); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') }); + await s.service.ensure(project, 'req', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); - await s.service.ensure(project, 'after-login', 'c1'); + await s.service.ensure(project, 'after-login', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2); }); it('reuses a healthy existing page without invoking the Agent or reopening the browser', async () => { const s = setup(); Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: s.snapshot }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready', browser: s.snapshot }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); expect(s.browser.open).not.toHaveBeenCalled(); expect(s.inspectWork).not.toHaveBeenCalled(); @@ -164,7 +335,7 @@ describe('opening a work preview', () => { const s = setup(); Object.assign(s.snapshot, { state: 'crashed', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: s.snapshot.url, projectPath: project.path })); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); @@ -174,10 +345,10 @@ describe('opening a work preview', () => { const healthy = { ...s.snapshot, browserId: 'b1', state: 'attached' as const, url: 'http://localhost:3000' }; s.browser.getSnapshot.mockResolvedValue(healthy); s.responds.mockResolvedValue(true); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null }); s.browser.open.mockResolvedValue({ ...healthy, browserId: 'restored', generation: 2 }); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } }); expect(s.browser.open).toHaveBeenCalledWith({ projectId: 'p1', projectPath: '/project', url: healthy.url, visible: false }); expect(s.conversations.getSnapshot).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); @@ -187,72 +358,72 @@ describe('opening a work preview', () => { const s = setup(); s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, browserId: 'b1', state: 'attached', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null }); s.responds.mockResolvedValue(false); - expect(await s.service.ensure(project, 'retry', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'retry', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.browser.open).not.toHaveBeenCalled(); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('sends the fixed action once across simultaneous clicks and later polls', async () => { const s = setup(); - await Promise.all([s.service.ensure(project, 'req', 'c1'), s.service.ensure(project, 'other', 'c1')]); + await Promise.all([s.service.ensure(project, 'req', 'c1', true), s.service.ensure(project, 'other', 'c1', true)]); s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'running', runId: 'work-run' } } as ConversationSnapshot); - expect(await s.service.ensure(project, 'another', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'another', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); expect(s.conversations.acceptPrompt).toHaveBeenCalledWith({ conversationId: 'c1', clientRequestId: 'work-preview-req', mode: 'prompt', text: OPEN_WORK_PROMPT }); Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' }); s.responds.mockResolvedValue(true); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'ready' }); }); it('waits for current project work and then starts without steering the active run', async () => { const s = setup(); s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never); - expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toEqual({ status: 'waiting' }); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('settles failures without resubmission until an explicit retry', async () => { const s = setup(); - await s.service.ensure(project, 'req', 'c1'); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' }); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' }); + await s.service.ensure(project, 'req', 'c1', true); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); - await s.service.ensure(project, 'retry', 'c1'); + await s.service.ensure(project, 'retry', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2); }); it('does not automatically resend an uncertain prompt acceptance', async () => { const s = setup(); s.conversations.acceptPrompt.mockRejectedValue(new Error('unknown receipt')); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' }); - await s.service.ensure(project, 'req', 'c1'); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'failed' }); + await s.service.ensure(project, 'req', 'c1', true); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('waits for the accepted run instead of mistaking the preceding idle snapshot for completion', async () => { const s = setup(); - await s.service.ensure(project, 'req', 'c1'); + await s.service.ensure(project, 'req', 'c1', true); s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'idle', runId: 'old-run' } } as ConversationSnapshot); - expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' }); + expect(await s.service.ensure(project, 'req', 'c1', true)).toMatchObject({ status: 'starting' }); expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1); }); it('creates a conversation if needed without submitting a student draft', async () => { const s = setup(); s.conversations.listConversations.mockResolvedValue([]); - expect(await s.service.ensure(project, 'req')).toMatchObject({ status: 'starting', conversation }); + expect(await s.service.ensure(project, 'req', undefined, true)).toMatchObject({ status: 'starting', conversation }); expect(s.conversations.createConversation).toHaveBeenCalledWith({ projectId: 'p1', title: '新对话' }); }); it('cancels dispatch when the project changes during a browser check', async () => { const s = setup(); - s.ensureActive.mockResolvedValueOnce().mockRejectedValue(new Error('project changed')); - await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed'); + s.ensureActive.mockResolvedValueOnce().mockRejectedValue(Object.assign(new Error('project changed'), { code: 'PROJECT_NOT_ACTIVE' })); + await expect(s.service.ensure(project, 'req', 'c1', true)).rejects.toThrow('project changed'); expect(s.conversations.acceptPrompt).not.toHaveBeenCalled(); }); diff --git a/tests/unit/use-work-preview.test.tsx b/tests/unit/use-work-preview.test.tsx index b252c48e..6e8ebe55 100644 --- a/tests/unit/use-work-preview.test.tsx +++ b/tests/unit/use-work-preview.test.tsx @@ -1,12 +1,32 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkPreview } from '@/pages/Chat/use-work-preview'; +import { AppError } from '@/lib/error-model'; const ensure = vi.hoisted(() => vi.fn()); vi.mock('@/lib/coding-work-preview', () => ({ ensureWorkPreview: ensure })); beforeEach(() => { vi.useFakeTimers(); ensure.mockReset(); }); afterEach(() => vi.useRealTimers()); describe('work preview request lifecycle', () => { + it('keeps the real model-required reason from the Host error instead of a generic retry', async () => { + ensure.mockRejectedValue(new AppError('UNKNOWN', 'backend failure', undefined, { backendCode: 'CODING_MIGRATION_MODEL_REQUIRED' })); + const { result } = renderHook(() => useWorkPreview({ projectId: 'p1', enabled: true, onResult: vi.fn() })); + await act(async () => {}); + expect(result.current.result).toMatchObject({ status: 'failed', errorCode: 'CODING_MIGRATION_MODEL_REQUIRED', message: expect.stringContaining('选择一个可用模型') }); + await act(async () => vi.advanceTimersByTimeAsync(60_000)); + expect(ensure).toHaveBeenCalledOnce(); + }); + + it('permits assistant help only after an explicit choice and only for that action', async () => { + ensure.mockResolvedValue({ status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP' }); + const { result, rerender } = renderHook(({ revision }) => useWorkPreview({ projectId: 'p1', conversationId: 'c1', enabled: true, revision, onResult: vi.fn() }), { initialProps: { revision: 0 } }); + await act(async () => {}); + expect(ensure.mock.calls[0]).toHaveLength(3); + await act(async () => result.current.requestHelp()); + expect(ensure.mock.calls[1]).toEqual(['p1', expect.any(String), 'c1', true]); + await act(async () => rerender({ revision: 1 })); + expect(ensure.mock.calls[2]).toHaveLength(3); + }); it('stops polling an empty project and checks again when the work tab is reopened', async () => { ensure.mockResolvedValue({ status: 'empty' }); const onResult = vi.fn(); diff --git a/tests/unit/work-entry-store.test.ts b/tests/unit/work-entry-store.test.ts new file mode 100644 index 00000000..3ccbe2b4 --- /dev/null +++ b/tests/unit/work-entry-store.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadWorkEntry, saveWorkEntry } from '../../electron/coding-runtime/work-entry-store'; +import type { WorkEntry, WorkEntryProject } from '../../electron/coding-runtime/work-entry-contracts'; + +const directories: string[] = []; + +async function project(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-entry-')); + directories.push(directory); + await mkdir(path.join(directory, '.makelore')); + return { id: `p-${directories.length}`, path: await realpath(directory) }; +} + +function entry(target: WorkEntryProject, patch: Partial = {}): WorkEntry { + return { + version: 1, projectId: target.id, projectPath: target.path, + url: 'http://127.0.0.1:5179/play?level=2#game', ...patch, + }; +} + +function entryFile(target: WorkEntryProject): string { + return path.join(target.path, '.makelore/work-entry.json'); +} + +async function corrupt(target: WorkEntryProject, value: unknown): Promise { + await writeFile(entryFile(target), JSON.stringify(value)); +} + +afterEach(async () => { + vi.resetModules(); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe('project work entry persistence', () => { + it('reloads a project-bound work address from disk after a fresh module instance', async () => { + const target = await project(); + const value = entry(target); + await saveWorkEntry(target, value); + vi.resetModules(); + const fresh = await import('../../electron/coding-runtime/work-entry-store'); + expect(await fresh.loadWorkEntry(target)).toEqual(value); + expect(JSON.parse(await readFile(entryFile(target), 'utf8'))).toEqual(value); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); + + it('preserves an optional document fingerprint through an atomic save and fresh reload', async () => { + const target = await project(); + const value = { ...entry(target), documentHash: '0123456789abcdef'.repeat(4) }; + await saveWorkEntry(target, value); + vi.resetModules(); + const fresh = await import('../../electron/coding-runtime/work-entry-store'); + expect(await fresh.loadWorkEntry(target)).toEqual(value); + expect(JSON.parse(await readFile(entryFile(target), 'utf8')).documentHash).toBe(value.documentHash); + }); + + it.each([null, 123, '', 'a'.repeat(63), 'a'.repeat(65), 'A'.repeat(64), 'g'.repeat(64)])('rejects an invalid document fingerprint: %j', async (documentHash) => { + const target = await project(); + const value = { ...entry(target), documentHash }; + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value as unknown as WorkEntry)).rejects.toThrow(); + }); + + it('binds the saved entry to the canonical project root', async () => { + const target = await project(); + const alias = path.join(target.path, 'project-alias'); + await symlink(target.path, alias, 'dir'); + const viaAlias = { ...target, path: alias }; + await saveWorkEntry(viaAlias, entry(viaAlias)); + expect(await loadWorkEntry(target)).toEqual(entry(target)); + expect(await loadWorkEntry(viaAlias)).toEqual(entry(target)); + }); + + it('persists safe Vite options for a nested work directory', async () => { + const target = await project(); + await mkdir(path.join(target.path, 'Alice 的游戏')); + const value = entry(target, { launch: { + kind: 'vite', directory: './Alice 的游戏', + args: ['--base', '/game/', '--mode', 'development', '--port', '5179', '--strictPort', '--force'], + } }); + await saveWorkEntry(target, value); + expect(await loadWorkEntry(target)).toEqual({ ...value, launch: { ...value.launch, directory: 'Alice 的游戏' } }); + }); + + it('accepts a root Vite work and a nested static entry without executing a recipe', async () => { + const target = await project(); + const vite = entry(target, { launch: { kind: 'vite', directory: '.', args: ['--base', './'] } }); + await saveWorkEntry(target, vite); + expect(await loadWorkEntry(target)).toEqual(vite); + await writeFile(path.join(target.path, 'index.html'), '

Root

'); + const rootStatic = entry(target, { launch: { kind: 'static', directory: '.', entry: 'index.html' } }); + await saveWorkEntry(target, rootStatic); + expect(await loadWorkEntry(target)).toEqual(rootStatic); + await mkdir(path.join(target.path, 'game/pages'), { recursive: true }); + await writeFile(path.join(target.path, 'game/pages/index.html'), '

Alice

'); + const staticEntry = entry(target, { launch: { kind: 'static', directory: 'game', entry: 'pages/index.html' } }); + await saveWorkEntry(target, staticEntry); + expect(await loadWorkEntry(target)).toEqual(staticEntry); + }); + + it('rejects copied entries with a different project id or root', async () => { + const source = await project(); + const destination = await project(); + await saveWorkEntry(source, entry(source)); + await copyFile(entryFile(source), entryFile(destination)); + expect(await loadWorkEntry(destination)).toBeNull(); + await corrupt(destination, entry(source, { projectId: destination.id })); + expect(await loadWorkEntry(destination)).toBeNull(); + await expect(saveWorkEntry(destination, entry(source))).rejects.toThrow(); + await expect(saveWorkEntry(destination, entry(source, { projectId: destination.id }))).rejects.toThrow(); + }); + + it.each([ + 'https://example.com/game', 'http://localhost.example.com/game', 'file:///project/index.html', + 'http://user:password@localhost:5179/', 'http://192.168.1.2:5179/', 'http://localhost:5179/\n', + `http://localhost:5179/${'a'.repeat(2_048)}`, + ])('rejects an unsafe work URL: %s', async (url) => { + const target = await project(); + const value = entry(target, { url }); + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value)).rejects.toThrow(); + }); + + it.each(['http://localhost:5179/', 'https://127.0.0.1:5179/', 'http://[::1]:5179/index.html'])('accepts loopback HTTP addresses: %s', async (url) => { + const target = await project(); + await saveWorkEntry(target, entry(target, { url })); + expect((await loadWorkEntry(target))?.url).toBe(url); + }); + + it('ignores missing, malformed, unsupported and oversized records', async () => { + const target = await project(); + expect(await loadWorkEntry(target)).toBeNull(); + for (const text of ['not JSON', '{}', JSON.stringify({ ...entry(target), version: 2 }), ' '.repeat(16 * 1024 + 1)]) { + await writeFile(entryFile(target), text); + expect(await loadWorkEntry(target)).toBeNull(); + } + await writeFile(entryFile(target), Buffer.from([0xff, 0xfe])); + expect(await loadWorkEntry(target)).toBeNull(); + await rm(entryFile(target)); + await mkdir(entryFile(target)); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + }); + + it('leaves missing metadata absent on reads and rejects arbitrary launch commands', async () => { + const target = await project(); + await corrupt(target, { ...entry(target), launch: { kind: 'shell', command: 'npm run dev' } }); + expect(await loadWorkEntry(target)).toBeNull(); + await corrupt(target, { ...entry(target), launch: { kind: 'vite', directory: '.', args: [], command: 'npm run dev' } }); + expect(await loadWorkEntry(target)).toBeNull(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target, { url: 'https://example.com/' }))).rejects.toThrow(); + expect(await readdir(target.path)).toEqual([]); + }); + + it('creates metadata safely when saving an imported folder and reloads the entry', async () => { + const target = await project(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + const value = entry(target); + expect(await loadWorkEntry(target)).toBeNull(); + expect(await readdir(target.path)).toEqual([]); + await saveWorkEntry(target, value); + expect(await loadWorkEntry(target)).toEqual(value); + expect(await readdir(target.path)).toEqual(['.makelore']); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); + + it('rejects a colliding metadata file instead of replacing it with a directory', async () => { + const target = await project(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + await writeFile(path.join(target.path, '.makelore'), 'Existing user file'); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + expect(await readFile(path.join(target.path, '.makelore'), 'utf8')).toBe('Existing user file'); + }); + + it('rejects metadata directory and entry symlinks without changing the outside file', async () => { + const target = await project(); + const outside = await project(); + await saveWorkEntry(outside, entry(outside)); + const outsideBefore = await readFile(entryFile(outside), 'utf8'); + await symlink(entryFile(outside), entryFile(target), 'file'); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + await rm(entryFile(target)); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + await symlink(path.join(outside.path, '.makelore'), path.join(target.path, '.makelore'), 'dir'); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entry(target))).rejects.toThrow(); + expect(await readFile(entryFile(outside), 'utf8')).toBe(outsideBefore); + }); + + it.each(['../outside', '/absolute', 'C:/outside', 'game/../../outside', 'game\\outside'])('rejects recipe directory path escape: %s', async (directory) => { + const target = await project(); + const value = entry(target, { launch: { kind: 'vite', directory, args: [] } }); + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value)).rejects.toThrow(); + }); + + it('rejects launch directory and static entry symlinks, including in-project links', async () => { + const target = await project(); + await mkdir(path.join(target.path, 'game')); + await writeFile(path.join(target.path, 'game/index.html'), '

Game

'); + await symlink(path.join(target.path, 'game'), path.join(target.path, 'linked-game'), 'dir'); + const directoryLink = entry(target, { launch: { kind: 'vite', directory: 'linked-game', args: [] } }); + await corrupt(target, directoryLink); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, directoryLink)).rejects.toThrow(); + await symlink(path.join(target.path, 'game/index.html'), path.join(target.path, 'game/linked.html'), 'file'); + const entryLink = entry(target, { launch: { kind: 'static', directory: 'game', entry: 'linked.html' } }); + await corrupt(target, entryLink); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, entryLink)).rejects.toThrow(); + }); + + it.each([ + ['--config', 'outside.config.js'], ['--host', '0.0.0.0'], ['--open'], ['--port', '0'], + ['--port', '65536'], ['--mode', 'dev; touch outside'], ['--base', 'https://example.com/'], + ['--base', '//example.com/'], ['--base', '/../outside'], ['--port', '5179', '--port', '5199'], + ['--mode'], ['game'], + ])('rejects unsupported or unsafe persisted Vite args: %j', async (...args) => { + const target = await project(); + const value = entry(target, { launch: { kind: 'vite', directory: '.', args } }); + await corrupt(target, value); + expect(await loadWorkEntry(target)).toBeNull(); + await expect(saveWorkEntry(target, value)).rejects.toThrow(); + }); + + it('rejects a static entry escaping its directory and preserves the last complete valid entry', async () => { + const target = await project(); + await mkdir(path.join(target.path, 'game')); + await writeFile(path.join(target.path, 'index.html'), '

Root

'); + const valid = entry(target); + await saveWorkEntry(target, valid); + const invalid = entry(target, { launch: { kind: 'static', directory: 'game', entry: '../index.html' } }); + await expect(saveWorkEntry(target, invalid)).rejects.toThrow(); + expect(await loadWorkEntry(target)).toEqual(valid); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); + + it('publishes complete records during concurrent writes and removes temporary files', async () => { + const target = await project(); + await rm(path.join(target.path, '.makelore'), { recursive: true }); + await Promise.all([5179, 5199, 5200].map((port) => saveWorkEntry(target, entry(target, { url: `http://localhost:${port}/` })))); + const loaded = await loadWorkEntry(target); + expect(loaded).toMatchObject({ projectId: target.id, projectPath: target.path }); + expect(['http://localhost:5179/', 'http://localhost:5199/', 'http://localhost:5200/']).toContain(loaded?.url); + expect(await readdir(path.join(target.path, '.makelore'))).toEqual(['work-entry.json']); + }); +}); diff --git a/tests/unit/work-launcher.test.ts b/tests/unit/work-launcher.test.ts new file mode 100644 index 00000000..38896318 --- /dev/null +++ b/tests/unit/work-launcher.test.ts @@ -0,0 +1,325 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer, get, type Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import { discoverWorkLaunch, startWorkLaunch, stopWorkLaunch, workLaunchMatches } from '../../electron/coding-runtime/work-launcher'; +import type { WorkLaunchSpec } from '../../electron/coding-runtime/work-entry-contracts'; + +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, spawn: vi.fn(original.spawn) }; +}); + +const directories: string[] = []; +const servers: Server[] = []; +const html = 'Saved game

Play

'; + +async function fixture(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'makelore-work-launch-')); + directories.push(directory); + return directory; +} + +async function staticFixture(root: string, directory = '.'): Promise { + const target = path.resolve(root, directory); + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, 'index.html'), html); + await writeFile(path.join(target, 'main.js'), 'console.log("play")'); + return { kind: 'static', directory, entry: 'index.html' }; +} + +async function viteFixture(root: string, directory = '.', dev = 'vite --host 0.0.0.0 --port 5199'): Promise { + const target = path.resolve(root, directory); + await mkdir(path.join(target, 'node_modules', 'vite', 'bin'), { recursive: true }); + await mkdir(path.join(target, 'node_modules', 'vite', 'dist', 'node'), { recursive: true }); + await writeFile(path.join(target, 'index.html'), html); + await writeFile(path.join(target, 'package.json'), JSON.stringify({ scripts: { dev } })); + await writeFile(path.join(target, 'node_modules', 'vite', 'package.json'), JSON.stringify({ name: 'vite', type: 'module' })); + await writeFile(path.join(target, 'node_modules', 'vite', 'bin', 'vite.js'), '// installed CLI entry'); + await writeFile(path.join(target, 'node_modules', 'vite', 'dist', 'node', 'index.js'), ` + import http from 'node:http'; + export async function createServer(options) { + const server = http.createServer((_req,res) => { + res.setHeader('content-type','text/html'); + res.end(${JSON.stringify(html.replace('', ''))}); + }); + return {config:{root:process.cwd(),base:options.base || '/'},async listen() { + await new Promise(resolve => server.listen(options.server.port,options.server.host,resolve)); + }}; + } + `); +} + +async function listen(server: Server, port = 0): Promise { + servers.push(server); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('port unavailable'); + return `http://127.0.0.1:${address.port}/`; +} + +function request(url: string, requestPath?: string): Promise<{ status?: number; contentType?: string; body: string }> { + return new Promise((resolve, reject) => { + const address = new URL(url); + get({ hostname: address.hostname, port: address.port, path: requestPath ?? address.pathname }, (response) => { + let body = ''; + response.on('data', (bytes: Buffer) => { body += bytes.toString('utf8'); }); + response.on('end', () => resolve({ status: response.statusCode, contentType: response.headers['content-type'], body })); + }).on('error', reject); + }); +} + +afterEach(async () => { + for (const result of vi.mocked(spawn).mock.results) { + if (result.type !== 'return') continue; + const child = result.value; + if (!child.pid || child.exitCode !== null || child.signalCode !== null) continue; + const exited = once(child, 'exit').catch(() => undefined); + if (process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill(); } + } else child.kill(); + await exited; + } + vi.mocked(spawn).mockClear(); + for (const server of servers.splice(0)) await new Promise((resolve) => server.close(() => resolve())); + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); +}); + +describe('existing project work discovery', () => { + it('finds a single nested Vite work, normalizing only recognized flags without needing installed dependencies', async () => { + const root = await fixture(); + await mkdir(path.join(root, 'game')); + await writeFile(path.join(root, 'game', 'package.json'), JSON.stringify({ scripts: { dev: 'vite --host=0.0.0.0 --port=5179 --mode play --strictPort' } })); + expect(await discoverWorkLaunch(root)).toEqual({ kind: 'vite', directory: 'game', args: ['--port', '5179', '--mode', 'play', '--strictPort'] }); + }); + + it('does not discover generated nested copies underneath a recognized work', async () => { + const root = await fixture(); + await viteFixture(root, 'game'); + await staticFixture(root, 'game/dist'); + expect(await discoverWorkLaunch(root)).toMatchObject({ kind: 'vite', directory: 'game' }); + }); + + it('requires help when separate existing works are ambiguous', async () => { + const root = await fixture(); + await staticFixture(root, 'first'); + await staticFixture(root, 'second'); + await expect(discoverWorkLaunch(root)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); + }); + + it('ignores private directories and symlinked projects', async () => { + const root = await fixture(); + const outside = await fixture(); + await staticFixture(root, '.makelore/cache'); + await staticFixture(root, 'node_modules/template'); + await staticFixture(outside); + await symlink(outside, path.join(root, 'linked-game'), 'dir'); + expect(await discoverWorkLaunch(root)).toBeNull(); + }); + + it.each(['vite && touch owned', 'vite --open', 'npm run serve', 'vite --config ../../other.js'])('does not reinterpret unsupported script %s as a static website', async (dev) => { + const root = await fixture(); + await viteFixture(root, '.', dev); + await expect(discoverWorkLaunch(root)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +describe('model-free work launch', () => { + it('reopens a stopped static work on the same saved local port with real HTTP readiness', async () => { + const root = await fixture(); + const spec = await staticFixture(root); + const oldServer = createServer(); + const previousUrl = await listen(oldServer); + await new Promise((resolve) => oldServer.close(() => resolve())); + const url = await startWorkLaunch({ id: 'game', path: root }, spec, previousUrl); + expect(url).toBe(previousUrl); + expect(await request(url)).toMatchObject({ status: 200, body: html }); + expect(await request(url, '/main.js')).toMatchObject({ contentType: 'text/javascript; charset=utf-8', body: 'console.log("play")' }); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); + expect(await readFile(path.join(root, '.makelore', 'logs', 'static-preview.log'), 'utf8')).toContain('Makelore launch pid='); + }); + + it('starts the installed Vite runtime directly without a shell and forces a loopback-only server', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + if (!spec) throw new Error('missing recipe'); + const url = await startWorkLaunch({ id: 'game', path: root }, spec); + expect(await request(url)).toMatchObject({ status: 200 }); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); + const canonicalRoot = await realpath(root); + expect(spawn).toHaveBeenCalledWith(process.execPath, expect.any(Array), expect.objectContaining({ shell: false, detached: true, cwd: canonicalRoot })); + const args = vi.mocked(spawn).mock.calls[0][1] as string[]; + expect(args).toContain(path.join(canonicalRoot, 'node_modules', 'vite', 'bin', 'vite.js')); + expect(args.slice(-5)).toEqual(['--host', '127.0.0.1', '--port', String(new URL(url).port), '--strictPort']); + expect(args).not.toContain('npm'); + }); + + it('serves a real installed Vite project using its configured base through the owned Node launcher', async () => { + const root = await fixture(); + await viteFixture(root, '.', 'vite'); + await rm(path.join(root, 'node_modules', 'vite'), { recursive: true }); + await symlink(await realpath(path.resolve('node_modules/vite')), path.join(root, 'node_modules', 'vite'), 'dir'); + await writeFile(path.join(root, 'main.js'), 'document.body.dataset.playable = "yes"'); + await writeFile(path.join(root, 'vite.config.js'), 'export default { base: "/game/" }'); + const spec = await discoverWorkLaunch(root); + const url = await startWorkLaunch({ id: 'game', path: root }, spec!); + expect(new URL(url).pathname).toBe('/game/'); + expect((await request(url)).body).toContain('/@vite/client'); + expect((await request(url, '/game/main.js')).body).toContain('dataset.playable'); + expect(await workLaunchMatches({ id: 'game', path: root }, spec!, url)).toBe(true); + }); + + it('uses a free port and leaves an unrelated service at the saved port untouched', async () => { + const root = await fixture(); + const spec = await staticFixture(root); + const unrelated = await listen(createServer((_req, response) => { response.setHeader('content-type', 'text/html'); response.end('Other project'); })); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, unrelated)).toBe(false); + const url = await startWorkLaunch({ id: 'game', path: root }, spec, unrelated); + expect(url).not.toBe(unrelated); + expect((await request(unrelated)).body).toContain('Other project'); + expect((await request(url)).body).toBe(html); + }); + + it('reports missing dependencies without attempting an installation', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + await rm(path.join(root, 'node_modules'), { recursive: true }); + await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_DEPENDENCIES_MISSING' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('reports a failed local start rather than returning an address for an unready process', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + await writeFile(path.join(root, 'node_modules', 'vite', 'dist', 'node', 'index.js'), 'process.exit(12)'); + await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_START_FAILED' }); + }); + + it('rejects recipes after their package command or file location changes', async () => { + const root = await fixture(); + await viteFixture(root); + const spec = await discoverWorkLaunch(root); + await writeFile(path.join(root, 'package.json'), JSON.stringify({ scripts: { dev: 'vite && touch changed' } })); + await expect(startWorkLaunch({ id: 'game', path: root }, spec!)).rejects.toMatchObject({ code: 'WORK_PREVIEW_NEEDS_HELP' }); + await expect(startWorkLaunch({ id: 'game', path: root }, { kind: 'static', directory: '../outside', entry: 'index.html' })).rejects.toMatchObject({ code: 'WORK_PREVIEW_ENTRY_MISSING' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('blocks traversal, private data and symlinked files in the static server', async () => { + const root = await fixture(); + const outside = await fixture(); + const spec = await staticFixture(root); + await writeFile(path.join(outside, 'secret.txt'), 'private'); + await symlink(path.join(outside, 'secret.txt'), path.join(root, 'linked.txt')); + const url = await startWorkLaunch({ id: 'game', path: root }, spec); + expect((await request(url, '/%2e%2e/secret.txt')).status).toBe(403); + expect((await request(url, '/linked.txt')).status).toBe(403); + expect((await request(url, '/.makelore/logs/static-preview.log')).status).toBe(403); + await rm(path.join(root, 'index.html')); + await symlink(path.join(outside, 'secret.txt'), path.join(root, 'index.html')); + await expect(startWorkLaunch({ id: 'game', path: root }, spec)).rejects.toMatchObject({ code: 'WORK_PREVIEW_ENTRY_MISSING' }); + }); + + it('only stops its own newly started service and leaves unrelated local addresses alone', async () => { + const root = await fixture(); + const spec = await staticFixture(root); + const project = { id: 'game', path: root }; + const unrelated = await listen(createServer((_req, response) => response.end('Other work'))); + await stopWorkLaunch(project, unrelated); + expect((await request(unrelated)).body).toBe('Other work'); + const url = await startWorkLaunch(project, spec); + const child = vi.mocked(spawn).mock.results.at(-1)!.value; + const exited = once(child, 'exit'); + await stopWorkLaunch(project, url); + await exited; + await expect(request(url)).rejects.toBeDefined(); + expect((await request(unrelated)).body).toBe('Other work'); + }); +}); + +describe('legacy Vite address identity', () => { + it('rejects a different game sharing title and module URL, while accepting exact original TSX sourcesContent', async () => { + const root = await fixture(); + const expected = 'const App = () =>
My saved game
; export default App;'; + const other = 'const App = () =>
A different game
; export default App;'; + await writeFile(path.join(root, 'index.html'), html.replace('/main.js', '/main.tsx')); + await writeFile(path.join(root, 'main.tsx'), expected); + const spec: WorkLaunchSpec = { kind: 'vite', directory: '.', args: [] }; + let servedSource = other; + const url = await listen(createServer((req, response) => { + if (req.url === '/main.tsx') { + const map = Buffer.from(JSON.stringify({ sourcesContent: [servedSource] })).toString('base64'); + response.setHeader('content-type', 'text/javascript'); + response.end(`const App = transformedJSX();\n//# sourceMappingURL=data:application/json;base64,${map}`); + } else { + response.setHeader('content-type', 'text/html'); + response.end(html.replace('/main.js', '/main.tsx').replace('', '')); + } + })); + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(false); + servedSource = expected; + expect(await workLaunchMatches({ id: 'game', path: root }, spec, url)).toBe(true); + }); + + it('declines a shared bootstrap whose imported local game has not been verified', async () => { + const root = await fixture(); + const shared = 'import App from "./App.js"; document.body.append(App());'; + await writeFile(path.join(root, 'index.html'), html); + await writeFile(path.join(root, 'main.js'), shared); + const url = await listen(createServer((req, response) => { + response.setHeader('content-type', req.url === '/main.js' ? 'text/javascript' : 'text/html'); + response.end(req.url === '/main.js' ? shared : html.replace('', '')); + })); + expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false); + }); + + it('verifies original raw sources and rejects a different imported game behind an identical bootstrap', async () => { + const root = await fixture(); + const bootstrap = 'import App from "./App.js"; document.body.append(App());'; + const expectedApp = 'export default () => "My saved game";'; + let servedApp = 'export default () => "A different game";'; + await writeFile(path.join(root, 'index.html'), html); + await writeFile(path.join(root, 'main.js'), bootstrap); + await writeFile(path.join(root, 'App.js'), expectedApp); + const url = await listen(createServer((req, response) => { + if (req.url?.endsWith('?raw')) { + response.setHeader('content-type', 'text/javascript'); + const original = req.url.startsWith('/main.js') ? bootstrap : servedApp; + response.end(`export default ${JSON.stringify(original)};`); + } else { + response.setHeader('content-type', 'text/html'); + response.end(html.replace('', '')); + } + })); + const project = { id: 'game', path: root }; + const spec: WorkLaunchSpec = { kind: 'vite', directory: '.', args: [] }; + expect(await workLaunchMatches(project, spec, url)).toBe(false); + servedApp = expectedApp; + expect(await workLaunchMatches(project, spec, url)).toBe(true); + }); + + it('declines unresolved local aliases instead of accepting an identical bootstrap for another game', async () => { + const root = await fixture(); + const source = 'import Game from "@/game"; document.body.append(Game());'; + await writeFile(path.join(root, 'index.html'), html); + await writeFile(path.join(root, 'main.js'), source); + const url = await listen(createServer((req, response) => { + if (req.url === '/main.js?raw') { + response.setHeader('content-type', 'text/javascript'); + response.end(`export default ${JSON.stringify(source)};`); + } else { + response.setHeader('content-type', 'text/html'); + response.end(html.replace('', '')); + } + })); + expect(await workLaunchMatches({ id: 'game', path: root }, { kind: 'vite', directory: '.', args: [] }, url)).toBe(false); + }); +}); From 049e34cc77338f3778d82323539e9c0f6066a09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 11:55:06 +0800 Subject: [PATCH 14/17] Record shared settings sidebar trial verification --- .../20261014-code-settings-sidebar-8f4b2c71.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md b/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md index 52783b85..143c6dac 100644 --- a/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md +++ b/.project-docs/30-worklog/tasks/20261014-code-settings-sidebar-8f4b2c71.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-code-settings-sidebar-8f4b2c71 - Base commit: 5e3afa841a86b005a6f19462e1634d4bf92cd881 - Owner: codex -- Status: In Progress +- Status: Ready for Integration ## Scope @@ -22,19 +22,27 @@ - Implemented a shared Code sidebar route scope for `/chat`, `/project-config` and descendants. TitleBar uses a sidebar-specific layout flag so configuration title/project context/logo and main content padding retain their existing behavior. - Removed configuration-only suppression of brand/header and gallery footer; project/session/account state and handlers are shared. Moved Code CSS loading to MainLayout so direct configuration entry and reload include the approved skin. Only two CSS scoping rules changed; footer rules and accepted placement remain exact. -- README behavior updated; focused Electron and trial update pending. +- README behavior updated. Product implementation `6bb15ae`, focused Electron coverage `2c671ae`; final complete product source `ec07c67ef4a9218dbadcb73c9e6e79fd0da2c172`. +- During work, actual runtime advanced first to WeChat copy `d0451e9`, then full Main/Renderer `40930aec` / PID93092. Retained both committed product deltas so relative to the actual current runtime only the approved five Renderer files differ; Main/shared/resources/package remain exact. +- A whole-source merge pulled a foreign task record and failed the documentation gate. Preserved that known owned merge as backup branch `codex/20261014-code-settings-sidebar-8f4b2c71-import-snapshot`, returned to the prior owned snapshot, and imported only committed product/relevant test deltas. Peer record remains untouched in its owned worktree; final boundary gate passes. +- Immutable overlay `.trial-runtime/code-settings-sidebar-20261014-8f4b2c71` prepared, safely activated, reloaded in the same native window, finalized and verified `active-verified`. Effective Renderer ec07c67, actual Main40930/PID93092 and all Main bytes preserved; 17 inherited features plus `code-settings-sidebar`. Pointer stale Main manifest/snapshot references were corrected from exact actual process/manifest/closure evidence during finalization. +- User logged in and confirmed idle; fresh UI was checked again at activation and refresh. No credentials/profile files read, no input discarded, no business settings/channel/project/model mutations. Native new entry `index-B5J3Jpk-.js` observed in read-only DevTools, closed after inspection. Final actual Code screenshot/AX retained Alice, width256, project/session/footer/account. Native body action timing is inconsistent, so settings traversal is explicitly fixture-verified rather than claimed as a fresh native traversal. ## Verification - Official Concurrent/Planning Gates passed; 64 peer records/192 sections inspected read-only, no blocking semantic conflict. - Typecheck, scoped ESLint, default full `build:vite` passed. - 40 focused unit tests passed across layout peek, sidebar project actions, community, module navigation and Canvas layout. -- Focused Electron flow and native trial refresh pending. +- Matching final full-build typecheck passed. Focused real Electron run: 2 passed (8.5s), including actual conversation menu → agent configuration, matching visible sidebar/topnav geometry/colors, unchanged configuration body, plugin route, direct reload and collapse/expand; plus actual isolated Main restart/stopped-service/static-render work restoration. +- Initial E2E assertion also compared the unused inherited text color of the empty titlebar background div. Final test excludes only that non-rendered field and retains all actual text/icon/background/border/geometry assertions. +- 117 preserved-work-entry tests passed across four files after importing the latest active Main; no real account or model execution. +- Independent source review found no actionable issue. Final helper verify, artifact closure/source scope, accepted footer rules, root native refresh receipt, and documentation/whitespace checks passed. Evidence/logs/PNGs and operations are in the task runtime stage. +- Full-runtime predecessor remains recorded as pending actual Alice work-entry acceptance; this task independently fixture-verifies preservation and does not falsely promote the preceding stage to native active-verified. ## Follow-ups -- None recorded. +- Pre-existing actual-account work-entry acceptance remains with task `20261014-work-entry-restore-91c7e6a2`; no remaining work for the requested shared sidebar. ## Promotion Candidates -- None recorded. +- Target: integrated UI/current-state documentation. Proposal: Code configuration and plugin resource routes reuse the same left navigation rail and shared CSS loading as chat, while configuration main content retains its own layout. Evidence: this user request, exact-build metrics/color/reload Electron acceptance and immutable overlay receipt. Impact: supersedes the earlier config-only brand/gallery omission. Human confirmation: already provided by the user request; no additional product decision needed. From f980827200a6ceac521fb38c361f5a57b80d4561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 12:12:17 +0800 Subject: [PATCH 15/17] Simplify Code and Canvas project sidebars --- .../20261014-sidebar-simplify-a6d32f90.md | 40 +++++++++++++++++++ README.md | 4 +- .../layout/ImageWorkspaceSidebar.tsx | 16 +------- src/components/layout/Sidebar.tsx | 5 --- src/pages/Chat/classroom-workspace.css | 2 +- tests/e2e/main-navigation.spec.ts | 20 ++++------ tests/unit/image-workspace-sidebar.test.tsx | 17 ++++++-- 7 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md diff --git a/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md b/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md new file mode 100644 index 00000000..ec2d308d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md @@ -0,0 +1,40 @@ +# Task: Simplify Code brand area and Canvas project labels + +## Identity + +- Task ID: 20261014-sidebar-simplify-a6d32f90 +- Mode: Feature +- Branch: codex/20261014-sidebar-simplify-a6d32f90-sidebar-simplify +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-sidebar-simplify-a6d32f90 +- Base commit: 049e34cc77338f3778d82323539e9c0f6066a09a +- Owner: codex +- Status: In Progress + +## Scope + +- Remove the Code rail brand/logo/caption, align New Project at the former brand top and My Projects/list at the former New Project top; apply consistently to chat/configuration via the shared rail. Rename Canvas desktop rail heading to My Projects and show only project names, without version/date metadata. + +## Intent And Constraints + +- User explicitly supersedes prior Code brand retention. Preserve top return/collapse controls including Linux navigation, shared sidebar width/collapse, Code gallery/Alice footer, configuration content/guards, APIs/project identities/menu actions, Canvas sort/selection/loading. Desktop left rail only; compact Canvas project sheet/status presentation is outside the supplied screenshot scope. Preserve actual trial Main40930/PID93092 and all 18 inherited features; no Main restart or real business writes. + +## Outcome + +- Removed only the Code sidebar brand block/import; the center welcome artwork and top navigation remain. Removed 10px content top padding as well as the 46px brand block, placing New Project at the prior brand top and My Projects heading at the prior New Project top. Chat/configuration continue sharing one rail; bottom gallery/account rules unchanged. +- Canvas desktop rail is My Projects, with each row showing its real title only. Removed the date formatter; kept updatedAt ordering, selection identity and all existing project actions/loading states. +- README and focused existing tests updated; trial update pending. + +## Verification + +- Official ownership and Planning Gate passed after 66 peers/198 scope/intent/promotion sections were inspected read-only, no blocking semantic conflicts. +- Standard typecheck, scoped ESLint and default full `build:vite` passed. +- 31 Code/layout/navigation/community tests and 6 Canvas sidebar tests passed. The Canvas selection test also verifies recent-first order and exact identity after hiding metadata. +- Focused Electron chat/configuration geometry/navigation tests running; immutable trial update pending. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 4d84e8fe..6851d50a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。服务端显示微信退出仍在处理中时,禁止新的连接、启停和绑定变更,保留刷新与原操作恢复;管理写入前再次读取当前状态。客户端已知目标智能体停用时明确显示不可用,并禁止恢复自动回复,仍可更换目标或安全暂停、解绑。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对,保留原记录并避免盲目重试;账号关系或目标智能体问题可显式刷新状态后再次确认原操作,刷新本身不重放操作;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 -- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏沿用 Agents 的图标徽章与文字入口,新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 +- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏沿用 Agents 的图标徽章与文字入口,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 @@ -41,7 +41,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;进入“会话编程设置”后的项目配置页及插件资源页复用相同的左侧工作栏,品牌、返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;进入“会话编程设置”后的项目配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index ad7bd91b..637cd34f 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -34,17 +34,6 @@ type ProjectDialogState = | { mode: 'create'; project: null } | { mode: 'rename'; project: DesignWorkspaceSummary }; -function formatUpdatedAt(value: string): string { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return ''; - const date = new Date(timestamp); - const now = new Date(); - if (date.toDateString() === now.toDateString()) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }); -} - export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) { const authenticated = useAuthStore((state) => state.isAuthenticated()); const navigate = useNavigate(); @@ -178,7 +167,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
- 设计项目 + 我的项目 {(status === 'error' || status === 'unavailable') && (
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 7223d4cf..fe0f6836 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,3 @@ -import classroomMark from '@/assets/consultations/mark.svg'; import { CodingProjectConversations } from '@/pages/Chat/CodingProjectConversations'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -442,10 +441,6 @@ export function Sidebar({ {workspaceLayout && !['darwin', 'win32'].includes(window.electron?.platform ?? '') &&
} - {workspaceLayout && } {!workspaceLayout && }
} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index f1c2a902..cc9f2b36 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -18,7 +18,7 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-brand:hover { background: #eaf1f8; } .classroom-brand-mark { display: grid; place-items: center; width: 40px; height: 42px; flex-shrink: 0; border: 1px solid #f1d1aa; border-radius: 12px; background: #fff0dd; box-shadow: 0 3px 0 #ecdbc5; } .classroom-brand-caption { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: #64758a; } -[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-content { padding: 10px 12px 14px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-content { padding: 0 12px 14px; } [data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] { min-height: 48px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; color: #26384d; box-shadow: none; font-size: 14px; font-weight: 600; } [data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] svg { color: #c44b23; } [data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"]:hover { background: #fff0e6; } diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 2ee0f717..8f433dd3 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -410,21 +410,16 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(moduleReturn).toHaveAttribute('aria-label', '返回首页'); await expect(moduleReturn).toHaveAttribute('title', '返回首页'); await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible(); - const brandBox = await sidebar.locator('.classroom-brand').boundingBox(); + await expect(sidebar.locator('.classroom-brand')).toHaveCount(0); + await expect(sidebar).not.toContainText('把脑洞变成作品'); const returnBox = await moduleReturn.boundingBox(); const collapseBox = await page.getByRole('button', { name: '折叠侧栏' }).boundingBox(); const sidebarBox = await sidebar.boundingBox(); - expect(brandBox).not.toBeNull(); expect(returnBox).not.toBeNull(); expect(collapseBox).not.toBeNull(); - expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(brandBox!.y); + expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(sidebarBox!.y); const headerBox = await page.getByTestId('coding-conversation-header').boundingBox(); - const tabsBox = await page.getByRole('tablist', { name: '当前工作' }).boundingBox(); - const toolbarBox = await page.locator('.classroom-workspace-toolbar').boundingBox(); expect(Math.abs(returnBox!.y + returnBox!.height / 2 - headerBox!.y - headerBox!.height / 2)).toBeLessThan(1); - expect(Math.abs(brandBox!.y + brandBox!.height / 2 - tabsBox!.y - tabsBox!.height / 2)).toBeLessThan(1); - expect(brandBox!.y - sidebarBox!.y).toBe(0); - expect(brandBox!.height).toBe(toolbarBox!.height); expect(returnBox!.width).toBe(32); expect(Math.abs(returnBox!.y + returnBox!.height / 2 - collapseBox!.y - collapseBox!.height / 2)).toBeLessThan(1); expect(returnBox!.x + returnBox!.width).toBeLessThanOrEqual(collapseBox!.x); @@ -436,7 +431,8 @@ test.describe('Makelore module navigation without setup flow', () => { const createBox = await sidebar.getByTestId('sidebar-create-project').boundingBox(); const headingBox = await sidebar.locator('.classroom-projects-heading').boundingBox(); const accountBox = await sidebar.getByTestId('sidebar-member-menu-trigger').boundingBox(); - expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(10); + expect(createBox!.y).toBe(sidebarBox!.y); + expect(headingBox!.y - sidebarBox!.y).toBe(56); expect(communityBox!.x).toBe(createBox!.x); expect(communityBox!.y).toBeGreaterThan(headingBox!.y + headingBox!.height); expect(accountBox!.y - communityBox!.y - communityBox!.height).toBeCloseTo(0, 0); @@ -460,7 +456,7 @@ test.describe('Makelore module navigation without setup flow', () => { const fullscreenReturn = await moduleReturn.boundingBox(); expect(fullscreenReturn!.y).toBe(returnBox!.y); expect(await page.getByRole('button', { name: '折叠侧栏' }).boundingBox()).toEqual(collapseBox); - expect(await sidebar.locator('.classroom-brand').boundingBox()).toEqual(brandBox); + expect(await sidebar.getByTestId('sidebar-create-project').boundingBox()).toEqual(createBox); await page.screenshot({ path: test.info().outputPath('chat-fullscreen-return-aligned.png') }); await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('leave-full-screen')); await expect.poll(async () => (await moduleReturn.boundingBox())!.x).toBe(returnBox!.x); @@ -486,13 +482,13 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '171'); const readCodeChrome = () => page.evaluate(() => { + if (document.querySelector('[data-testid="sidebar"] .classroom-brand')) throw new Error('Removed Code brand reappeared'); const selectors = { sidebar: '[data-testid="sidebar"]', titlebar: '[data-testid="titlebar-sidebar-surface"]', navigation: '[data-testid="classroom-titlebar-navigation"]', home: '[data-testid="sidebar-module-switcher-trigger"]', toggle: '[data-testid="titlebar-sidebar-toggle"]', - brand: '[data-testid="sidebar"] .classroom-brand', create: '[data-testid="sidebar-create-project"]', heading: '[data-testid="sidebar"] .classroom-projects-heading', community: '[data-testid="sidebar"] .community-link[data-variant="sidebar"]', @@ -752,7 +748,7 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0); await expect(page.getByText('一念成光,万物可创。')).toHaveCount(0); await expect(page.getByRole('img', { name: 'Makelore logo' })).toHaveCount(0); - await expect(page.getByTestId('sidebar').locator('.classroom-brand')).toBeVisible(); + await expect(page.getByTestId('sidebar').locator('.classroom-brand')).toHaveCount(0); await expect(page.getByTestId('sidebar-module-switcher-trigger')).toHaveText(''); await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible(); await expect(page.getByTestId('sidebar-create-project')).toBeVisible(); diff --git a/tests/unit/image-workspace-sidebar.test.tsx b/tests/unit/image-workspace-sidebar.test.tsx index a9d3a86c..5f29ba5a 100644 --- a/tests/unit/image-workspace-sidebar.test.tsx +++ b/tests/unit/image-workspace-sidebar.test.tsx @@ -189,12 +189,23 @@ describe('ImageWorkspaceSidebar', () => { expect(actions.renameProject).not.toHaveBeenCalled(); }); - it('selects a project by its workspace identity from the project row', () => { - const { target, actions } = prepareProjects(); + it('shows only project titles under My Projects and keeps recent-first selection by workspace identity', () => { + const { workspace, target, actions } = prepareProjects(); renderSidebar(); const sidebar = screen.getByTestId('sidebar-image-workspace'); - fireEvent.click(within(sidebar).getByRole('button', { name: new RegExp(`^${target.title}`) })); + expect(within(sidebar).getByText('我的项目', { exact: true })).toBeInTheDocument(); + expect(within(sidebar).queryByText('设计项目', { exact: true })).not.toBeInTheDocument(); + expect(sidebar).not.toHaveTextContent('设计版本'); + const activeProject = within(sidebar).getByRole('button', { name: workspace.workspace.title, exact: true }); + const newerProject = within(sidebar).getByRole('button', { name: target.title, exact: true }); + expect(activeProject.textContent).toBe(workspace.workspace.title); + expect(newerProject.textContent).toBe(target.title); + expect(activeProject).toHaveAttribute('aria-current', 'page'); + // The September project is newer than the active August project, despite + // arriving second in bootstrap. Removing its date must not change order. + expect(newerProject.compareDocumentPosition(activeProject)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + fireEvent.click(newerProject); expect(actions.selectProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId); expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas'); From aa6570484c0d6ab90a8ecbbc861406bd1e4360e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 12:16:33 +0800 Subject: [PATCH 16/17] docs: hand off verified sidebar changes for unified trial update --- .../tasks/20261014-sidebar-simplify-a6d32f90.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md b/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md index ec2d308d..e619402c 100644 --- a/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md +++ b/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-sidebar-simplify-a6d32f90 - Base commit: 049e34cc77338f3778d82323539e9c0f6066a09a - Owner: codex -- Status: In Progress +- Status: Ready for Integration ## Scope @@ -22,18 +22,20 @@ - Removed only the Code sidebar brand block/import; the center welcome artwork and top navigation remain. Removed 10px content top padding as well as the 46px brand block, placing New Project at the prior brand top and My Projects heading at the prior New Project top. Chat/configuration continue sharing one rail; bottom gallery/account rules unchanged. - Canvas desktop rail is My Projects, with each row showing its real title only. Removed the date formatter; kept updatedAt ordering, selection identity and all existing project actions/loading states. -- README and focused existing tests updated; trial update pending. +- README and focused existing tests updated. Product implementation committed at f980827200a6ceac521fb38c361f5a57b80d4561; final metadata commit will follow. Runtime activation is deliberately handed to the user-authorized consolidation conversation, rather than racing its unified application restart. ## Verification - Official ownership and Planning Gate passed after 66 peers/198 scope/intent/promotion sections were inspected read-only, no blocking semantic conflicts. - Standard typecheck, scoped ESLint and default full `build:vite` passed. - 31 Code/layout/navigation/community tests and 6 Canvas sidebar tests passed. The Canvas selection test also verifies recent-first order and exact identity after hiding metadata. -- Focused Electron chat/configuration geometry/navigation tests running; immutable trial update pending. +- Focused real isolated Electron checks passed: shared Code chat/configuration chrome and sidebar module-return entry, 2 tests in 7.7s. Inspected final configuration screenshot; exact 3 source files independently reviewed without blockers. Tests used fixtures and did not change Alice business data. +- Runtime preparation scope proof and passing build/E2E/Canvas unit logs are retained in /Users/chillishark/Makelore 麦洛/.trial-runtime/sidebar-simplify-20261014-a6d32f90. No activation, reload, restart, current.json write, draft discard, or real business/configuration write was performed. Actual application remains Main40930/PID93092 and Renderer ec07. ## Follow-ups -- None recorded. +- User raw instruction verified by read_thread in coordination conversation codex://threads/01a12eba-e98b-76a3-8fd5-206f2820d623: wait for five named conversations, have them report completion, then consolidate/push and restart the trial once. That directly authorizes this completion report; current in-progress status already sent there. Coordinating conversation owns integrated build, activation, final real UI acceptance and cleanup. Source worktree remains available; do not use the single-task snapshot to overwrite the eventual integrated build. +- Handoff repository /Users/chillishark/Makelore 麦洛/makelore; task 20261014-sidebar-simplify-a6d32f90; branch codex/20261014-sidebar-simplify-a6d32f90-sidebar-simplify; owned worktree is the Identity path. This HEAD includes the earlier Code gallery/footer, home sidebar defaults and shared Code settings rail chain (base049e34c, Renderer ec07); their source task records remain reachable in history. ## Promotion Candidates From f35de7ce28cbb29258da98957ff9d2362c22df52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 12:17:09 +0800 Subject: [PATCH 17/17] docs: record safe runtime handoff without overlay activation --- .../30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md b/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md index e619402c..f41829d7 100644 --- a/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md +++ b/.project-docs/30-worklog/tasks/20261014-sidebar-simplify-a6d32f90.md @@ -30,7 +30,7 @@ - Standard typecheck, scoped ESLint and default full `build:vite` passed. - 31 Code/layout/navigation/community tests and 6 Canvas sidebar tests passed. The Canvas selection test also verifies recent-first order and exact identity after hiding metadata. - Focused real isolated Electron checks passed: shared Code chat/configuration chrome and sidebar module-return entry, 2 tests in 7.7s. Inspected final configuration screenshot; exact 3 source files independently reviewed without blockers. Tests used fixtures and did not change Alice business data. -- Runtime preparation scope proof and passing build/E2E/Canvas unit logs are retained in /Users/chillishark/Makelore 麦洛/.trial-runtime/sidebar-simplify-20261014-a6d32f90. No activation, reload, restart, current.json write, draft discard, or real business/configuration write was performed. Actual application remains Main40930/PID93092 and Renderer ec07. +- Runtime preparation was safely stopped without a snapshot/manifest because an ancestor work-entry receipt was updated after the prior overlay hash anchor; no inheritance checks were relaxed further. The authorized consolidation will create a fresh full integrated build. Scope proof and passing build/E2E/Canvas unit logs are retained in /Users/chillishark/Makelore 麦洛/.trial-runtime/sidebar-simplify-20261014-a6d32f90. No activation, reload, restart, current.json write, draft discard, or real business/configuration write was performed. Actual application remains Main40930/PID93092 and Renderer ec07. ## Follow-ups