From bb104fffb95777438a80c66ab5e2e87e7c4eb79b Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 25 Oct 2026 08:04:08 -0500 Subject: [PATCH] Keep chat turns in the order they were sent. The post-reply refetch and the stream-start ref copy could redraw bubbles out of order. Closes #136. --- .../ThemeSettingsCard/ThemeSettingsCard.tsx | 6 +- .../contexts/MessageContext.order.test.tsx | 128 ++++++++++++++++++ llm-fe/src/llm-fe/contexts/MessageContext.tsx | 24 ++-- llm-fe/src/llm-fe/data.test.ts | 1 + llm-fe/src/llm-fe/data.ts | 16 ++- .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 2 +- llm-fe/src/llm-fe/utils/promptOrder.test.ts | 61 +++++++++ llm-fe/src/llm-fe/utils/promptOrder.ts | 22 +++ 8 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 llm-fe/src/llm-fe/contexts/MessageContext.order.test.tsx create mode 100644 llm-fe/src/llm-fe/utils/promptOrder.test.ts create mode 100644 llm-fe/src/llm-fe/utils/promptOrder.ts diff --git a/llm-fe/src/llm-fe/components/ThemeSettingsCard/ThemeSettingsCard.tsx b/llm-fe/src/llm-fe/components/ThemeSettingsCard/ThemeSettingsCard.tsx index aafbe48..2bf868b 100644 --- a/llm-fe/src/llm-fe/components/ThemeSettingsCard/ThemeSettingsCard.tsx +++ b/llm-fe/src/llm-fe/components/ThemeSettingsCard/ThemeSettingsCard.tsx @@ -139,7 +139,7 @@ const ErrorText = styled.p` `; const CONTEXT_TOOLTIP = - 'This will use previous conversations to better customize the experience.'; + 'Follow-ups use earlier messages in this chat. Turn off to answer each message on its own.'; function prefsErrorMessage(error: unknown, fallback: string): string { const axiosError = error as { @@ -162,7 +162,7 @@ const ThemeSettingsCard = (): JSX.Element => { const themeColors = Object.keys(palettes); const [order, setOrder] = useState(true); - const [useConversationContext, setUseConversationContext] = useState(false); + const [useConversationContext, setUseConversationContext] = useState(true); const [prefsLoading, setPrefsLoading] = useState(true); const [contextSaving, setContextSaving] = useState(false); const [prefsError, setPrefsError] = useState(''); @@ -276,7 +276,7 @@ const ThemeSettingsCard = (): JSX.Element => { - Use previous conversations + Remember this chat diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.order.test.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.order.test.tsx new file mode 100644 index 0000000..f060a58 --- /dev/null +++ b/llm-fe/src/llm-fe/contexts/MessageContext.order.test.tsx @@ -0,0 +1,128 @@ +import React, { useContext } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { MessageContext, MessageProvider } from './MessageContext'; +import { WebSocketContext } from './WebSocketContext'; +import { AccountContext } from './AccountContext'; +import { ConversationContext } from './ConversationContext'; +import { ConversationPrompt } from '../data'; + +jest.mock('../../axiosApi', () => ({ + axiosInstance: { + get: jest.fn().mockResolvedValue({ data: [] }), + }, +})); + +jest.mock('../utils/analytics', () => ({ + AnalyticsEvents: { + CONVERSATION_CREATED: 'Conversation Created', + ACTIVITY_STAGE_COMPLETED: 'Activity Stage Completed', + }, + trackEvent: jest.fn(), +})); + +const OrderProbe = () => { + const ctx = useContext(MessageContext); + return ( +
+ + {ctx.conversationDetails.map((detail) => detail.message).join('|')} + + +
+ ); +}; + +describe('MessageContext turn order (#136)', () => { + it('sorts a scrambled transcript and keeps that order when the reply finishes', async () => { + let callback: ((message: string) => void) | null = null; + render( + {} }} + > + {}, + selectedConversation: 7, + setSelectedConversation: () => {}, + deleteConversation: () => {}, + }} + > + void) => { + callback = cb; + }, + jest.fn(), + null, + jest.fn(), + true, + 'CONNECTED', + 0, + ] as never + } + > + + + + + + , + ); + await act(async () => {}); + + act(() => { + screen.getByRole('button', { name: 'seed' }).click(); + }); + expect(screen.getByTestId('order')).toHaveTextContent( + 'Tell me a joke|meditation ladder|I dont know|', + ); + + const send = (message: string) => { + act(() => { + callback?.(message); + }); + }; + send('CONVERSATION_ID'); + send('7'); + send('START_OF_THE_STREAM_ENDER_GAME_42'); + send('To help everyone step'); + send('END_OF_THE_STREAM_ENDER_GAME_42'); + + expect(screen.getByTestId('order').textContent).toBe( + 'Tell me a joke|meditation ladder|I dont know|To help everyone step', + ); + }); +}); diff --git a/llm-fe/src/llm-fe/contexts/MessageContext.tsx b/llm-fe/src/llm-fe/contexts/MessageContext.tsx index 7372f13..8b27c47 100644 --- a/llm-fe/src/llm-fe/contexts/MessageContext.tsx +++ b/llm-fe/src/llm-fe/contexts/MessageContext.tsx @@ -13,6 +13,7 @@ import { parseVersionedFrame, } from "../utils/wsFrames"; import { parseChatErrorPayload } from "../utils/chatErrors"; +import { sortPrompts } from "../utils/promptOrder"; type MessageProviderProps ={ children? : ReactNode; @@ -61,7 +62,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => { const {selectedConversation, setSelectedConversation} = useContext(ConversationContext); const [stateMessage, setStateMessage] = useState('') - const [conversationDetails, setConversationDetails] = useState([]) + const [conversationDetails, setConversationDetailsState] = useState([]) const [isGeneratingMessage, setIsGeneratingMessage] = useState(false) const [streamInterrupted, setStreamInterrupted] = useState(false) const [activityStage, setActivityStage] = useState(null) @@ -72,6 +73,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => { const messageRef = useRef('') const messageResponsePart = useRef(0); const conversationRef = useRef(conversationDetails) + const setConversationDetails = useCallback((next: ConversationPrompt[]) => { + const ordered = sortPrompts(next) + conversationRef.current = ordered + setConversationDetailsState(ordered) + }, []) const selectedConversationRef = useRef(undefined) const isGeneratingRef = useRef(false) const prevReconnectGenerationRef = useRef(0) @@ -124,7 +130,6 @@ const MessageProvider = ( {children}: MessageProviderProps) => { new ConversationPrompt({ message: '', user_created: false }), ) } - conversationRef.current = tempConversations setConversationDetails(tempConversations) } catch (err) { console.log('Failed to refetch conversation after reconnect', err) @@ -261,15 +266,14 @@ const MessageProvider = ( {children}: MessageProviderProps) => { if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){ messageResponsePart.current = 0 - - conversationRef.current.pop() - + + const details = [...conversationRef.current] + details.pop() const finalized = new ConversationPrompt({ message: `${messageRef.current}`, user_created: false, }) - conversationRef.current = [...conversationRef.current, finalized] - setConversationDetails([...conversationRef.current]) + setConversationDetails([...details, finalized]) messageRef.current = '' setStateMessage('') isGeneratingRef.current = false @@ -279,7 +283,9 @@ const MessageProvider = ( {children}: MessageProviderProps) => { schedulePostStreamRefetch() } else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){ - conversationRef.current = conversationDetails + // Do not copy conversationDetails from this closure. A START frame + // can arrive on the subscription from before the optimistic turn, + // and that copy wiped conversationRef (#136). isGeneratingRef.current = true setIsGeneratingMessage(true) setStreamInterrupted(false) @@ -335,7 +341,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => { /* unsubscribe from channel during cleanup */ unsubscribe(channelName) } - }, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation, schedulePostStreamRefetch]) + }, [account, subscribe, unsubscribe, selectedConversation, setSelectedConversation, schedulePostStreamRefetch, setConversationDetails]) return( diff --git a/llm-fe/src/llm-fe/data.test.ts b/llm-fe/src/llm-fe/data.test.ts index cfe8ff1..98cb41e 100644 --- a/llm-fe/src/llm-fe/data.test.ts +++ b/llm-fe/src/llm-fe/data.test.ts @@ -29,6 +29,7 @@ describe('ConversationPrompt', () => { expect(prompt.message).toBe('hello'); expect(prompt.user_created).toBe(true); expect(prompt.created_timestamp).toEqual(created); + expect(prompt.localKey).toBe('id-7'); }); }); diff --git a/llm-fe/src/llm-fe/data.ts b/llm-fe/src/llm-fe/data.ts index 3da3485..77a9196 100644 --- a/llm-fe/src/llm-fe/data.ts +++ b/llm-fe/src/llm-fe/data.ts @@ -22,8 +22,12 @@ export interface ConversationPromptType { image?: string | null, } +let localPromptKey = 0; + export class ConversationPrompt{ id: number | undefined; + /** Stable React key. Optimistic turns have no server id yet (#136). */ + localKey: string = ''; message: string = ''; user_created: boolean = false; created_timestamp: Date = new Date(); @@ -35,7 +39,11 @@ export class ConversationPrompt{ image: string | null = null; constructor(initializer?: any){ - if(!initializer) return; + if(!initializer) { + localPromptKey += 1; + this.localKey = `local-${localPromptKey}`; + return; + } if (initializer.id) this.id = initializer.id; if (initializer.message) this.message = initializer.message; if (initializer.user_created) this.user_created = initializer.user_created; @@ -47,6 +55,12 @@ export class ConversationPrompt{ if (initializer.feedback !== undefined) this.feedback = initializer.feedback; if (initializer.file_type) this.file_type = initializer.file_type; if (initializer.image) this.image = initializer.image; + if (initializer.localKey) this.localKey = String(initializer.localKey); + else if (initializer.id) this.localKey = `id-${initializer.id}`; + else { + localPromptKey += 1; + this.localKey = `local-${localPromptKey}`; + } } } diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index 29db94e..75e6ea7 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -622,7 +622,7 @@ const AsyncDashboardInner = (): JSX.Element => { : convo_detail.message; return ( { + it('orders by created time, not the array the server happened to return', () => { + const prompts = [ + new ConversationPrompt({ + id: 4, + message: 'I dont know', + user_created: true, + created_timestamp: new Date('2026-10-25T12:03:00Z'), + }), + new ConversationPrompt({ + id: 2, + message: 'Tell me a joke', + user_created: true, + created_timestamp: new Date('2026-10-25T12:01:00Z'), + }), + new ConversationPrompt({ + id: 5, + message: 'form errors', + user_created: false, + created_timestamp: new Date('2026-10-25T12:04:00Z'), + }), + new ConversationPrompt({ + id: 3, + message: 'meditation ladder', + user_created: false, + created_timestamp: new Date('2026-10-25T12:02:00Z'), + }), + ]; + + expect(sortPrompts(prompts).map((prompt) => prompt.message)).toEqual([ + 'Tell me a joke', + 'meditation ladder', + 'I dont know', + 'form errors', + ]); + }); + + it('keeps a same-millisecond user turn ahead of the empty assistant placeholder', () => { + const stamp = new Date('2026-10-25T12:05:00Z'); + const prompts = [ + new ConversationPrompt({ + message: '', + user_created: false, + created_timestamp: stamp, + }), + new ConversationPrompt({ + message: 'I dont know', + user_created: true, + created_timestamp: stamp, + }), + ]; + + expect(sortPrompts(prompts).map((prompt) => prompt.user_created)).toEqual([ + true, + false, + ]); + }); +}); diff --git a/llm-fe/src/llm-fe/utils/promptOrder.ts b/llm-fe/src/llm-fe/utils/promptOrder.ts new file mode 100644 index 0000000..601474d --- /dev/null +++ b/llm-fe/src/llm-fe/utils/promptOrder.ts @@ -0,0 +1,22 @@ +import { ConversationPrompt } from '../data'; + +function timeMs(prompt: ConversationPrompt): number { + const stamp = prompt.created_timestamp; + const ms = stamp instanceof Date ? stamp.getTime() : NaN; + return Number.isNaN(ms) ? 0 : ms; +} + +/** Oldest first. Same instant: lower id, then the user turn before the assistant placeholder. */ +export function comparePrompts(a: ConversationPrompt, b: ConversationPrompt): number { + const dt = timeMs(a) - timeMs(b); + if (dt !== 0) return dt; + const ia = a.id ?? Number.MAX_SAFE_INTEGER; + const ib = b.id ?? Number.MAX_SAFE_INTEGER; + if (ia !== ib) return ia - ib; + if (a.user_created !== b.user_created) return a.user_created ? -1 : 1; + return 0; +} + +export function sortPrompts(prompts: ConversationPrompt[]): ConversationPrompt[] { + return [...prompts].sort(comparePrompts); +} -- 2.54.0