mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 09:02:08 +08:00
fix(chat): sort messages appropriately
This commit is contained in:
@@ -325,6 +325,7 @@ function ChatInterface({
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||
isProcessing={isProcessing}
|
||||
chatMessages={chatMessages}
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ChatMessagesPaneProps {
|
||||
onWheel: () => void;
|
||||
onTouchMove: () => void;
|
||||
isLoadingSessionMessages: boolean;
|
||||
/** True while the viewed session has an active provider run in flight. */
|
||||
isProcessing?: boolean;
|
||||
chatMessages: ChatMessage[];
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
@@ -68,6 +70,7 @@ export default function ChatMessagesPane({
|
||||
onWheel,
|
||||
onTouchMove,
|
||||
isLoadingSessionMessages,
|
||||
isProcessing = false,
|
||||
chatMessages,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
@@ -147,7 +150,7 @@ export default function ChatMessagesPane({
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||
>
|
||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
|
||||
|
||||
@@ -166,6 +166,108 @@ function hasServerEchoForLocalUser(
|
||||
});
|
||||
}
|
||||
|
||||
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
|
||||
const timeA = readMessageTime(a) ?? 0;
|
||||
const timeB = readMessageTime(b) ?? 0;
|
||||
if (timeA !== timeB) {
|
||||
return timeA - timeB;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many user turns precede `message` in a chronologically merged view
|
||||
* of server + realtime rows. Used to match a realtime row to the correct turn
|
||||
* on disk when several turns share identical assistant text.
|
||||
*/
|
||||
function getUserTurnOrdinalBefore(
|
||||
message: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): number {
|
||||
const messageTime = readMessageTime(message);
|
||||
let userCount = 0;
|
||||
|
||||
for (const candidate of [...serverMessages, ...realtimeMessages].sort(compareMessagesChronologically)) {
|
||||
if (candidate.id === message.id) {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidateTime = readMessageTime(candidate);
|
||||
if (
|
||||
messageTime !== null
|
||||
&& candidateTime !== null
|
||||
&& candidateTime > messageTime
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (candidate.kind === 'text' && candidate.role === 'user') {
|
||||
userCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, userCount - 1);
|
||||
}
|
||||
|
||||
function findServerTurnRangeByOrdinal(
|
||||
serverMessages: NormalizedMessage[],
|
||||
turnOrdinal: number,
|
||||
): { start: number; end: number } | null {
|
||||
let userCount = -1;
|
||||
let start = -1;
|
||||
|
||||
for (let index = 0; index < serverMessages.length; index++) {
|
||||
const message = serverMessages[index];
|
||||
if (message.kind === 'text' && message.role === 'user') {
|
||||
userCount++;
|
||||
if (userCount === turnOrdinal) {
|
||||
start = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let end = serverMessages.length;
|
||||
for (let index = start + 1; index < serverMessages.length; index++) {
|
||||
if (serverMessages[index].kind === 'text' && serverMessages[index].role === 'user') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function isAssistantTextEchoedInSameTurnOnServer(
|
||||
message: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): boolean {
|
||||
const assistantText = (message.content || '').trim();
|
||||
if (!assistantText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnOrdinal = getUserTurnOrdinalBefore(message, serverMessages, realtimeMessages);
|
||||
const turnRange = findServerTurnRangeByOrdinal(serverMessages, turnOrdinal);
|
||||
if (!turnRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return serverMessages
|
||||
.slice(turnRange.start + 1, turnRange.end)
|
||||
.some((serverMessage) =>
|
||||
serverMessage.kind === 'text'
|
||||
&& serverMessage.role === 'assistant'
|
||||
&& (serverMessage.content || '').trim() === assistantText,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
|
||||
* while the sessions API soon returns the same reply with a different id.
|
||||
@@ -203,22 +305,92 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a server refresh, drop only the realtime rows the persisted transcript
|
||||
* already owns. Anything not yet on disk (common right after `complete`, while
|
||||
* JSONL indexing lags) stays in `realtimeMessages` so the chat pane never
|
||||
* flashes the empty "Continue your conversation" state.
|
||||
*/
|
||||
function pruneRealtimeSupersededByServer(
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): NormalizedMessage[] {
|
||||
if (realtimeMessages.length === 0) {
|
||||
return realtimeMessages;
|
||||
}
|
||||
|
||||
const serverIds = new Set(serverMessages.map((message) => message.id));
|
||||
|
||||
return realtimeMessages.filter((message) => {
|
||||
if (serverIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.id.startsWith('local_') && hasServerEchoForLocalUser(message, serverMessages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.kind === 'stream_delta' || message.id === `__streaming_${message.sessionId}`) {
|
||||
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.kind === 'text' && message.role === 'assistant') {
|
||||
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.kind === 'text' && message.role === 'user') {
|
||||
return !hasServerEchoForLocalUser(message, serverMessages);
|
||||
}
|
||||
|
||||
if (message.kind === 'tool_use' && message.toolId) {
|
||||
if (serverMessages.some((serverMessage) => serverMessage.kind === 'tool_use' && serverMessage.toolId === message.toolId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
|
||||
if (realtime.length === 0) return server;
|
||||
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
|
||||
const serverIds = new Set(server.map(m => m.id));
|
||||
const extra = realtime.filter((m) => {
|
||||
if (serverIds.has(m.id)) return false;
|
||||
if (realtime.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(server);
|
||||
}
|
||||
if (server.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(realtime);
|
||||
}
|
||||
|
||||
const serverIds = new Set(server.map((message) => message.id));
|
||||
const extra = realtime.filter((message) => {
|
||||
if (serverIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
// Optimistic user rows use `local_*` ids; once the same text exists on the
|
||||
// server-backed copy from the same send window, drop the realtime echo to
|
||||
// avoid duplicate bubbles without hiding repeated prompts from history.
|
||||
if (m.id.startsWith('local_')) {
|
||||
if (hasServerEchoForLocalUser(m, server)) return false;
|
||||
if (message.id.startsWith('local_')) {
|
||||
if (hasServerEchoForLocalUser(message, server)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (extra.length === 0) return server;
|
||||
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
|
||||
|
||||
if (extra.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(server);
|
||||
}
|
||||
|
||||
// Interleave by timestamp so live rows stay with their turn instead of
|
||||
// piling up at the bottom after every refresh.
|
||||
return dedupeAdjacentAssistantEchoes(
|
||||
[...server, ...extra].sort(compareMessagesChronologically),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,8 +611,13 @@ export function useSessionStore() {
|
||||
slot.total = data.total ?? slot.serverMessages.length;
|
||||
slot.hasMore = Boolean(data.hasMore);
|
||||
slot.fetchedAt = Date.now();
|
||||
// drop realtime messages that the server has caught up with to prevent unbounded growth.
|
||||
slot.realtimeMessages = [];
|
||||
// Only drop realtime rows the server transcript now owns. A blind clear
|
||||
// here caused the chat pane to flash "Continue your conversation" after
|
||||
// `complete` while JSONL / provider_session_id indexing was still behind.
|
||||
slot.realtimeMessages = pruneRealtimeSupersededByServer(
|
||||
slot.serverMessages,
|
||||
slot.realtimeMessages,
|
||||
);
|
||||
recomputeMergedIfNeeded(slot);
|
||||
notify(sessionId);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user