Compare commits
5
Commits
v0.15.14
...
3ebb4f0b79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebb4f0b79 | ||
|
|
c7f09e1e53 | ||
|
|
d9dcb65d10 | ||
|
|
1044a32b3b | ||
|
|
c6a8455f88 |
+7
-1
@@ -983,9 +983,15 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.14`; Agent `0.15.6` remains paired with Desktop `0.15.14`, with
|
||||
- The current Desktop candidate is `0.16.1`; Agent `0.15.6` remains paired with Desktop `0.16.1`, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.16.1` smooths the message-to-composer transition: messages extend to the composer
|
||||
outline before fading, and light/dark theme backgrounds and focus boundaries no longer create
|
||||
an abrupt solid band above the input. Schema remains unchanged.
|
||||
- Desktop `0.16.0` moves expert and team-role selection into the composer and persists the
|
||||
selection per conversation. Historical messages retain the role used for their request,
|
||||
including after a role is renamed or removed; schema remains unchanged.
|
||||
- Desktop `0.15.14` restores continuation for legacy Supervisor reviews that failed because a
|
||||
source changed or was removed. Sources that keep changing are re-frozen only within a bounded
|
||||
number of attempts and then omitted so the review can finish while retaining surviving progress.
|
||||
|
||||
+3
-1
@@ -159,7 +159,9 @@
|
||||
### 开源、构建与发布
|
||||
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.14`,Agent 保持 `0.15.6`、最低要求 Desktop `0.15.14`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- 当前 Desktop 候选为 `0.16.1`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.16.1` 平滑消息与输入框的过渡:消息会延伸到输入框轮廓前再淡出,浅色和深色主题的背景与焦点边界也不再在输入框上方形成突兀的实色横带;schema 保持不变。
|
||||
- Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。
|
||||
- Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。
|
||||
- Desktop `0.15.13` / Agent `0.15.6` 将桌面业务 SQLite 的唯一 owner 移到一个类型化 storage utility process,Assistant、Knowledge、附件、运行绑定和账本保留原路径及事务语义。直连模型在工具失败后可继续,分页输出、Magic Notes 标签、监督来源别名、设置直达预加载和文件元数据同步修正;schema 仍为 62。storage/监督 focused 编排已通过,但 C01–C07 并发验收矩阵、跨平台 storage 进程安装和低端机对照仍是明确后续门槛,不作通用性能承诺。见[发布准备](./docs/development/release-preparation-0.15.13.md)。
|
||||
- Desktop `0.15.12` / Agent `0.15.5` 增加 Telegram 私聊配置(真实 Bot 与模型完整验收仍待完成)和本地日期自定义回顾。监督图谱/来源按需读取、索引 Worker 查询、摘要缓存和选择列表窗口化减少重复工作,最终发布仍可阻塞 Main;未发布回顾省略已删除会话并重算受影响批次,历史结果保留。临时模型失败最多多试一次,可能增加费用;远程启动取消、通道交付、长消息展开状态及历史缺失时长得到修正。schema 62 增加通道支持和查询索引,升级前备份完整数据目录,降级需恢复备份。两个独立开发审计阶段共记录 15 次外部文本请求,本次发布准备没有新增;精确源码 CI 与原生打包仍为门槛。见[发布准备](./docs/development/release-preparation-0.15.12.md)。
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@
|
||||
- 页面顶部和底部内边距默认为 `32px`。
|
||||
- `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
|
||||
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
|
||||
- 输入区顶部向消息区延伸 `--space-4`(16px)的透明到 `--surface-raised` 渐变,柔化消息裁切边缘;渐变不占布局空间、不拦截鼠标事件,并随主题切换。
|
||||
- 输入区外层不铺整块背景、不画横向分隔线。输入区以上内边距的高度向上叠入消息区,输入框上方保留 `--space-4`(16px)的透明到页面底色渐变,渐变终点对齐输入框上缘,让滚动的消息一直延伸到输入框轮廓前再淡出;输入框上缘以下才使用实色底;渐变位于输入框之下、不拦截鼠标事件,并随主题切换。输入框上缘只保留自身 1px 描边,不加向上的阴影,避免与渐变终点叠成粗边界。
|
||||
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
|
||||
|
||||
## 5. 信息层级
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Desktop 0.16.0 发布准备
|
||||
|
||||
日期:2026-10-09。基线 `v0.15.14`,候选源码为本次发布准备提交。
|
||||
本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。标准六平台 Desktop,
|
||||
不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 专家和团队角色选择移动到 Composer 输入框顶部,发送前更容易发现。
|
||||
- 角色选择按会话保存,切换会话不会带入其他会话的选择。
|
||||
- 历史消息保留请求发送时使用的角色,即使角色后来重命名或删除,历史上下文仍可识别。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本候选包含 Composer、会话选择状态、历史消息元数据和角色选择的回归测试。
|
||||
完整跨平台安装、远程 Host 和未覆盖的整包验收仍以 CI 与发布工作流结果为准。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再由用户确认后创建不可变的 `v0.16.0` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,18 @@
|
||||
# Desktop 0.16.1 发布准备
|
||||
|
||||
日期:2026-10-10。基线 `v0.16.0`,候选源码包含 `d9dcb65d` 和 `c7f09e1e`
|
||||
两个 Composer 视觉修复提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
|
||||
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 消息内容延伸到 Composer 输入框轮廓前再淡出,不再被输入区上方的实色区域提前截断。
|
||||
- 浅色和深色主题使用匹配的消息到输入框渐变,输入框焦点边界不再形成粗边。
|
||||
- 滚动到底部按钮保持与新的输入区过渡相配的可见位置。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.1` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.14",
|
||||
"version": "0.16.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.14",
|
||||
"version": "0.16.1",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.14",
|
||||
"version": "0.16.1",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,62 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.16.1",
|
||||
"releasedAt": "2026-10-10",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**让消息自然延伸到输入框边缘**:修复聊天消息在输入框上方提前被实色区域截断的问题,现在消息会沿着输入框轮廓自然淡出,输入区和消息区的过渡更平滑。",
|
||||
"**改善输入框与消息区的主题适配**:调整浅色和深色主题下的渐变、背景和焦点边界,减少输入框上方出现粗边或明显横线的问题;滚动到底部按钮也会保持合适的可见位置。"
|
||||
],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Let messages fade naturally to the composer edge**: Chat messages no longer stop at a solid band above the composer. They now fade along the composer outline for a smoother transition between the message area and input area.",
|
||||
"**Improve composer and message-area theme behavior**: Light and dark theme gradients, backgrounds, and focus boundaries are refined to reduce thick edges and visible horizontal bands above the composer; the scroll-to-bottom control also remains positioned for clear access."
|
||||
],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"releasedAt": "2026-10-09",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**在输入框中选择专家或团队角色**:发送消息前可以直接在输入框顶部选择专家或团队角色,选择入口更接近实际发送操作。每个会话独立保存自己的选择,切换会话时不会误用其他会话的角色。",
|
||||
"**保留历史消息使用的角色**:已发送的回复会保留请求当时使用的专家或团队角色,即使之后重命名或删除该角色,历史记录仍能准确显示当时的使用情况。"
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Choose an expert or team role from the composer**: Select an expert or team role directly at the top of the composer before sending a message. Each conversation keeps its own selection, so switching conversations does not accidentally carry over another conversation’s role.",
|
||||
"**Preserve the role used by historical messages**: Sent replies retain the expert or team role selected when the request was made. Later renaming or removing that role does not make existing history lose the context of how it was produced."
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.14",
|
||||
"releasedAt": "2026-10-09",
|
||||
|
||||
@@ -134,6 +134,27 @@ it('persists the story graph switch per conversation without schema migration or
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists the composer role per conversation and the role snapshot on replies', async () => {
|
||||
const database = await createDatabase()
|
||||
const expertId = randomUUID()
|
||||
const header = { id: randomUUID(), title: 'Role preference', updatedAt: 1000, selectedExpertId: expertId }
|
||||
const team = { id: randomUUID(), title: 'Team preference', updatedAt: 1000, selectedExpertId: 'team' as const }
|
||||
const plain = { id: randomUUID(), title: 'No preference', updatedAt: 1000 }
|
||||
const reply = { id: randomUUID(), role: 'assistant' as const, content: 'Reviewed', state: 'complete' as const,
|
||||
createdAt: 900, expert: { kind: 'expert' as const, id: expertId, name: '代码审查专家' } }
|
||||
try {
|
||||
database.saveLocalConversations([{ header, messages: [reply] }, { header: team, messages: [] }, { header: plain, messages: [] }])
|
||||
database.close()
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.getConversation(header.id)).toMatchObject({ selectedExpertId: expertId, messages: [{ expert: reply.expert }] })
|
||||
expect(database.getConversation(team.id).selectedExpertId).toBe('team')
|
||||
expect(database.getConversation(plain.id).selectedExpertId).toBeUndefined()
|
||||
database.saveLocalConversations([{ header: { ...header, selectedExpertId: undefined, updatedAt: 1001 }, messages: [] }])
|
||||
expect(database.getConversation(header.id).selectedExpertId).toBeUndefined()
|
||||
expect(localConversationHeaderSchema.safeParse({ ...plain, selectedExpertId: 'not-an-id' }).success).toBe(false)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
||||
|
||||
@@ -364,6 +364,7 @@ type MessageMetadata = {
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
subagents?: ConversationSnapshot['messages'][number]['subagents']
|
||||
expert?: ConversationMessage['expert']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
||||
@@ -1238,6 +1239,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
knowledgeLibraryIds: true,
|
||||
knowledgeRetrievalMode: true,
|
||||
storyGraphEnabled: true,
|
||||
selectedExpertId: true,
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
@@ -1246,7 +1248,7 @@ function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -1264,18 +1266,20 @@ function parseConversationContextState(
|
||||
function serializeConversationContextState(
|
||||
conversation: Pick<
|
||||
ConversationSnapshot,
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
|
||||
>
|
||||
): string | null {
|
||||
return conversation.knowledgeLibraryIds !== undefined ||
|
||||
conversation.knowledgeRetrievalMode !== undefined ||
|
||||
conversation.storyGraphEnabled !== undefined ||
|
||||
conversation.selectedExpertId !== undefined ||
|
||||
conversation.contextMetrics ||
|
||||
conversation.contextCompressionState
|
||||
? JSON.stringify({
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState
|
||||
})
|
||||
@@ -1346,6 +1350,7 @@ function toConversationSnapshot(
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -1377,6 +1382,7 @@ function serializeConversationMessageMetadata(
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
subagents: message.subagents,
|
||||
expert: message.expert,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
@@ -1784,6 +1790,7 @@ function storedRemoteAssistantMessage(
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -5638,6 +5645,7 @@ export class AssistantDatabase {
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
|
||||
@@ -8751,13 +8751,16 @@ describe("App", () => {
|
||||
const conversationSettings = within(composer!).getByRole("group", {
|
||||
name: "对话设置",
|
||||
});
|
||||
// Order: context sources, expert, Runtime, then Runtime-specific settings.
|
||||
// The role chip sits in the input's top-left corner, before the textarea.
|
||||
const inputArea = composer!.querySelector<HTMLElement>(".composer__input")!;
|
||||
const expertTrigger = within(inputArea).getByRole("button", { name: "专家角色:通用助手" });
|
||||
expect(expertTrigger.compareDocumentPosition(screen.getByLabelText("向 GoodBuddy 提问")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(conversationSettings).queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
|
||||
// Toolbar order: context sources, Runtime, then Runtime-specific settings.
|
||||
const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
|
||||
const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
|
||||
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(
|
||||
within(conversationSettings).queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
@@ -12583,7 +12586,8 @@ describe("App", () => {
|
||||
|
||||
it("can dispatch a request to the parallel expert team", async () => {
|
||||
render(<App />);
|
||||
|
||||
// The role belongs to the loaded conversation.
|
||||
await screen.findByLabelText("发送");
|
||||
selectComposerOption("专家角色", "专家团队(并行)");
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "制定发布计划" },
|
||||
@@ -12671,6 +12675,43 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the role per conversation and snapshots it on the reply", async () => {
|
||||
const expertId = "00000000-0000-4000-8000-000000000511";
|
||||
vi.mocked(api.experts.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: expertId,
|
||||
name: "审查专家",
|
||||
description: "代码审查",
|
||||
systemInstructions: "Review code.",
|
||||
routingKeywords: [],
|
||||
enabled: true,
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
updatedAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const { container } = render(<App />);
|
||||
await waitFor(() => expect(api.experts.list).toHaveBeenCalled());
|
||||
const firstTitle = container.querySelector(".conversation-item--active .conversation-item__title")?.textContent;
|
||||
const expertMenu = openComposerMenu("专家角色");
|
||||
fireEvent.click(
|
||||
(await within(expertMenu).findByText("审查专家", { selector: "span" })).closest<HTMLButtonElement>("button")!,
|
||||
);
|
||||
expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "审查这个改动" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ expertId })));
|
||||
expect(await screen.findByText("审查专家", { selector: ".message__role" })).toBeVisible();
|
||||
|
||||
const create = screen.getAllByRole("button", { name: "新建对话" })[0]!;
|
||||
fireEvent.click(create);
|
||||
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:通用助手"));
|
||||
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!)
|
||||
.getByTitle(firstTitle && firstTitle !== "新对话" ? firstTitle : "审查这个改动"));
|
||||
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家"));
|
||||
});
|
||||
|
||||
it("shows Subagent states and records child expert activity", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
|
||||
+41
-10
@@ -842,6 +842,7 @@ function toConversationSnapshots(
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
@@ -1153,7 +1154,6 @@ function App(): React.JSX.Element {
|
||||
const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedExpertId, setSelectedExpertId] = useState("");
|
||||
const [activeProjectId, setActiveProjectId] = useState("");
|
||||
const activeProjectIdRef = useRef(activeProjectId);
|
||||
const workspaceChangesRequestRef = useRef(0);
|
||||
@@ -2280,6 +2280,34 @@ function App(): React.JSX.Element {
|
||||
// Header fields of the active conversation, value-compared: message updates
|
||||
// (streaming flushes, tool events) do not re-render App.
|
||||
const activeConversation = useActiveConversationView(conversationStore, activeId);
|
||||
// The role follows the conversation; a deleted role reads as the general
|
||||
// assistant.
|
||||
const resolveExpertSelection = useCallback(
|
||||
(value: string | undefined): string =>
|
||||
!value || value === "team" ||
|
||||
assistantExperts.some((expert) => expert.id === value)
|
||||
? value ?? ""
|
||||
: "",
|
||||
[assistantExperts],
|
||||
);
|
||||
const selectedExpertId = resolveExpertSelection(activeConversation?.selectedExpertId);
|
||||
const setSelectedExpertId = useCallback(
|
||||
(value: string): void => {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeId &&
|
||||
(conversation.selectedExpertId ?? "") !== value
|
||||
? {
|
||||
...conversation,
|
||||
selectedExpertId: value ? value : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
},
|
||||
[activeId, setConversations],
|
||||
);
|
||||
const enabledKnowledgeLibraryIds =
|
||||
activeConversation?.knowledgeLibraryIds ?? [];
|
||||
const setEnabledKnowledgeLibraryIds = useCallback(
|
||||
@@ -5113,7 +5141,7 @@ function App(): React.JSX.Element {
|
||||
: (queuedInput.expertId ?? "")
|
||||
: runtime?.capability === "image-generation"
|
||||
? ""
|
||||
: selectedExpertId;
|
||||
: resolveExpertSelection(conversationSnapshot.selectedExpertId);
|
||||
const knowledgeLibraryIdsSnapshot =
|
||||
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
|
||||
const smartRoutingSnapshot =
|
||||
@@ -5210,6 +5238,14 @@ function App(): React.JSX.Element {
|
||||
const executionPrompt = memoryContext
|
||||
? `${prompt}\n\n${memoryContext}`
|
||||
: prompt;
|
||||
const selectedExpert = selectedExpertSnapshot && selectedExpertSnapshot !== "team"
|
||||
? assistantExperts.find((expert) => expert.id === selectedExpertSnapshot)
|
||||
: undefined;
|
||||
const expertSnapshot: Message["expert"] = selectedExpertSnapshot === "team"
|
||||
? { kind: "team" }
|
||||
: selectedExpert
|
||||
? { kind: "expert", id: selectedExpert.id, name: selectedExpert.name.trim().slice(0, 80) }
|
||||
: undefined;
|
||||
const assistantMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
@@ -5218,6 +5254,7 @@ function App(): React.JSX.Element {
|
||||
createdAt: Date.now(),
|
||||
state: "streaming",
|
||||
status: t("chat.status.preparingRequest"),
|
||||
...(expertSnapshot ? { expert: expertSnapshot } : {}),
|
||||
};
|
||||
|
||||
activeRuns.current.set(requestId, {
|
||||
@@ -6512,16 +6549,10 @@ function App(): React.JSX.Element {
|
||||
onClose: () => {
|
||||
commitView(viewRef.current);
|
||||
},
|
||||
// Each conversation keeps its role; one that no longer resolves reads as
|
||||
// the general assistant (see resolveExpertSelection).
|
||||
onExpertsChanged: (experts: AssistantExpert[]) => {
|
||||
setAssistantExperts(experts);
|
||||
if (
|
||||
(selectedExpertId === "team" && experts.length < 2) ||
|
||||
(selectedExpertId &&
|
||||
selectedExpertId !== "team" &&
|
||||
!experts.some((expert) => expert.id === selectedExpertId))
|
||||
) {
|
||||
setSelectedExpertId("");
|
||||
}
|
||||
},
|
||||
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
|
||||
onProjectsDeleted: removeProjectsFromUi,
|
||||
|
||||
@@ -180,6 +180,23 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the GoodBuddy name and shows the saved role as a caption below it', () => {
|
||||
const props = { ...callbacks, artifactById: new Map(), conversationId: 'role', hiddenMessageCount: 0,
|
||||
isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: 3 }
|
||||
const messages: Message[] = [
|
||||
{ id: 'expert', role: 'assistant', content: 'a', createdAt: 1, state: 'complete',
|
||||
expert: { kind: 'expert', id: '00000000-0000-4000-8000-000000000901', name: '代码审查专家' } },
|
||||
{ id: 'team', role: 'assistant', content: 'b', createdAt: 2, state: 'complete', expert: { kind: 'team' } },
|
||||
{ id: 'plain', role: 'assistant', content: 'c', createdAt: 3, state: 'complete' }
|
||||
]
|
||||
const view = render(<ChatTimeline {...props} messages={messages} />)
|
||||
const articles = view.container.querySelectorAll('article.message')
|
||||
expect([...articles].map(article => article.querySelector('.message__meta strong')?.textContent))
|
||||
.toEqual(['GoodBuddy', 'GoodBuddy', 'GoodBuddy'])
|
||||
expect([...articles].map(article => article.querySelector('.message__role')?.textContent ?? null))
|
||||
.toEqual(['代码审查专家', '专家团队(并行)', null])
|
||||
})
|
||||
|
||||
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
|
||||
const onAddToNote = vi.fn()
|
||||
const messages: Message[] = [
|
||||
|
||||
@@ -73,6 +73,7 @@ export type Message = {
|
||||
contextCompressions?: ConversationMessage['contextCompressions']
|
||||
tools?: ToolActivity[]
|
||||
subagents?: SubagentActivity[]
|
||||
expert?: ConversationMessage['expert']
|
||||
approval?: {
|
||||
id: string
|
||||
taskId?: string
|
||||
@@ -754,6 +755,14 @@ function ChatMessageRowView({
|
||||
: t('chat.status.waitingForProgress'))
|
||||
: message.status
|
||||
|
||||
const roleCaption = message.role !== 'assistant'
|
||||
? undefined
|
||||
: message.expert?.kind === 'expert'
|
||||
? message.expert.name
|
||||
: message.expert?.kind === 'team'
|
||||
? t('composer.experts.team')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
@@ -768,7 +777,7 @@ function ChatMessageRowView({
|
||||
<UserRound size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message__header">
|
||||
<div className={`message__header${roleCaption ? ' message__header--with-role' : ''}`}>
|
||||
<div className="message__meta">
|
||||
<strong>
|
||||
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
|
||||
@@ -786,6 +795,11 @@ function ChatMessageRowView({
|
||||
)}
|
||||
<span>{formatTime(message.createdAt, locale)}</span>
|
||||
</div>
|
||||
{roleCaption && (
|
||||
<small className="message__role" title={t('composer.expertLabel')}>
|
||||
{roleCaption}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="message__body">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
|
||||
@@ -653,6 +653,22 @@ export const Composer = memo(function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__input">
|
||||
<div className="composer__input-header">
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert composer-picker--chip"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === "image-generation"
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={13} />}
|
||||
menuOpen={composerMenuOpen === "expert"}
|
||||
onChange={actions.selectExpert}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
</div>
|
||||
<ComposerDraftText
|
||||
conversationId={conversationId}
|
||||
store={composerDrafts}
|
||||
@@ -1024,20 +1040,6 @@ export const Composer = memo(function Composer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === "image-generation"
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "expert"}
|
||||
onChange={actions.selectExpert}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
<div className="runtime-picker">
|
||||
<button
|
||||
aria-expanded={runtimeMenuOpen}
|
||||
|
||||
@@ -364,7 +364,7 @@ describe('WorkspacePrimitives', () => {
|
||||
|
||||
it('wraps native client and compaction actions without overlapping composer metadata', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
|
||||
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*\}/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
|
||||
|
||||
@@ -41,6 +41,7 @@ export function toConversationMessage(message: Message): ConversationMessage {
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
subagents: message.subagents,
|
||||
expert: message.expert,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
@@ -64,6 +65,7 @@ export function toLocalConversationHeader(
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useConversationActivitySummary(
|
||||
export type ActiveConversationView = Pick<
|
||||
Conversation,
|
||||
| "id" | "title" | "projectId" | "runtimeSelection" | "knowledgeLibraryIds"
|
||||
| "knowledgeRetrievalMode" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
|
||||
| "knowledgeRetrievalMode" | "selectedExpertId" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
|
||||
> & {
|
||||
/** Untouched greeting-only conversation: shown with the default title. */
|
||||
unused: boolean;
|
||||
@@ -188,6 +188,7 @@ export function selectActiveConversationView(
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
remote: conversation.remote,
|
||||
branch: conversation.branch,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
@@ -214,6 +215,7 @@ export function sameActiveConversationView(
|
||||
return left.id === right.id && left.title === right.title &&
|
||||
left.projectId === right.projectId &&
|
||||
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
||||
left.selectedExpertId === right.selectedExpertId &&
|
||||
left.unused === right.unused && left.historyLoaded === right.historyLoaded &&
|
||||
left.running === right.running && left.messageCount === right.messageCount &&
|
||||
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
|
||||
|
||||
@@ -6483,7 +6483,7 @@ button > svg {
|
||||
.chat-scroll-to-bottom {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: var(--space-3);
|
||||
bottom: var(--space-6);
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: 40px;
|
||||
@@ -6695,6 +6695,23 @@ button > svg {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message__header--with-role {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message__role {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message--user .message__header {
|
||||
width: min(78%, 720px);
|
||||
align-self: flex-end;
|
||||
@@ -7890,24 +7907,54 @@ button > svg {
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
--composer-fade-color: var(--surface-raised);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
/* Overlap the message region by the top padding so messages run under the
|
||||
fade right up to the composer outline instead of stopping at a solid band. */
|
||||
margin-top: calc(-1 * var(--space-3));
|
||||
padding:
|
||||
var(--space-3)
|
||||
var(--chat-inline-padding)
|
||||
var(--space-2);
|
||||
background: var(--surface-raised);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composer-wrap > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.composer-wrap::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
bottom: calc(100% - var(--space-3));
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
height: var(--space-4);
|
||||
background: linear-gradient(to bottom, transparent, var(--surface-raised));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--composer-fade-color) 55%, transparent) 35%,
|
||||
color-mix(in srgb, var(--composer-fade-color) 90%, transparent) 70%,
|
||||
var(--composer-fade-color)
|
||||
);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Below the composer the wrap stays opaque so nothing shows through its
|
||||
bottom padding and metadata row. */
|
||||
.composer-wrap::after {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
background: var(--composer-fade-color);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -8383,7 +8430,7 @@ button > svg {
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent),
|
||||
var(--shadow-card);
|
||||
}
|
||||
|
||||
@@ -8401,7 +8448,7 @@ button > svg {
|
||||
.composer__input textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
min-height: 56px;
|
||||
max-height: 220px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -8697,6 +8744,42 @@ button > svg {
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
/* Role chip in the top-left corner of the input area. */
|
||||
.composer__input-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
margin: calc(var(--space-2) * -1) 0 var(--space-2) calc(var(--space-2) * -1);
|
||||
}
|
||||
|
||||
.composer-picker--chip > .model-button {
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
border-color: transparent;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.composer-picker--chip > .model-button[aria-expanded="true"] {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-picker--chip > .model-button:disabled {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer-picker--chip .model-button__label {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
|
||||
.composer-picker--runtime > .model-button,
|
||||
.composer-picker--runtime-action > .model-button {
|
||||
@@ -17713,12 +17796,15 @@ textarea.field-control {
|
||||
.settings-page,
|
||||
.workspace,
|
||||
.chat,
|
||||
.composer-wrap,
|
||||
.workspace-panel-scroll
|
||||
) {
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .composer-wrap {
|
||||
--composer-fade-color: var(--surface-canvas);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.sidebar,
|
||||
.topbar,
|
||||
|
||||
@@ -519,6 +519,21 @@ export type ConversationAnsweredQuestion = z.infer<
|
||||
typeof conversationAnsweredQuestionSchema
|
||||
>
|
||||
|
||||
export const conversationMessageExpertSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('expert'),
|
||||
id: assistantIdSchema,
|
||||
name: z.string().trim().min(1).max(80)
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('team') }).strict()
|
||||
])
|
||||
|
||||
export type ConversationMessageExpert = z.infer<
|
||||
typeof conversationMessageExpertSchema
|
||||
>
|
||||
|
||||
export const conversationMessageSchema = z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -541,6 +556,11 @@ export const conversationMessageSchema = z
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).optional(),
|
||||
subagents: z.array(conversationSubagentActivitySchema).optional(),
|
||||
/**
|
||||
* Snapshot of the role selected when the reply was requested, so later
|
||||
* renames or deletions do not rewrite history.
|
||||
*/
|
||||
expert: conversationMessageExpertSchema.optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
.array(
|
||||
@@ -720,6 +740,8 @@ export const conversationSnapshotSchema = z
|
||||
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||
storyGraphEnabled: z.boolean().optional(),
|
||||
/** Role picked in the composer for this conversation; absent means the general assistant. */
|
||||
selectedExpertId: z.union([assistantIdSchema, z.literal('team')]).optional(),
|
||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||
contextCompressionState:
|
||||
conversationContextCompressionStateSchema.optional(),
|
||||
|
||||
Reference in New Issue
Block a user