From 625182c3dc1eddd8a6b6996611da032ff8e5bb99 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 15 Sep 2026 23:39:06 +0800 Subject: [PATCH] feat: pin conversations and float their action menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 会话菜单新增置顶和取消置顶,重启后仍保留;操作菜单现在悬浮显示,不再挤动下方会话。 --- .../technical-design.md | 7 +- .../application-tool-navigation/ui-design.md | 18 ++ docs/features/assistant-workbar/README.md | 2 +- .../execution-history-storage.md | 26 ++- src/main/assistant/assistant-database.test.ts | 109 ++++++++++- src/main/assistant/assistant-database.ts | 37 +++- .../assistant-storage-upgrade.test.ts | 3 +- src/main/ipc.ts | 14 +- src/preload/index.ts | 3 + src/renderer/src/App.test.tsx | 179 ++++++++++++++++-- src/renderer/src/App.tsx | 133 ++++++++++++- src/renderer/src/AppSidebarLayout.test.ts | 9 + src/renderer/src/WorkspacePrimitives.tsx | 3 + src/renderer/src/i18n/locales/en-US/app.ts | 4 + src/renderer/src/i18n/locales/zh-CN/app.ts | 4 + src/renderer/src/styles.css | 68 +++++-- src/shared/assistant-contracts.ts | 12 ++ src/shared/contracts.ts | 3 + src/shared/ipc-channels.ts | 1 + 19 files changed, 583 insertions(+), 52 deletions(-) diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index 46098696..5981fe3b 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -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。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。 设置离开检查通过后才提交项目、会话及界面清理状态。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index bdf0536d..cf330d3e 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -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. 未来插件入口 插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与四个系统工具的位置 diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index d0990146..c73a70f9 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -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 传递复用、消息恢复与真实部署验收;尚未实现 | diff --git a/docs/features/assistant-workbar/execution-history-storage.md b/docs/features/assistant-workbar/execution-history-storage.md index 0a788e34..82990f3f 100644 --- a/docs/features/assistant-workbar/execution-history-storage.md +++ b/docs/features/assistant-workbar/execution-history-storage.md @@ -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` 强行绕过版本检查。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 7b54f36b..d386f80c 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -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() diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index d233b300..28009a56 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -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 { diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts index a620a48f..fcef2c58 100644 --- a/src/main/assistant/assistant-storage-upgrade.test.ts +++ b/src/main/assistant/assistant-storage-upgrade.test.ts @@ -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, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fdeb9fb4..e607e800 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 + ): Promise 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) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8235f652..5649199b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1088,6 +1088,9 @@ const desktopApi: DesktopApi = { batch ) }, + setPinned: async (input) => { + await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input) + }, branchLocal: (input: ConversationBranchInput) => ipcRenderer.invoke( ipcChannels.conversationsBranchLocal, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index f5785e26..5052196a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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>>(); 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(".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(); 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); const topbar = container.querySelector(".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(); 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(); 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}`, }); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4245c4b0..4e03e140 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(); 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(), ); + const conversationActionsRef = useRef(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('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 => { + 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 => { if (deletingConversationId) { return; @@ -8855,6 +8942,7 @@ function App(): React.JSX.Element { }} > + {conversation.pinned && {t("conversation.actions.pinned")}} {branchSourceTitle && ( - {conversationActionsId === conversation.id && ( + {sidebarOpen && conversationActionsId === conversation.id && createPortal(
{ + 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('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}`} > + {!conversation.remote && ( <> )} {!conversation.remote && ( )} -
+ , document.body )} {!conversation.remote && renamingConversationId === conversation.id && ( diff --git a/src/renderer/src/AppSidebarLayout.test.ts b/src/renderer/src/AppSidebarLayout.test.ts index 72949c6b..7f44dea3 100644 --- a/src/renderer/src/AppSidebarLayout.test.ts +++ b/src/renderer/src/AppSidebarLayout.test.ts @@ -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 diff --git a/src/renderer/src/WorkspacePrimitives.tsx b/src/renderer/src/WorkspacePrimitives.tsx index 6f665c24..f372996f 100644 --- a/src/renderer/src/WorkspacePrimitives.tsx +++ b/src/renderer/src/WorkspacePrimitives.tsx @@ -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({ ) : (