fix(chat): do not replay entry animations for already-seen fresh messages (#2124) (#2732)

This commit is contained in:
Serhii Dziupin
2026-08-07 09:16:34 +03:00
committed by GitHub
parent 3fc136c95e
commit 87dbc59bf1
2 changed files with 92 additions and 4 deletions
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { MessageFreshnessDetector } from './messageFreshness';
import type { Message } from '@opencode-ai/sdk/v2';
const makeAssistantMessage = (id: string, created: number): Message =>
({
id,
role: 'assistant',
sessionID: 'session-a',
time: { created },
}) as unknown as Message;
describe('MessageFreshnessDetector.shouldAnimateMessage', () => {
let detector: MessageFreshnessDetector;
beforeEach(() => {
detector = MessageFreshnessDetector.getInstance();
detector.clearAll();
});
test('fresh message animates once and is recorded as seen', () => {
detector.recordSessionStart('session-a');
const message = makeAssistantMessage('msg-fresh', Date.now());
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(true);
expect(detector.hasBeenAnimated('msg-fresh')).toBe(true);
});
test('regression #2124: fresh message does not re-animate when returning to the session', () => {
detector.recordSessionStart('session-a');
const message = makeAssistantMessage('msg-fresh', Date.now());
// First visit: the message is fresh and animates.
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(true);
// User switches away and back; ChatViewport remounts and re-evaluates
// before recordSessionStart runs again, so the old session start time
// is still in effect. The message must not animate a second time.
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false);
});
test('stale history message never animates and is recorded as seen', () => {
detector.recordSessionStart('session-a');
const message = makeAssistantMessage('msg-old', Date.now() - 60_000);
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false);
expect(detector.hasBeenAnimated('msg-old')).toBe(true);
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false);
});
test('message evaluated without a recorded session start does not animate and is recorded', () => {
const message = makeAssistantMessage('msg-no-session', Date.now());
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false);
expect(detector.hasBeenAnimated('msg-no-session')).toBe(true);
// Recording the session start afterwards must not resurrect the animation.
detector.recordSessionStart('session-a');
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false);
});
test('non-assistant messages never animate', () => {
detector.recordSessionStart('session-a');
const message = {
id: 'msg-user',
role: 'user',
sessionID: 'session-a',
time: { created: Date.now() },
} as unknown as Message;
expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false);
});
test('a new fresh message still animates after older fresh messages were seen', () => {
detector.recordSessionStart('session-a');
const first = makeAssistantMessage('msg-first', Date.now());
const second = makeAssistantMessage('msg-second', Date.now());
expect(detector.shouldAnimateMessage(first, 'session-a')).toBe(true);
expect(detector.shouldAnimateMessage(second, 'session-a')).toBe(true);
expect(detector.shouldAnimateMessage(second, 'session-a')).toBe(false);
});
});
+7 -4
View File
@@ -44,10 +44,13 @@ export class MessageFreshnessDetector {
const isFresh = message.time.created > (sessionStartTime - 5000);
if (!isFresh) {
this.seenMessageIds.add(message.id);
this.messageCreationTimes.set(message.id, message.time.created);
}
// Record fresh messages too so they animate at most once per detector
// lifetime. The detector is a module singleton that outlives ChatViewport
// remounts; without this, switching away and back re-evaluates the same
// message against the stale session start time (recordSessionStart runs
// in an effect after the first render) and replays the entry animation.
this.seenMessageIds.add(message.id);
this.messageCreationTimes.set(message.id, message.time.created);
return isFresh;
}