From 87dbc59bf18016d094c5ad9861c67044831444b3 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 7 Aug 2026 09:16:34 +0300 Subject: [PATCH] fix(chat): do not replay entry animations for already-seen fresh messages (#2124) (#2732) --- packages/ui/src/lib/messageFreshness.test.ts | 85 ++++++++++++++++++++ packages/ui/src/lib/messageFreshness.ts | 11 ++- 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/lib/messageFreshness.test.ts diff --git a/packages/ui/src/lib/messageFreshness.test.ts b/packages/ui/src/lib/messageFreshness.test.ts new file mode 100644 index 00000000..bd59bffd --- /dev/null +++ b/packages/ui/src/lib/messageFreshness.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/lib/messageFreshness.ts b/packages/ui/src/lib/messageFreshness.ts index e32c5843..230a747c 100644 --- a/packages/ui/src/lib/messageFreshness.ts +++ b/packages/ui/src/lib/messageFreshness.ts @@ -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; }