diff --git a/README.md b/README.md index 70855fce..4ff551b0 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,16 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 项目智能体与对话 -- 项目下仅保留与项目卡片左边框对齐的“+ 新对话”;项目卡片右侧的“…”菜单提供“已归档对话”和“项目设置”。对话行的“…”菜单支持重命名和归档,归档列表可查看并恢复原对话,也可返回最近对话;新建或恢复后回到最近对话列表。归档保留历史、附件和 Pi Session,不中止正在执行的任务;已归档对话仍可查看、处理审批或中止,恢复后才能继续发送或创建分支。全部归档后显示空列表,不自动创建空对话。 +- 项目下保留与项目卡片左边框对齐的“+ 新对话”;项目卡片右侧的“…”菜单仅提供“项目设置”。对话行的“…”菜单仅提供“重命名”和“删除对话”。删除先停止该对话的运行,再清除对话索引、对应的 Pi 会话日志及本地显示缓存,不保留在内部回收目录;不影响项目文件与其他对话。删除当前对话回到空白输入页,不自动创建或打开另一个对话。旧归档记录显示在普通列表中,打开时解除旧归档状态;不再提供归档列表或归档操作。 - 新对话使用第一条实际用户消息的首行作为标题,合并空白并截取最多 32 个字符;纯图片消息使用“附件对话”,命令和空消息不参与命名。不额外调用模型,不随后续消息改名。历史标题保持原样,手动重命名优先,标题最多 200 个字符;仅改标题不改变最近对话排序。Main 在本地元数据落盘后通过独立的 metadata-changed 事件通知列表,事件重连也会刷新列表。 - 新项目自动建立一个内部默认编程智能体,`defaultAgentId` 固定其身份;创建项目不再要求创建或选择智能体。有可用的全局默认模型时复制精确账号与模型,没有时保留待选择状态。旧项目优先保留原默认或置顶/首个可用智能体,保留所有历史绑定;全部停用时新增默认配置,不复活旧配置。 - Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 - 有效项目直接进入会话工作区。打开项目不创建空会话;首次发送或点击项目下的新建入口才创建本地元数据。首条消息无需等待运行时 Snapshot 返回。 -- 项目设置直接编辑默认编程配置的模型、系统指令和需要分配的 Skill;会话菜单可进入该历史会话绑定的编程配置。默认模型只影响新对话,现有对话独立切换模型与思考选项,原有配置编辑不会改写其他对话。 +- 项目设置仅提供默认编程配置的模型选择,不展示项目提示词或项目 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 变更快照中的逐文件差异。 @@ -170,7 +170,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 e505abad..508180fb 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 { @@ -426,6 +444,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'); @@ -469,6 +488,7 @@ export class CodingConversationService { try { await this.assertUnarchived(conversationId); await this.ensurePrepared(conversationId); + this.assertNotDeleting(conversationId); const acceptance = await this.runtime.prompt({ conversationId, clientRequestId, @@ -732,6 +752,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 () => { @@ -756,6 +777,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); @@ -806,4 +828,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 e05ff0f3..39b059fa 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -5,14 +5,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, @@ -131,7 +129,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); @@ -357,17 +354,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(() => { @@ -508,7 +501,6 @@ export function Sidebar({ {visibleProjects.map((project) => { const active = activeProject?.id === project.id; - const archived = active && archivedProjectId === project.id; return (
@@ -548,14 +540,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({ )} - +