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({