feat: add independent conversation branching

Local conversations can now continue in a new branch after stable content is persisted. Each branch uses new conversation and message IDs, keeps its source visible, and remains independent without copying Tasks, queue items, runs, memories, or artifact ownership.

Conversation Task rows are also denser, align status markers at the right edge, and use an accessible checked success marker without redundant completion text.

Release note: 本地会话现在可从“在新会话中继续”创建独立分支并显示来源;Task 子项布局更紧凑,状态统一显示在右侧。
This commit is contained in:
mesalogo
2026-08-20 17:13:57 +08:00
parent 65c3ab9ab0
commit aa339a73d6
25 changed files with 1392 additions and 67 deletions
+1 -1
View File
@@ -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、模型、审批策略、预算和超时,并支持模板复用。
+1 -1
View File
@@ -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 本地模型。
+14 -6
View File
@@ -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 知识库
@@ -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。
- 生成完成、任务完成、任务失败和等待确认。
- 未读数量、全部已读和按类别过滤。
+6 -7
View File
@@ -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 内呈现
+6 -3
View File
@@ -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. 兼容映射
+5 -7
View File
@@ -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 中。
+2 -1
View File
@@ -118,7 +118,8 @@ GoodBuddy 应能够:
- 默认工作模式、Runtime、模型连接、Skills、MCP 和知识库范围。
- 项目可归档、恢复和导出。
会话支持置顶、归档、重命名、删除、按项目筛选和搜索。
会话支持置顶、归档、重命名、删除、按项目筛选和搜索;稳定的本地会话还可复制当前聊天
内容到独立分支,并保留不依赖来源生命周期的来源标识。
### 4.2 工作模式
+361 -4
View File
@@ -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<string, unknown>
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-')
+198 -11
View File
@@ -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 {
@@ -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)
})
+57 -1
View File
@@ -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
+15
View File
@@ -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) => {
+6
View File
@@ -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<ConversationSnapshot>,
deleteLocal: (conversationId: string) =>
ipcRenderer.invoke(
ipcChannels.conversationsDeleteLocal,
+11
View File
@@ -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')
})
})
+307 -3
View File
@@ -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(<App />)
@@ -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(<App />)
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(<App />)
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(<App />)
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([
{
+243 -13
View File
@@ -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 (
<span
aria-label={label}
className="conversation-branch-badge"
title={label}
>
<GitFork aria-hidden="true" size={13} />
</span>
)
}
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<void> => {
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 (
<div className="conversation-entry" key={conversation.id}>
<div
@@ -7508,6 +7649,11 @@ function App(): React.JSX.Element {
}}
>
<span className="conversation-item__primary">
{branchSourceTitle && (
<ConversationBranchBadge
sourceTitle={branchSourceTitle}
/>
)}
{conversation.remote && (
<b className="conversation-source-badge">
{
@@ -7595,6 +7741,50 @@ function App(): React.JSX.Element {
className="conversation-actions"
id={`conversation-actions-${conversation.id}`}
>
{!conversation.remote && (
<>
<button
aria-describedby={
branchDisabledReason
? branchDisabledReasonId
: undefined
}
aria-disabled={
branchDisabledReason ? true : undefined
}
onClick={() => {
if (branchDisabledReason) {
notify({
tone: 'info',
message: branchDisabledReason
})
return
}
setConversationActionsId('')
void branchConversation(conversation)
}}
title={branchDisabledReason}
type="button"
>
<GitFork
aria-hidden="true"
className="conversation-branch-icon"
size={14}
/>
{branchingConversationId === conversation.id
? t('conversation.branch.creating')
: t('conversation.actions.branch')}
</button>
{branchDisabledReason && (
<span
className="sr-only"
id={branchDisabledReasonId}
>
{branchDisabledReason}
</span>
)}
</>
)}
{!conversation.remote && (
<button
onClick={() => {
@@ -7722,6 +7912,24 @@ function App(): React.JSX.Element {
task,
assistantSchedules
)
const statusLabel = tWorkspace(
`task.status.${task.status}`
)
const metadata = schedule
? [
tWorkspace(`task.mode.${schedule.workMode}`),
tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
),
task.status === 'completed'
? undefined
: statusLabel
]
.filter((value) => value !== undefined)
.join(' · ')
: task.status === 'completed'
? ''
: statusLabel
return (
<li key={task.id}>
<button
@@ -7738,21 +7946,34 @@ function App(): React.JSX.Element {
}}
type="button"
>
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span className="conversation-task-child__title">
{task.title}
</span>
<small className="conversation-task-child__meta">
{schedule
? `${tWorkspace(`task.mode.${schedule.workMode}`)} · ${tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
)} · `
: ''}
{tWorkspace(`task.status.${task.status}`)}
</small>
{metadata && (
<small className="conversation-task-child__meta">
{metadata}
</small>
)}
{task.status === 'completed' ? (
<span
className="task-status-dot task-status-dot--completed conversation-task-child__completed-status"
title={statusLabel}
>
<Check
aria-hidden="true"
size={7}
strokeWidth={3}
/>
<span className="sr-only">
{statusLabel}
</span>
</span>
) : (
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
)}
</button>
</li>
)
@@ -7847,7 +8068,7 @@ function App(): React.JSX.Element {
className="conversation-title"
title={activeConversation?.title}
>
<span>
<span className="conversation-title__text">
{activeConversation
? getConversationDisplayTitle(
activeConversation,
@@ -7858,6 +8079,15 @@ function App(): React.JSX.Element {
? t('conversation.remoteTitle')
: t('conversation.defaultTitle'))}
</span>
{activeConversation?.branch && (
<ConversationBranchBadge
sourceTitle={
conversationTitles.get(
activeConversation.branch.sourceConversationId
) ?? activeConversation.branch.sourceTitle
}
/>
)}
{activeConversation?.remote && (
<b className="conversation-source-badge">
{
@@ -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
@@ -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:
@@ -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: '远程通道会话只能从对应消息应用继续发起',
+49 -5
View File
@@ -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,
+37
View File
@@ -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)
})
})
+19
View File
@@ -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
+4
View File
@@ -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<ConversationSnapshot[]>
replace: (conversations: ConversationSnapshot[]) => Promise<void>
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
branchLocal: (
input: ConversationBranchInput
) => Promise<ConversationSnapshot>
deleteLocal: (conversationId: string) => Promise<boolean>
onChanged: (
listener: (conversationId?: string) => void
+1
View File
@@ -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',