@@ -63,6 +63,9 @@ export function useChatHistory() {
6363 // Track last snapshot parameters so debounced file-change saves use the same message ID
6464 const lastSnapshotParamsRef = useRef < { chatIdx : string ; chatSummary ?: string } | null > ( null ) ;
6565
66+ /* Serialization lock to prevent concurrent storeMessageHistory calls which cause 'urlId' uniqueness constraint errors in IndexedDB */
67+ const isStoringRef = useRef ( false ) ;
68+
6669 useEffect ( ( ) => {
6770 if ( ! db ) {
6871 setReady ( true ) ;
@@ -358,80 +361,94 @@ export function useChatHistory() {
358361 return ;
359362 }
360363
361- const { firstArtifact } = workbenchStore ;
362- messages = messages . filter ( ( m ) => ! m . annotations ?. includes ( 'no-store' ) ) ;
363-
364364 /*
365- * Ensure chatId is set on the very first message .
366- * Always use a sequential numeric ID from getNextId() for consistency .
365+ * Skip if another storeMessageHistory call is already in-flight .
366+ * The 50ms sampler will try again with the latest messages .
367367 */
368- if ( initialMessages . length === 0 && ! chatId . get ( ) ) {
369- const nextId = await getNextId ( db ) ;
370- chatId . set ( nextId ) ;
371- versionsStore . setDBContext ( db , nextId ) ;
368+ if ( isStoringRef . current ) {
369+ return ;
372370 }
373371
374- /*
375- * Ensure urlId is set once and never changes.
376- * Derive it from the numeric chatId so URLs are always consistent
377- * (e.g. /chat/1, /chat/2) regardless of whether artifacts exist.
378- * Previously, artifact-based IDs like "2-1771470328283-0" were used
379- * when the AI generated artifacts, causing inconsistent URLs.
380- */
381- let resolvedUrlId = urlId ;
372+ isStoringRef . current = true ;
382373
383- if ( ! resolvedUrlId ) {
384- const id = chatId . get ( ) ! ;
385- resolvedUrlId = await getUrlId ( db , id ) ;
386- setUrlId ( resolvedUrlId ) ;
387- navigateChat ( resolvedUrlId ) ;
388- }
374+ try {
375+ const { firstArtifact } = workbenchStore ;
376+ messages = messages . filter ( ( m ) => ! m . annotations ?. includes ( 'no-store' ) ) ;
377+
378+ /*
379+ * Ensure chatId is set on the very first message.
380+ * Always use a sequential numeric ID from getNextId() for consistency.
381+ */
382+ if ( initialMessages . length === 0 && ! chatId . get ( ) ) {
383+ const nextId = await getNextId ( db ) ;
384+ chatId . set ( nextId ) ;
385+ versionsStore . setDBContext ( db , nextId ) ;
386+ }
387+
388+ /*
389+ * Ensure urlId is set once and never changes.
390+ * Derive it from the numeric chatId so URLs are always consistent
391+ * (e.g. /chat/1, /chat/2) regardless of whether artifacts exist.
392+ * Previously, artifact-based IDs like "2-1771470328283-0" were used
393+ * when the AI generated artifacts, causing inconsistent URLs.
394+ */
395+ let resolvedUrlId = urlId ;
396+
397+ if ( ! resolvedUrlId ) {
398+ const id = chatId . get ( ) ! ;
399+ resolvedUrlId = await getUrlId ( db , id ) ;
400+ setUrlId ( resolvedUrlId ) ;
401+ navigateChat ( resolvedUrlId ) ;
402+ }
389403
390- let chatSummary : string | undefined = undefined ;
391- const lastMessage = messages [ messages . length - 1 ] ;
404+ let chatSummary : string | undefined = undefined ;
405+ const lastMessage = messages [ messages . length - 1 ] ;
392406
393- if ( lastMessage . role === 'assistant' ) {
394- const annotations = lastMessage . annotations as JSONValue [ ] ;
395- const filteredAnnotations = ( annotations ?. filter (
396- ( annotation : JSONValue ) =>
397- annotation && typeof annotation === 'object' && Object . keys ( annotation ) . includes ( 'type' ) ,
398- ) || [ ] ) as ( Record < string , unknown > & { type : string } ) [ ] ;
407+ if ( lastMessage . role === 'assistant' ) {
408+ const annotations = lastMessage . annotations as JSONValue [ ] ;
409+ const filteredAnnotations = ( annotations ?. filter (
410+ ( annotation : JSONValue ) =>
411+ annotation && typeof annotation === 'object' && Object . keys ( annotation ) . includes ( 'type' ) ,
412+ ) || [ ] ) as ( Record < string , unknown > & { type : string } ) [ ] ;
399413
400- if ( filteredAnnotations . find ( ( annotation ) => annotation . type === 'chatSummary' ) ) {
401- chatSummary = filteredAnnotations . find ( ( annotation ) => annotation . type === 'chatSummary' ) ?. summary as
402- | string
403- | undefined ;
414+ if ( filteredAnnotations . find ( ( annotation ) => annotation . type === 'chatSummary' ) ) {
415+ chatSummary = filteredAnnotations . find ( ( annotation ) => annotation . type === 'chatSummary' ) ?. summary as
416+ | string
417+ | undefined ;
418+ }
404419 }
405- }
406420
407- // Save params so debounced file-change subscriber can re-save with updated files
408- lastSnapshotParamsRef . current = { chatIdx : messages [ messages . length - 1 ] . id , chatSummary } ;
421+ // Save params so debounced file-change subscriber can re-save with updated files
422+ lastSnapshotParamsRef . current = { chatIdx : messages [ messages . length - 1 ] . id , chatSummary } ;
409423
410- takeSnapshot ( messages [ messages . length - 1 ] . id , workbenchStore . files . get ( ) , resolvedUrlId , chatSummary ) ;
424+ takeSnapshot ( messages [ messages . length - 1 ] . id , workbenchStore . files . get ( ) , resolvedUrlId , chatSummary ) ;
411425
412- if ( ! description . get ( ) && firstArtifact ?. title ) {
413- description . set ( firstArtifact ?. title ) ;
414- }
426+ if ( ! description . get ( ) && firstArtifact ?. title ) {
427+ description . set ( firstArtifact ?. title ) ;
428+ }
415429
416- // Ensure chatId.get() is used for the final setMessages call
417- const finalChatId = chatId . get ( ) ;
430+ // Ensure chatId.get() is used for the final setMessages call
431+ const finalChatId = chatId . get ( ) ;
418432
419- if ( ! finalChatId ) {
420- logger . error ( 'Cannot save messages, chat ID is not set.' ) ;
421- toast . error ( 'Failed to save chat messages: Chat ID missing.' ) ;
433+ if ( ! finalChatId ) {
434+ logger . error ( 'Cannot save messages, chat ID is not set.' ) ;
435+ toast . error ( 'Failed to save chat messages: Chat ID missing.' ) ;
422436
423- return ;
424- }
437+ return ;
438+ }
425439
426- await setMessages (
427- db ,
428- finalChatId ,
429- [ ...archivedMessages , ...messages ] ,
430- resolvedUrlId , // Always use the resolved urlId, not stale useState
431- description . get ( ) ,
432- undefined ,
433- chatMetadata . get ( ) ,
434- ) ;
440+ await setMessages (
441+ db ,
442+ finalChatId ,
443+ [ ...archivedMessages , ...messages ] ,
444+ resolvedUrlId , // Always use the resolved urlId, not stale useState
445+ description . get ( ) ,
446+ undefined ,
447+ chatMetadata . get ( ) ,
448+ ) ;
449+ } finally {
450+ isStoringRef . current = false ;
451+ }
435452 } ,
436453 duplicateCurrentChat : async ( listItemId : string ) => {
437454 if ( ! db || ( ! mixedId && ! listItemId ) ) {
0 commit comments