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
@@ -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)
}
}
}
}