Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions packages/core/src/db/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,23 @@ describe('messages', () => {
});

describe('listMessages', () => {
test('returns rows from query result', async () => {
const messages: MessageRow[] = [
mockMessage,
test('queries newest-first then reverses to chronological order', async () => {
// DB returns DESC (newest first); listMessages reverses to oldest-first
// for display so callers see the natural reading order.
const newestFirst: MessageRow[] = [
{ ...mockMessage, id: 'msg-124', role: 'assistant', content: 'Hi!' },
mockMessage,
];
mockQuery.mockResolvedValueOnce(createQueryResult(messages));
mockQuery.mockResolvedValueOnce(createQueryResult(newestFirst));

const result = await listMessages('conv-456');

expect(result).toEqual(messages);
// Caller-visible order: oldest first (reversed from query result)
expect(result).toEqual([...newestFirst].reverse());
expect(mockQuery).toHaveBeenCalledWith(
`SELECT * FROM remote_agent_messages
WHERE conversation_id = $1
ORDER BY created_at ASC
ORDER BY created_at DESC
LIMIT $2`,
['conv-456', 200]
);
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/db/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ export async function addMessage(
}

/**
* List messages for a conversation, oldest first.
* List the most recent messages for a conversation, returned oldest-first.
*
* The DB query orders DESC and takes the top `limit` (i.e. the newest N), then
* reverses to oldest-first for the chronological-display contract callers
* expect. This matters for conversations with more than `limit` messages: the
* previous "ORDER BY created_at ASC" returned the *oldest* N, which made the
* latest messages invisible in the Web UI for any conversation past the cap.
*
* conversationId is the database UUID (not platform_conversation_id).
*/
export async function listMessages(
Expand All @@ -58,11 +65,13 @@ export async function listMessages(
const result = await pool.query<MessageRow>(
`SELECT * FROM remote_agent_messages
WHERE conversation_id = $1
ORDER BY created_at ASC
ORDER BY created_at DESC
LIMIT $2`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
[conversationId, limit]
);
return result.rows;
// Reverse to oldest-first so callers don't have to. (DB-side reverse via
// a subquery is also possible but adds a layer of indirection for no gain.)
return [...result.rows].reverse();
}

/**
Expand Down
24 changes: 14 additions & 10 deletions packages/web/src/components/chat/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,17 +446,21 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea
.then((rows: MessageResponse[]) => {
if (rows.length === 0) return;
const hydrated = rows.map(mapMessageRow);
// Preserve client-only system messages (e.g., sync status) when rehydrating
// Merge by id rather than replacing the whole list. Anything in the
// current React state that the DB snapshot doesn't contain is kept
// verbatim — that includes:
// - client-only system messages (sync banners, errors)
// - in-flight tool-call/streaming messages with synthetic
// 'msg-{timestamp}' IDs that never collide with DB IDs
// - any messages newer than the server response (race window)
// The previous implementation replaced wholesale and only carried
// system messages forward, which silently dropped the live state of
// long conversations whose tail wasn't in the server's window.
setMessages(prev => {
const systemMessages = prev.filter(m => m.role === 'system');
if (systemMessages.length === 0) return hydrated;
// Interleave system messages at their original positions by timestamp
const merged = [...hydrated];
for (const sys of systemMessages) {
const insertIdx = merged.findIndex(m => m.timestamp > sys.timestamp);
if (insertIdx === -1) merged.push(sys);
else merged.splice(insertIdx, 0, sys);
}
const hydratedIds = new Set(hydrated.map(m => m.id));
const clientOnly = prev.filter(m => !hydratedIds.has(m.id));
const merged = [...hydrated, ...clientOnly];
merged.sort((a, b) => a.timestamp - b.timestamp);
return merged;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
})
Expand Down