From bf101c1e52820cc7a4282699fb6e1daeecb94819 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:09:51 +0800 Subject: [PATCH] feat: replace Code conversation archive with deletion --- .../20261014-delete-conversations-9f2c6a81.md | 67 ++++++ README.md | 8 +- electron/api/coding-composition.ts | 4 + .../coding-runtime/conversation-service.ts | 40 +++- electron/coding-runtime/pi/resource-loader.ts | 29 ++- src/components/layout/Sidebar.tsx | 24 +-- src/lib/agent-session-sync.ts | 52 ++++- src/pages/Chat/CodingChatPanel.tsx | 7 + src/pages/Chat/CodingProjectConversations.tsx | 98 ++------- src/stores/coding-conversations.ts | 67 +++++- src/stores/coding-workspace.ts | 52 ++++- tests/e2e/pi-coding-first-chat.spec.ts | 140 ++++++++----- tests/unit/agent-session-sync.test.ts | 124 ++++++++++- tests/unit/coding-chat-panel.test.tsx | 192 +++++++++--------- .../unit/coding-conversations-store.test.tsx | 163 +++++++++++++++ tests/unit/coding-core-routes.test.ts | 111 ++++++++-- tests/unit/coding-workspace-store.test.ts | 55 +++++ tests/unit/pi-resource-loader.test.ts | 37 ++++ tests/unit/sidebar-project-actions.test.tsx | 23 +-- 19 files changed, 1001 insertions(+), 292 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-delete-conversations-9f2c6a81.md diff --git a/.project-docs/30-worklog/tasks/20261014-delete-conversations-9f2c6a81.md b/.project-docs/30-worklog/tasks/20261014-delete-conversations-9f2c6a81.md new file mode 100644 index 00000000..b6e33758 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-delete-conversations-9f2c6a81.md @@ -0,0 +1,67 @@ +# Task: Replace Code conversation archive with deletion + +## Identity + +- Task ID: 20261014-delete-conversations-9f2c6a81 +- Mode: Feature +- Branch: codex/20261014-delete-conversations-9f2c6a81-delete-conversations +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-delete-conversations-9f2c6a81 +- Base commit: 140f6be8db10be34472aba15d80721c33969010a +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Remove Code archived-conversation navigation and per-conversation programming settings; replace archive with true conversation deletion, including its persisted session records and Renderer caches. + +## Intent And Constraints + +- User explicitly authorizes deletion behavior on menu click, superseding prior reversible archive design. Do not delete actual user data during development; tests use isolated fixtures. No confirmation flow added. Preserve other conversations/project files, project-level consultation history and preview state. No app update/merge/push/cleanup requested. +- Base140f6be includes the preceding requested removal of prompt/Skills editing. Primary checkout remains untouched. + +## Project Context Loaded + +Task context: +- Official own identity, feature mode, absolute worktree, branch and base verified against registry/Git. +- All61 peer records/183 specified sections read; no missing or concrete conflict. Child-language placeholder scope unknown without dependency. Prior archive constraints superseded by explicit latest user directive. Preserve concurrent project preview/gallery work. + +Read: +- Own record, entry/read-before-planning/memory-index, AGENTS/planning-gate and previous fully loaded positioning/current-state/decision/overview/domain/success/evidence/reflection/commitment context on350f7d7. Canonical diff to current base empty. +- Sidebar/list, workspace/cache stores, existing DELETE route/service/session materialization, focused tests and README. + +Relevant understanding: +- Main owns runtime/persistence deletion. Current DELETE disposes execution and removes the index but archives session JSONL to trash; true deletion requires a distinct scoped session-delete callback covering actual Pi timestamp filenames. +- Renderer deletion needs tombstones to reject late events/in-flight requests. Workspace metadata mutations remain serialized and project-scoped. Project-level teacher history and shared attachment hash blobs have independent ownership and must not be blindly removed. +- Legacy archived conversations return to the ordinary list; explicit opening restores their legacy archived flag before continuing. No new archive navigation/action remains. Stable IDs and unrelated project resources preserved. +- Files: Sidebar/list, workspace/cache, Main delete/session composition and focused UI/store/service/resource tests. Existing broader E2E baseline assertions are outside scope. + +Gate result: Passed. + +## Plan + +1. Remove archive/settings UI and connect deletion with project-safe list/cache removal; keep legacy records accessible. +2. Make Main delete persisted target sessions rather than move them to trash; reject concurrent reopen during deletion. +3. Verify active/selected/background deletion, failure handling and late event races, real filesystem cleanup and isolated desktop interaction; document and commit. + +## Outcome + +- Project overflow menu now contains only project settings. Conversation overflow contains only rename and direct delete, with no conversation settings or archive action. Legacy archived records appear in the ordinary list and restore their legacy flag on explicit opening. +- Workspace DELETE mutations are serialized, project-scoped and tombstoned against late list/metadata resurrection. Failed deletion retains the list item and reports the error. Successful deletion forgets Renderer history/drafts/requests before removing the row. Background deletion retains the current selection; selected deletion returns to an empty composer rather than automatically selecting/creating another conversation. +- Deletion-in-progress project markers protect early metadata updates; cache priming is explicitly distinct from selection so automatic prewarming cannot clear the empty-state marker. Both cases are covered by regression tests. +- Main now wires a dedicated physical session-delete callback, covering legacy id filenames and real Pi timestamp filenames in sessions/trash. It disposes the target runtime, waits for preparation, blocks reopen during deletion, coalesces concurrent deletes, clears source references/index and emits metadata updates. Other sessions/project files and project-level teacher history remain independently owned. +- Renderer cache lifecycle tombstones reject late snapshot/SSE/patch/submission/recovery writes. Observation upload queues/timers/localStorage are forgotten and failed uploads cannot requeue deleted conversations. An upload already sent through the existing API cannot be aborted; no remote/shared-history deletion API introduced. + +## Verification + +- Main routes/session resources:42 tests passed; cache:46; session-sync:13; workspace/sidebar:14; chat UI:38. Total153 focused unit tests passed, including real temporary filesystem cleanup, failed DELETE, cross-project completion, pending metadata and stale prewarming. +- Independent Main/Renderer reviews identified the cache-prime empty-state race; repaired and regression verified. Scoped ESLint, typecheck, diff checks and fresh production build passed. +- Final isolated Electron scenarios2/2 passed: rename, background/legacy/current-running deletion, failure retention/toast, no archive/settings conversation actions, current deletion empty-state; project menu only settings, target validation,128/171/256px alignment and preview-menu behavior. Logs `/tmp/delete-conversations-electron-final-9f2c6a81/run.log`; generated main entry restored byte-for-byte and isolated processes stopped. Screenshots internally reviewed by test agent. No user account/project data or running application modified. +- Initial desktop/current-deletion test exposed the automatic cache-prime marker reset, then final153 focused unit tests and desktop runs passed after correction. A subsequent screenshot-only repeat of the deletion scenario also passed; no expanded suite claimed. Changed-file lint/diff checks passed after final test edits. + +## Follow-ups + +- Running-app update, integration, push and cleanup remain separate user actions. + +## Promotion Candidates + +- Target: current-state / Code conversation navigation and lifecycle. Proposal: archive UI replaced by true target-conversation deletion, only rename/delete in conversation menu and only settings in project menu; preserve independent project resources/history and explicitly distinguish cache priming from user selection. Evidence: latest user directive, scoped deletion implementation and targeted unit/desktop verification. Future impact: prevent reintroducing trash-backed 'deletion' or automatic selection after removal. No conflict; human confirmation already supplied by current request. Promote during a later integration task only. diff --git a/README.md b/README.md index 369818a6..194cc525 100644 --- a/README.md +++ b/README.md @@ -150,16 +150,16 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 项目智能体与对话 -- 项目下仅保留与项目卡片左边框对齐的“+ 新对话”;项目卡片右侧的“…”菜单提供“已归档对话”和“项目设置”。对话行的“…”菜单支持重命名和归档,归档列表可查看并恢复原对话,也可返回最近对话;新建或恢复后回到最近对话列表。归档保留历史、附件和 Pi Session,不中止正在执行的任务;已归档对话仍可查看、处理审批或中止,恢复后才能继续发送或创建分支。全部归档后显示空列表,不自动创建空对话。 +- 项目下保留与项目卡片左边框对齐的“+ 新对话”;项目卡片右侧的“…”菜单仅提供“项目设置”。对话行的“…”菜单仅提供“重命名”和“删除对话”。删除先停止该对话的运行,再清除对话索引、对应的 Pi 会话日志及本地显示缓存,不保留在内部回收目录;不影响项目文件与其他对话。删除当前对话回到空白输入页,不自动创建或打开另一个对话。旧归档记录显示在普通列表中,打开时解除旧归档状态;不再提供归档列表或归档操作。 - 新对话使用第一条实际用户消息的首行作为标题,合并空白并截取最多 32 个字符;纯图片消息使用“附件对话”,命令和空消息不参与命名。不额外调用模型,不随后续消息改名。历史标题保持原样,手动重命名优先,标题最多 200 个字符;仅改标题不改变最近对话排序。Main 在本地元数据落盘后通过独立的 metadata-changed 事件通知列表,事件重连也会刷新列表。 - 新项目自动建立一个内部默认编程智能体,`defaultAgentId` 固定其身份;创建项目不再要求创建或选择智能体。有可用的全局默认模型时复制精确账号与模型,没有时保留待选择状态。旧项目优先保留原默认或置顶/首个可用智能体,保留所有历史绑定;全部停用时新增默认配置,不复活旧配置。 - Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 - 有效项目直接进入会话工作区。打开项目不创建空会话;首次发送或点击项目下的新建入口才创建本地元数据。首条消息无需等待运行时 Snapshot 返回。 -- 项目设置仅提供默认编程配置的模型选择,不展示项目提示词或项目 Skills 编辑入口;知识文件与插件仍在各自入口管理。会话菜单可进入该历史会话绑定的编程配置。默认模型只影响新对话,现有对话独立切换模型与思考选项,已有消息记录不会被改写;保存模型配置保留原有 Agent 元数据。 +- 项目设置仅提供默认编程配置的模型选择,不展示项目提示词或项目 Skills 编辑入口;知识文件与插件仍在各自入口管理。默认模型只影响新对话,现有对话独立切换模型与思考选项,已有消息记录不会被改写;保存模型配置保留原有 Agent 元数据。 - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 -- 会话列表直接挂在左侧项目下,不展示中间智能体列;同一项目内不同历史智能体的会话统一列出,保留归档、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 +- 会话列表直接挂在左侧项目下,不展示中间智能体列;同一项目内不同历史智能体的会话统一列出,支持删除、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 - Conversation 历史按需从 Main-owned Snapshot 读取。Session hydration 沿完整 active branch 投影可见历史;公开 SSE 交付 Snapshot、会话元数据变更通知,以及按 Conversation、worker generation 分组的 `patch-batch`。Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。Main 发出 `lifecycle:sleep` 时页面关闭旧事件流;视图挂载、项目上下文变化、窗口重新可见或获得焦点时,已选 Conversation 会静默重读权威 Snapshot,从而收敛后台已结束的任务,但绝不重放已接受或状态不确定的 mutation。时间线默认挂载最近 120 个节点,接近顶部时按 100 个节点自动追加更早内容并补偿新增高度,保持当前阅读锚点。 - 核心时间线按每条用户消息组织为“用户消息、连续处理过程、结论回复”。Pi 的流式 thinking、工具、压缩、重试、通知和 `subagent.v1` 单个/并行/串行子任务共享一条轻量过程轨道;运行时显示“处理中”和活动指示,完成后按耗时(历史缺少计时时按项目数)收纳为可展开的“已处理”摘要。文字层级按结构化事件类型划分,不按文案关键词猜测:助手在工具调用前后直接对用户说的过程正文与最终结论都使用黑色;thinking、工具输入输出、压缩、子任务和运行状态使用浅灰辅助文字,仍在运行的灰色缩略文字带有低对比度 Shimmer 擦拭高光,结束后停止动画;错误状态保持红色。折叠的 thinking、助手过程正文和工具输出固定展示第一个可见块的首个非空行,横向偏移始终为零;尾部流式更新不会替换未变化的首行预览,展开后仍可查看完整内容。两类流式文字均在原位更新且不做位移动画,助手过程正文也不使用 Shimmer。工具调用用一行摘要持续展示工具名称、最新输入或输出和运行状态,并可按流式批次轻量整行轮动。思考、助手过程正文和工具节点均可展开查看完整详情,工具的累计输出和浏览器附件留在对应的二级展开区而不形成独立气泡。结论回复按安全 Markdown 排版标题、列表、引用、代码和表格;可点击的 HTTP/HTTPS 与本地 HTML 链接统一使用无底色的橙色文字。左键通过 Main-owned 系统入口交给默认浏览器打开;右键可打开系统菜单,网页链接可复制地址,本地链接还可在访达或系统文件夹中显示。同一回复已经给出完整 HTML 路径时,对应的相对查看路径也会解析到该文件。内部 `turn-start`/`turn-end` 只维持事件顺序、不呈现在界面。 - Composer 使用贴底的复合输入面板,支持文字、登录后的语音转写,以及粘贴或选择 PNG/JPEG/WebP/GIF 图片;每条消息最多 16 张图片、最多并行上传 4 张。语音录制通过现有 Main-owned Works Speech Host API 转成文字,并在项目或账号切换时立即停止;当前对话的模型与思考强度组合入口、语音及发送/中止入口保留在底部控制行,模型入口默认使用透明底色。模型与思考强度通过向上展开的分级菜单切换,只列出当前 Provider 或 Makelore 已验证模型档案实际支持的选项;Qwen3.6 Plus 使用混合思考开关,Qwen3.8 Max 暴露低/中/高三档并把产品“高”映射到服务端 `xhigh`。模型仅报告 `off` 时明确显示“不可调”,不伪造未知模型的推理档位。切换完成后静默刷新当前 Snapshot,只有真正恢复 worker 时才显示本地 Agent 重连提示。重复读取 Snapshot 不发布无变化的 worker 状态;Snapshot 与 SSE 批次竞态时,Renderer 会丢弃已覆盖事件并只应用连续的新尾部,避免形成伪重连循环。上下文接近模型限制或发生溢出时由 Pi 自动整理,Composer 不提供手动整理入口;自动整理的进度和结果仍显示在对话过程轨道中。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。发送后立即展示图片附件;Pi 实时消息与历史会话恢复都将图片映射到同一附件存储,重复加载复用已有文件。智能体报告 `changed-file.v1` 后,输入框上方会展示当前轮次的文件数与增删行统计,并可展开查看 Main-owned 变更快照中的逐文件差异。 @@ -168,7 +168,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。 - 隐藏 Conversation 的红色新消息标记只在出现新的待回答交互,或当前任务进入 completed、failed、aborted 终态时设置;助手流式文字、思考、工具过程和单个工具失败不会提前触发红点。 -- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供统一智能体入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。作品区直接显示地址栏和页面,不再重复显示“作品预览”标题、图标及关闭横栏;通过上方“操作对话”标签切回对话。点击智能体头像或主动气泡中的聊天入口,会同时打开智能体咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到智能体气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用;没有可用页面时先做本地文件检查,确认项目只有配置、知识或图片等资料、尚无作品时,在作品区显示“还没有作品,快和老师聊聊吧”,不创建操作对话、不调用模型、不新增聊天消息。空态停止定时轮询,再次进入作品区会重新检查。存在作品代码、启动文件或无法确定为空时,仍向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示;不把服务未启动当作没有作品。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置、归档和会话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 +- 核心聊天页左侧显示品牌、项目及其对话;标题栏显示项目与当前 Conversation,并在右上角提供统一智能体入口和必要的中止/恢复按钮。中央“操作对话/作品”切换真实对话与项目浏览器预览,切换时保留操作草稿。作品区直接显示地址栏和页面,不再重复显示“作品预览”标题、图标及关闭横栏;通过上方“操作对话”标签切回对话。点击智能体头像或主动气泡中的聊天入口,会同时打开智能体咨询并默认切到“作品”;仍可手动切回操作对话,单纯收到智能体气泡不会切换页面。点击“作品”会由 Main 检查已知本地页面:可访问则直接复用;没有可用页面时先做本地文件检查,确认项目只有配置、知识或图片等资料、尚无作品时,在作品区显示“还没有作品,快和老师聊聊吧”,不创建操作对话、不调用模型、不新增聊天消息。空态停止定时轮询,再次进入作品区会重新检查。存在作品代码、启动文件或无法确定为空时,仍向编程 Agent 发送固定的内部打开作品指令,检查现有服务、按项目实际脚本启动并调用 `agent_browser` 展示;不把服务未启动当作没有作品。当前项目正在制作时先等待;同一启动过程防止重复派发。失败时提供明确恢复入口:登录失效可“登录并继续”,返回相同账号和项目后接着打开;作品未打开可“帮我检查并打开”;普通操作中断可“帮我继续”,先核对已有进度再继续,保留当前输入草稿。仅对连接检查做两次有上限的自动重连并复用请求身份,不自动重放结果不确定的操作。最新失败提供一个恢复入口,空错误不再显示“本次处理失败。”;作品成功打开后收起对应失败提示。后台休眠释放预览窗口后,Main 在本次应用会话内保留项目对应的已验证地址,包括从地址栏或 Agent 成功打开的页面;回到前台或收到关闭状态时自动检查并恢复仍可访问的作品,不重复调用 Agent,也不依赖模型登录。手动打开成功会覆盖旧的启动请求结果,但不关闭后续恢复检查。窗口显示时发生关闭竞态会自动重试两次;仍无法显示则提供“重新打开作品”,不向学生暴露“开发浏览器已关闭”等内部错误,也不无限停留在打开中。内部指令不消耗学生草稿,也不参与对话自动命名,时间线隐藏这条内部指令及“已点击打开作品”操作记录,保留真实的处理结果和错误。学生作品区不展示 Console/Network 调试栏,也不订阅这两个面板的调试事件;Agent 的后台调试能力保留。项目设置和对话管理保留在左侧。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 - 普通消息接收成功不显示额外的技术提示;准备、错误和排队状态仍正常反馈。发送状态按请求对应的运行结束收敛,保留期间输入的新草稿;恢复历史时优先以持久消息身份关联,避免相同文本串联到其他请求。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index f2a65621..e8f02db1 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -41,6 +41,7 @@ import { createDataServiceOperations } from '../services/data-service-client'; import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session'; import { archivePiConversationSession, + deletePiConversationSession, getPiManagedPaths, } from '../coding-runtime/pi/resource-loader'; import { createProjectPluginService } from '../coding-plugins/project-service'; @@ -515,6 +516,9 @@ export function createCodingComposition( const conversations = new CodingConversationService(projects, runtime, { readHistory: (projectId, conversation) => readCodingConversationHistory(options.paths.userDataDir, projectId, conversation), onDelete: (projectId, conversationId) => teacher.removeSource(projectId, conversationId), + deleteSession: async ({ projectId, sessionKey }) => { + await deletePiConversationSession({ userDataDir: options.paths.userDataDir, projectId, sessionKey }); + }, archiveSession: async ({ projectId, sessionKey }) => { await archivePiConversationSession({ userDataDir: options.paths.userDataDir, diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 2c42d482..2420776d 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -50,6 +50,7 @@ interface AcceptanceRecord { export interface CodingConversationServiceOptions { readHistory?(projectId: string, conversation: CodingConversationV2): Promise; onDelete?(projectId: string, conversationId: string): Promise; + deleteSession?(input: { projectId: string; sessionKey: string }): Promise; archiveSession?(input: { projectId: string; sessionKey: string; @@ -267,6 +268,7 @@ class PatchBatchScheduler { export class CodingConversationService { private readonly prepareFlights = new Map>(); private readonly acceptances = new Map(); + private readonly deleteFlights = new Map>(); constructor( readonly projects: CodingProjectService, @@ -307,7 +309,9 @@ export class CodingConversationService { } private async assertUnarchived(conversationId: string): Promise { + this.assertNotDeleting(conversationId); const { conversation } = await this.projects.findActiveConversation(conversationId); + this.assertNotDeleting(conversationId); if (conversation.archivedAt) { throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_ARCHIVED', '请先恢复已归档的对话,再继续操作。'); } @@ -380,13 +384,26 @@ export class CodingConversationService { } async deleteConversation(conversationId: string): Promise { - const { project, conversation } = await this.projects.findActiveConversation(conversationId); + const prior = this.deleteFlights.get(conversationId); + if (prior) return await prior; + const flight = this.performDelete(conversationId).finally(() => { + if (this.deleteFlights.get(conversationId) === flight) this.deleteFlights.delete(conversationId); + }); + this.deleteFlights.set(conversationId, flight); + return await flight; + } + + private async performDelete(conversationId: string): Promise { + const { project } = await this.projects.findActiveConversation(conversationId); + await this.prepareFlights.get(conversationId)?.catch(() => undefined); try { await this.runtime.dispose(conversationId, 'conversation_deleted'); } catch (error) { runtimeError(error); } - await this.archiveSession(project.id, conversation.sessionKey); + // A prepare already in flight may have created the durable session binding. + const conversation = await this.projects.conversationStore(project.path).get(conversationId); + await this.deleteSession(project.id, conversation?.sessionKey); await this.options.onDelete?.(project.id, conversationId); await persist(() => this.projects.conversationStore(project.path).delete(conversationId)); this.titleContexts.delete(conversationId); @@ -394,6 +411,7 @@ export class CodingConversationService { for (const key of [...this.acceptances.keys()]) { if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key); } + this.metadataChanged(project.id, conversationId); } async getSnapshot(conversationId: string): Promise { @@ -419,6 +437,7 @@ export class CodingConversationService { attachments?: unknown; }): Promise { const conversationId = requiredString(input.conversationId, 'Conversation id', 128); + this.assertNotDeleting(conversationId); const clientRequestId = typeof input.clientRequestId === 'string' ? input.clientRequestId : ''; if (!REQUEST_ID_PATTERN.test(clientRequestId)) { throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Client request id is invalid'); @@ -462,6 +481,7 @@ export class CodingConversationService { try { await this.assertUnarchived(conversationId); await this.ensurePrepared(conversationId); + this.assertNotDeleting(conversationId); const acceptance = await this.runtime.prompt({ conversationId, clientRequestId, @@ -725,6 +745,7 @@ export class CodingConversationService { private ensurePrepared(conversationId: string): Promise { const id = conversationId.trim(); + this.assertNotDeleting(id); const prior = this.prepareFlights.get(id); if (prior) return prior; const flight = (async () => { @@ -749,6 +770,7 @@ export class CodingConversationService { modelResolution: conversation.modelResolution, }, }; + this.assertNotDeleting(id); try { await this.runtime.prepare(prepared); } catch (error) { runtimeError(error); } if (this.titleContexts.has(id)) { const snapshot = await this.runtime.getSnapshot(id); @@ -799,4 +821,18 @@ export class CodingConversationService { } await persist(() => this.options.archiveSession!({ projectId, sessionKey })); } + + private assertNotDeleting(conversationId: string): void { + if (this.deleteFlights.has(conversationId)) { + throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_DELETING', '对话正在删除,请稍候。'); + } + } + + private async deleteSession(projectId: string, sessionKey: string | undefined): Promise { + if (!sessionKey) return; + if (!this.options.deleteSession) { + throw new CodingConversationServiceError(500, 'CODING_STORAGE_WRITE_FAILED', 'Coding session deletion is unavailable'); + } + await persist(() => this.options.deleteSession!({ projectId, sessionKey })); + } } diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index d328b3a4..c21f71ea 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -1,4 +1,4 @@ -import { mkdir, rename, stat } from 'node:fs/promises'; +import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json'; import { validateSessionKey } from '../../coding-projects/conversation-store'; @@ -166,6 +166,33 @@ export async function archivePiConversationSession(input: { } } +export async function deletePiConversationSession(input: { + userDataDir: string; + projectId: string; + sessionKey: string; +}): Promise { + const projectId = managedSegment(input.projectId, 'Project id'); + const sessionKey = validateSessionKey(input.sessionKey); + const paths = getPiManagedPaths(input.userDataDir); + const basename = `${sessionKey}.jsonl`; + for (const root of [paths.sessionsDir, paths.trashDir]) { + const directory = path.join(root, projectId); + const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return []; + throw error; + }); + for (const entry of entries) { + // Pi 0.84 stores sessions as _.jsonl; + // earlier managed sessions used the session id alone. + const timestamp = entry.name.endsWith(`_${basename}`) + ? entry.name.slice(0, -(`_${basename}`.length)) + : ''; + if (entry.name !== basename && !/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/u.test(timestamp)) continue; + await rm(path.join(directory, entry.name), { force: true }); + } + } +} + function normalizeSkillEntries(skillEntries: readonly PiSkillEntry[]): PiSkillEntry[] { const result: PiSkillEntry[] = []; const seen = new Set(); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c79271eb..bfd040c4 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -6,14 +6,12 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { - Archive, Bot, BotMessageSquare, Cpu, FolderKanban, Gamepad2, LogOut, - MessageSquare, MoreHorizontal, Plus, Settings as SettingsIcon, @@ -117,7 +115,6 @@ export function Sidebar({ } = useCurrentUserProfile(); const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot); const [projectsOpen, setProjectsOpen] = useState(true); - const [archivedProjectId, setArchivedProjectId] = useState(null); const [projectMenuOpen, setProjectMenuOpen] = useState(false); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false); @@ -344,17 +341,13 @@ export function Sidebar({ await readAndEnterProject(project); }, [readAndEnterProject]); - const openProjectAction = async (project: CodingProjectSummary, action: 'settings' | 'archived' | 'recent') => { + const openProjectSettings = async (project: CodingProjectSummary) => { const opening = openProject(project); const intent = projectEntryIntentRef.current; await opening; if (intent !== projectEntryIntentRef.current) return; if (codingWorkspaceStore.getState().activeProjectId !== project.id) return; - if (action === 'settings') { - navigate('/project-config'); - } else { - setArchivedProjectId(action === 'archived' ? project.id : null); - } + navigate('/project-config'); }; useEffect(() => { @@ -499,7 +492,6 @@ export function Sidebar({ {visibleProjects.map((project) => { const active = activeProject?.id === project.id; - const archived = active && archivedProjectId === project.id; return (
@@ -539,14 +531,7 @@ export function Sidebar({ > void openProjectAction(project, archived ? 'recent' : 'archived')} - > - {archived ? - void openProjectAction(project, 'settings')} + onSelect={() => void openProjectSettings(project)} >
diff --git a/src/lib/agent-session-sync.ts b/src/lib/agent-session-sync.ts index e39193dc..c280813c 100644 --- a/src/lib/agent-session-sync.ts +++ b/src/lib/agent-session-sync.ts @@ -25,7 +25,10 @@ type PendingUpload = { const pendingUploads = new Map(); const uploadTimers = new Map>(); -const inFlightUploads = new Map>(); +const inFlightUploads = new Map }>(); +// Code conversation IDs are stable local UUIDs across account changes. Keep +// these tombstones for the Renderer lifetime so late snapshots cannot requeue. +const forgottenCodingConversationIds = new Set(); let pendingUploadsHydrated = false; const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'); @@ -233,7 +236,8 @@ function hydratePendingUploads(): void { // Queue entries are account-bound. Do not guess an owner for records // written by an older build, otherwise a different login could receive // another account's local conversation data. - if (!userId || !isAgentSessionData(data)) continue; + if (!userId || !isAgentSessionData(data) + || forgottenCodingConversationIds.has(data.session_id)) continue; const retryCount = typeof record?.retryCount === 'number' && Number.isFinite(record.retryCount) ? Math.max(0, Math.floor(record.retryCount)) : 0; @@ -279,10 +283,12 @@ function getRetryDelay(retryCount: number): number { } async function uploadWithCurrentProfile(userId: string, data: AgentSessionData): Promise { + if (forgottenCodingConversationIds.has(data.session_id)) return; if (getCurrentAccountKey() !== userId) { throw new Error('Authenticated account changed during Agent session sync'); } const accessToken = await useAuthStore.getState().getValidAccessToken(); + if (forgottenCodingConversationIds.has(data.session_id)) return; if (!accessToken) throw new Error('No authenticated session for Agent session sync'); if (getCurrentAccountKey() !== userId) { @@ -302,19 +308,20 @@ async function uploadWithCurrentProfile(userId: string, data: AgentSessionData): async function flushUpload(key: string): Promise { const existing = inFlightUploads.get(key); - if (existing) return existing; + if (existing) return existing.task; + + hydratePendingUploads(); + const pending = pendingUploads.get(key); + if (!pending || forgottenCodingConversationIds.has(pending.data.session_id)) return; + if (getCurrentAccountKey() !== pending.userId) return; const task = (async () => { - hydratePendingUploads(); - const pending = pendingUploads.get(key); - if (!pending) return; - if (getCurrentAccountKey() !== pending.userId) return; - pendingUploads.delete(key); persistPendingUploads(); try { await uploadWithCurrentProfile(pending.userId, pending.data); } catch { + if (forgottenCodingConversationIds.has(pending.data.session_id)) return; if (!pendingUploads.has(key)) { const retryCount = pending.retryCount + 1; pendingUploads.set(key, { userId: pending.userId, data: pending.data, retryCount }); @@ -326,20 +333,21 @@ async function flushUpload(key: string): Promise { return; } - if (pendingUploads.has(key)) { + if (!forgottenCodingConversationIds.has(pending.data.session_id) && pendingUploads.has(key)) { scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS); } })(); - inFlightUploads.set(key, task); + inFlightUploads.set(key, { sessionId: pending.data.session_id, task }); try { await task; } finally { - if (inFlightUploads.get(key) === task) inFlightUploads.delete(key); + if (inFlightUploads.get(key)?.task === task) inFlightUploads.delete(key); } } function queueAgentSessionData(data: AgentSessionData): void { + if (forgottenCodingConversationIds.has(data.session_id)) return; const userId = getCurrentAccountKey(); if (!userId) return; @@ -363,6 +371,7 @@ export function queueAgentSessionSync( /** Queue the final product Conversation snapshot after a completed prompt turn. */ export function queueCodingConversationSessionSync(snapshot: ConversationSnapshot): void { + if (forgottenCodingConversationIds.has(snapshot.conversation.id.trim())) return; const messageNodes = snapshot.nodes.filter((node) => node.kind === 'message'); let lastUserIndex = -1; for (let index = messageNodes.length - 1; index >= 0; index -= 1) { @@ -406,6 +415,26 @@ export function queueCodingConversationSessionSync(snapshot: ConversationSnapsho queueAgentSessionData(data); } +/** Forget local retry data after deletion; existing shared observations stay intact. */ +export function forgetCodingConversationSessionSync(conversationId: string): void { + const sessionId = conversationId.trim(); + if (!sessionId) return; + + forgottenCodingConversationIds.add(sessionId); + hydratePendingUploads(); + for (const [key, pending] of pendingUploads) { + if (pending.data.session_id !== sessionId) continue; + clearUploadTimer(key); + pendingUploads.delete(key); + } + // The profile upload API cannot abort a request already sent. Its completion + // may update the shared profile version, but must never restore this queue. + for (const [key, upload] of inFlightUploads) { + if (upload.sessionId === sessionId) clearUploadTimer(key); + } + persistPendingUploads(); +} + /** Flush persisted session snapshots after login or profile bootstrap. */ export async function flushPendingAgentSessionSync(): Promise { hydratePendingUploads(); @@ -426,5 +455,6 @@ export function resetAgentSessionSyncForTests(): void { uploadTimers.clear(); pendingUploads.clear(); inFlightUploads.clear(); + forgottenCodingConversationIds.clear(); pendingUploadsHydrated = false; } diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index eab8c99a..67458215 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -159,11 +159,13 @@ export function CodingChatPanel({ (state) => state.conversationErrorsByProjectId, ); const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds); + const deletingConversationProjectIds = useCodingWorkspaceStore((state) => state.deletingConversationProjectIds); const loadWorkspace = useCodingWorkspaceStore((state) => state.load); const patchConversation = useCodingWorkspaceStore((state) => state.patchConversation); const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation); const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId); + const selectionClearedByDeletionProjectId = useCodingConversationStore((state) => state.selectionClearedByDeletionProjectId); const connectionError = useCodingConversationStore((state) => state.globalError); const primeConversation = useCodingConversationStore((state) => state.primeConversation); const selectConversation = useCodingConversationStore((state) => state.selectConversation); @@ -376,6 +378,8 @@ export function CodingChatPanel({ } return; } + if (selectionClearedByDeletionProjectId === activeProject.id + || (selectedConversationId && deletingConversationProjectIds[selectedConversationId] === activeProject.id)) return; const next = newestConversation(conversations); if (next) { markConversationUnread(next.id, false); @@ -386,6 +390,9 @@ export function CodingChatPanel({ clearConversationSelection(); }, [ activeProject, + selectionClearedByDeletionProjectId, + selectedConversationId, + deletingConversationProjectIds, clearConversationSelection, conversations, markConversationUnread, diff --git a/src/pages/Chat/CodingProjectConversations.tsx b/src/pages/Chat/CodingProjectConversations.tsx index 95e0ca9a..38b1a152 100644 --- a/src/pages/Chat/CodingProjectConversations.tsx +++ b/src/pages/Chat/CodingProjectConversations.tsx @@ -1,13 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Archive, - ArchiveRestore, LoaderCircle, Plus, MoreHorizontal, Pencil, - Settings2, + Trash2, MessageSquare, } from 'lucide-react'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; @@ -21,13 +19,11 @@ import { createLocalConversationSnapshot } from './coding-chat-snapshot'; export function CodingProjectConversations({ projectId, compact = false, - archived = false, - onArchivedChange, + onMenuOpenChange, }: { projectId: string; compact?: boolean; - archived?: boolean; - onArchivedChange?: (archived: boolean) => void; + onMenuOpenChange?: (open: boolean) => void; }) { const navigate = useNavigate(); const conversations = useCodingWorkspaceStore((state) => state.conversations); @@ -36,18 +32,6 @@ export function CodingProjectConversations({ const summaries = useCodingConversationStore((state) => state.summariesByConversationId); const [busy, setBusy] = useState(null); const [rename, setRename] = useState(null); - const selectedConversation = conversations.find((item) => item.id === selected); - const previousSelection = useRef(selectedConversation); - useEffect(() => { - const previous = previousSelection.current; - if ( - previous?.id === selectedConversation?.id && - previous?.archivedAt && - !selectedConversation?.archivedAt - ) - onArchivedChange?.(false); - previousSelection.current = selectedConversation; - }, [selectedConversation, onArchivedChange]); if (activeProjectId !== projectId) return null; const act = async (key: string, operation: () => Promise) => { setBusy(key); @@ -61,8 +45,12 @@ export function CodingProjectConversations({ }; const select = async (conversation: CodingConversationMetadata) => { if (codingWorkspaceStore.getState().activeProjectId !== projectId) return; + const opened = conversation.archivedAt + ? await codingWorkspaceStore.getState().patchConversation(conversation.id, { archived: false }) + : conversation; + if (codingWorkspaceStore.getState().activeProjectId !== projectId) return; const state = codingConversationStore.getState(); - state.primeConversation(createLocalConversationSnapshot(projectId, conversation)); + state.primeConversation(createLocalConversationSnapshot(projectId, opened)); state.markUnread(conversation.id, false); if (conversation.unread) void codingWorkspaceStore @@ -91,7 +79,6 @@ export function CodingProjectConversations({ const workspace = codingWorkspaceStore.getState(); const conversation = await workspace.createConversation(); if (codingWorkspaceStore.getState().activeProjectId === projectId) { - onArchivedChange?.(false); await select(conversation); } }) @@ -101,25 +88,9 @@ export function CodingProjectConversations({ 新对话
- {archived && ( -
- 已归档对话 - -
- )} {conversations - .filter((item) => Boolean(item.archivedAt) === archived) - .sort((a, b) => - (archived ? b.archivedAt! : b.updatedAt).localeCompare( - archived ? a.archivedAt! : a.updatedAt - ) - ) + .slice() + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) .map((item) => { const summary = summaries[item.id]; const running = @@ -146,11 +117,11 @@ export function CodingProjectConversations({ )} - + - - - ; - }; const { codingConversationStore } = await import('@/stores/coding-conversations'); - render(); - const menu = await screen.findByRole('button', { name: '对话操作:新对话' }); - await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id)); - fireEvent.keyDown(menu, { key: 'Enter' }); - fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' })); - await screen.findByRole('heading', { name: '今天,你想创造什么?' }); - expect(projectApi.create).not.toHaveBeenCalled(); - expect(conversationApi.abort).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: '打开归档列表' })); - expect(screen.getByText('已归档对话')).toBeVisible(); - fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true })); - await screen.findByText('此对话已归档,任务仍在运行。'); - expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '恢复对话' })); - await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument()); + const { codingWorkspaceStore } = await import('@/stores/coding-workspace'); + render(<>); + const list = within(await screen.findByTestId('project-conversations')); + await waitFor(() => expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status).toBe('running')); + fireEvent.keyDown(list.getByRole('button', { name: '对话操作:新对话' }), { key: 'Enter' }); + expect((await screen.findAllByRole('menuitem')).map(item => item.textContent?.trim())).toEqual(['重命名', '删除对话']); + fireEvent.click(screen.getByRole('menuitem', { name: '删除对话', exact: true })); + await waitFor(() => expect(projectApi.deleteConversation).toHaveBeenCalledWith(conversation.id)); expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id); - expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false }); - expect(screen.queryByText('已归档对话')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument(); - expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true })).toHaveAttribute('aria-current', 'page'); + expect(list.getByRole('button', { name: conversation.title, exact: true })).toBeVisible(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Main can publish the refreshed list before the DELETE response reaches the renderer. + await act(async () => codingWorkspaceStore.setState({ conversations: [other] })); + expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id); + expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id); + expect(list.getByRole('button', { name: other.title, exact: true })).not.toHaveAttribute('aria-current', 'page'); + + await act(async () => removal.resolve()); + + await screen.findByRole('heading', { name: '今天,你想创造什么?' }); + expect(codingConversationStore.getState().selectedConversationId).toBeNull(); + expect(codingConversationStore.getState().entriesByConversationId[conversation.id]).toBeUndefined(); + expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument(); + expect(list.getByRole('button', { name: other.title, exact: true })).toBeVisible(); + expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id); + expect(projectApi.create).not.toHaveBeenCalled(); + expect(projectApi.patch).not.toHaveBeenCalled(); expect(conversationApi.abort).not.toHaveBeenCalled(); }); - it('returns to recent conversations when restoring an unselected archived conversation from its menu', async () => { - const archivedConversation = { - ...conversation, - id: 'archived-conversation', - title: '归档中的对话', - archivedAt: '2026-09-20T00:00:00Z', - }; + it.each([null, '2026-09-20T00:00:00Z'])('deletes an unselected conversation without changing the current conversation (legacy archived=%s)', async (archivedAt) => { + const other = { ...conversation, id: 'unselected-delete', title: '后台对话', archivedAt, updatedAt: '2025-01-01T00:00:00Z' }; projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); - projectApi.conversations.mockResolvedValue([conversation, archivedConversation]); - projectApi.patch.mockResolvedValue({ ...archivedConversation, archivedAt: null }); + projectApi.conversations.mockResolvedValue([conversation, other]); + projectApi.deleteConversation.mockResolvedValue(undefined); conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation)); - const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations'); const { codingConversationStore } = await import('@/stores/coding-conversations'); - const { useState } = await import('react'); - const CodingChatPanel = () => { - const [archived, setArchived] = useState(true); - return <> - - - ; - }; - render(); + render(<>); await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id)); const list = within(screen.getByTestId('project-conversations')); - expect(list.getByText('已归档对话')).toBeVisible(); - expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument(); - - fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${archivedConversation.title}` }), { key: 'Enter' }); - fireEvent.click(await screen.findByRole('menuitem', { name: '恢复到最近对话' })); - - await waitFor(() => expect(list.queryByText('已归档对话')).not.toBeInTheDocument()); - expect(projectApi.patch).toHaveBeenCalledWith(archivedConversation.id, { archived: false }); - expect(list.getByRole('button', { name: archivedConversation.title, exact: true })).toBeVisible(); - expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page'); + expect(list.getByRole('button', { name: other.title, exact: true })).toBeVisible(); + fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${other.title}` }), { key: 'Enter' }); + fireEvent.click(await screen.findByRole('menuitem', { name: '删除对话', exact: true })); + await waitFor(() => expect(list.queryByRole('button', { name: other.title, exact: true })).not.toBeInTheDocument()); + expect(projectApi.deleteConversation).toHaveBeenCalledExactlyOnceWith(other.id); expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id); - expect(conversationApi.snapshot).not.toHaveBeenCalledWith(archivedConversation.id); + expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page'); + expect(codingConversationStore.getState().entriesByConversationId[other.id]).toBeUndefined(); + expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id); + expect(projectApi.patch).not.toHaveBeenCalled(); + expect(projectApi.create).not.toHaveBeenCalled(); + expect(conversationApi.abort).not.toHaveBeenCalled(); + }); + + it('keeps the conversation and selection and reports a failed deletion', async () => { + const { toast } = await import('sonner'); + const toastError = vi.spyOn(toast, 'error'); + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config }); + projectApi.conversations.mockResolvedValue([conversation]); + projectApi.deleteConversation.mockRejectedValue(new Error('删除失败,请重试')); + conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation)); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + render(<>); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id)); + fireEvent.keyDown(screen.getByRole('button', { name: '对话操作:新对话' }), { key: 'Enter' }); + fireEvent.click(await screen.findByRole('menuitem', { name: '删除对话', exact: true })); + await waitFor(() => expect(toastError).toHaveBeenCalledWith('删除失败,请重试')); + expect(projectApi.deleteConversation).toHaveBeenCalledExactlyOnceWith(conversation.id); + expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id); + expect(codingConversationStore.getState().entriesByConversationId[conversation.id]).toBeDefined(); + expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page'); expect(projectApi.create).not.toHaveBeenCalled(); }); - it('keeps the new-conversation label visible in compact archive view and returns to recent conversations after creation', async () => { - const archivedConversation = { ...conversation, archivedAt: '2026-09-20T00:00:00Z' }; - const createdConversation = { ...conversation, id: 'created-conversation', title: '新建的对话' }; + it('lists legacy archived conversations and restores one before selecting it', async () => { + const legacy = { ...conversation, id: 'legacy-conversation', title: '以前的对话', archivedAt: '2026-09-20T00:00:00Z' }; + const restored = { ...legacy, archivedAt: null }; + const restoreFlight = deferred(); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); - projectApi.conversations.mockResolvedValue([archivedConversation]); - projectApi.create.mockResolvedValue(createdConversation); + projectApi.conversations.mockResolvedValue([conversation, legacy]); + projectApi.patch.mockReturnValue(restoreFlight.promise); conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); - conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, createdConversation)); - const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + conversationApi.snapshot.mockImplementation(async id => createLocalConversationSnapshot(project.id, id === legacy.id ? restored : conversation)); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations'); const { codingConversationStore } = await import('@/stores/coding-conversations'); - const { useState } = await import('react'); - const CodingChatPanel = () => { - const [archived, setArchived] = useState(true); - return <> - - - ; - }; - render(); - const list = within(await screen.findByTestId('project-conversations')); - const create = list.getByRole('button', { name: '新建对话', exact: true }); - expect(create).toHaveTextContent('新对话'); - expect(list.getByText('已归档对话')).toBeVisible(); + render(<>); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id)); + const list = within(screen.getByTestId('project-conversations')); + expect(list.getByRole('button', { name: '新建对话', exact: true })).toHaveTextContent('新对话'); + fireEvent.click(list.getByRole('button', { name: legacy.title, exact: true })); + await waitFor(() => expect(projectApi.patch).toHaveBeenCalledWith(legacy.id, { archived: false })); + expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id); + expect(conversationApi.snapshot).not.toHaveBeenCalledWith(legacy.id); + await act(async () => restoreFlight.resolve(restored)); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(legacy.id)); + expect(list.getByRole('button', { name: legacy.title, exact: true })).toHaveAttribute('aria-current', 'page'); + expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument(); + expect(projectApi.deleteConversation).not.toHaveBeenCalled(); expect(projectApi.create).not.toHaveBeenCalled(); - - fireEvent.click(create); - - await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(createdConversation.id)); - expect(projectApi.create).toHaveBeenCalledOnce(); - expect(list.queryByText('已归档对话')).not.toBeInTheDocument(); - expect(list.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument(); - expect(list.queryByRole('button', { name: archivedConversation.title, exact: true })).not.toBeInTheDocument(); - expect(list.getByRole('button', { name: createdConversation.title, exact: true })).toHaveAttribute('aria-current', 'page'); - expect(conversationApi.submit).not.toHaveBeenCalled(); }); it('renames an unselected conversation from its menu without preparing that conversation', async () => { diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index d2e3a5c2..b14f7713 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -139,6 +139,169 @@ function ids() { } describe('coding Conversation store', () => { + function expectForgotten(store: ReturnType, id: string) { + const state = store.getState(); + expect(state.entriesByConversationId).not.toHaveProperty(id); + expect(state.summariesByConversationId).not.toHaveProperty(id); + expect(state.draftsByConversationId).not.toHaveProperty(id); + expect(state.requestsByConversationId).not.toHaveProperty(id); + expect(state.selectedConversationId).not.toBe(id); + } + + it('forgets only the deleted conversation and ignores late events and local writes', async () => { + const source = new FakeEventSource(); + const getSnapshot = vi.fn(async (id: string) => snapshot(id)); + const forgetSettledSessionSync = vi.fn(); + const queueSettledSessionSync = vi.fn(); + const store = createCodingConversationStore({ + getSnapshot, + openEvents: async () => source as unknown as EventSource, + forgetSettledSessionSync, + queueSettledSessionSync, + }); + store.getState().primeConversation(snapshot('conversation-a')); + store.getState().setDraft('conversation-a', 'remove this draft'); + await store.getState().selectConversation('conversation-b'); + store.getState().setDraft('conversation-b', 'keep this draft'); + const otherEntry = store.getState().entriesByConversationId['conversation-b']; + const otherSummary = store.getState().summariesByConversationId['conversation-b']; + const otherDraft = store.getState().draftsByConversationId['conversation-b']; + + store.getState().forgetConversation('conversation-a'); + expect(forgetSettledSessionSync).toHaveBeenCalledWith('conversation-a'); + expect(store.getState().selectedConversationId).toBe('conversation-b'); + expect(store.getState().selectionClearedByDeletionProjectId).toBeNull(); + source.emit('snapshot', snapshotEvent(snapshot('conversation-a', 2, 20))); + source.emit('patch-batch', patchEvent('conversation-a', 21, { op: 'run.state', run: { status: 'running' } }, 2)); + store.getState().primeConversation(snapshot('conversation-a')); + store.getState().setDraft('conversation-a', 'late draft'); + store.getState().markUnread('conversation-a', true); + await store.getState().selectConversation('conversation-a'); + + expectForgotten(store, 'conversation-a'); + expect(getSnapshot.mock.calls).toEqual([['conversation-b']]); + expect(queueSettledSessionSync).not.toHaveBeenCalled(); + expect(store.getState().entriesByConversationId['conversation-b']).toBe(otherEntry); + expect(store.getState().summariesByConversationId['conversation-b']).toBe(otherSummary); + expect(store.getState().draftsByConversationId['conversation-b']).toBe(otherDraft); + store.getState().forgetConversation('conversation-b'); + expect(store.getState().selectedConversationId).toBeNull(); + expect(store.getState().selectionClearedByDeletionProjectId).toBe(snapshot('conversation-b').conversation.projectId); + store.getState().forgetConversation('conversation-a'); + expect(store.getState().selectionClearedByDeletionProjectId).toBe(snapshot('conversation-b').conversation.projectId); + store.getState().primeConversation(snapshot('conversation-c')); + expect(store.getState().selectionClearedByDeletionProjectId).toBe(snapshot('conversation-b').conversation.projectId); + await store.getState().selectConversation('conversation-c'); + expect(store.getState().selectionClearedByDeletionProjectId).toBeNull(); + store.getState().forgetConversation('conversation-c'); + expect(store.getState().selectionClearedByDeletionProjectId).toBe(snapshot('conversation-c').conversation.projectId); + await store.getState().selectConversation('conversation-d'); + expect(store.getState().selectionClearedByDeletionProjectId).toBeNull(); + store.getState().disconnectEvents(); + }); + + it('keeps the deleted selection empty when remaining conversations are warmed until an explicit selection', async () => { + const source = new FakeEventSource(); + const store = createCodingConversationStore({ + getSnapshot: async (id) => snapshot(id), + openEvents: async () => source as unknown as EventSource, + forgetSettledSessionSync: vi.fn(), + }); + store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b'))); + await store.getState().selectConversation('conversation-a'); + store.getState().forgetConversation('conversation-a'); + const deletedProjectId = snapshot('conversation-a').conversation.projectId; + + // The chat surface warms every remaining row when the metadata list changes. + store.getState().primeConversation(snapshot('conversation-b')); + store.getState().primeConversation(snapshot('conversation-c')); + expect(store.getState().selectedConversationId).toBeNull(); + expect(store.getState().selectionClearedByDeletionProjectId).toBe(deletedProjectId); + + await store.getState().selectConversation('conversation-b'); + expect(store.getState().selectedConversationId).toBe('conversation-b'); + expect(store.getState().selectionClearedByDeletionProjectId).toBeNull(); + store.getState().disconnectEvents(); + }); + + it.each(['resolved', 'failed'] as const)('does not restore deleted state when an in-flight prompt %s', async (outcome) => { + const pending = deferred(); + const store = createCodingConversationStore({ + submitPrompt: (input) => input.conversationId === 'conversation-a' + ? pending.promise : Promise.resolve(acceptance(input.conversationId, input.clientRequestId)), + createId: ids(), + forgetSettledSessionSync: vi.fn(), + }); + store.getState().primeConversation(snapshot('conversation-a')); + store.getState().primeConversation(snapshot('conversation-b')); + store.getState().setDraft('conversation-a', 'pending text'); + await store.getState().submitPrompt({ conversationId: 'conversation-b', mode: 'prompt', text: 'keep this request' }); + const otherRequests = store.getState().requestsByConversationId['conversation-b']; + const flight = store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' }); + expect(Object.keys(store.getState().requestsByConversationId['conversation-a'])).toHaveLength(1); + store.getState().forgetConversation('conversation-a'); + const rejected = expect(flight).rejects.toThrow(outcome === 'resolved' ? '对话已删除' : 'request failed'); + if (outcome === 'resolved') pending.resolve(acceptance('conversation-a', 'request-2')); + else pending.reject(new Error('request failed')); + await rejected; + expectForgotten(store, 'conversation-a'); + expect(store.getState().requestsByConversationId['conversation-b']).toBe(otherRequests); + }); + + it.each(['resolved', 'failed'] as const)('discards a deleted conversation recovery buffer when its snapshot %s', async (outcome) => { + const pending = deferred(); + const getSnapshot = vi.fn(() => pending.promise); + const store = createCodingConversationStore({ getSnapshot, forgetSettledSessionSync: vi.fn() }); + store.getState().primeConversation(snapshot('conversation-a')); + store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 3, { op: 'run.state', run: { status: 'running' } })); + const flight = store.getState().loadSnapshot('conversation-a', 'recovering'); + store.getState().forgetConversation('conversation-a'); + const rejected = expect(flight).rejects.toThrow(outcome === 'resolved' ? '对话已删除' : 'snapshot failed'); + if (outcome === 'resolved') pending.resolve(snapshot('conversation-a', 1, 1)); + else pending.reject(new Error('snapshot failed')); + await rejected; + await Promise.resolve(); + await expect(store.getState().loadSnapshot('conversation-a')).rejects.toThrow('对话已删除'); + expect(getSnapshot).toHaveBeenCalledTimes(1); + expectForgotten(store, 'conversation-a'); + }); + + it.each(['resolved', 'failed'] as const)('does not reload or restore a deleted conversation when recovery %s', async (outcome) => { + const pending = deferred(); + const getSnapshot = vi.fn(); + const recover = vi.fn(() => pending.promise); + const store = createCodingConversationStore({ getSnapshot, recover, forgetSettledSessionSync: vi.fn() }); + store.getState().primeConversation(snapshot('conversation-a')); + const flight = store.getState().recoverConversation('conversation-a'); + store.getState().forgetConversation('conversation-a'); + const rejected = expect(flight).rejects.toThrow(outcome === 'resolved' ? '对话已删除' : 'recovery failed'); + if (outcome === 'resolved') pending.resolve(); + else pending.reject(new Error('recovery failed')); + await rejected; + await expect(store.getState().recoverConversation('conversation-a')).rejects.toThrow('对话已删除'); + expect(recover).toHaveBeenCalledTimes(1); + expect(getSnapshot).not.toHaveBeenCalled(); + expectForgotten(store, 'conversation-a'); + }); + + it('does not submit after the conversation is deleted during attachment preparation', async () => { + const pending = deferred<[]>(); + const submitPrompt = vi.fn(); + const store = createCodingConversationStore({ submitPrompt, createId: ids(), forgetSettledSessionSync: vi.fn() }); + store.getState().primeConversation(snapshot('conversation-a')); + const flight = store.getState().submitPrompt({ + conversationId: 'conversation-a', mode: 'prompt', text: 'pending attachment', + prepareAttachments: () => pending.promise, + }); + store.getState().forgetConversation('conversation-a'); + const rejected = expect(flight).rejects.toThrow('对话已删除'); + pending.resolve([]); + await rejected; + await expect(store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt', text: 'late send' })).rejects.toThrow('对话已删除'); + expect(submitPrompt).not.toHaveBeenCalled(); + expectForgotten(store, 'conversation-a'); + }); + it('refreshes only metadata on title events and EventSource reconnect without preparing workers', async () => { const previous = codingWorkspaceStore.getState(); const refresh = vi.fn(async () => undefined); diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index 4312efdb..33d0399e 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -33,7 +33,7 @@ import type { PrepareConversationInput, PromptConversationInput, } from '../../electron/coding-runtime/contracts'; -import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader'; +import { deletePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader'; import { PiProviderConfigError } from '../../electron/coding-runtime/pi/provider-config'; import type { ProductModelRef } from '../../shared/coding-conversation-contracts'; @@ -201,6 +201,19 @@ describe('PI-100 coding core Host contract', () => { ); expect(composition.runtime.getDiagnostics().workers).toEqual([]); expect(getLocalProxyCredential).not.toHaveBeenCalled(); + await composition.conversations.deleteConversation(conversation.id); + const bound = await composition.conversations.createConversation({ projectId: created.project.id, agentId: 'builder', title: 'Bound session' }); + await composition.projects.conversationStore(projectPath).ensureSessionBinding(bound.id, async () => ({ + piSessionId: 'composition-session', sessionKey: 'composition-session', + })); + const sessionDirectory = path.join(userDataDir, 'coding-runtime', 'pi', 'sessions', created.project.id); + await mkdir(sessionDirectory, { recursive: true }); + const sessionFile = path.join(sessionDirectory, '2026-10-14T12-34-56-789Z_composition-session.jsonl'); + await writeFile(sessionFile, 'history'); + await composition.conversations.deleteConversation(bound.id); + await expect(readFile(sessionFile)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(composition.runtime.getDiagnostics().workers).toEqual([]); + expect(getLocalProxyCredential).not.toHaveBeenCalled(); } finally { await composition.shutdown(); } @@ -364,7 +377,7 @@ describe('PI-100 coding core Host contract', () => { }); }); - it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => { + it('stops the runtime and permanently removes a bound session before deleting metadata', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); const sessionKey = 'session-delete'; @@ -372,7 +385,7 @@ describe('PI-100 coding core Host contract', () => { piSessionId: 'pi-session-delete', sessionKey, })); - const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-trash-')); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-delete-')); roots.push(userDataDir); const sourceDirectory = path.join( userDataDir, @@ -382,18 +395,28 @@ describe('PI-100 coding core Host contract', () => { 'project-a', ); await mkdir(sourceDirectory, { recursive: true }); - await writeFile(path.join(sourceDirectory, `${sessionKey}.jsonl`), 'session-data'); - const conversations = new CodingConversationService(result.projects, result.runtime, { - archiveSession: async (input) => { - await archivePiConversationSession({ userDataDir, ...input }); - }, - }); + const sessionPath = path.join(sourceDirectory, `2026-10-14T12-34-56-789Z_${sessionKey}.jsonl`); + await writeFile(sessionPath, 'session-data'); const dispose = vi.spyOn(result.runtime, 'dispose'); + const onDelete = vi.fn(async () => undefined); + const archiveSession = vi.fn(async () => undefined); + const conversations = new CodingConversationService(result.projects, result.runtime, { + deleteSession: async (input) => { + expect(dispose).toHaveBeenCalledWith(conversation.id, 'conversation_deleted'); + await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull(); + await deletePiConversationSession({ userDataDir, ...input }); + }, + archiveSession, + onDelete, + }); await conversations.deleteConversation(conversation.id); expect(dispose).toHaveBeenCalledWith(conversation.id, 'conversation_deleted'); + expect(onDelete).toHaveBeenCalledWith('project-a', conversation.id); + expect(archiveSession).not.toHaveBeenCalled(); await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull(); + await expect(readFile(sessionPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); await expect(readFile(path.join( userDataDir, 'coding-runtime', @@ -401,10 +424,10 @@ describe('PI-100 coding core Host contract', () => { 'trash', 'project-a', `${sessionKey}.jsonl`, - ), 'utf8')).resolves.toBe('session-data'); + ), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('preserves Conversation metadata when session archival fails', async () => { + it('preserves Conversation metadata when session deletion fails', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ @@ -412,7 +435,7 @@ describe('PI-100 coding core Host contract', () => { sessionKey: 'session-preserved', })); const conversations = new CodingConversationService(result.projects, result.runtime, { - archiveSession: async () => { throw new Error(`disk path=${result.root}`); }, + deleteSession: async () => { throw new Error(`disk path=${result.root}`); }, }); await expect(conversations.deleteConversation(conversation.id)).rejects.toMatchObject({ @@ -422,6 +445,70 @@ describe('PI-100 coding core Host contract', () => { await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull(); }); + it('refuses to replace permanent deletion with archival for a bound session', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ + piSessionId: 'pi-session-preserved', sessionKey: 'session-preserved', + })); + const archiveSession = vi.fn(async () => undefined); + const conversations = new CodingConversationService(result.projects, result.runtime, { archiveSession }); + await expect(conversations.deleteConversation(conversation.id)).rejects.toMatchObject({ + status: 500, code: 'CODING_STORAGE_WRITE_FAILED', + }); + expect(archiveSession).not.toHaveBeenCalled(); + await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.not.toBeNull(); + }); + + it('coalesces repeated deletion and blocks new prompts while the runtime is stopping', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + let finishDispose!: () => void; + const disposing = new Promise((resolve) => { finishDispose = resolve; }); + const dispose = vi.spyOn(result.runtime, 'dispose').mockImplementation(async () => await disposing); + const prepare = vi.spyOn(result.runtime, 'prepare'); + const first = result.conversations.deleteConversation(conversation.id); + const second = result.conversations.deleteConversation(conversation.id); + await vi.waitFor(() => expect(dispose).toHaveBeenCalledTimes(1)); + await expect(result.conversations.acceptPrompt({ + conversationId: conversation.id, clientRequestId: 'late-prompt', mode: 'prompt', text: 'Do not restart', + })).rejects.toMatchObject({ status: 409, code: 'CODING_CONVERSATION_DELETING' }); + await expect(result.conversations.getSnapshot(conversation.id)).rejects.toMatchObject({ + status: 409, code: 'CODING_CONVERSATION_DELETING', + }); + expect(prepare).not.toHaveBeenCalled(); + finishDispose(); + await Promise.all([first, second]); + await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull(); + }); + + it('waits for preparation and removes the session binding created while deletion was requested', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + let finishPreparation!: () => void; + const preparing = new Promise((resolve) => { finishPreparation = resolve; }); + const originalPrepare = result.runtime.prepare.bind(result.runtime); + const prepare = vi.spyOn(result.runtime, 'prepare').mockImplementation(async (input) => { + await preparing; + await result.projects.conversationStore(result.root).ensureSessionBinding(conversation.id, async () => ({ + piSessionId: 'late-session', sessionKey: 'late-session', + })); + return await originalPrepare(input); + }); + const deleteSession = vi.fn(async () => undefined); + const conversations = new CodingConversationService(result.projects, result.runtime, { deleteSession }); + const prompt = conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'preparing', mode: 'prompt', text: 'Prepare' }); + const promptRejected = expect(prompt).rejects.toMatchObject({ code: 'CODING_CONVERSATION_DELETING' }); + await vi.waitFor(() => expect(prepare).toHaveBeenCalledOnce()); + const deleting = conversations.deleteConversation(conversation.id); + const runtimePrompt = vi.spyOn(result.runtime, 'prompt'); + finishPreparation(); + await Promise.all([deleting, promptRejected]); + expect(runtimePrompt).not.toHaveBeenCalled(); + expect(deleteSession).toHaveBeenCalledWith({ projectId: 'project-a', sessionKey: 'late-session' }); + await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toBeNull(); + }); + it('returns 202 acceptance, deduplicates requests, and exposes only safe diagnostics', async () => { const result = await setup(); const conversation = await createConversation(result.conversations); diff --git a/tests/unit/coding-workspace-store.test.ts b/tests/unit/coding-workspace-store.test.ts index 40015a82..106b25f9 100644 --- a/tests/unit/coding-workspace-store.test.ts +++ b/tests/unit/coding-workspace-store.test.ts @@ -79,6 +79,61 @@ function deferred() { } describe('coding workspace store', () => { + it('deletes only the target and rejects stale list and metadata resurrection', async () => { + const first = conversation('conversation-deleted', 'agent-a'); + const second = conversation('conversation-kept', 'agent-a'); + const stale = deferred(); + const removeConversation = vi.fn().mockResolvedValue(undefined); + const listConversations = vi.fn().mockResolvedValueOnce([first, second]) + .mockReturnValueOnce(stale.promise).mockResolvedValue([second]); + const store = createCodingWorkspaceStore({ + listProjects: async () => ({ projects: [project], activeProjectId: project.id }), + getConfig: async () => ({ project, config: config([agent('agent-a')]) }), + listConversations, removeConversation, + }); + await store.getState().load(); + const refresh = store.getState().refreshConversations(project.id); + await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2)); + await store.getState().removeConversation(first.id); + stale.resolve([first, second]); + await refresh; + store.getState().upsertConversation(project.id, first); + expect(removeConversation).toHaveBeenCalledWith(first.id); + expect(store.getState().conversations).toEqual([second]); + }); + + it('keeps a conversation available when deletion fails', async () => { + const first = conversation('conversation-delete-failed', 'agent-a'); + const store = createCodingWorkspaceStore({ + listProjects: async () => ({ projects: [project], activeProjectId: project.id }), + getConfig: async () => ({ project, config: config([agent('agent-a')]) }), + listConversations: async () => [first], + removeConversation: async () => { throw new Error('delete failed'); }, + }); + await store.getState().load(); + await expect(store.getState().removeConversation(first.id)).rejects.toThrow('delete failed'); + expect(store.getState().conversations).toEqual([first]); + expect(store.getState().conversationErrorsByProjectId[project.id][first.id]).toBe('delete failed'); + }); + + it('does not overwrite another project after a pending deletion completes', async () => { + const first = conversation('conversation-old-project', 'agent-a'); + const other = conversation('conversation-new-project', 'agent-b'); + const deletion = deferred(); + const store = createCodingWorkspaceStore({ + listProjects: async () => ({ projects: [project], activeProjectId: project.id }), + getConfig: async () => ({ project, config: config([agent('agent-a')]) }), + listConversations: async () => [first], + removeConversation: () => deletion.promise, + }); + await store.getState().load(); + const pending = store.getState().removeConversation(first.id); + store.setState({ activeProjectId: 'other-project', conversations: [other] }); + deletion.resolve(); + await pending; + expect(store.getState().conversations).toEqual([other]); + }); + it('discards a stale list read and refreshes after a manual rename', async () => { const old = conversation('conversation-a', 'agent-a'); const stale = deferred(); diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 078d6301..61e14055 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -7,6 +7,7 @@ import { CodingCapabilityRegistryImpl } from '@electron/coding-plugins/registry' import type { PiProviderSelection } from '@electron/coding-runtime/pi/provider-config'; import { buildPiManagedInputArgs, + deletePiConversationSession, getPiManagedPaths, MAKELORE_DEFAULT_LANGUAGE_PROMPT, materializePiAgentResources, @@ -62,6 +63,42 @@ async function fixtureRoot(): Promise<{ } describe('Pi managed resource loader', () => { + it('permanently deletes only the selected session, including timestamped files and trash copies', async () => { + const { userDataDir } = await fixtureRoot(); + const paths = getPiManagedPaths(userDataDir); + const deletedPaths: string[] = []; + const retainedPaths: string[] = []; + for (const root of [paths.sessionsDir, paths.trashDir]) { + for (const projectId of ['project-a', 'project-b']) { + const directory = path.join(root, projectId); + await mkdir(directory, { recursive: true }); + for (const filename of [ + 'session-a.jsonl', + '2026-10-14T12-34-56-789Z_session-a.jsonl', + 'session-b.jsonl', + '2026-10-14T12-34-56-789Z_session-b.jsonl', + 'another_session-a.jsonl', + ]) { + const target = path.join(directory, filename); + await writeFile(target, 'session-data'); + (projectId === 'project-a' && ['session-a.jsonl', '2026-10-14T12-34-56-789Z_session-a.jsonl'].includes(filename) + ? deletedPaths : retainedPaths).push(target); + } + } + } + await deletePiConversationSession({ userDataDir, projectId: 'project-a', sessionKey: 'session-a' }); + for (const filename of deletedPaths) await expect(readFile(filename)).rejects.toMatchObject({ code: 'ENOENT' }); + for (const filename of retainedPaths) await expect(readFile(filename, 'utf8')).resolves.toBe('session-data'); + await expect(deletePiConversationSession({ userDataDir, projectId: 'project-a', sessionKey: 'session-a' })).resolves.toBeUndefined(); + await expect(deletePiConversationSession({ userDataDir, projectId: 'missing-project', sessionKey: 'session-a' })).resolves.toBeUndefined(); + }); + + it('rejects paths outside a selected project session during deletion', async () => { + const { userDataDir } = await fixtureRoot(); + await expect(deletePiConversationSession({ userDataDir, projectId: '../outside', sessionKey: 'session-a' })).rejects.toThrow(); + await expect(deletePiConversationSession({ userDataDir, projectId: 'project-a', sessionKey: '../outside' })).rejects.toThrow(); + }); + it('materializes only managed prompt and explicitly paired package Skills', async () => { const fixture = await fixtureRoot(); const prompt = 'PRIVATE PARTNER PROMPT CONTENT'; diff --git a/tests/unit/sidebar-project-actions.test.tsx b/tests/unit/sidebar-project-actions.test.tsx index 6d822906..444b0fc8 100644 --- a/tests/unit/sidebar-project-actions.test.tsx +++ b/tests/unit/sidebar-project-actions.test.tsx @@ -56,9 +56,7 @@ vi.mock('@/stores/providers', () => ({ useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState), })); vi.mock('@/pages/Chat/CodingProjectConversations', () => ({ - CodingProjectConversations: ({ archived }: { archived: boolean }) => ( -
{archived ? 'archived' : 'recent'}
- ), + CodingProjectConversations: () =>
recent
, })); vi.mock('@/components/account/PointWallet', () => ({ PointWallet: () => null })); vi.mock('@/components/community/CommunityLink', () => ({ CommunityLink: () => null })); @@ -97,7 +95,7 @@ describe('Sidebar project actions', () => { projectConfigState.load.mockResolvedValue({ status: 'valid', config: {} }); }); - it.each(['项目设置', '已归档对话'])('%s for the current project supersedes an earlier pending project open', async (action) => { + it('settings for the current project supersedes an earlier pending project open', async () => { await renderSidebar(); let resolveProjectB!: (value: unknown) => void; const pendingProjectB = new Promise((resolve) => { resolveProjectB = resolve; }); @@ -108,11 +106,8 @@ describe('Sidebar project actions', () => { fireEvent.click(screen.getByRole('button', { name: '进入项目 B' })); expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id); expect(codingState.setActiveProject).not.toHaveBeenCalled(); - await selectProjectAction('A', action); - await waitFor(() => { - if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config'); - else expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived'); - }); + await selectProjectAction('A', '项目设置'); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/project-config')); await act(async () => { resolveProjectB({ status: 'valid', config: {} }); @@ -121,18 +116,14 @@ describe('Sidebar project actions', () => { expect(codingState.activeProjectId).toBe(projects[0].id); expect(codingState.setActiveProject).not.toHaveBeenCalledWith(projects[1].id); - if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config'); - else { - expect(screen.getByTestId('location')).toHaveTextContent('/chat'); - expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived'); - } + expect(screen.getByTestId('location')).toHaveTextContent('/project-config'); }); - it.each(['项目设置', '已归档对话'])('rejects %s for an inactive project with invalid configuration', async (action) => { + it('rejects settings for an inactive project with invalid configuration', async () => { await renderSidebar(); projectConfigState.load.mockResolvedValue({ status: 'invalid', config: null, error: '配置文件无效' }); - await selectProjectAction('B', action); + await selectProjectAction('B', '项目设置'); expect(await screen.findByRole('alertdialog')).toHaveTextContent('配置文件无效'); expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);