feat: pin conversations and float their action menus

Persist conversation pins across restarts and show pinned conversations first, including older history. Anchor action menus above the list without shifting neighboring rows, with keyboard dismissal and sidebar-close cleanup.

Add schema 36 migration, focused regression coverage, and feature documentation. File-level staging also includes existing workbar styles and browser contract adjustments in shared files.

Validation: 145 backend tests and 237 App tests passed; typecheck and lint passed. Full test run timed out with Runtime and Agent package failures; native Electron visual validation remains pending.

Release note: 会话菜单新增置顶和取消置顶,重启后仍保留;操作菜单现在悬浮显示,不再挤动下方会话。
This commit is contained in:
mesalogo
2026-09-15 23:39:06 +08:00
parent b16b0d10e7
commit 625182c3dc
19 changed files with 583 additions and 52 deletions
@@ -425,9 +425,10 @@ resourceMonitor.getLocalSnapshot(input: {
- `AssistantDatabase.listTasks()` 保留最近历史窗口(默认 100 条),额外包含窗口外当前
运行或等待处理的可见 Task;`activeVisibleTaskSelect` 与 `toTask` 的有效状态保持一致,
待调度运行规范化为 `queued`,不因旧 Task 状态被误纳入实时集合。
- `listConversations()` 保留最近 100 条活动会话,同时包含上述 Task 关联的较早活动会话,
以及含 `streaming` 消息的较早活动会话。两类列表去重后仍按原时间顺序返回,结果允许超过
历史窗口大小,避免后台工作被最近记录挤出。
- `listConversations()` 的范围与排序遵守
[会话列表读取与前端保留](../assistant-workbar/execution-history-storage.md#会话列表读取与前端保留),
包含上述 Task 关联及含 `streaming` 消息的较早活动会话,避免后台工作被最近记录挤出。
`listTasks()` 仍按原时间顺序返回;列表结果允许超过历史窗口大小。
- `openActivityConversation` 按确切 Conversation ID 导航;内存中缺失时刷新持久化会话
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
设置离开检查通过后才提交项目、会话及界面清理状态。
@@ -199,6 +199,24 @@ CPU 4% · 内存 620 MB · 桌面进程 7
- 工具提示不得超出窗口;靠近底边时显示在按钮上方。
- 粗指针设备不依赖悬停,按钮的可访问名称和选中反馈保持有效。
### 会话置顶与操作菜单
会话行的“更多会话操作”提供“置顶会话/取消置顶”,本地与消息通道会话均可使用。
置顶会话显示带“已置顶”可访问名称的图标;当前项目和搜索过滤仍生效,过滤后的列表先显示
置顶会话,两组内各按更新时间倒序。取消置顶恢复时间排序,不改变会话更新时间。
置顶状态重启后保留;保存成功后更新图标和顺序,失败保留原状态并显示应用错误通知。
存储尚未就绪或置顶请求进行中时禁用置顶操作。
操作菜单通过 body Portal 浮在会话列表上方,不撑开会话行。菜单锚定更多按钮,默认位于
按钮下方,随滚动、窗口及菜单尺寸变化重新定位,距窗口边缘至少 `8px`;空间不足时限制
宽高并允许菜单内部滚动。使用共享表面色、边框、圆角与浮层阴影。
入口提供 `aria-haspopup="menu"`、展开状态和控制关系;菜单使用 `menu`/`menuitem`。
打开后聚焦首个可用按钮,上下方向键循环移动,Home/End 到首尾;Escape 关闭并返回
触发按钮,点击外部或焦点离开时关闭。本地会话继续提供分支、重命名和删除确认,复制与
导出也适用于消息通道会话。持久化与刷新规则见
[会话置顶存储](../assistant-workbar/execution-history-storage.md#会话置顶)。
## 9. 未来插件入口
插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与四个系统工具的位置
+1 -1
View File
@@ -10,7 +10,7 @@
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务差量存储、远程去重、旧库转换、会话摘要与完整历史按需读取、内存引用释放 |
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务差量存储、远程去重、旧库转换、会话置顶、摘要与完整历史按需读取、内存引用释放 |
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 |
| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 |
| [Runtime 执行清单技术设计](./runtime-checklist-technical-design.md) | OC/CN 原生清单提取、远程 Agent 传递复用、消息恢复与真实部署验收;尚未实现 |
@@ -39,11 +39,11 @@
本次读取优化不修改 schema、迁移或用户数据:
- Renderer 启动及焦点、会话、队列刷新使用 `conversations.listSummaries(detailIds)`。
普通未打开会话只返回标题、项目、上下文设置和消息数量/首条角色,不解析或跨 IPC
普通未打开会话只返回标题、项目、置顶状态、上下文设置和消息数量/首条角色,不解析或跨 IPC
传输正文、工具、子任务及图片操作元数据。
- 当前会话、现有 KeepAlive 视图,以及 Main 仍持有请求、数据库中仍有活动 Task 或
streaming 消息的会话返回完整消息。保留既有最近 100 条加较早活动会话的列表规则,
请求中的已缓存会话即使更早也仍能刷新。
streaming 消息的会话返回完整消息。列表包含最近 100 条、较早活动会话、请求中的已缓存
会话及全部置顶会话,按置顶优先、组内更新时间倒序返回;仅置顶不会加载完整历史。
- 打开历史通过 `conversations.get(id)` 读取全部消息,不使用会截断历史的分页上限。
加载时明确显示进度;失败保留会话与输入,提供原地重试。发送、队列派发和压缩上下文
必须先确认完整历史;复制与导出临时读取完整内容,不把未打开会话留入视图缓存。
@@ -65,6 +65,20 @@
此优化不等于工具折叠内容的延迟渲染,也不保证操作系统立即归还全部 Private Commit。
实际测量与适用边界见[实现与验证进度](./progress.md)。
## 会话置顶
界面行为见[会话置顶与操作菜单](../application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
`conversations.pinned` 是桌面数据库维护的会话属性,本地和消息通道会话共用,默认未置顶。
完整快照与摘要均返回 `pinned`,旧快照缺省按未置顶处理。写入只通过
`conversations.setPinned({ conversationId, pinned })`,经 Preload、可信发送方校验和严格
Schema 校验后更新活动会话,再发布会话变更通知;不存在的会话报错。该写入不修改时间戳、
消息或其他会话字段,普通自动保存 header 排除 `pinned`,不会覆盖置顶状态。
尚未落库的本地草稿先等待保存队列并保存,再设置置顶;消息通道会话直接调用专用接口。
Renderer 合并时独立接收 Main 的置顶属性,即使本地正文更新时间更晚也不忽略它。
请求修订号和进行中标记防止较早发出的列表/详情响应覆盖刚更新的置顶状态。
此属性不修改 Runtime 或远端 Agent 协议。
## 旧库升级
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
@@ -77,8 +91,10 @@
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理,
成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。
子任务整理对应 schema 35;schema 34 用户也会执行该整理,成功后后续启动直接跳过。
继续复用 schema 34 的子任务事件部分索引和同一差量表示。当前业务初始化随后在事务中
升级为 schema 36,新增 `pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1))`,
已有会话默认未置顶,保留原消息与时间戳;失败回滚本次字段迁移。
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
升级前备份,不要修改 `user_version` 强行绕过版本检查。
+107 -2
View File
@@ -8,6 +8,8 @@ import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
conversationSnapshotSchema,
conversationSetPinnedSchema,
localConversationHeaderSchema,
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import {
@@ -19,6 +21,95 @@ import type { ImageOperation } from '../../shared/image-generation-contracts'
const temporaryDirectories: string[] = []
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 }
const message = { id: randomUUID(), role: 'user' as const, content: 'Keep me', state: 'complete' as const, createdAt: 900 }
try {
database.saveLocalConversations([{ header, messages: [message] }])
const original = database.getConversation(header.id)
expect(original.pinned).toBe(false)
expect(conversationSnapshotSchema.parse({ ...header, messages: [] }).pinned).toBeUndefined()
expect(localConversationHeaderSchema.safeParse({ ...header, pinned: false }).success).toBe(false)
expect(conversationSetPinnedSchema.safeParse({ conversationId: header.id, pinned: 'true' }).success).toBe(false)
expect(conversationSetPinnedSchema.safeParse({ conversationId: header.id, pinned: true, updatedAt: 2000 }).success).toBe(false)
database.setConversationPinned({ conversationId: header.id, pinned: true })
database.setConversationPinned({ conversationId: header.id, pinned: true })
// Even a stale snapshot passed directly to storage cannot overwrite the pin.
database.saveLocalConversations([{ header: { ...header, ...{ pinned: false } }, messages: [message] }])
expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true })
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true })
database.setConversationPinned({ conversationId: header.id, pinned: false })
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual(original)
expect(() => database.setConversationPinned({ conversationId: randomUUID(), pinned: true })).toThrow('对话不存在')
} finally {
database.close()
}
})
it('includes older pinned conversations beyond the recent 100 and sorts pins by update time', async () => {
const database = await createDatabase()
const headers = Array.from({ length: 103 }, (_, index) => ({
id: randomUUID(), title: `Conversation ${index}`, updatedAt: 1000 + index
}))
try {
database.saveLocalConversations(headers.map(header => ({ header, messages: [] })))
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: true })
database.setConversationPinned({ conversationId: headers[1]!.id, pinned: true })
const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(3).reverse().map(header => header.id)]
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds)
const summaries = database.listConversationSummaries()
expect(summaries.map(item => item.id)).toEqual(expectedIds)
expect(summaries.slice(0, 2).every(item => item.pinned)).toBe(true)
expect(database.searchConversations('Conversation 0')).toEqual([headers[0]!.id])
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: false })
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds.filter(id => id !== headers[0]!.id))
expect(database.getConversation(headers[0]!.id)).toMatchObject({ pinned: false, updatedAt: 1000 })
} finally {
database.close()
}
})
it('migrates schema 35 conversations to unpinned while retaining existing data across reopening', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-conversation-pins-'))
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize('C:\\Workspace')
const header = { id: randomUUID(), title: 'Before migration', updatedAt: 1000 }
database.saveLocalConversations([{ header, messages: [] }])
const original = database.getConversation(header.id)
database.close()
const legacy = new DatabaseSync(path)
legacy.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 35;')
legacy.close()
try {
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual(original)
database.setConversationPinned({ conversationId: header.id, pinned: true })
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true })
} finally {
database.close()
}
const inspected = new DatabaseSync(path)
try {
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: 36 })
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
expect(inspected.prepare('SELECT created_at, updated_at FROM conversations WHERE id = ?').get(header.id)).toEqual({
created_at: new Date(1000).toISOString(), updated_at: new Date(1000).toISOString()
})
} finally {
inspected.close()
}
})
it('atomically completes conversation images and retains operations and upload references across saves and reopening', async () => {
const database = await createDatabase()
const conversationId = randomUUID()
@@ -732,6 +823,7 @@ describe('AssistantDatabase', () => {
oldDatabase.exec(`
DROP TABLE model_usage_calls;
ALTER TABLE projects DROP COLUMN runtime_selection_json;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 3;
`)
oldDatabase.close()
@@ -888,6 +980,7 @@ describe('AssistantDatabase', () => {
.all()
legacy.exec(`
DROP TABLE project_execution_spaces;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 26;
`)
legacy.close()
@@ -997,6 +1090,7 @@ describe('AssistantDatabase', () => {
project_id TEXT PRIMARY KEY,
runtime_bundle_digest TEXT NOT NULL
);
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 30;
`)
legacy.close()
@@ -1068,6 +1162,7 @@ describe('AssistantDatabase', () => {
INSERT INTO project_runtime_validations
(project_id, runtime_bundle_digest)
VALUES ('${project.id}', 'sha256:${'d'.repeat(64)}');
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 30;
`)
legacy.close()
@@ -1158,6 +1253,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1198,6 +1294,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1239,6 +1336,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1276,6 +1374,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1302,6 +1401,7 @@ describe('AssistantDatabase', () => {
SET updated_at = created_at || '-edited';
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1325,6 +1425,7 @@ describe('AssistantDatabase', () => {
const versionFive = new DatabaseSync(databasePath)
versionFive.exec(`
DROP TABLE computer_control_actions;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 5;
`)
versionFive.close()
@@ -1445,6 +1546,7 @@ describe('AssistantDatabase', () => {
)
raw.exec(`
DROP INDEX IF EXISTS idx_tasks_schedule;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 21;
COMMIT;
`)
@@ -1532,6 +1634,7 @@ describe('AssistantDatabase', () => {
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DELETE FROM magic_todos;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 9;
`)
legacy.close()
@@ -1583,7 +1686,7 @@ describe('AssistantDatabase', () => {
now,
now
)
legacy.exec('PRAGMA user_version = 16')
legacy.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 16')
legacy.close()
const migrated = new AssistantDatabase(databasePath)
@@ -2528,7 +2631,7 @@ describe('AssistantDatabase', () => {
WHERE id = ?`
)
.run(entry.id)
legacy.exec('PRAGMA user_version = 23')
legacy.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 23')
legacy.close()
const migrated = new AssistantDatabase(databasePath)
@@ -2663,6 +2766,7 @@ describe('AssistantDatabase', () => {
ON channel_events(claimed_at);
INSERT INTO channel_events(channel, event_id, claimed_at)
VALUES ('weixin', 'legacy-event', 1);
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 18;
`)
legacy.close()
@@ -3169,6 +3273,7 @@ describe('AssistantDatabase', () => {
ALTER TABLE task_events DROP COLUMN remote_semantic_sequence;
ALTER TABLE task_events DROP COLUMN remote_operation_id;
ALTER TABLE task_events DROP COLUMN remote_binding_id;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 31;
`)
legacy.close()
+33 -4
View File
@@ -9,6 +9,7 @@ import {
conversationAnsweredQuestionSchema,
conversationMessageSchema,
conversationSnapshotSchema,
conversationSetPinnedSchema,
expertCreateSchema,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
@@ -34,6 +35,7 @@ import type {
ConversationToolActivity,
ConversationMessage,
ConversationSnapshot,
ConversationSetPinnedInput,
ExpertCreateInput,
ExpertUpdateInput,
HeartbeatCreateInput,
@@ -102,7 +104,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 36
export type RemoteTaskEventInput = {
taskId: string
@@ -209,6 +211,7 @@ type ConversationRow = {
account_display: string | null
branch_source_conversation_id: string | null
branch_source_title: string | null
pinned: number
updated_at: string
}
@@ -1149,6 +1152,7 @@ function toConversationSnapshot(
}
: {}),
title: conversation.title,
pinned: conversation.pinned === 1,
updatedAt: Date.parse(conversation.updated_at),
// Startup recovery is the only authority for interruption: it persists
// the terminal state once. Deriving it while reading would also hit
@@ -2634,7 +2638,7 @@ export class AssistantDatabase {
external_account_id, external_conversation_id,
conversation_type, account_display,
branch_source_conversation_id, branch_source_title,
updated_at
pinned, updated_at
FROM conversations
WHERE status = 'active'
AND (
@@ -2642,11 +2646,12 @@ export class AssistantDatabase {
ORDER BY updated_at DESC LIMIT 100)
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
OR id IN (SELECT value FROM json_each(?))
OR pinned = 1
OR EXISTS (SELECT 1 FROM messages
WHERE conversation_id = conversations.id
AND state = 'streaming')
)
ORDER BY updated_at DESC`
ORDER BY pinned DESC, updated_at DESC`
)
.all(JSON.stringify([...extraIds])) as ConversationRow[]
}
@@ -2715,7 +2720,7 @@ export class AssistantDatabase {
external_account_id, external_conversation_id,
conversation_type, account_display,
branch_source_conversation_id, branch_source_title,
updated_at
pinned, updated_at
FROM conversations
WHERE id = ? AND status = 'active'`
)
@@ -2740,6 +2745,16 @@ export class AssistantDatabase {
return toConversationSnapshot(conversation, messages)
}
setConversationPinned(input: ConversationSetPinnedInput): void {
const { conversationId, pinned } = conversationSetPinnedSchema.parse(input)
const result = this.requireDatabase().prepare(
`UPDATE conversations SET pinned = ? WHERE id = ? AND status = 'active'`
).run(pinned ? 1 : 0, conversationId)
if (result.changes !== 1) {
throw new Error('对话不存在')
}
}
repairConversationRuntimeSelections(
settings: RuntimeSelectionRepairSettings
): number {
@@ -9916,6 +9931,20 @@ export class AssistantDatabase {
// Startup's worker has already compacted full and schema-34 delta events.
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
}
if (version.user_version < 36) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(`
ALTER TABLE conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0
CHECK(pinned IN (0, 1));
PRAGMA user_version = 36;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
@@ -317,7 +317,8 @@ describe('subagent progress storage', () => {
const legacy = new DatabaseSync(path)
const expected: SubagentEvent[] = []
try {
legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`)
legacy.exec(`ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = ${sourceVersion}; BEGIN`)
const insert = legacy.prepare(
`INSERT INTO task_events(
task_id, kind, payload_json, created_at, remote_binding_id,
+13 -1
View File
@@ -205,6 +205,7 @@ import {
conversationSnapshotsSchema,
conversationListRequestSchema,
conversationSearchRequestSchema,
conversationSetPinnedSchema,
type ConversationSnapshot,
localConversationSaveBatchSchema,
memoryCreateSchema,
@@ -1259,7 +1260,7 @@ export function registerIpcHandlers(
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
): Promise<unknown>
type(
conversationId: string,
ref: string,
@@ -6828,6 +6829,17 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.conversationsSetPinned,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.setConversationPinned(
conversationSetPinnedSchema.parse(input)
)
publishConversationChange()
}
)
registerHandler(
ipcChannels.conversationsBranchLocal,
(event, input: unknown) => {
+3
View File
@@ -1088,6 +1088,9 @@ const desktopApi: DesktopApi = {
batch
)
},
setPinned: async (input) => {
await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input)
},
branchLocal: (input: ConversationBranchInput) =>
ipcRenderer.invoke(
ipcChannels.conversationsBranchLocal,
+161 -18
View File
@@ -496,6 +496,7 @@ const api: DesktopApi = {
search: vi.fn(async () => []),
replace: vi.fn(async () => {}),
saveLocal: vi.fn(async () => {}),
setPinned: vi.fn(async () => {}),
branchLocal: vi.fn(async (input) => ({
id: crypto.randomUUID(),
branch: {
@@ -1091,6 +1092,7 @@ describe("App", () => {
};
});
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]);
vi.mocked(api.conversations.setPinned).mockReset().mockResolvedValue();
const conversationFixtures = new Map<string, Awaited<ReturnType<DesktopApi["conversations"]["get"]>>>();
vi.mocked(api.conversations.listSummaries).mockReset()
.mockImplementation(async () => {
@@ -1349,7 +1351,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
expect(screen.getByRole("menu")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
@@ -1365,7 +1367,7 @@ describe("App", () => {
.toHaveClass("conversation-item--active");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
selectProjectOption(project.name);
expect(sidebar.querySelector(".conversation-actions")).not.toBeInTheDocument();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
@@ -1626,11 +1628,11 @@ describe("App", () => {
vi.mocked(api.conversations.get).mockResolvedValue(second);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Copy unopened"));
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "复制完整会话" }));
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("All retained text")));
expect(api.conversations.get).toHaveBeenCalledExactlyOnceWith(second.id);
fireEvent.click(screen.getByLabelText("更多会话操作 Copy unopened"));
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "复制完整会话" }));
await waitFor(() => expect(api.conversations.get).toHaveBeenCalledTimes(2));
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
});
@@ -3114,7 +3116,145 @@ describe("App", () => {
expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument();
});
it("keeps conversation actions in the conversation list", async () => {
it("clamps the conversation portal and supports keyboard and outside dismissal", async () => {
render(<App />);
const trigger = await screen.findByLabelText("更多会话操作 新对话");
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ right: 5000, bottom: 5000 } as DOMRect);
fireEvent.click(trigger);
const menu = screen.getByRole("menu");
expect(menu.parentElement).toBe(document.body);
expect(parseFloat(menu.style.left)).toBeLessThanOrEqual(window.innerWidth - 8);
expect(parseFloat(menu.style.top)).toBeLessThanOrEqual(window.innerHeight - 8);
expect(within(menu).getByRole("menuitem", { name: "置顶会话" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "End" });
expect(within(menu).getByRole("menuitem", { name: "删除对话 新对话" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Home" });
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(within(menu).getByRole("menuitem", { name: "在新会话中继续" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await waitFor(() => expect(trigger).toHaveFocus());
fireEvent.click(trigger);
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
fireEvent.click(trigger);
act(() => screen.getByLabelText("向 GoodBuddy 提问").focus());
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("dismisses the conversation portal and delete confirmation when resizing closes the sidebar", async () => {
const originalWidth = window.innerWidth;
const { container } = render(<App />);
const trigger = await screen.findByLabelText("更多会话操作 新对话");
fireEvent.click(trigger);
const menu = screen.getByRole("menu");
expect(menu.parentElement).toBe(document.body);
fireEvent.click(within(menu).getByRole("menuitem", { name: "删除对话 新对话" }));
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
try {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 899 });
fireEvent(window, new Event("resize"));
expect(container.querySelector(".sidebar")).toHaveAttribute("inert");
expect(menu).not.toBeInTheDocument();
expect(screen.queryByRole("alertdialog", { hidden: true })).not.toBeInTheDocument();
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
expect(screen.queryByRole("menu", { hidden: true })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("button", { name: "对话" })).toHaveFocus());
fireEvent.click(trigger);
expect(screen.getByRole("menuitem", { name: "删除对话 新对话" })).toBeVisible();
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(api.conversations.deleteLocal).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
});
it("persists a draft before pinning and reports pin failures without changing its state", async () => {
vi.mocked(api.conversations.list).mockResolvedValue([{ id: "saved", projectId, title: "Saved discussion", updatedAt: 100, messages: [] }]);
const { container } = render(<App />);
await screen.findByLabelText("更多会话操作 Saved discussion");
fireEvent.click(container.querySelector(".new-chat")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveFocus());
const trigger = await screen.findByLabelText("更多会话操作 新对话");
fireEvent.click(trigger);
const pin = screen.getByRole("menuitem", { name: "置顶会话" });
await waitFor(() => expect(pin).toBeEnabled());
let persistedBeforePin = false;
vi.mocked(api.conversations.setPinned).mockImplementationOnce(async ({ conversationId }) => {
persistedBeforePin = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
.some(entry => entry.header.id === conversationId);
});
fireEvent.click(pin);
await waitFor(() => expect(api.conversations.setPinned).toHaveBeenCalledOnce());
expect(persistedBeforePin).toBe(true);
expect(await screen.findByRole("img", { name: "已置顶" })).toBeVisible();
expect(api.conversations.setPinned).toHaveBeenCalledWith({ conversationId: expect.any(String), pinned: true });
vi.mocked(api.conversations.setPinned).mockRejectedValueOnce(new Error("storage failed"));
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: "取消置顶" }));
expect(await screen.findByRole("alert")).toHaveTextContent("无法更新会话置顶状态,请重试");
expect(screen.getByRole("img", { name: "已置顶" })).toBeVisible();
});
it("sorts pinned conversations first and ignores a refresh started before unpinning", async () => {
const snapshots: ConversationSnapshot[] = [
{ id: "pin-new", projectId, title: "Newer discussion", updatedAt: 300, messages: [] },
{ id: "pin-old", projectId, title: "Pinned discussion", updatedAt: 100, pinned: true, messages: [] },
];
vi.mocked(api.conversations.list).mockResolvedValue(snapshots);
const { container } = render(<App />);
await screen.findByLabelText("更多会话操作 Pinned discussion");
const titles = (): string[] => Array.from(container.querySelectorAll(".conversation-item__title")).map(item => item.textContent!);
expect(titles()).toEqual(["Pinned discussion", "Newer discussion"]);
let resolveRefresh!: (value: ConversationSnapshot[]) => void;
vi.mocked(api.conversations.listSummaries).mockImplementationOnce(() => new Promise(resolve => { resolveRefresh = resolve; }));
fireEvent(window, new Event("focus"));
await waitFor(() => expect(resolveRefresh).toBeDefined());
fireEvent.click(screen.getByLabelText("更多会话操作 Pinned discussion"));
fireEvent.click(screen.getByRole("menuitem", { name: "取消置顶" }));
await waitFor(() => expect(titles()).toEqual(["Newer discussion", "Pinned discussion"]));
vi.mocked(api.conversations.list).mockResolvedValue(snapshots.map(item => ({ ...item, pinned: false })));
await act(async () => resolveRefresh(snapshots));
await waitFor(() => expect(api.conversations.listSummaries).toHaveBeenCalledTimes(3));
expect(titles()).toEqual(["Newer discussion", "Pinned discussion"]);
expect(screen.queryByRole("img", { name: "已置顶" })).not.toBeInTheDocument();
});
it("uses the dedicated pin update for channel conversations", async () => {
vi.mocked(api.conversations.list).mockResolvedValue([{
id: "pin-channel", projectId, title: "Channel discussion", updatedAt: 100, messages: [],
remote: { channel: "weixin", accountDisplay: "Channel account", conversationType: "direct" },
}]);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Channel discussion"));
expect(screen.queryByRole("menuitem", { name: "重命名会话" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "置顶会话" }));
await waitFor(() => expect(api.conversations.setPinned).toHaveBeenCalledWith({ conversationId: "pin-channel", pinned: true }));
expect(api.conversations.saveLocal).not.toHaveBeenCalled();
expect(await screen.findByRole("img", { name: "已置顶" })).toBeVisible();
});
it.each([false, true])("merges persisted pin metadata into a newer local conversation (summary: %s)", async (summary) => {
const snapshot: ConversationSnapshot = { id: "pin-merge", projectId, title: "Original discussion", updatedAt: 100, messages: [] };
vi.mocked(api.conversations.list).mockResolvedValue([snapshot]);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Original discussion"));
fireEvent.click(screen.getByRole("menuitem", { name: "重命名会话" }));
const input = screen.getByLabelText("重命名会话 Original discussion");
fireEvent.change(input, { target: { value: "Locally renamed" } });
fireEvent.submit(input.closest("form")!);
vi.mocked(api.conversations.listSummaries).mockResolvedValue([{
...snapshot, pinned: true, ...(summary ? { messageSummary: { count: 0 } } : {}),
}]);
fireEvent(window, new Event("focus"));
expect(await screen.findByRole("img", { name: "已置顶" })).toBeVisible();
expect(screen.getByLabelText("更多会话操作 Locally renamed")).toBeInTheDocument();
});
it("anchors conversation actions in a body portal and preserves rename and copy", async () => {
const { container } = render(<App />);
const topbar = container.querySelector<HTMLElement>(".topbar");
const conversationList =
@@ -3152,17 +3292,20 @@ describe("App", () => {
const conversationMenuTrigger =
within(conversationList).getByLabelText("更多会话操作 新对话");
fireEvent.click(conversationMenuTrigger);
const renameButton = within(conversationList).getByRole("button", {
const menu = screen.getByRole("menu");
expect(menu.parentElement).toBe(document.body);
expect(conversationList).not.toContainElement(menu);
const renameButton = within(menu).getByRole("menuitem", {
name: "重命名会话",
});
expect(renameButton).toBeVisible();
expect(
within(conversationList).getByRole("button", {
within(menu).getByRole("menuitem", {
name: "复制完整会话",
}),
).toBeVisible();
expect(
within(conversationList).getByRole("button", {
within(menu).getByRole("menuitem", {
name: "导出 Markdown",
}),
).toBeVisible();
@@ -3184,7 +3327,7 @@ describe("App", () => {
within(conversationList).getByLabelText("更多会话操作 重命名后的会话"),
);
fireEvent.click(
within(conversationList).getByRole("button", {
screen.getByRole("menuitem", {
name: "复制完整会话",
}),
);
@@ -3271,7 +3414,7 @@ describe("App", () => {
expect(await screen.findByText("探索不同发布方案")).toBeInTheDocument();
fireEvent.click(await screen.findByLabelText("更多会话操作 方案讨论"));
fireEvent.click(screen.getByRole("button", { name: "重命名会话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "重命名会话" }));
const renameInput = screen.getByLabelText("重命名会话 方案讨论");
fireEvent.change(renameInput, {
target: { value: "方案讨论更新" },
@@ -3281,7 +3424,7 @@ describe("App", () => {
vi.mocked(api.conversations.saveLocal).mockClear();
fireEvent.click(screen.getByLabelText("更多会话操作 方案讨论更新"));
const branchButton = screen.getByRole("button", {
const branchButton = screen.getByRole("menuitem", {
name: "在新会话中继续",
});
await waitFor(() => expect(branchButton).toBeEnabled());
@@ -3357,7 +3500,7 @@ describe("App", () => {
const actionTrigger =
await screen.findByLabelText("更多会话操作 保留的来源会话");
fireEvent.click(actionTrigger);
const branchButton = screen.getByRole("button", {
const branchButton = screen.getByRole("menuitem", {
name: "在新会话中继续",
});
await waitFor(() => expect(branchButton).toBeEnabled());
@@ -3408,7 +3551,7 @@ describe("App", () => {
fireEvent.click(
await screen.findByLabelText("更多会话操作 等待队列的会话"),
);
const branchButton = screen.getByRole("button", {
const branchButton = screen.getByRole("menuitem", {
name: "在新会话中继续",
});
await waitFor(() =>
@@ -3850,7 +3993,7 @@ describe("App", () => {
render(<App />);
const menuTrigger = screen.getByLabelText("更多会话操作 新对话");
fireEvent.click(menuTrigger);
const deleteTrigger = screen.getByRole("button", {
const deleteTrigger = screen.getByRole("menuitem", {
name: "删除对话 新对话",
});
fireEvent.click(deleteTrigger);
@@ -3873,7 +4016,7 @@ describe("App", () => {
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() =>
expect(
screen.getByRole("button", { name: "删除对话 新对话" }),
screen.getByRole("menuitem", { name: "删除对话 新对话" }),
).toHaveFocus(),
);
expect(
@@ -3882,7 +4025,7 @@ describe("App", () => {
}),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "删除对话 新对话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "删除对话 新对话" }));
fireEvent.click(
screen.getByRole("button", {
name: "确认永久删除对话 新对话",
@@ -3926,7 +4069,7 @@ describe("App", () => {
render(<App />);
fireEvent.click(await screen.findByLabelText(`更多会话操作 ${title}`));
fireEvent.click(screen.getByRole("button", { name: `删除对话 ${title}` }));
fireEvent.click(screen.getByRole("menuitem", { name: `删除对话 ${title}` }));
fireEvent.click(
screen.getByRole("button", {
name: `确认永久删除对话 ${title}`,
@@ -3966,7 +4109,7 @@ describe("App", () => {
await waitFor(() => expect(run).toHaveBeenCalledOnce());
fireEvent.click(await screen.findByLabelText(`更多会话操作 ${title}`));
fireEvent.click(screen.getByRole("button", { name: `删除对话 ${title}` }));
fireEvent.click(screen.getByRole("menuitem", { name: `删除对话 ${title}` }));
const confirm = screen.getByRole("button", {
name: `确认永久删除对话 ${title}`,
});
+127 -6
View File
@@ -28,6 +28,8 @@ import {
MoreHorizontal,
Paperclip,
PanelLeft,
Pin,
PinOff,
Search,
Send,
Settings,
@@ -1510,7 +1512,7 @@ function mergePersistedConversations(
local.messages.some(message => message.approval || message.pendingQuestions?.length || message.state === "streaming") ||
(!local.messageSummary && retainDetails.has(conversation.id)));
const next = local && local.updatedAt > conversation.updatedAt
? { ...local, messageSummary: conversation.messageSummary, messages: [] }
? { ...local, pinned: conversation.pinned, messageSummary: conversation.messageSummary, messages: [] }
: conversation;
if (keepMessages) {
// A list reply started before navigation must not unload the newly
@@ -1563,7 +1565,7 @@ function mergePersistedConversations(
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
];
const next = localIsNewer
? { ...local, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
? { ...local, pinned: conversation.pinned, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
: { ...conversation, messages };
persistedLocal.set(conversation.id, conversation);
return withRecoveredQuestions(next);
@@ -1573,7 +1575,7 @@ function mergePersistedConversations(
merged.push(conversation);
}
}
return merged.sort((left, right) => right.updatedAt - left.updatedAt);
return merged.sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt);
}
function getProjectDefaultRuntimeSelection(
@@ -2559,7 +2561,14 @@ function App(): React.JSX.Element {
const [conversationLoadError, setConversationLoadError] = useState<string>();
const [conversationLoadRetry, setConversationLoadRetry] = useState(0);
const [conversationActionsId, setConversationActionsId] = useState("");
const [pinningConversationId, setPinningConversationId] = useState("");
const conversationPinRevisionRef = useRef(0);
const conversationPinPendingRef = useRef(false);
const [confirmingConversationId, setConfirmingConversationId] = useState("");
if (!sidebarOpen && (conversationActionsId || confirmingConversationId)) {
setConversationActionsId("");
setConfirmingConversationId("");
}
const [deletingConversationId, setDeletingConversationId] = useState("");
const [branchingConversationId, setBranchingConversationId] = useState("");
const [renamingConversationId, setRenamingConversationId] = useState("");
@@ -2723,6 +2732,39 @@ function App(): React.JSX.Element {
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>(),
);
const conversationActionsRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const surface = conversationActionsRef.current;
const trigger = conversationActionTriggerRefs.current.get(conversationActionsId);
if (!surface || !trigger) return;
const position = (): void => {
const anchor = trigger.getBoundingClientRect();
const bounds = surface.getBoundingClientRect();
surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px`;
surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px`;
};
position();
surface.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true });
const dismiss = (event: Event): void => {
if (event.target instanceof Node && !surface.contains(event.target) && !trigger.contains(event.target)) {
setConversationActionsId("");
setConfirmingConversationId("");
}
};
const resizeObserver = new ResizeObserver(position);
resizeObserver.observe(surface);
document.addEventListener("pointerdown", dismiss);
document.addEventListener("focusin", dismiss);
window.addEventListener("resize", position);
window.addEventListener("scroll", position, true);
return () => {
resizeObserver.disconnect();
document.removeEventListener("pointerdown", dismiss);
document.removeEventListener("focusin", dismiss);
window.removeEventListener("resize", position);
window.removeEventListener("scroll", position, true);
};
}, [conversationActionsId]);
const handleChatScrollSnapshotChange = useCallback(
(conversationId: string, snapshot: ChatScrollSnapshot): void => {
setChatScrollSnapshots((current) => ({
@@ -3827,7 +3869,7 @@ function App(): React.JSX.Element {
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
conversation.title.toLocaleLowerCase().includes(query) ||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
);
).sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt);
}, [
activeProject,
activeProjectId,
@@ -5129,12 +5171,16 @@ function App(): React.JSX.Element {
if (!current.messageSummary) return Promise.resolve(current);
const pending = conversationHistoryRequests.current.get(conversationId);
if (pending) return pending;
const pinRevision = conversationPinRevisionRef.current;
const request = window.goodbuddy.conversations.get(conversationId).then(snapshot => {
const latest = conversationsRef.current.find(item => item.id === conversationId);
if (!latest || deletingLocalConversationIdsRef.current.has(conversationId)) {
throw new Error(tRef.current("notices.remoteConversationRefreshFailed"));
}
if (!latest.messageSummary) return latest;
if (pinRevision !== conversationPinRevisionRef.current || conversationPinPendingRef.current) {
snapshot = { ...snapshot, pinned: latest.pinned };
}
const next = mergePersistedConversations(
conversationsRef.current, [snapshot], persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
@@ -5274,6 +5320,7 @@ function App(): React.JSX.Element {
refreshInFlight = true;
refreshQueued = false;
const sequence = refreshSequence;
const pinRevision = conversationPinRevisionRef.current;
const tasksRefresh = window.goodbuddy.tasks
.list()
.then((tasks) => {
@@ -5320,6 +5367,14 @@ function App(): React.JSX.Element {
if (!active || sequence !== refreshSequence) {
return;
}
if (pinRevision !== conversationPinRevisionRef.current) {
queueRefresh();
return;
}
if (conversationPinPendingRef.current) {
const currentPins = new Map(conversationsRef.current.map(item => [item.id, item.pinned]));
persisted = persisted.map(item => ({ ...item, pinned: currentPins.has(item.id) ? currentPins.get(item.id) : item.pinned }));
}
const remote = persisted.filter(
(conversation) => conversation.remote,
);
@@ -6675,6 +6730,38 @@ function App(): React.JSX.Element {
);
};
const setConversationPinned = async (conversation: Conversation): Promise<void> => {
if (conversationPinPendingRef.current || !conversationStoreReady) return;
conversationPinPendingRef.current = true;
conversationPinRevisionRef.current += 1;
setPinningConversationId(conversation.id);
try {
if (!conversation.remote) {
await conversationPersistenceQueueRef.current;
if (!persistedLocalConversationsRef.current.has(conversation.id)) {
const draft = conversationsRef.current.find(item => item.id === conversation.id);
if (!draft) return;
await window.goodbuddy.conversations.saveLocal([{
header: toLocalConversationHeader(draft),
messages: draft.messages.map(toConversationMessage),
}]);
persistedLocalConversationsRef.current.set(draft.id, draft);
}
}
const pinned = !conversation.pinned;
await window.goodbuddy.conversations.setPinned({ conversationId: conversation.id, pinned });
const next = conversationsRef.current.map(item => item.id === conversation.id ? { ...item, pinned } : item);
conversationsRef.current = next;
setConversations(next);
} catch {
notify({ tone: "error", message: t("notices.conversationPinFailed") });
} finally {
conversationPinPendingRef.current = false;
conversationPinRevisionRef.current += 1;
setPinningConversationId("");
}
};
const deleteConversation = async (conversationId: string): Promise<void> => {
if (deletingConversationId) {
return;
@@ -8855,6 +8942,7 @@ function App(): React.JSX.Element {
}}
>
<span className="conversation-item__primary">
{conversation.pinned && <Pin className="conversation-pin" size={13} role="img" aria-label={t("conversation.actions.pinned")}><title>{t("conversation.actions.pinned")}</title></Pin>}
{branchSourceTitle && (
<ConversationBranchBadge
sourceTitle={branchSourceTitle}
@@ -8908,6 +8996,7 @@ function App(): React.JSX.Element {
)}
<button
aria-controls={`conversation-actions-${conversation.id}`}
aria-haspopup="menu"
aria-expanded={conversationActionsId === conversation.id}
aria-label={t("conversation.actions.more", {
title: conversationTitle,
@@ -8937,17 +9026,45 @@ function App(): React.JSX.Element {
<MoreHorizontal size={14} />
</button>
</div>
{conversationActionsId === conversation.id && (
{sidebarOpen && conversationActionsId === conversation.id && createPortal(
<div
ref={conversationActionsRef}
role="menu"
onKeyDown={(event) => {
if (event.defaultPrevented) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setConversationActionsId("");
setConfirmingConversationId("");
focusConversationActions(conversation.id);
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled):not([aria-disabled="true"])'));
const index = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}}
aria-label={t("conversation.actions.region", {
title: conversationTitle,
})}
className="conversation-actions"
id={`conversation-actions-${conversation.id}`}
>
<button role="menuitem" type="button" disabled={!conversationStoreReady || Boolean(pinningConversationId)} onClick={() => {
setConversationActionsId("");
focusConversationActions(conversation.id);
void setConversationPinned(conversation);
}}>
{conversation.pinned ? <PinOff size={14} /> : <Pin size={14} />}
{t(conversation.pinned ? "conversation.actions.unpin" : "conversation.actions.pin")}
</button>
{!conversation.remote && (
<>
<button
role="menuitem"
aria-describedby={
branchDisabledReason
? branchDisabledReasonId
@@ -8991,6 +9108,7 @@ function App(): React.JSX.Element {
)}
{!conversation.remote && (
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
setRenamingConversationId(conversation.id);
@@ -9002,6 +9120,7 @@ function App(): React.JSX.Element {
</button>
)}
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
void copyConversation(conversation).finally(() =>
@@ -9014,6 +9133,7 @@ function App(): React.JSX.Element {
{t("conversation.actions.copy")}
</button>
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
exportConversation(conversation);
@@ -9026,6 +9146,7 @@ function App(): React.JSX.Element {
</button>
{!conversation.remote && (
<DestructiveConfirmActions
triggerRole="menuitem"
cancelAriaLabel={t("conversation.delete.cancelAria", {
title: conversationTitle,
})}
@@ -9058,7 +9179,7 @@ function App(): React.JSX.Element {
triggerLabel={t("conversation.delete.trigger")}
/>
)}
</div>
</div>, document.body
)}
{!conversation.remote &&
renamingConversationId === conversation.id && (
@@ -8,6 +8,15 @@ const stylesheet = readFileSync(
).replaceAll('\r\n', '\n')
describe('primary sidebar layout', () => {
it('floats conversation actions outside list layout with viewport limits', () => {
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
expect(rule).toContain('position: fixed;')
expect(rule).toContain('max-width: calc(100vw - 16px);')
expect(rule).toContain('max-height: calc(100vh - 16px);')
expect(rule).toContain('overflow: auto;')
expect(rule).toContain('z-index: calc(var(--z-dialog) - 1);')
})
it('keeps the resize hit target from adding space beside the divider', () => {
const rule = stylesheet.match(
/\.primary-sidebar-resize-handle\s*\{([^}]*)\}/u
+3
View File
@@ -371,6 +371,7 @@ export function DestructiveConfirmActions({
onConfirm,
onRequestConfirm,
triggerAriaLabel,
triggerRole,
triggerLabel
}: {
cancelAriaLabel?: string
@@ -384,6 +385,7 @@ export function DestructiveConfirmActions({
onConfirm: () => void
onRequestConfirm: () => void
triggerAriaLabel?: string
triggerRole?: 'menuitem'
triggerLabel: string
}): React.JSX.Element {
const { t } = useTranslation('workspace')
@@ -473,6 +475,7 @@ export function DestructiveConfirmActions({
) : (
<button
aria-label={triggerAriaLabel}
role={triggerRole}
className="danger-button danger-button--quiet"
disabled={disabled}
onClick={onRequestConfirm}
@@ -112,6 +112,9 @@ export const app = {
region: 'Conversation actions for {{title}}',
branch: 'Continue in a new conversation',
rename: 'Rename conversation',
pin: 'Pin conversation',
unpin: 'Unpin conversation',
pinned: 'Pinned',
copy: 'Copy full conversation',
export: 'Export Markdown'
},
@@ -694,6 +697,7 @@ export const app = {
'Failed to resume this conversation’s send queue',
conversationPersistenceFailed:
'Failed to save conversations. Check local storage.',
conversationPinFailed: 'Could not update the conversation pin. Please try again.',
activityHistoryPersistenceFailed:
'Failed to save run history. Check local storage.',
activityHistoryReadFailed: 'Failed to read run history',
@@ -105,6 +105,9 @@ export const app = {
region: '{{title}} 的会话操作',
branch: '在新会话中继续',
rename: '重命名会话',
pin: '置顶会话',
unpin: '取消置顶',
pinned: '已置顶',
copy: '复制完整会话',
export: '导出 Markdown'
},
@@ -646,6 +649,7 @@ export const app = {
conversationQueueResumeFailed:
'无法继续执行当前对话的待发送队列',
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
conversationPinFailed: '无法更新会话置顶状态,请重试',
activityHistoryPersistenceFailed: '运行记录保存失败,请检查本地存储',
activityHistoryReadFailed: '运行记录读取失败',
remoteConversationRefreshFailed: '远程通道会话刷新失败',
+57 -11
View File
@@ -3940,7 +3940,7 @@ button > svg {
display: flex;
min-width: 0;
min-height: 46px;
padding: 6px 46px 0 8px;
padding: 6px 8px 0;
border-bottom: 1px solid var(--border-default);
background: var(--surface-subtle);
flex: 0 0 46px;
@@ -3960,10 +3960,16 @@ button > svg {
display: none;
}
.workbar-shell__tab-strip {
display: flex;
width: max-content;
align-items: flex-end;
gap: var(--space-1);
}
.workbar-shell__tablist {
display: flex;
width: max-content;
min-width: 100%;
align-items: flex-end;
gap: 2px;
}
@@ -3975,10 +3981,10 @@ button > svg {
max-width: 180px;
height: 39px;
align-items: center;
border: 1px solid transparent;
border: 1px solid var(--border-subtle);
border-bottom: 0;
border-radius: var(--radius-control) var(--radius-control) 0 0;
background: transparent;
background: color-mix(in srgb, var(--surface-muted) 60%, var(--surface-subtle));
transition:
border-color var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
@@ -3986,7 +3992,7 @@ button > svg {
}
.workbar-shell__tab-item:hover {
background: var(--surface-muted);
background: var(--surface-subtle);
}
.workbar-shell__tab-item--active {
@@ -4047,6 +4053,7 @@ button > svg {
white-space: nowrap;
}
.workbar-shell__scroll-button,
.workbar-shell__tab-close,
.workbar-shell__add {
display: grid;
@@ -4113,9 +4120,25 @@ button > svg {
}
.workbar-shell__add {
flex: 0 0 28px;
margin-bottom: 5px;
background: var(--accent-subtle);
color: var(--accent);
background: transparent;
color: var(--text-muted);
}
.workbar-shell__scroll-button {
flex: 0 0 28px;
margin-bottom: 5px;
}
.workbar-shell__scroll-button:hover:not(:disabled) {
background: var(--surface-muted);
color: var(--text-primary);
}
.workbar-shell__scroll-button:disabled {
opacity: 0.4;
cursor: default;
}
.workbar-shell__content,
@@ -12084,13 +12107,31 @@ details.settings-section > :not(summary) + :not(summary) {
}
.conversation-actions {
position: fixed;
z-index: calc(var(--z-dialog) - 1);
display: grid;
width: max-content;
min-width: 160px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
overflow: auto;
padding: var(--space-1);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
margin: 2px 5px var(--space-2);
background: var(--surface-subtle);
gap: 2px;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-1);
-webkit-app-region: no-drag;
}
.conversation-pin {
flex: 0 0 auto;
color: var(--text-secondary);
}
.conversation-actions button[type='button'] {
min-height: 32px;
font-size: var(--font-body);
}
.conversation-actions button,
@@ -12121,6 +12162,11 @@ details.settings-section > :not(summary) + :not(summary) {
opacity: 0.65;
}
.conversation-actions button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.conversation-rename {
display: grid;
padding: var(--space-1);
+12
View File
@@ -553,6 +553,8 @@ export const conversationSnapshotSchema = z
.optional(),
branch: conversationBranchSchema.optional(),
title: z.string().trim().min(1).max(200),
// Absent in older snapshots means unpinned; written only via setPinned.
pinned: z.boolean().optional(),
updatedAt: z.number().int().nonnegative(),
// Main-only live projection. Never part of a saved conversation header.
activeRequest: z.object({
@@ -583,6 +585,15 @@ export const conversationSearchRequestSchema = z.object({
conversationIds: z.array(assistantIdSchema).default([])
}).strict()
export const conversationSetPinnedSchema = z.object({
conversationId: assistantIdSchema,
pinned: z.boolean()
}).strict()
export type ConversationSetPinnedInput = z.infer<
typeof conversationSetPinnedSchema
>
export const conversationSnapshotsSchema = z
.array(conversationSnapshotSchema)
.max(100)
@@ -590,6 +601,7 @@ export const conversationSnapshotsSchema = z
export const localConversationHeaderSchema = conversationSnapshotSchema
.omit({
messages: true,
pinned: true,
remote: true,
activeRequest: true
})
+3
View File
@@ -46,6 +46,7 @@ import {
type LiveAgentQuestion,
type ConversationAttachment,
type ConversationBranchInput,
type ConversationSetPinnedInput,
type ConversationQueueItem,
type ConversationContextCompressionState,
type LocalConversationSaveBatch,
@@ -1393,6 +1394,7 @@ export const browserLiveStateSchema = z
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema.optional(),
openerTabId: browserTabIdSchema.optional(),
ownerWindowId: z.number().int().nonnegative().optional(),
status: browserStatusSchema,
sessionActive: z.boolean(),
@@ -2059,6 +2061,7 @@ export type DesktopApi = {
search: (query: string, conversationIds?: string[]) => Promise<string[]>
replace: (conversations: ConversationSnapshot[]) => Promise<void>
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
setPinned: (input: ConversationSetPinnedInput) => Promise<void>
branchLocal: (
input: ConversationBranchInput
) => Promise<ConversationSnapshot>
+1
View File
@@ -190,6 +190,7 @@ export const ipcChannels = {
conversationsSearch: 'conversations:search',
conversationsReplace: 'conversations:replace',
conversationsSaveLocal: 'conversations:save-local',
conversationsSetPinned: 'conversations:set-pinned',
conversationsBranchLocal: 'conversations:branch-local',
conversationsDeleteLocal: 'conversations:delete-local',
conversationsChanged: 'conversations:changed',