diff --git a/UI-DESIGN.md b/UI-DESIGN.md index daf8d0e9..e8dbdc12 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -295,6 +295,8 @@ - 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。 - 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 +- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守 + [会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。 - 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。 - 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。 - 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守 diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index e434faf8..d406ee4f 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -1,5 +1,28 @@ # 应用导航实施进度 +## 2026-09-28:并行会话输出时保持列表顺序 + +修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建 +时间,摘要查询补充只读 `latestMessageAt`。交互规则见[会话置顶与操作菜单](./ui-design.md#会话置顶与操作菜单), +时间字段与合并边界见[会话列表读取与前端保留](../assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。 +本地及远程事件的更新时间写入、快照合并判定和增量保存保持原实现。 + +聚焦验证命令: + +```text +npx vitest run src/renderer/src/chat-conversation.test.ts src/renderer/src/App.test.tsx src/main/assistant/assistant-database.test.ts -t "conversation display order|parallel streaming conversations|summary and detail order|sorts pinned conversations|merges persisted pin metadata|lists lightweight history|persists only the changed conversation|preserves live .* across snapshot|loads only the opened history" +``` + +结果:3 个文件、13 项通过。覆盖并行文字增量与完成后顺序、保存时间继续推进、本地和消息 +通道的摘要/详情刷新、新消息前移、置顶合并、同时间固定顺序、空会话回退、摘要附带实时 +消息及真实 SQLite 摘要时间。历史加载回归继续验证刷新不重复保存未变化的消息。 +修改过的 7 个 TypeScript 文件定向 ESLint 通过。中文文档按 deai-writing 自查清单复核。 + +本轮验证为 App 组件事件回归与真实 SQLite 测试,未进行 Electron 窗口或真实模型调用。 +改动仅涉及桌面展示及桌面 Main 的只读摘要,未改变 Agent 协议或桌面到 Agent 的执行路径, +无需部署 Host。全仓 typecheck、ESLint 和 `git diff --check` 通过;全量测试运行十分钟后 +超时,期间报告配置摘要和 Agent 离线安装测试失败,未取得全仓测试通过结果。 + 2026-09-23 文档校正:当前 `heartbeat` 已作为可选应用管理,默认值与执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行),设置与导航契约见[技术设计](./technical-design.md#4-应用导航偏好)。下方日期记录保留当时实现与验证结果;其中“心跳始终显示”“无应用级开关”和旧字段删除说明不再代表当前行为。本次仅核对源码并更新文档,未重新执行功能测试。 ## 2026-09-18:窄幅单列应用列表 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index c093f79c..399aac56 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -231,7 +231,10 @@ CPU 4% · 内存 620 MB · 桌面进程 7 会话行的“更多会话操作”提供“置顶会话/取消置顶”,本地与消息通道会话均可使用。 置顶会话显示带“已置顶”可访问名称的图标;当前项目和搜索过滤仍生效,过滤后的列表先显示 -置顶会话,两组内各按更新时间倒序。取消置顶恢复时间排序,不改变会话更新时间。 +置顶会话,两组内各按最新消息的创建时间倒序;时间相同按会话 ID 固定顺序,无消息时使用 +会话更新时间。新增消息可以前移会话,同一消息的文字、推理、工具进度和完成状态更新不 +改变排序。有消息的会话重命名或修改设置也不前移。摘要刷新、打开详情和重新启动沿用同一 +规则,行右侧仍显示实际更新时间。取消置顶恢复消息时间排序,不改变会话更新时间。 置顶状态重启后保留;保存成功后更新图标和顺序,失败保留原状态并显示应用错误通知。 存储尚未就绪或置顶请求进行中时禁用置顶操作。 diff --git a/docs/features/assistant-workbar/execution-history-storage.md b/docs/features/assistant-workbar/execution-history-storage.md index f1f8983f..925f3450 100644 --- a/docs/features/assistant-workbar/execution-history-storage.md +++ b/docs/features/assistant-workbar/execution-history-storage.md @@ -52,11 +52,11 @@ WAL 增长与 SQL 全历史扫描次数的定向验证见 [BUILD.md](../../../BU 本次读取优化不修改 schema、迁移或用户数据: - Renderer 启动及焦点、会话、队列刷新使用 `conversations.listSummaries(detailIds)`。 - 普通未打开会话只返回标题、项目、置顶状态、上下文设置和消息数量/首条角色,不解析或跨 IPC + 普通未打开会话只返回标题、项目、置顶状态、上下文设置和消息数量/首条角色/最新消息创建时间,不解析或跨 IPC 传输正文、工具、子任务及图片操作元数据。 - 当前会话、现有 KeepAlive 视图,以及 Main 仍持有请求、数据库中仍有活动 Task 或 streaming 消息的会话返回完整消息。列表包含全部 active 会话,不限制数量, - 按置顶优先、组内更新时间倒序返回;仅置顶不会加载完整历史。 + Main 按置顶优先、组内更新时间倒序返回;Renderer 使用下述展示排序。仅置顶不会加载完整历史。 - 打开历史通过 `conversations.get(id)` 读取全部消息,不使用会截断历史的分页上限。 加载时明确显示进度;失败保留会话与输入,提供原地重试。发送、队列派发和压缩上下文 必须先确认完整历史,发送时不按消息条数截断;复制与导出临时读取完整内容,不把未打开会话留入视图缓存。 @@ -71,6 +71,11 @@ WAL 增长与 SQL 全历史扫描次数的定向验证见 [BUILD.md](../../../BU 接口保留供其他调用方使用。 - 图片状态通过同一合并函数处理。本地更新与 Main 图片操作按原有时间优先级合并, 未变化消息返回原对象,避免每次列表刷新后把无变化的整个历史当作脏消息再次保存。 +- 展示顺序由 Renderer 的 `sortConversationsForDisplay` 统一派生,规则见 + [会话列表排序](../application-tool-navigation/ui-design.md#会话置顶与操作菜单)。完整消息取 + `createdAt` 最大值;摘要的 `latestMessageAt` 在已有聚合查询中取 `MAX(created_at)`, + 与摘要上附带的实时消息取最大值。该字段仅用于桌面只读投影,不增加数据库列或持久化副本。 + `updatedAt`/`updated_at` 继续随流式事件更新,原有快照合并、终态优先与逐消息保存规则不变。 - 本机和托管 SSH 的 Renderer/Main 读取、持久化与恢复投影共用以上实现。Agent 的 transcript、原生 Session、权限及模型桥协议未改变;完整列表、摘要和详情都附带 Main 当前恢复请求的问题投影。 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 0024bb61..bc7d537c 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -545,6 +545,10 @@ describe('AssistantDatabase', () => { expect(summaries).toHaveLength(100) expect(summaries.every(item => item.messages.length === 0 && item.messageSummary?.count === 16 && item.messageSummary.firstRole === 'assistant')).toBe(true) + for (const snapshot of snapshots) { + expect(summaries.find(item => item.id === snapshot.id)?.messageSummary?.latestMessageAt) + .toBe(Math.max(...snapshot.messages.map(message => message.createdAt))) + } expect(JSON.stringify(summaries).length).toBeLessThan(JSON.stringify(snapshots).length * 0.02) const id = snapshots[0]!.id const detail = database.listConversationSummaries([id]).find(item => item.id === id)! @@ -557,12 +561,14 @@ describe('AssistantDatabase', () => { expect(database.searchConversations(' ')).toEqual([]) const added = { id: randomUUID(), title: 'Newer', updatedAt: 1000 } database.saveLocalConversations([{ header: added, messages: [] }]) + expect(database.listConversationSummaries().find(item => item.id === added.id)?.messageSummary?.latestMessageAt).toBeUndefined() expect(database.listConversationSummaries().some(item => item.id === id)).toBe(true) expect(database.searchConversations('äpfel 中文 0 %_')).toEqual([id]) expect(database.listConversationSummaries([id]).find(item => item.id === id)?.messages).toHaveLength(16) // A header-only save of a summary cannot replace or erase messages. database.saveLocalConversations([{ header: { id, title: 'Renamed', updatedAt: 101 }, messages: [] }]) expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages) + expect(database.listConversationSummaries().find(item => item.id === id)?.messageSummary?.latestMessageAt).toBe(15) } finally { database.close() } }) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 9a8d2a19..68a97488 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -2889,6 +2889,7 @@ export class AssistantDatabase { const summaryStatement = database.prepare( `SELECT count(*) AS count, max(state = 'streaming') AS streaming, + max(created_at) AS latestMessageAt, (SELECT role FROM messages WHERE conversation_id = ? ORDER BY sequence LIMIT 1) AS firstRole FROM messages WHERE conversation_id = ?` ) @@ -2899,12 +2900,16 @@ export class AssistantDatabase { return conversations.map((conversation) => { if (detailIds && !detailIds.has(conversation.id) && !activeIds.has(conversation.id)) { const summary = summaryStatement.get(conversation.id, conversation.id) as { - count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null + count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null; latestMessageAt: string | null } if (!summary.streaming) { return { ...toConversationSnapshot(conversation, []), - messageSummary: { count: summary.count, firstRole: summary.firstRole ?? undefined } + messageSummary: { + count: summary.count, + firstRole: summary.firstRole ?? undefined, + latestMessageAt: summary.latestMessageAt === null ? undefined : Date.parse(summary.latestMessageAt) + } } } } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 1f9e0ce5..80dd5dbf 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -3644,6 +3644,69 @@ describe("App", () => { expect(screen.queryByRole("img", { name: "已置顶" })).not.toBeInTheDocument(); }); + it("keeps parallel streaming conversations in message order while persisting fresh deltas", async () => { + const snapshots: ConversationSnapshot[] = ["First", "Second"].map((title, index) => ({ + id: `parallel-${index}`, projectId, title, updatedAt: 100 - index, messages: [], + })); + vi.mocked(api.conversations.list).mockResolvedValue(snapshots); + const { container } = render(); + await screen.findByLabelText("更多会话操作 First"); + const titles = (): string[] => Array.from(container.querySelectorAll(".conversation-item__title")).map(item => item.textContent!); + for (const [index, title] of ["First", "Second"].entries()) { + fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${title}`) })); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: `Request ${title}` } }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledTimes(index + 1)); + } + expect(titles()).toEqual(["Second", "First"]); + const first = run.mock.calls[0]![0]; + const second = run.mock.calls[1]![0]; + const savedBefore = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch) + .filter(entry => entry.header.id === first.conversationId).at(-1)!; + for (const [request, delta] of [[first, "First output"], [second, "Second output"], [first, " continued"]] as const) { + act(() => agentListener?.({ requestId: request.requestId, type: "text", delta })); + await waitFor(() => { + expect(vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch) + .some(entry => entry.header.id === request.conversationId && entry.messages.some(message => message.content.includes(delta)))).toBe(true); + expect(titles()).toEqual(["Second", "First"]); + }); + } + const savedAfter = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch) + .filter(entry => entry.header.id === first.conversationId).at(-1)!; + expect(savedAfter.header.updatedAt).toBeGreaterThan(savedBefore.header.updatedAt); + act(() => agentListener?.({ requestId: first.requestId, type: "done" })); + expect(titles()).toEqual(["Second", "First"]); + act(() => agentListener?.({ requestId: second.requestId, type: "done" })); + await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument()); + expect(titles()).toEqual(["Second", "First"]); + }); + + it.each([false, true])("keeps summary and detail order stable across refreshes and moves a newly messaged conversation forward (channel: %s)", async (channel) => { + const snapshots: ConversationSnapshot[] = ["Earlier", "Later"].map((title, index) => ({ + id: `stable-${index}`, projectId, title, updatedAt: 900 - index, + ...(channel ? { remote: { channel: "weixin" as const, accountDisplay: "Account", conversationType: "direct" as const } } : {}), + messages: [{ id: `message-${index}`, role: "assistant", state: "complete", content: `Body ${title}`, createdAt: 100 + index }], + })); + vi.mocked(api.conversations.get).mockImplementation(async id => snapshots.find(item => item.id === id)!); + vi.mocked(api.conversations.listSummaries).mockImplementation(async (input) => snapshots.map(item => + input?.includes(item.id) ? item : { ...item, messages: [], messageSummary: { count: item.messages.length, firstRole: "assistant", latestMessageAt: Math.max(...item.messages.map(message => message.createdAt)) } })); + const { container } = render(); + const titles = (): string[] => Array.from(container.querySelectorAll(".conversation-item__title")).map(item => item.textContent!); + await waitFor(() => expect(titles()).toEqual(["Later", "Earlier"])); + fireEvent.click(container.querySelectorAll(".conversation-item")[0]!); + await screen.findByText("Body Later"); + snapshots[0] = { ...snapshots[0]!, updatedAt: 1000, title: "Refreshed Earlier" }; + fireEvent(window, new Event("focus")); + await waitFor(() => expect(titles()).toEqual(["Later", "Refreshed Earlier"])); + fireEvent.click(container.querySelectorAll(".conversation-item")[1]!); + await screen.findByText("Body Earlier"); + expect(titles()).toEqual(["Later", "Refreshed Earlier"]); + snapshots[0] = { ...snapshots[0]!, updatedAt: 1100, messages: [...snapshots[0]!.messages, + { id: "new-message", role: "user", state: "complete", content: "New turn", createdAt: 200 }] }; + fireEvent(window, new Event("focus")); + await waitFor(() => expect(titles()).toEqual(["Refreshed Earlier", "Later"])); + }); + 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: [], diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1e8be694..41a7bd83 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -178,7 +178,7 @@ import { messageRenderBatchSize, type ChatScrollSnapshot, } from "./ChatHistoryPane"; -import { isUnusedConversation, type Conversation } from "./chat-conversation"; +import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation"; import { clearLegacyActivityHistory, loadLegacyActivityHistory, @@ -1234,7 +1234,7 @@ function mergePersistedConversations( merged.push(conversation); } } - return merged.sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt); + return sortConversationsForDisplay(merged); } function getProjectDefaultRuntimeSelection( @@ -3694,7 +3694,7 @@ function App(): React.JSX.Element { ); const filteredConversations = useMemo(() => { const query = deferredSearchQuery.trim().toLocaleLowerCase(); - return conversations.filter( + return sortConversationsForDisplay(conversations.filter( (conversation) => (!activeProjectId || conversation.projectId === activeProjectId) && (activeProject?.kind !== "channel" || @@ -3703,7 +3703,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, diff --git a/src/renderer/src/chat-conversation.test.ts b/src/renderer/src/chat-conversation.test.ts new file mode 100644 index 00000000..bc669354 --- /dev/null +++ b/src/renderer/src/chat-conversation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { sortConversationsForDisplay, type Conversation } from "./chat-conversation"; + +describe("conversation display order", () => { + it("uses stable message times for details, summaries and live messages attached to summaries", () => { + const conversations: Conversation[] = [ + { id: "detail", title: "Detail", updatedAt: 900, messages: [ + { id: "a", role: "user", content: "", state: "complete", createdAt: 200 }, + { id: "b", role: "assistant", content: "", state: "streaming", createdAt: 100 }, + ] }, + { id: "summary", title: "Summary", updatedAt: 800, messages: [], messageSummary: { count: 2, latestMessageAt: 300 } }, + { id: "live", title: "Live", updatedAt: 700, messageSummary: { count: 2, latestMessageAt: 100 }, messages: [ + { id: "c", role: "assistant", content: "", state: "streaming", createdAt: 400 }, + ] }, + { id: "empty", title: "Empty", updatedAt: 500, messages: [] }, + { id: "pinned", title: "Pinned", updatedAt: 1, messages: [], pinned: true }, + ]; + const sorted = sortConversationsForDisplay(conversations); + expect(sorted.map(item => item.id)).toEqual(["pinned", "empty", "live", "summary", "detail"]); + expect(conversations[0]?.id).toBe("detail"); + expect(sorted.at(-1)).toBe(conversations[0]); + }); + + it("breaks equal message times by ID even when refresh input order and updatedAt change", () => { + const a: Conversation = { id: "a", title: "A", updatedAt: 20, messages: [], messageSummary: { count: 1, latestMessageAt: 0 } }; + const b: Conversation = { ...a, id: "b", updatedAt: 30 }; + expect(sortConversationsForDisplay([b, a]).map(item => item.id)).toEqual(["a", "b"]); + expect(sortConversationsForDisplay([{ ...a, updatedAt: 40 }, b]).map(item => item.id)).toEqual(["a", "b"]); + }); +}); diff --git a/src/renderer/src/chat-conversation.ts b/src/renderer/src/chat-conversation.ts index 6aae6e0c..4492d152 100644 --- a/src/renderer/src/chat-conversation.ts +++ b/src/renderer/src/chat-conversation.ts @@ -5,6 +5,21 @@ export type Conversation = Omit & { messages: Message[]; }; +export function sortConversationsForDisplay(conversations: readonly Conversation[]): Conversation[] { + // updatedAt tracks snapshot freshness, including every streaming delta. + // Compute stable message times once per conversation, not per comparison. + return conversations.map(conversation => ({ + conversation, + time: conversation.messages.reduce( + (latest, message) => Math.max(latest ?? message.createdAt, message.createdAt), + conversation.messageSummary?.latestMessageAt, + ) ?? conversation.updatedAt, + })).sort((left, right) => + Number(Boolean(right.conversation.pinned)) - Number(Boolean(left.conversation.pinned)) || + right.time - left.time || left.conversation.id.localeCompare(right.conversation.id), + ).map(item => item.conversation); +} + export function isUnusedConversation(conversation: Conversation): boolean { return ( conversation.title === "新对话" && diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 6d9711e4..d3deb1b5 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -617,6 +617,7 @@ export type ConversationListSnapshot = ConversationSnapshot & { messageSummary?: { count: number firstRole?: ConversationMessage['role'] + latestMessageAt?: number } } export const conversationListRequestSchema = z.object({