fix: keep conversation order stable during streaming

Sort pinned and unpinned conversations by their latest message creation time so parallel replies no longer repeatedly swap sidebar positions. Include the same timestamp in lightweight summaries while preserving updatedAt for persistence and snapshot merging.

Validation: 13 focused tests, typecheck and ESLint passed. The full test run timed out after ten minutes and reported configuration-summary and Agent offline-install failures.

Release note: Conversation order stays stable while replies stream or tools report progress; new messages still move conversations forward, with pinned conversations first.
This commit is contained in:
mesalogo
2026-09-28 01:40:01 +08:00
parent ee1bf8a187
commit 8e607366ca
11 changed files with 162 additions and 9 deletions
+2
View File
@@ -295,6 +295,8 @@
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
@@ -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:窄幅单列应用列表
@@ -231,7 +231,10 @@ CPU 4% · 内存 620 MB · 桌面进程 7
会话行的“更多会话操作”提供“置顶会话/取消置顶”,本地与消息通道会话均可使用。
置顶会话显示带“已置顶”可访问名称的图标;当前项目和搜索过滤仍生效,过滤后的列表先显示
置顶会话,两组内各按更新时间倒序。取消置顶恢复时间排序,不改变会话更新时间。
置顶会话,两组内各按最新消息的创建时间倒序;时间相同按会话 ID 固定顺序,无消息时使用
会话更新时间。新增消息可以前移会话,同一消息的文字、推理、工具进度和完成状态更新不
改变排序。有消息的会话重命名或修改设置也不前移。摘要刷新、打开详情和重新启动沿用同一
规则,行右侧仍显示实际更新时间。取消置顶恢复消息时间排序,不改变会话更新时间。
置顶状态重启后保留;保存成功后更新图标和顺序,失败保留原状态并显示应用错误通知。
存储尚未就绪或置顶请求进行中时禁用置顶操作。
@@ -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 当前恢复请求的问题投影。
@@ -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() }
})
+7 -2
View File
@@ -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)
}
}
}
}
+63
View File
@@ -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(<App />);
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(<App />);
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: [],
+4 -4
View File
@@ -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,
@@ -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"]);
});
});
+15
View File
@@ -5,6 +5,21 @@ export type Conversation = Omit<ConversationListSnapshot, "messages"> & {
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<number | undefined>(
(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 === "新对话" &&
+1
View File
@@ -617,6 +617,7 @@ export type ConversationListSnapshot = ConversationSnapshot & {
messageSummary?: {
count: number
firstRole?: ConversationMessage['role']
latestMessageAt?: number
}
}
export const conversationListRequestSchema = z.object({