diff --git a/FEATURES.md b/FEATURES.md index 649617bb..6180e352 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -13,7 +13,7 @@ - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 - [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。 -- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。 +- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 diff --git a/README.md b/README.md index d8521992..e40090e0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - **多 Runtime**:支持直连模型、OpenCode、Continue 和预览版 DeepSeek Harness,统一处理取消、超时、输出限制和进程退出;原生能力清单将 Tools 与 Commands、LSP、Formatters 分开,并显示来源及 Ask/Execute 可用性。内置 OpenCode 提供 Agent、Tool、Command 与原生 Compact,Continue 提供 Rules、Prompt 预设、结构化提问与 GoodBuddy 手动摘要压缩,并明确标记当前版本无法静态发现原生 Tools。 - **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。 - **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。 -- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳。 +- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。 - **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。 - **桌面上下文**:可选择文件、截图、应用窗口、剪贴板和语音作为上下文。 - **离线语音**:支持 SenseVoice、Paraformer 和 Whisper 本地模型。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 722b891b..9ce7301a 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -283,15 +283,19 @@ - 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 - 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量; - 展开后的每个 Task 子项使用共享状态点,展开层级只到 Task,不展示 Job、Subjob 或 Run。 + 展开后的每个 Task 子项左侧显示名称,模式、计划和共享状态标记收在右侧,与父会话的 + 更新时间位置一致;展开层级只到 Task,不展示 Job、Subjob 或 Run。 - 会话标题保持单行且不挤压时间或操作按钮。标题实际溢出时,悬停会话行才在标题的固定裁切 区域内平滑横向滑动以展示完整名称;移开后快速复位。未溢出标题不产生位移动效, `prefers-reduced-motion` 下禁用滑动并保留完整标题提示。 +- 分支会话在侧栏标题前和聊天顶栏标题旁显示同一个横向分叉徽标;徽标通过可访问名称和 + 工具提示说明来源会话,不能只靠图形表达。分支不改成树状会话列表,也不挤压更新时间。 - 新建 Task 后可以首次自动展开所属 Conversation;用户手动折叠后保持其选择,后台状态变化 只更新状态提示,不强制展开或抢占焦点。 -- Task 子项复用任务中心的共享状态点:运行中使用脉冲强调色,已完成使用静态成功色, - 失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;旁边同时显示 - 本地化状态文字,不能只靠颜色或动效表达状态。 +- Task 子项右侧复用任务中心的共享状态标记:运行中使用脉冲强调色,已完成使用带勾成功点, + 失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时 + 显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠 + 颜色或动效表达状态。 - 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。 ### 6.9 助手工作栏 @@ -506,6 +510,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 使用 `reading` 壳层,消息流与输入区共享宽度。 - 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。 +- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行 + 或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态, + 使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 - 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。 - 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。 @@ -532,8 +539,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。 - 侧栏中有关联 Task 的 Conversation 显示行首展开按钮,父行不显示任务标签或数量;展开后 最多直接显示 3 个 Task,“查看全部 N 个任务”打开该 Conversation 的完整任务区。 -- Task 子项以共享状态点开头,并显示名称、本地化的 Ask/Execute、计划摘要和聚合状态。点击 - 子项打开同一 Conversation 并定位 Task,不继续展开 Job/Run。 +- Task 子项左侧显示名称,右侧显示本地化的 Ask/Execute、计划摘要和共享状态标记,与父会话 + 的更新时间位置一致;已完成使用带勾成功点,不重复显示完成文字。点击子项打开同一 + Conversation 并定位 Task,不继续展开 Job/Run。 - 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。 ### 13.3 知识库 diff --git a/docs/design/cross-platform-assistant-product-design.md b/docs/design/cross-platform-assistant-product-design.md index 0ab086f3..1a140f26 100644 --- a/docs/design/cross-platform-assistant-product-design.md +++ b/docs/design/cross-platform-assistant-product-design.md @@ -298,7 +298,7 @@ #### 功能项 -- 新建、重命名、置顶、搜索、删除会话。 +- 新建、重命名、置顶、搜索、删除会话,以及从稳定的本地会话复制当前聊天内容到独立分支。 - 多轮上下文和会话级模型选择。 - 流式输出、停止生成、重新生成、编辑后重发。 - Markdown、表格、代码块、公式和引用渲染。 @@ -315,6 +315,8 @@ 3. 工具调用显示为独立步骤卡片,不与普通文本混合隐藏。 4. 用户停止后立即中断网络请求和后续工具步骤。 5. 引用标记可打开原始文件、页码或文本片段。 +6. 创建会话分支前先保存当前稳定内容;分支使用新会话与消息 ID,显示来源徽标,不继承 + Task、队列、运行或成果归属。 #### 异常与边界 @@ -627,8 +629,8 @@ Task 身份和左侧行首展开入口,不改变 Conversation 类型或复制 - 系统通知和应用内通知。 - 保留 Task Center 作为 Task 入口,显示范围、状态、最近进展和需要关注信息。 -- 左侧会话列表对关联 Task 显示行首展开按钮,展开后的 Task 子项使用任务图标,父行不重复 - 任务标签,展开层级只到 Task。 +- 左侧会话列表对关联 Task 显示行首展开按钮,展开后的 Task 子项左侧显示名称、右侧显示 + 状态标记,父行不重复任务标签,展开层级只到 Task。 - 普通 Conversation、Job、Run、工具步骤和智能心跳记录不作为顶层 Task。 - 生成完成、任务完成、任务失败和等待确认。 - 未读数量、全部已读和按类别过滤。 diff --git a/docs/prd/task-and-job/scheduled-task-prd.md b/docs/prd/task-and-job/scheduled-task-prd.md index d74ecc27..5a5e5a95 100644 --- a/docs/prd/task-and-job/scheduled-task-prd.md +++ b/docs/prd/task-and-job/scheduled-task-prd.md @@ -256,18 +256,17 @@ Conversation ```text ▾ 产品发布讨论 10:24 - ▣ 每周进度总结 - 每周五 17:00 · Execute · 下次 8 月 21 日 - - ▣ 发布前检查 - 单次 · Execute · 等待确认 + 每周进度总结 每周 · Execute · 等待中 ● + 发布前检查 单次 · Execute ✓ ``` -- 父会话行不重复显示任务标签或数量;Task 身份只在展开后的子项中使用稳定任务图标表达。 +- 父会话行不重复显示任务标签或数量;Task 子项名称在左,模式、计划和状态标记收在右侧, + 与父会话的更新时间位置一致。 - 点击 Conversation 标题打开聊天;点击 Task 子项打开同一 Conversation 并定位到该 Task。 - 新建 Task 成功后首次自动展开。用户手动折叠后持久化其选择,后台运行不强制展开。 - 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。 -- Task 子项显示本地化的模式、计划和状态文字;状态不能只靠任务图标颜色表达。 +- Task 子项显示本地化的模式、计划和非完成态状态文字;状态标记固定在行尾。已完成使用 + 带勾成功点,并将完成文案保留给辅助技术,避免在紧凑元数据中重复显示且不只靠颜色表达。 - 左侧只展开 Task;当前产品 UI 的其他区域也不提供 Job/Run 树或独立导航。 ## 10. Conversation 内呈现 diff --git a/docs/prd/task-and-job/task-and-job-model.md b/docs/prd/task-and-job/task-and-job-model.md index c1f3e84d..f934c8d0 100644 --- a/docs/prd/task-and-job/task-and-job-model.md +++ b/docs/prd/task-and-job/task-and-job-model.md @@ -37,8 +37,11 @@ Conversation 是用户消息、助手消息和面向用户结果的内容容器 - 多个 Task 可以共享同一 Conversation 的可见上下文,但各自拥有独立配置、计划、权限 快照、状态、Job、Run 和成果引用。 - Conversation 标题与 Task 名称相互独立。创建或重命名 Task 不静默修改现有会话标题。 +- 从稳定的本地 Conversation 创建分支会生成新的 Conversation 和消息 ID,并保存直接来源 + 标识;它只复制可见聊天内容与会话级 Runtime/知识检索选择,不复制 Task、发送队列、Job、 + Run、记忆或 Artifact 归属。来源与分支后续独立更新,删除来源不删除已创建的分支。 - 左侧会话列表根据显式 Task 关联显示行首展开按钮;父会话行不重复任务标签,展开后的 - Task 子项使用任务图标,并只展开到 Task 层。 + Task 子项左侧显示名称,右侧显示状态标记,并只展开到 Task 层。 - 并行 Job 不直接无序写入消息流;进度留在各自 Task/Job 状态中,最终文本以带来源元数据 的完整消息写入 Conversation。 - 删除 Conversation 前必须说明关联 Task 数量,并先停止或结算仍活动的 Job。 @@ -193,10 +196,10 @@ waiting_approval > failed > running > paused > idle - 无 Task 的 Conversation 保持现有单行样式。 - 有 Task 的 Conversation 显示行首展开按钮,父会话行不重复任务标签或数量。 -- 展开后只显示带任务图标和本地化摘要的 Task,不继续显示 Job、Subjob 或 Run。 +- 展开后只显示名称、本地化摘要和右侧状态标记,不继续显示 Job、Subjob 或 Run。 - 新建 Task 成功后首次自动展开;用户手动折叠后保持选择,后台状态变化不强制展开。 - 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。 -- Task 子项的任务图标表示身份;运行、审批、失败和暂停同时使用本地化状态文字。 +- Task 子项的状态标记固定在右侧;运行、审批、失败和暂停同时使用本地化状态文字。 - 删除最后一个关联 Task 后,Conversation 的展开按钮自动消失。 ## 9. 兼容映射 diff --git a/docs/prd/task-and-job/task-center-prd.md b/docs/prd/task-and-job/task-center-prd.md index 097283ce..c372b1d5 100644 --- a/docs/prd/task-and-job/task-center-prd.md +++ b/docs/prd/task-and-job/task-center-prd.md @@ -70,19 +70,17 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run ```text ▾ 产品发布讨论 10:24 - ▣ 每周进度总结 - 每周五 17:00 · Execute · 下次 8 月 21 日 - - ▣ 发布前检查 - 单次 · Execute · 等待确认 + 每周进度总结 每周 · Execute · 等待中 ● + 发布前检查 单次 · Execute ✓ ``` - 无 Task 的 Conversation 保持现有单行样式。 - 有 Task 时在行最左侧显示独立展开按钮;父会话行不重复显示任务标签或数量。 - 会话标题溢出时保持时间和操作区固定;悬停会话行后,标题在自身裁切区域内横向滑动展示 完整名称。未溢出标题不滑动,减少动态效果偏好下使用完整标题提示而不产生位移。 -- 展开后每个 Task 子项使用任务图标,并显示本地化的模式、计划和状态;不显示 Job、 - Subjob、Run 或工具步骤。 +- 展开后每个 Task 子项左侧显示名称,右侧显示本地化的模式、计划和状态标记,与父会话 + 更新时间对齐;已完成使用带勾成功点并将文案保留给辅助技术。列表不显示 Job、Subjob、 + Run 或工具步骤。 - 点击 Conversation 标题打开聊天;点击 Task 打开同一 Conversation 并定位该 Task。 - 新建 Task 后首次自动展开;用户手动折叠后保持选择。 - 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。 diff --git a/docs/roadmap/long-term-assistant-roadmap.md b/docs/roadmap/long-term-assistant-roadmap.md index f61d6680..954f5302 100644 --- a/docs/roadmap/long-term-assistant-roadmap.md +++ b/docs/roadmap/long-term-assistant-roadmap.md @@ -118,7 +118,8 @@ GoodBuddy 应能够: - 默认工作模式、Runtime、模型连接、Skills、MCP 和知识库范围。 - 项目可归档、恢复和导出。 -会话支持置顶、归档、重命名、删除、按项目筛选和搜索。 +会话支持置顶、归档、重命名、删除、按项目筛选和搜索;稳定的本地会话还可复制当前聊天 +内容到独立分支,并保留不依赖来源生命周期的来源标识。 ### 4.2 工作模式 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 09f8ede0..4122c5fb 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { builtInDefaultProjectSeedDescription, builtInDefaultProjectSeedName, + conversationSnapshotSchema, isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts' import { AssistantDatabase } from './assistant-database' @@ -161,7 +162,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 25', async () => { + it('migrates existing databases to schema version 26', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -190,7 +191,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(25) + ).toBe(26) expect( current .prepare( @@ -213,6 +214,18 @@ describe('AssistantDatabase', () => { }) ]) ) + expect( + current + .prepare('PRAGMA table_info(conversations)') + .all() + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'branch_source_conversation_id' + }), + expect.objectContaining({ name: 'branch_source_title' }) + ]) + ) const foreignKeys = current .prepare('PRAGMA foreign_key_list(model_usage_calls)') .all() as Array<{ @@ -275,6 +288,15 @@ describe('AssistantDatabase', () => { ) .get() ).toEqual({ name: 'conversation_queue_items' }) + expect( + current + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'index' + AND name = 'conversations_branch_source_idx'` + ) + .get() + ).toEqual({ name: 'conversations_branch_source_idx' }) current.close() }) @@ -476,7 +498,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(25) + ).toBe(26) expect( current .prepare( @@ -614,7 +636,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 25 }) + ).toEqual({ user_version: 26 }) expect( inspected .prepare( @@ -2503,6 +2525,341 @@ describe('AssistantDatabase', () => { database.close() }) + it('atomically branches a stable local conversation without cloning owned work', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-branched-conversation-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const project = database.listProjects()[0]! + const sourceConversationId = + '00000000-0000-4000-8000-000000000541' + const sourceMessageIds = [ + '00000000-0000-4000-8000-000000000542', + '00000000-0000-4000-8000-000000000543' + ] + database.replaceConversations([ + { + id: sourceConversationId, + projectId: project.id, + runtimeSelection: { + provider: 'model', + profileId: channelDefaultProfileId + }, + knowledgeRetrievalMode: 'always', + contextMetrics: { + runtimeSelectionKey: `model:${channelDefaultProfileId}`, + contextTokens: 4_096, + source: 'estimated', + basis: 'conversation' + }, + contextCompressionState: { + coveredHistoryDigest: 'b'.repeat(64), + coveredMessageCount: 2, + coveredFromMessageId: sourceMessageIds[0], + coveredThroughMessageId: sourceMessageIds[1], + summary: '来源会话摘要' + }, + title: '发布方案讨论', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: sourceMessageIds[0]!, + role: 'user', + content: '比较两个发布方案', + createdAt: 1_775_000_000_000, + state: 'complete', + attachments: [ + { + id: '00000000-0000-4000-8000-000000000544', + name: '发布清单.md', + size: 128, + preview: '发布步骤', + kind: 'text' + } + ] + }, + { + id: sourceMessageIds[1]!, + role: 'assistant', + content: '可以从风险和成本两个方向比较。', + createdAt: 1_775_000_001_000, + state: 'complete', + task: { + id: '00000000-0000-4000-8000-000000000545', + title: '来源任务' + }, + artifactIds: [ + '00000000-0000-4000-8000-000000000546' + ] + } + ] + } + ]) + const raw = new DatabaseSync(databasePath) + raw + .prepare( + `UPDATE messages + SET request_id = ? + WHERE id = ?` + ) + .run( + '00000000-0000-4000-8000-000000000547', + sourceMessageIds[1]! + ) + const sourceMetadataRow = raw + .prepare( + `SELECT metadata_json + FROM messages + WHERE id = ?` + ) + .get(sourceMessageIds[1]!) as { metadata_json: string } + raw + .prepare( + `UPDATE messages + SET metadata_json = ? + WHERE id = ?` + ) + .run( + JSON.stringify({ + ...(JSON.parse(sourceMetadataRow.metadata_json) as object), + subagents: [ + { + childTaskId: + '00000000-0000-4000-8000-000000000549', + expertId: + '00000000-0000-4000-8000-000000000550', + expertName: '来源专家', + routingMode: 'manual', + state: 'completed' + } + ] + }), + sourceMessageIds[1]! + ) + raw.close() + + const sourceBefore = database.getConversation(sourceConversationId) + const branch = database.branchLocalConversation({ + sourceConversationId, + title: '发布方案讨论 · 分支' + }) + + expect(conversationSnapshotSchema.safeParse(branch).success).toBe(true) + expect(branch).toMatchObject({ + projectId: project.id, + runtimeSelection: { + provider: 'model', + profileId: channelDefaultProfileId + }, + knowledgeRetrievalMode: 'always', + branch: { + sourceConversationId, + sourceTitle: '发布方案讨论' + }, + title: '发布方案讨论 · 分支' + }) + expect(branch).not.toHaveProperty('contextMetrics') + expect(branch).not.toHaveProperty('contextCompressionState') + expect(branch.id).not.toBe(sourceConversationId) + expect(branch.messages.map((message) => message.content)).toEqual( + sourceBefore.messages.map((message) => message.content) + ) + expect( + branch.messages.every( + (message) => !sourceMessageIds.includes(message.id) + ) + ).toBe(true) + expect(branch.messages[0]?.attachments).toEqual( + sourceBefore.messages[0]?.attachments + ) + expect(branch.messages[1]?.task).toBeUndefined() + expect(branch.messages[1]?.artifactIds).toBeUndefined() + expect(database.getConversation(sourceConversationId)).toEqual( + sourceBefore + ) + + const durable = new DatabaseSync(databasePath) + expect( + durable + .prepare( + `SELECT sequence, request_id + FROM messages + WHERE conversation_id = ? + ORDER BY sequence` + ) + .all(branch.id) + ).toEqual([ + { sequence: 0, request_id: null }, + { sequence: 1, request_id: null } + ]) + const branchMetadataRow = durable + .prepare( + `SELECT metadata_json + FROM messages + WHERE conversation_id = ? AND sequence = 1` + ) + .get(branch.id) as { metadata_json: string } + const branchMetadata = JSON.parse( + branchMetadataRow.metadata_json + ) as Record + expect(branchMetadata).not.toHaveProperty('artifactIds') + expect(branchMetadata).not.toHaveProperty('subagents') + expect(branchMetadata).not.toHaveProperty('task') + durable.close() + + database.saveLocalConversations([ + { + header: { + id: branch.id, + projectId: branch.projectId, + runtimeSelection: branch.runtimeSelection, + knowledgeRetrievalMode: branch.knowledgeRetrievalMode, + branch: branch.branch, + title: '发布方案讨论 · 分支(独立更新)', + updatedAt: 1_775_000_003_000 + }, + messages: [ + { + id: '00000000-0000-4000-8000-000000000548', + role: 'user', + content: '只在分支中继续', + createdAt: 1_775_000_003_000, + state: 'complete' + } + ] + } + ]) + expect(database.getConversation(sourceConversationId)).toEqual( + sourceBefore + ) + expect(database.getConversation(branch.id)).toMatchObject({ + title: '发布方案讨论 · 分支(独立更新)', + messages: [ + { content: '比较两个发布方案' }, + { content: '可以从风险和成本两个方向比较。' }, + { content: '只在分支中继续' } + ] + }) + + expect(database.deleteLocalConversation(sourceConversationId)).toBe( + true + ) + expect(database.getConversation(branch.id)).toMatchObject({ + branch: { + sourceConversationId, + sourceTitle: '发布方案讨论' + }, + messages: [ + { content: '比较两个发布方案' }, + { content: '可以从风险和成本两个方向比较。' }, + { content: '只在分支中继续' } + ] + }) + database.close() + }) + + it('rejects unstable or remote branch sources and rolls back copy failures', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-conversation-branch-guards-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + const sourceConversationId = + '00000000-0000-4000-8000-000000000551' + const sourceMessageId = + '00000000-0000-4000-8000-000000000552' + database.replaceConversations([ + { + id: sourceConversationId, + title: '必须稳定后分支', + updatedAt: 1, + messages: [ + { + id: sourceMessageId, + role: 'user', + content: '稳定内容', + createdAt: 1, + state: 'complete' + } + ] + } + ]) + const queued = database.enqueueConversationUserInput({ + conversationId: sourceConversationId, + label: '待发送内容', + payloadJson: '{}' + }) + expect(() => + database.branchLocalConversation({ + sourceConversationId, + title: '不应创建的队列分支' + }) + ).toThrow('仍有待发送内容') + database.removeConversationUserQueueItem(queued.id) + + const taskId = '00000000-0000-4000-8000-000000000553' + database.createTask({ + id: taskId, + conversationId: sourceConversationId, + title: '运行中的任务', + instructions: '保持运行', + workMode: 'ask' + }) + database.updateTaskStatus(taskId, 'running') + expect(() => + database.branchLocalConversation({ + sourceConversationId, + title: '不应创建的运行分支' + }) + ).toThrow('仍有正在运行的任务') + database.updateTaskStatus(taskId, 'completed') + + const channelProject = database.ensureChannelProjects( + 'C:\\Users\\test', + channelDefaultProfileId + )[0]! + const remote = database.getOrCreateRemoteConversation({ + projectId: channelProject.id, + channel: 'weixin', + accountId: 'default', + externalConversationId: 'branch-protected', + conversationType: 'direct', + title: '远程来源', + accountDisplay: '发送者 ****0551' + }) + expect(() => + database.branchLocalConversation({ + sourceConversationId: remote.id, + title: '不应创建的远程分支' + }) + ).toThrow('只能从现有的本地会话创建分支') + + const countBeforeFailure = database.listConversations().length + const raw = new DatabaseSync(databasePath) + raw.exec(` + CREATE TRIGGER reject_branched_message + BEFORE INSERT ON messages + WHEN NEW.conversation_id != '${sourceConversationId}' + BEGIN + SELECT RAISE(ABORT, 'forced branch copy failure'); + END; + `) + raw.close() + expect(() => + database.branchLocalConversation({ + sourceConversationId, + title: '必须完整回滚' + }) + ).toThrow('forced branch copy failure') + expect(database.listConversations()).toHaveLength(countBeforeFailure) + database.close() + }) + it('keeps incremental local conversation storage bounded to 500 messages', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-bounded-local-conversation-') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 5003dd50..95ca112d 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -19,6 +19,7 @@ import type { AssistantSchedule, AssistantTask, ConversationQueueItem, + ConversationBranchInput, ConversationMessage, ConversationSnapshot, ExpertCreateInput, @@ -122,6 +123,8 @@ type ConversationRow = { external_conversation_id: string | null conversation_type: 'direct' | 'group' | null account_display: string | null + branch_source_conversation_id: string | null + branch_source_title: string | null updated_at: string } @@ -892,6 +895,16 @@ function toConversationSnapshot( } } : {}), + ...(conversation.branch_source_conversation_id && + conversation.branch_source_title + ? { + branch: { + sourceConversationId: + conversation.branch_source_conversation_id, + sourceTitle: conversation.branch_source_title + } + } + : {}), title: conversation.title, updatedAt: Date.parse(conversation.updated_at), messages: messages.map((message) => { @@ -1525,7 +1538,9 @@ export class AssistantDatabase { `SELECT id, project_id, runtime_selection_json, knowledge_retrieval_mode, context_state_json, title, channel, external_account_id, external_conversation_id, - conversation_type, account_display, updated_at + conversation_type, account_display, + branch_source_conversation_id, branch_source_title, + updated_at FROM conversations WHERE status = 'active' ORDER BY updated_at DESC @@ -1560,7 +1575,9 @@ export class AssistantDatabase { `SELECT id, project_id, runtime_selection_json, knowledge_retrieval_mode, context_state_json, title, channel, external_account_id, external_conversation_id, - conversation_type, account_display, updated_at + conversation_type, account_display, + branch_source_conversation_id, branch_source_title, + updated_at FROM conversations WHERE id = ? AND status = 'active'` ) @@ -1686,9 +1703,10 @@ export class AssistantDatabase { const insertConversation = database.prepare( `INSERT INTO conversations (id, project_id, runtime_selection_json, knowledge_retrieval_mode, - context_state_json, work_mode, title, status, created_at, - updated_at) - VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)` + context_state_json, work_mode, title, + branch_source_conversation_id, branch_source_title, status, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)` ) const insertMessage = database.prepare( `INSERT INTO messages @@ -1710,6 +1728,8 @@ export class AssistantDatabase { conversation.knowledgeRetrievalMode ?? null, serializeConversationContextState(conversation), conversation.title, + conversation.branch?.sourceConversationId ?? null, + conversation.branch?.sourceTitle ?? null, updatedAt, updatedAt ) @@ -1743,15 +1763,17 @@ export class AssistantDatabase { const insertConversation = database.prepare( `INSERT INTO conversations (id, project_id, runtime_selection_json, knowledge_retrieval_mode, - context_state_json, work_mode, title, status, created_at, - updated_at) - VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)` + context_state_json, work_mode, title, + branch_source_conversation_id, branch_source_title, status, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)` ) const updateConversation = database.prepare( `UPDATE conversations SET project_id = ?, runtime_selection_json = ?, knowledge_retrieval_mode = ?, context_state_json = ?, - title = ?, status = 'active', + title = ?, branch_source_conversation_id = ?, + branch_source_title = ?, status = 'active', updated_at = MAX(updated_at, ?) WHERE id = ? AND channel IS NULL` ) @@ -1807,6 +1829,8 @@ export class AssistantDatabase { header.knowledgeRetrievalMode ?? null, serializeConversationContextState(header), header.title, + header.branch?.sourceConversationId ?? null, + header.branch?.sourceTitle ?? null, updatedAt, header.id ) @@ -1823,6 +1847,8 @@ export class AssistantDatabase { header.knowledgeRetrievalMode ?? null, serializeConversationContextState(header), header.title, + header.branch?.sourceConversationId ?? null, + header.branch?.sourceTitle ?? null, updatedAt, updatedAt ) @@ -1878,6 +1904,128 @@ export class AssistantDatabase { } } + branchLocalConversation( + input: ConversationBranchInput + ): ConversationSnapshot { + const database = this.requireDatabase() + const destinationConversationId = randomUUID() + const now = new Date().toISOString() + database.exec('BEGIN IMMEDIATE') + try { + const source = database + .prepare( + `SELECT id, project_id, runtime_selection_json, + knowledge_retrieval_mode, title + FROM conversations + WHERE id = ? AND status = 'active' AND channel IS NULL` + ) + .get(input.sourceConversationId) as + | { + id: string + project_id: string | null + runtime_selection_json: string | null + knowledge_retrieval_mode: 'auto' | 'always' | null + title: string + } + | undefined + if (!source) { + throw new Error('只能从现有的本地会话创建分支') + } + const activeTask = database + .prepare( + `SELECT 1 + FROM tasks + WHERE conversation_id = ? + AND status IN ('running', 'waiting_approval') + LIMIT 1` + ) + .get(source.id) + if (activeTask) { + throw new Error('当前会话仍有正在运行的任务,请等待完成后再创建分支') + } + const queuedItem = database + .prepare( + `SELECT 1 + FROM conversation_queue_items + WHERE conversation_id = ? + AND status IN ('pending', 'dispatching') + LIMIT 1` + ) + .get(source.id) + if (queuedItem) { + throw new Error('当前会话仍有待发送内容,请处理后再创建分支') + } + const messageStates = database + .prepare( + `SELECT state + FROM messages + WHERE conversation_id = ? + ORDER BY sequence ASC` + ) + .all(source.id) as Array<{ state: MessageRow['state'] }> + if ( + messageStates.some((message) => message.state === 'streaming') + ) { + throw new Error('当前会话仍在生成内容,请等待完成后再创建分支') + } + database + .prepare( + `INSERT INTO conversations + (id, project_id, runtime_selection_json, + knowledge_retrieval_mode, context_state_json, work_mode, + title, branch_source_conversation_id, branch_source_title, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, 'ask', ?, ?, ?, 'active', ?, ?)` + ) + .run( + destinationConversationId, + source.project_id, + source.runtime_selection_json, + source.knowledge_retrieval_mode, + input.title, + source.id, + source.title, + now, + now + ) + const copiedMessages = database + .prepare( + `INSERT INTO messages + (id, conversation_id, request_id, role, content, state, + sequence, metadata_json, created_at) + SELECT lower( + hex(randomblob(4)) || '-' || + hex(randomblob(2)) || '-4' || + substr(hex(randomblob(2)), 2) || '-' || + substr('89ab', (random() & 3) + 1, 1) || + substr(hex(randomblob(2)), 2) || '-' || + hex(randomblob(6)) + ), + ?, NULL, role, content, state, + ROW_NUMBER() OVER (ORDER BY sequence) - 1, + json_remove( + metadata_json, + '$.artifactIds', + '$.subagents', + '$.task' + ), + created_at + FROM messages + WHERE conversation_id = ? + ORDER BY sequence` + ) + .run(destinationConversationId, source.id) + if (copiedMessages.changes !== messageStates.length) { + throw new Error('分支消息复制不完整') + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + return this.getConversation(destinationConversationId) + } + deleteLocalConversation(conversationId: string): boolean { const database = this.requireDatabase() database.exec('BEGIN IMMEDIATE') @@ -5799,12 +5947,12 @@ export class AssistantDatabase { const version = database .prepare('PRAGMA user_version') .get() as { user_version: number } - if (version.user_version > 25) { + if (version.user_version > 26) { throw new Error( `当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试` ) } - if (version.user_version === 25) { + if (version.user_version === 26) { return } if (version.user_version < 1) { @@ -5837,6 +5985,8 @@ export class AssistantDatabase { work_mode TEXT NOT NULL DEFAULT 'ask' CHECK(work_mode IN ('ask', 'execute')), title TEXT NOT NULL, + branch_source_conversation_id TEXT, + branch_source_title TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')), created_at TEXT NOT NULL, @@ -5855,6 +6005,9 @@ export class AssistantDatabase { created_at TEXT NOT NULL, UNIQUE(conversation_id, sequence) ); + CREATE INDEX conversations_branch_source_idx + ON conversations(branch_source_conversation_id) + WHERE branch_source_conversation_id IS NOT NULL; CREATE TABLE tasks ( id TEXT PRIMARY KEY, project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, @@ -7177,6 +7330,40 @@ export class AssistantDatabase { throw error } } + if (version.user_version < 26) { + const conversationColumns = new Set( + ( + database.prepare('PRAGMA table_info(conversations)').all() as Array<{ + name: string + }> + ).map((column) => column.name) + ) + database.exec('BEGIN IMMEDIATE') + try { + if (!conversationColumns.has('branch_source_conversation_id')) { + database.exec(` + ALTER TABLE conversations + ADD COLUMN branch_source_conversation_id TEXT; + `) + } + if (!conversationColumns.has('branch_source_title')) { + database.exec(` + ALTER TABLE conversations + ADD COLUMN branch_source_title TEXT; + `) + } + database.exec(` + CREATE INDEX IF NOT EXISTS conversations_branch_source_idx + ON conversations(branch_source_conversation_id) + WHERE branch_source_conversation_id IS NOT NULL; + PRAGMA user_version = 26; + COMMIT; + `) + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/heartbeat-database.test.ts b/src/main/assistant/heartbeat-database.test.ts index 5bad560b..e5c12c31 100644 --- a/src/main/assistant/heartbeat-database.test.ts +++ b/src/main/assistant/heartbeat-database.test.ts @@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => { ).count check.close() migrated.close() - expect(version).toBe(25) + expect(version).toBe(26) expect(heartbeatTableCount).toBe(4) }) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index f43c9d06..0cd10fd2 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -2734,7 +2734,7 @@ describe('registerIpcHandlers local conversation persistence', () => { vi.clearAllMocks() }) - it('validates and forwards incremental saves and explicit deletions', async () => { + it('validates and forwards incremental saves, branches, and explicit deletions', async () => { const conversationId = '00000000-0000-4000-8000-000000000301' const queuedAttachmentId = @@ -2774,6 +2774,16 @@ describe('registerIpcHandlers local conversation persistence', () => { }) ), saveLocalConversations: vi.fn(), + branchLocalConversation: vi.fn((input) => ({ + id: '00000000-0000-4000-8000-000000000305', + branch: { + sourceConversationId: input.sourceConversationId, + sourceTitle: '增量会话' + }, + title: input.title, + updatedAt: 2, + messages: [] + })), deleteLocalConversation: vi.fn(() => true) } const contextManager = { @@ -2838,6 +2848,21 @@ describe('registerIpcHandlers local conversation persistence', () => { expect( assistantDatabase.saveLocalConversations ).toHaveBeenCalledWith(batch) + const branchInput = { + sourceConversationId: conversationId, + title: '增量会话 · 分支' + } + expect( + electronMocks.handlers.get( + ipcChannels.conversationsBranchLocal + )?.(event, branchInput) + ).toMatchObject({ + branch: { sourceConversationId: conversationId }, + title: branchInput.title + }) + expect( + assistantDatabase.branchLocalConversation + ).toHaveBeenCalledWith(branchInput) expect( electronMocks.handlers.get( ipcChannels.conversationsDeleteLocal @@ -2867,6 +2892,14 @@ describe('registerIpcHandlers local conversation persistence', () => { } ]) ).toThrow() + expect(() => + electronMocks.handlers.get( + ipcChannels.conversationsBranchLocal + )?.(event, { + sourceConversationId: 'not-a-uuid', + title: '无效分支' + }) + ).toThrow() expect(() => electronMocks.handlers.get( ipcChannels.conversationsDeleteLocal @@ -3348,6 +3381,17 @@ describe('registerIpcHandlers agent terminal state', () => { queueDueSchedules: vi.fn(() => []), queueScheduleNow: vi.fn(), completeScheduleRun: vi.fn(), + branchLocalConversation: vi.fn(() => ({ + id: '00000000-0000-4000-8000-000000000403', + branch: { + sourceConversationId: + '00000000-0000-4000-8000-000000000402', + sourceTitle: '来源会话' + }, + title: '来源会话 · 分支', + updatedAt: Date.now(), + messages: [] + })), listSchedules: vi.fn(() => []), createSchedule: vi.fn(), setScheduleEnabled: vi.fn(), @@ -3471,6 +3515,9 @@ describe('registerIpcHandlers agent terminal state', () => { handler: electronMocks.handlers.get(ipcChannels.agentRun), statusHandler: electronMocks.handlers.get(ipcChannels.agentStatus), cancelHandler: electronMocks.handlers.get(ipcChannels.agentCancel), + branchHandler: electronMocks.handlers.get( + ipcChannels.conversationsBranchLocal + ), knowledgeSearchHandler: electronMocks.handlers.get( ipcChannels.knowledgeSearch ), @@ -4016,6 +4063,15 @@ describe('registerIpcHandlers agent terminal state', () => { }) ).rejects.toThrow('当前对话已有执行中的请求') expect(selectedRuntimes.getRuntime).toHaveBeenCalledOnce() + expect(() => + harness.branchHandler?.(trustedEvent(harness.webContents), { + sourceConversationId: conversationId, + title: '等待 Runtime · 分支' + }) + ).toThrow('当前会话仍有正在执行的请求') + expect( + harness.assistantDatabase.branchLocalConversation + ).not.toHaveBeenCalled() resolveRuntime?.(runtime) await firstRun diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b9a13333..b69b4270 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -152,6 +152,7 @@ import { } from '../shared/magic-notes-contracts' import { assistantIdSchema, + conversationBranchInputSchema, conversationSnapshotsSchema, localConversationSaveBatchSchema, memoryCreateSchema, @@ -4623,6 +4624,20 @@ export function registerIpcHandlers( } ) + registerHandler( + ipcChannels.conversationsBranchLocal, + (event, input: unknown) => { + assertTrustedSender(event, window) + const parsed = conversationBranchInputSchema.parse(input) + if (isConversationExecuting(parsed.sourceConversationId)) { + throw new Error('当前会话仍有正在执行的请求,请等待完成后再创建分支') + } + return assistantDatabase.branchLocalConversation( + parsed + ) + } + ) + registerHandler( ipcChannels.conversationsDeleteLocal, (event, input: unknown) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index d74dd2db..08e21ed0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -48,6 +48,7 @@ import type { AssistantExpert, AssistantTask, TokenUsageSummary, + ConversationBranchInput, ConversationSnapshot, LocalConversationSaveBatch, WorkspaceChanges, @@ -629,6 +630,11 @@ const desktopApi: DesktopApi = { batch ) }, + branchLocal: (input: ConversationBranchInput) => + ipcRenderer.invoke( + ipcChannels.conversationsBranchLocal, + input + ) as Promise, deleteLocal: (conversationId: string) => ipcRenderer.invoke( ipcChannels.conversationsDeleteLocal, diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 37ed48e0..e921310e 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -129,4 +129,15 @@ describe('sandboxed preload', () => { expect(source).toContain('ipcChannels.conversationQueueDispatch') expect(source).toContain('listener(conversationId)') }) + + it('exposes a narrow local conversation branch operation', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'preload', 'index.ts'), + 'utf8' + ) + expect(source).toContain( + 'branchLocal: (input: ConversationBranchInput)' + ) + expect(source).toContain('ipcChannels.conversationsBranchLocal') + }) }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 69704f30..c4342bea 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -377,6 +377,16 @@ const api: DesktopApi = { list: vi.fn(async () => []), replace: vi.fn(async () => {}), saveLocal: vi.fn(async () => {}), + branchLocal: vi.fn(async (input) => ({ + id: crypto.randomUUID(), + branch: { + sourceConversationId: input.sourceConversationId, + sourceTitle: '新对话' + }, + title: input.title, + updatedAt: Date.now(), + messages: [] + })), deleteLocal: vi.fn(async () => true), onChanged: vi.fn(() => () => undefined) }, @@ -867,6 +877,18 @@ describe('App', () => { vi.mocked(api.conversations.saveLocal) .mockReset() .mockResolvedValue() + vi.mocked(api.conversations.branchLocal) + .mockReset() + .mockImplementation(async (input) => ({ + id: crypto.randomUUID(), + branch: { + sourceConversationId: input.sourceConversationId, + sourceTitle: '新对话' + }, + title: input.title, + updatedAt: Date.now(), + messages: [] + })) vi.mocked(api.conversations.deleteLocal) .mockReset() .mockResolvedValue(true) @@ -1404,9 +1426,11 @@ describe('App', () => { expect(within(taskRegion).getByText('Execute')).toBeInTheDocument() }) - it('uses shared status dots for Conversation Task children', async () => { + it('keeps completed Conversation Task metadata compact and accessible', async () => { const conversationId = '00000000-0000-4000-8000-000000000824' + const scheduleId = + '00000000-0000-4000-8000-000000000828' const conversation: ConversationSnapshot = { id: conversationId, projectId, @@ -1419,7 +1443,8 @@ describe('App', () => { id: '00000000-0000-4000-8000-000000000825', projectId, conversationId, - title: '已完成任务', + scheduleId, + title: '归档任务', instructions: '检查完成状态', origin: 'schedule', status: 'completed', @@ -1446,10 +1471,25 @@ describe('App', () => { createdAt: '2026-08-19T01:00:00.000Z' } ] + const schedule: AssistantSchedule = { + id: scheduleId, + projectId, + taskId: tasks[0]!.id, + conversationId, + title: tasks[0]!.title, + prompt: tasks[0]!.instructions, + workMode: 'ask', + recurrence: 'once', + nextRunAt: '2026-08-19T03:00:00.000Z', + enabled: false, + createdAt: tasks[0]!.createdAt, + updatedAt: tasks[0]!.createdAt + } vi.mocked(api.conversations.list).mockResolvedValue([ conversation ]) vi.mocked(api.tasks.list).mockResolvedValue(tasks) + vi.mocked(api.schedules.list).mockResolvedValue([schedule]) render() @@ -1458,8 +1498,32 @@ describe('App', () => { '展开或折叠“任务状态会话”中的 3 个任务' ) ) + const completedTitle = await screen.findByText('归档任务', { + selector: '.conversation-task-child__title' + }) + const completedTask = completedTitle.closest('button') + const completedStatus = completedTask?.querySelector( + '.conversation-task-child__completed-status' + ) + expect(completedStatus).toHaveClass( + 'task-status-dot', + 'task-status-dot--completed' + ) + expect(completedStatus).toHaveAttribute('title', '已完成') + expect( + completedStatus?.querySelector('.lucide-check') + ).toBeInTheDocument() + expect(completedTask?.firstElementChild).toBe(completedTitle) + expect(completedTask?.lastElementChild).toBe(completedStatus) + expect(completedTask).toHaveAccessibleName(/已完成/u) + expect( + completedTask?.querySelector('.conversation-task-child__meta') + ).toHaveTextContent('Ask · 仅一次') + expect( + completedTask?.querySelector('.conversation-task-child__meta') + ).not.toHaveTextContent('已完成') + for (const [title, status, label] of [ - ['已完成任务', 'completed', '已完成'], ['运行中任务', 'running', '运行中'], ['失败任务', 'failed', '失败'] ] as const) { @@ -1470,6 +1534,10 @@ describe('App', () => { expect( taskChild?.querySelector('.task-status-dot') ).toHaveClass(`task-status-dot--${status}`) + expect(taskChild?.firstElementChild).toBe(taskTitle) + expect(taskChild?.lastElementChild).toBe( + taskChild?.querySelector('.task-status-dot') + ) expect( taskChild?.querySelector('.conversation-task-child__meta') ).toHaveTextContent(label) @@ -2172,6 +2240,242 @@ describe('App', () => { expect(await screen.findByRole('status')).toBeVisible() }) + it('flushes and opens an independent conversation branch with provenance badges', async () => { + const sourceConversationId = + '00000000-0000-4000-8000-000000000421' + const branchConversationId = + '00000000-0000-4000-8000-000000000422' + const sourceConversation: ConversationSnapshot = { + id: sourceConversationId, + projectId, + title: '方案讨论', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000423', + role: 'user', + content: '探索不同发布方案', + createdAt: 1_775_000_000_000, + state: 'complete' + }, + { + id: '00000000-0000-4000-8000-000000000424', + role: 'assistant', + content: '可以比较风险、成本和回滚路径。', + createdAt: 1_775_000_001_000, + state: 'complete' + } + ] + } + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + sourceConversation + ]) + vi.mocked(api.conversations.branchLocal).mockImplementationOnce( + async (input) => ({ + ...sourceConversation, + id: branchConversationId, + branch: { + sourceConversationId, + sourceTitle: '方案讨论更新' + }, + contextMetrics: undefined, + contextCompressionState: undefined, + title: input.title, + updatedAt: 1_775_000_002_000, + messages: sourceConversation.messages.map((message, index) => ({ + ...message, + id: `00000000-0000-4000-8000-${String( + 430 + index + ).padStart(12, '0')}` + })) + }) + ) + const { container } = render() + expect( + await screen.findByText('探索不同发布方案') + ).toBeInTheDocument() + + fireEvent.click( + await screen.findByLabelText('更多会话操作 方案讨论') + ) + fireEvent.click( + screen.getByRole('button', { name: '重命名会话' }) + ) + const renameInput = screen.getByLabelText('重命名会话 方案讨论') + fireEvent.change(renameInput, { + target: { value: '方案讨论更新' } + }) + fireEvent.submit(renameInput.closest('form')!) + expect( + await screen.findAllByText('方案讨论更新') + ).toHaveLength(2) + vi.mocked(api.conversations.saveLocal).mockClear() + + fireEvent.click( + screen.getByLabelText('更多会话操作 方案讨论更新') + ) + const branchButton = screen.getByRole('button', { + name: '在新会话中继续' + }) + await waitFor(() => expect(branchButton).toBeEnabled()) + fireEvent.click(branchButton) + + await waitFor(() => + expect(api.conversations.saveLocal).toHaveBeenCalledWith([ + expect.objectContaining({ + header: expect.objectContaining({ + id: sourceConversationId, + title: '方案讨论更新' + }) + }) + ]) + ) + await waitFor(() => + expect(api.conversations.branchLocal).toHaveBeenCalledWith({ + sourceConversationId, + title: '方案讨论更新 · 分支' + }) + ) + expect( + vi.mocked(api.conversations.saveLocal).mock.invocationCallOrder.at(-1) + ).toBeLessThan( + vi.mocked(api.conversations.branchLocal).mock.invocationCallOrder[0]! + ) + expect( + await screen.findAllByLabelText( + '分支会话,来源:方案讨论更新' + ) + ).toHaveLength(2) + const sidebar = container.querySelector('.conversation-list') + const badge = within(sidebar as HTMLElement).getByLabelText( + '分支会话,来源:方案讨论更新' + ) + expect(badge).toHaveClass('conversation-branch-badge') + expect(badge.querySelector('.lucide-git-fork')).toBeInTheDocument() + expect( + container.querySelector('.conversation-title') + ).toHaveTextContent('方案讨论更新 · 分支') + await waitFor(() => + expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveFocus() + ) + expect( + screen.getAllByText('可以比较风险、成本和回滚路径。').length + ).toBeGreaterThan(0) + expect( + await screen.findByText('已创建并打开分支会话') + ).toBeInTheDocument() + }) + + it('keeps the source conversation open when branch persistence fails', async () => { + const sourceConversationId = + '00000000-0000-4000-8000-000000000441' + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: sourceConversationId, + projectId, + title: '保留的来源会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000442', + role: 'user', + content: '不能丢失的来源内容', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + vi.mocked(api.conversations.branchLocal).mockRejectedValueOnce( + new Error('分支数据写入失败') + ) + const { container } = render() + expect( + await screen.findByText('不能丢失的来源内容') + ).toBeInTheDocument() + + const actionTrigger = await screen.findByLabelText( + '更多会话操作 保留的来源会话' + ) + fireEvent.click(actionTrigger) + const branchButton = screen.getByRole('button', { + name: '在新会话中继续' + }) + await waitFor(() => expect(branchButton).toBeEnabled()) + fireEvent.click(branchButton) + + expect( + await screen.findByText('分支数据写入失败') + ).toBeInTheDocument() + expect( + container.querySelector('.conversation-title') + ).toHaveTextContent('保留的来源会话') + expect( + screen.getByText('不能丢失的来源内容') + ).toBeInTheDocument() + expect( + screen.queryByLabelText(/^分支会话,来源:/u) + ).not.toBeInTheDocument() + await waitFor(() => expect(actionTrigger).toHaveFocus()) + }) + + it('explains why a queued conversation cannot be branched', async () => { + const sourceConversationId = + '00000000-0000-4000-8000-000000000451' + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: sourceConversationId, + projectId, + title: '等待队列的会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000452', + role: 'user', + content: '还有待发送内容', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + vi.mocked(api.conversationQueue.list).mockResolvedValue([ + { + id: '00000000-0000-4000-8000-000000000453', + conversationId: sourceConversationId, + source: 'user', + label: '待发送', + createdAt: '2026-08-20T08:00:00.000Z' + } + ]) + render() + expect( + await screen.findByText('还有待发送内容') + ).toBeInTheDocument() + + fireEvent.click( + await screen.findByLabelText('更多会话操作 等待队列的会话') + ) + const branchButton = screen.getByRole('button', { + name: '在新会话中继续' + }) + await waitFor(() => + expect(branchButton).toHaveAttribute('aria-disabled', 'true') + ) + expect(branchButton).toHaveAccessibleDescription( + '会话运行或待发送内容处理完后才能创建分支' + ) + fireEvent.click(branchButton) + await waitFor(() => + expect( + screen.getAllByText( + '会话运行或待发送内容处理完后才能创建分支' + ) + ).toHaveLength(2) + ) + expect(api.conversations.branchLocal).not.toHaveBeenCalled() + }) + it('offers a floating control when more messages remain below', async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4f5fb755..fd880938 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,6 +11,7 @@ import { Download, Edit3, FileText, + GitFork, HeartPulse, Info, Library, @@ -119,6 +120,7 @@ import type { } from '../../shared/assistant-contracts' import { conversationAttachmentSchema, + conversationBranchSchema, conversationContextCompressionMarkerSchema, conversationContextMetricsSchema, conversationMessageBlocksSchema, @@ -679,6 +681,39 @@ function displayErrorMessage(reason: unknown, fallback: string): string { return fallback } +function createConversationBranchTitle( + sourceTitle: string, + suffix: string +): string { + const trailing = ` · ${suffix}` + if (trailing.length >= 200) { + return suffix.slice(0, 200) + } + return `${sourceTitle + .slice(0, 200 - trailing.length) + .trimEnd()}${trailing}` +} + +function ConversationBranchBadge({ + sourceTitle +}: { + sourceTitle: string +}): React.JSX.Element { + const { t } = useTranslation('app') + const label = t('conversation.branch.badge', { + title: sourceTitle + }) + return ( + + + ) +} + function isUnusedConversation(conversation: Conversation): boolean { return ( conversation.title === '新对话' && @@ -1143,6 +1178,8 @@ function isConversation(value: unknown): value is Conversation { (item.knowledgeRetrievalMode === undefined || item.knowledgeRetrievalMode === 'auto' || item.knowledgeRetrievalMode === 'always') && + (item.branch === undefined || + conversationBranchSchema.safeParse(item.branch).success) && (item.remote === undefined || (typeof item.remote === 'object' && item.remote !== null && @@ -1228,6 +1265,7 @@ function toConversationSnapshots( knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, + ...(conversation.branch ? { branch: conversation.branch } : {}), title: conversation.title, updatedAt: conversation.updatedAt, messages: conversation.messages @@ -1269,6 +1307,7 @@ function toLocalConversationHeader( knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, + ...(conversation.branch ? { branch: conversation.branch } : {}), title: conversation.title, updatedAt: conversation.updatedAt } @@ -2284,6 +2323,8 @@ function App(): React.JSX.Element { useState('') const [deletingConversationId, setDeletingConversationId] = useState('') + const [branchingConversationId, setBranchingConversationId] = + useState('') const [renamingConversationId, setRenamingConversationId] = useState('') const [notifications, notify] = useReducer( appNotificationReducer, @@ -3268,6 +3309,13 @@ function App(): React.JSX.Element { const activeProjectDisplayName = activeProject ? getProjectDisplayText(activeProject, tWorkspace).name : undefined + const queuedConversationIds = useMemo( + () => + new Set( + conversationQueueItems.map((item) => item.conversationId) + ), + [conversationQueueItems] + ) const filteredConversations = useMemo(() => { const query = deferredSearchQuery.trim().toLocaleLowerCase() const candidates = query @@ -5757,6 +5805,80 @@ function App(): React.JSX.Element { setActiveId(replacement.id) } + const branchConversation = async ( + sourceConversation: Conversation + ): Promise => { + if ( + branchingConversationId || + sourceConversation.remote || + !conversationStoreReady || + activeConversationIds.has(sourceConversation.id) || + queuedConversationIds.has(sourceConversation.id) + ) { + return + } + setBranchingConversationId(sourceConversation.id) + try { + persistLocalConversationChanges() + await conversationPersistenceQueueRef.current + if ( + persistedLocalConversationsRef.current.get( + sourceConversation.id + ) !== sourceConversation || + conversationsRef.current.find( + (conversation) => + conversation.id === sourceConversation.id + ) !== sourceConversation + ) { + throw new Error(t('notices.conversationPersistenceFailed')) + } + const sourceTitle = getConversationDisplayTitle( + sourceConversation, + t('conversation.defaultTitle') + ) + const branch = await window.goodbuddy.conversations.branchLocal({ + sourceConversationId: sourceConversation.id, + title: createConversationBranchTitle( + sourceTitle, + t('conversation.branch.suffix') + ) + }) + const nextBranch: Conversation = branch + persistedLocalConversationsRef.current.set( + nextBranch.id, + nextBranch + ) + setConversations((current) => [ + nextBranch, + ...current.filter( + (conversation) => conversation.id !== nextBranch.id + ) + ]) + setSelectedAssistantTaskId(undefined) + setActiveId(nextBranch.id) + setView('chat') + if (narrowWindow) { + closeNarrowSidebar() + } + notify({ + tone: 'success', + message: t('notices.conversationBranched') + }) + requestAnimationFrame(() => inputRef.current?.focus()) + } catch (error) { + notify({ + tone: 'error', + message: displayErrorMessage( + error, + t('notices.conversationBranchFailed') + ) + }) + focusConversationActions(sourceConversation.id) + } finally { + setBranchingConversationId('') + } + } + const focusConversationActions = (conversationId: string): void => { requestAnimationFrame(() => conversationActionTriggerRefs.current.get(conversationId)?.focus() @@ -7445,6 +7567,25 @@ function App(): React.JSX.Element { conversation, t('conversation.defaultTitle') ) + const branchSourceTitle = conversation.branch + ? conversationTitles.get( + conversation.branch.sourceConversationId + ) ?? conversation.branch.sourceTitle + : undefined + const branchUnavailable = + activeConversationIds.has(conversation.id) || + queuedConversationIds.has(conversation.id) + const branchDisabledReason = !conversationStoreReady + ? t('conversation.branch.storageUnavailable') + : branchingConversationId + ? branchingConversationId === conversation.id + ? t('conversation.branch.creating') + : t('conversation.branch.anotherCreating') + : branchUnavailable + ? t('conversation.branch.unavailable') + : undefined + const branchDisabledReasonId = + `conversation-branch-disabled-${conversation.id}` return (
+ {branchSourceTitle && ( + + )} {conversation.remote && ( { @@ -7595,6 +7741,50 @@ function App(): React.JSX.Element { className="conversation-actions" id={`conversation-actions-${conversation.id}`} > + {!conversation.remote && ( + <> + + {branchDisabledReason && ( + + {branchDisabledReason} + + )} + + )} {!conversation.remote && ( ) @@ -7847,7 +8068,7 @@ function App(): React.JSX.Element { className="conversation-title" title={activeConversation?.title} > - + {activeConversation ? getConversationDisplayTitle( activeConversation, @@ -7858,6 +8079,15 @@ function App(): React.JSX.Element { ? t('conversation.remoteTitle') : t('conversation.defaultTitle'))} + {activeConversation?.branch && ( + + )} {activeConversation?.remote && ( { diff --git a/src/renderer/src/WorkspacePrimitives.test.tsx b/src/renderer/src/WorkspacePrimitives.test.tsx index 767f0d6b..3c1f5391 100644 --- a/src/renderer/src/WorkspacePrimitives.test.tsx +++ b/src/renderer/src/WorkspacePrimitives.test.tsx @@ -178,6 +178,9 @@ describe('WorkspacePrimitives', () => { expect(forcedColors).toMatch( /\.task-status-dot--failed,[\s\S]*?forced-color-adjust:\s*none;/u ) + expect(forcedColors).toMatch( + /:root \.conversation-task-child__completed-status\s*\{[^}]*color:\s*Canvas;/u + ) expect(forcedColors).toMatch( /\.heartbeat-center__meter\s*\{[^}]*forced-color-adjust:\s*none;/u ) @@ -210,6 +213,12 @@ describe('WorkspacePrimitives', () => { }) it('keeps the conversation action close to the sidebar edge', () => { + expect(stylesheet).toMatch( + /\.conversation-row:has\(\.conversation-task-toggle\) \.conversation-item\s*\{[^}]*padding-left:\s*34px;/u + ) + expect(stylesheet).toMatch( + /\.conversation-task-toggle\s*\{[^}]*left:\s*3px;[^}]*width:\s*28px;[^}]*height:\s*28px;/u + ) expect(stylesheet).toMatch( /\.conversation-row \.conversation-item\s*\{[^}]*padding-right:\s*36px;/u ) @@ -225,6 +234,15 @@ describe('WorkspacePrimitives', () => { expect(stylesheet).not.toContain('.conversation-delete') }) + it('shows conversation provenance with a horizontal fork badge', () => { + expect(stylesheet).toMatch( + /\.conversation-branch-badge\s*\{[^}]*width:\s*20px;[^}]*height:\s*18px;[^}]*background:\s*var\(--accent-subtle\);[^}]*color:\s*var\(--accent\);/u + ) + expect(stylesheet).toMatch( + /\.conversation-branch-badge svg,\s*\.conversation-branch-icon\s*\{[^}]*transform:\s*rotate\(90deg\);/u + ) + }) + it('uses the shared control and menu surfaces for the rich project picker', () => { expect(stylesheet).toMatch( /\.project-switcher__trigger\s*\{[^}]*height:\s*var\(--control-height\);[^}]*border-radius:\s*var\(--radius-control\);[^}]*background:\s*var\(--surface-raised\);/u diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 166e124d..3e2c581c 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -86,9 +86,20 @@ export const app = { saveName: 'Save conversation name', cancelRename: 'Cancel rename', exportFallbackName: 'GoodBuddy conversation', + branch: { + badge: 'Branched conversation, source: {{title}}', + suffix: 'Branch', + creating: 'Creating branch', + anotherCreating: 'Another branch is being created. Please wait.', + storageUnavailable: + 'Conversation storage is not ready yet. Please wait.', + unavailable: + 'Finish the active run or pending items before creating a branch' + }, actions: { more: 'More conversation actions for {{title}}', region: 'Conversation actions for {{title}}', + branch: 'Continue in a new conversation', rename: 'Rename conversation', copy: 'Copy full conversation', export: 'Export Markdown' @@ -572,6 +583,9 @@ export const app = { clipboardUnavailable: 'Cannot access the clipboard. Check your system permissions.', conversationExported: 'Conversation exported', + conversationBranched: 'Branch created and opened', + conversationBranchFailed: + 'Failed to create a conversation branch. Try again.', imageUnavailable: 'Image content is unavailable', imageDownloadStarted: 'Image download started', remoteConversationReadOnly: diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index a51f66c5..eb3e4580 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -83,9 +83,18 @@ export const app = { saveName: '保存会话名称', cancelRename: '取消重命名', exportFallbackName: 'GoodBuddy 对话', + branch: { + badge: '分支会话,来源:{{title}}', + suffix: '分支', + creating: '正在创建分支', + anotherCreating: '另一条分支正在创建,请稍候', + storageUnavailable: '会话存储尚未就绪,请稍候', + unavailable: '会话运行或待发送内容处理完后才能创建分支' + }, actions: { more: '更多会话操作 {{title}}', region: '{{title}} 的会话操作', + branch: '在新会话中继续', rename: '重命名会话', copy: '复制完整会话', export: '导出 Markdown' @@ -537,6 +546,8 @@ export const app = { conversationCopied: '对话已复制到剪贴板', clipboardUnavailable: '无法访问剪贴板,请检查系统权限', conversationExported: '对话已导出', + conversationBranched: '已创建并打开分支会话', + conversationBranchFailed: '创建分支会话失败,请重试', imageUnavailable: '图片内容不可用', imageDownloadStarted: '图片下载已开始', remoteConversationReadOnly: '远程通道会话只能从对应消息应用继续发起', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 58b46e64..bc1cec0b 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -95,16 +95,16 @@ } .conversation-row:has(.conversation-task-toggle) .conversation-item { - padding-left: 42px; + padding-left: 34px; } .conversation-task-toggle { position: absolute; z-index: 1; - left: 7px; + left: 3px; display: grid; - width: 25px; - height: 25px; + width: 28px; + height: 28px; padding: 0; border: 0; border-radius: 6px; @@ -180,6 +180,14 @@ white-space: nowrap; } +.conversation-task-child__completed-status { + display: grid; + width: 10px; + height: 10px; + color: var(--text-on-accent); + place-items: center; +} + .conversation-task-view-all { justify-content: center; color: var(--accent); @@ -2792,6 +2800,7 @@ textarea:focus-visible { .conversation-item__primary > :where( + .conversation-branch-badge, .conversation-source-badge, .conversation-unread ) { @@ -2841,6 +2850,25 @@ textarea:focus-visible { transition-timing-function: linear; } +.conversation-branch-badge { + display: inline-flex; + width: 20px; + height: 18px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--border-default); + border-radius: 5px; + margin-right: var(--space-1); + background: var(--accent-subtle); + color: var(--accent); +} + +.conversation-branch-badge svg, +.conversation-branch-icon { + transform: rotate(90deg); +} + .conversation-source-badge { display: inline-flex; margin-right: var(--space-1); @@ -3926,12 +3954,17 @@ button > svg { font-weight: 600; } -.conversation-title span { +.conversation-title__text { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.conversation-title .conversation-branch-badge { + margin-right: 0; +} + .topbar__actions { display: flex; min-width: 0; @@ -8910,6 +8943,13 @@ details.settings-section > :not(summary) + :not(summary) { color: var(--accent); } +.conversation-actions button[aria-disabled='true'] { + background: transparent; + color: var(--text-muted); + cursor: help; + opacity: 0.65; +} + .conversation-rename { display: grid; padding: var(--space-1); @@ -13253,6 +13293,10 @@ details.settings-section > :not(summary) + :not(summary) { forced-color-adjust: none; } + :root .conversation-task-child__completed-status { + color: Canvas; + } + :root :is( .task-status-dot--failed, .task-status-dot--interrupted, diff --git a/src/shared/assistant-contracts.test.ts b/src/shared/assistant-contracts.test.ts index 4381aa41..ed99b7c1 100644 --- a/src/shared/assistant-contracts.test.ts +++ b/src/shared/assistant-contracts.test.ts @@ -3,6 +3,8 @@ import type { AssistantProject } from './assistant-contracts' import { builtInDefaultProjectSeedDescription, builtInDefaultProjectSeedName, + conversationBranchInputSchema, + conversationSnapshotSchema, isUntouchedBuiltInDefaultProject, projectCreateSchema, projectUpdateSchema @@ -72,3 +74,38 @@ describe('isUntouchedBuiltInDefaultProject', () => { expect(projectUpdateSchema.safeParse(input).success).toBe(false) }) }) + +describe('conversation branch contracts', () => { + it('accepts bounded branch provenance and rejects renderer extras', () => { + const sourceConversationId = + '00000000-0000-4000-8000-000000000201' + expect( + conversationBranchInputSchema.parse({ + sourceConversationId, + title: '方案讨论 · 分支' + }) + ).toEqual({ + sourceConversationId, + title: '方案讨论 · 分支' + }) + expect( + conversationBranchInputSchema.safeParse({ + sourceConversationId, + title: '方案讨论 · 分支', + copyTasks: true + }).success + ).toBe(false) + expect( + conversationSnapshotSchema.safeParse({ + id: '00000000-0000-4000-8000-000000000202', + branch: { + sourceConversationId, + sourceTitle: '方案讨论' + }, + title: '方案讨论 · 分支', + updatedAt: 1, + messages: [] + }).success + ).toBe(true) + }) +}) diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index b34ea529..30a566f0 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -346,6 +346,24 @@ export type ConversationContextCompressionState = z.infer< typeof conversationContextCompressionStateSchema > +export const conversationBranchInputSchema = z + .object({ + sourceConversationId: assistantIdSchema, + title: z.string().trim().min(1).max(200) + }) + .strict() + +export type ConversationBranchInput = z.infer< + typeof conversationBranchInputSchema +> + +export const conversationBranchSchema = z + .object({ + sourceConversationId: assistantIdSchema, + sourceTitle: z.string().trim().min(1).max(200) + }) + .strict() + export const conversationSnapshotSchema = z .object({ id: assistantIdSchema, @@ -363,6 +381,7 @@ export const conversationSnapshotSchema = z }) .strict() .optional(), + branch: conversationBranchSchema.optional(), title: z.string().trim().min(1).max(200), updatedAt: z.number().int().nonnegative(), messages: z diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index a325314a..085a51bd 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -36,6 +36,7 @@ import { type TokenUsageSummary, type ConversationSnapshot, type ConversationAttachment, + type ConversationBranchInput, type ConversationQueueItem, type ConversationContextCompressionState, type LocalConversationSaveBatch, @@ -1497,6 +1498,9 @@ export type DesktopApi = { list: () => Promise replace: (conversations: ConversationSnapshot[]) => Promise saveLocal: (batch: LocalConversationSaveBatch) => Promise + branchLocal: ( + input: ConversationBranchInput + ) => Promise deleteLocal: (conversationId: string) => Promise onChanged: ( listener: (conversationId?: string) => void diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 0496400d..47621f6a 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -95,6 +95,7 @@ export const ipcChannels = { conversationsList: 'conversations:list', conversationsReplace: 'conversations:replace', conversationsSaveLocal: 'conversations:save-local', + conversationsBranchLocal: 'conversations:branch-local', conversationsDeleteLocal: 'conversations:delete-local', conversationsChanged: 'conversations:changed', conversationQueueList: 'conversation-queue:list',