@open-mercato/ui 0.6.1-develop.3167.1.7aedd7b2ba → 0.6.1-develop.3179.1.9ee0441adc

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -119,6 +119,9 @@ function makeConversationId(): string {
119
119
 
120
120
  const SESSION_STORAGE_PREFIX = 'om-ai-chat:'
121
121
  const SESSION_STORAGE_VERSION = 1
122
+ const SESSION_UPDATED_EVENT = 'om-ai-chat-session-updated'
123
+ const SESSION_STREAM_STATE_EVENT = 'om-ai-chat-session-stream-state'
124
+ const activeSessionStreams = new Set<string>()
122
125
 
123
126
  interface PersistedAiChatSession {
124
127
  v: number
@@ -138,6 +141,28 @@ function getSessionStorageKey(agent: string, conversationId?: string | null): st
138
141
  return `${SESSION_STORAGE_PREFIX}${agent}`
139
142
  }
140
143
 
144
+ function writeSessionStreamState(storageKey: string, active: boolean): void {
145
+ if (active) {
146
+ activeSessionStreams.add(storageKey)
147
+ } else {
148
+ activeSessionStreams.delete(storageKey)
149
+ }
150
+ if (typeof window === 'undefined') return
151
+ try {
152
+ window.dispatchEvent(
153
+ new CustomEvent(SESSION_STREAM_STATE_EVENT, {
154
+ detail: { key: storageKey, active },
155
+ }),
156
+ )
157
+ } catch {
158
+ // ignore event dispatch failures in non-browser test environments
159
+ }
160
+ }
161
+
162
+ function isSessionStreamActive(storageKey: string): boolean {
163
+ return activeSessionStreams.has(storageKey)
164
+ }
165
+
141
166
  function readPersistedSession(
142
167
  agent: string,
143
168
  conversationId?: string | null,
@@ -169,6 +194,7 @@ function writePersistedSession(
169
194
  agent: string,
170
195
  session: PersistedAiChatSession,
171
196
  conversationId?: string | null,
197
+ options?: { notify?: boolean },
172
198
  ): void {
173
199
  if (typeof window === 'undefined') return
174
200
  try {
@@ -189,10 +215,15 @@ function writePersistedSession(
189
215
  })
190
216
  return { ...message, files: safeFiles }
191
217
  })
192
- window.localStorage.setItem(
193
- getSessionStorageKey(agent, conversationId),
194
- JSON.stringify({ ...session, messages }),
195
- )
218
+ const storageKey = getSessionStorageKey(agent, conversationId)
219
+ window.localStorage.setItem(storageKey, JSON.stringify({ ...session, messages }))
220
+ if (options?.notify) {
221
+ window.dispatchEvent(
222
+ new CustomEvent(SESSION_UPDATED_EVENT, {
223
+ detail: { key: storageKey, agent, conversationId: session.conversationId },
224
+ }),
225
+ )
226
+ }
196
227
  } catch {
197
228
  // Quota exceeded / privacy mode — silently drop persistence.
198
229
  }
@@ -550,6 +581,11 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
550
581
  typeof conversationIdInput === 'string' && conversationIdInput.length > 0
551
582
  ? conversationIdInput
552
583
  : mintedConversationIdRef.current
584
+ const persistKey =
585
+ typeof conversationIdInput === 'string' && conversationIdInput.length > 0
586
+ ? conversationIdInput
587
+ : null
588
+ const sessionStorageKey = getSessionStorageKey(agent, persistKey)
553
589
 
554
590
  const [messages, setMessages] = React.useState<AiChatMessage[]>(() => {
555
591
  if (persisted && persisted.messages.length > 0) {
@@ -565,13 +601,40 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
565
601
  // Persist messages + conversationId on every change. Skip during in-flight
566
602
  // streaming so we do not write the same growing string on every chunk —
567
603
  // the next idle tick captures the final assistant content.
568
- const [status, setStatusInternal] = React.useState<'idle' | 'submitting' | 'streaming'>('idle')
604
+ const [status, setStatusInternal] = React.useState<'idle' | 'submitting' | 'streaming'>(() =>
605
+ isSessionStreamActive(sessionStorageKey) ? 'streaming' : 'idle',
606
+ )
607
+ // Refs mirror the latest persist state so the unmount cleanup can flush
608
+ // an in-flight assistant message to localStorage even though the streaming
609
+ // skip above (and React's stale-closure semantics) would otherwise lose it.
610
+ // Without this, closing the dock or switching agents while the assistant
611
+ // is "Thinking" abandons the partial reply (issue #1816).
612
+ const latestMessagesRef = React.useRef<AiChatMessage[]>(messages)
613
+ const persistKeyRef = React.useRef<string | null>(null)
614
+ const agentRef = React.useRef<string>(agent)
615
+ const effectiveConversationIdRef = React.useRef<string>(effectiveConversationId)
616
+ const sessionStorageKeyRef = React.useRef<string>(sessionStorageKey)
617
+ const mountedRef = React.useRef(true)
569
618
  React.useEffect(() => {
570
- if (status !== 'idle') return
571
- const persistKey =
619
+ latestMessagesRef.current = messages
620
+ }, [messages])
621
+ React.useEffect(() => {
622
+ persistKeyRef.current =
572
623
  typeof conversationIdInput === 'string' && conversationIdInput.length > 0
573
624
  ? conversationIdInput
574
625
  : null
626
+ }, [conversationIdInput])
627
+ React.useEffect(() => {
628
+ agentRef.current = agent
629
+ }, [agent])
630
+ React.useEffect(() => {
631
+ effectiveConversationIdRef.current = effectiveConversationId
632
+ }, [effectiveConversationId])
633
+ React.useEffect(() => {
634
+ sessionStorageKeyRef.current = sessionStorageKey
635
+ }, [sessionStorageKey])
636
+ React.useEffect(() => {
637
+ if (status !== 'idle') return
575
638
  if (messages.length === 0) {
576
639
  clearPersistedSession(agent, persistKey)
577
640
  return
@@ -585,8 +648,45 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
585
648
  },
586
649
  persistKey,
587
650
  )
588
- }, [agent, conversationIdInput, effectiveConversationId, messages, status])
589
- const setStatus = setStatusInternal
651
+ }, [agent, effectiveConversationId, messages, persistKey, status])
652
+ const persistSnapshot = React.useCallback((snapshot: AiChatMessage[], notify = false) => {
653
+ if (snapshot.length === 0) return
654
+ writePersistedSession(
655
+ agentRef.current,
656
+ {
657
+ v: SESSION_STORAGE_VERSION,
658
+ conversationId: effectiveConversationIdRef.current,
659
+ messages: snapshot,
660
+ },
661
+ persistKeyRef.current,
662
+ { notify },
663
+ )
664
+ }, [])
665
+
666
+ const updateMessages = React.useCallback(
667
+ (
668
+ updater:
669
+ | AiChatMessage[]
670
+ | ((current: AiChatMessage[]) => AiChatMessage[]),
671
+ options?: { persistWhenUnmounted?: boolean },
672
+ ) => {
673
+ const current = latestMessagesRef.current
674
+ const next = typeof updater === 'function' ? updater(current) : updater
675
+ latestMessagesRef.current = next
676
+ if (mountedRef.current) {
677
+ setMessages(next)
678
+ } else if (options?.persistWhenUnmounted) {
679
+ persistSnapshot(next, true)
680
+ }
681
+ },
682
+ [persistSnapshot],
683
+ )
684
+
685
+ const setStatus = React.useCallback((next: 'idle' | 'submitting' | 'streaming') => {
686
+ if (mountedRef.current) {
687
+ setStatusInternal(next)
688
+ }
689
+ }, [])
590
690
  const [error, setError] = React.useState<AiChatErrorEnvelope | null>(null)
591
691
  const [lastRequestDebug, setLastRequestDebug] = React.useState<
592
692
  { url: string; body: unknown } | null
@@ -602,12 +702,14 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
602
702
  }, [onError])
603
703
 
604
704
  const emitError = React.useCallback((envelope: AiChatErrorEnvelope) => {
605
- setError(envelope)
606
- try {
607
- onErrorRef.current?.(envelope)
608
- } catch {
609
- // UI layer must never throw because a caller-supplied error handler
610
- // misbehaved.
705
+ if (mountedRef.current) {
706
+ setError(envelope)
707
+ try {
708
+ onErrorRef.current?.(envelope)
709
+ } catch {
710
+ // UI layer must never throw because a caller-supplied error handler
711
+ // misbehaved.
712
+ }
611
713
  }
612
714
  }, [])
613
715
 
@@ -616,18 +718,19 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
616
718
  abortRef.current.abort()
617
719
  abortRef.current = null
618
720
  }
721
+ writeSessionStreamState(sessionStorageKeyRef.current, false)
619
722
  setStatus('idle')
620
- }, [])
723
+ }, [setStatus])
621
724
 
622
725
  const reset = React.useCallback(() => {
623
726
  cancel()
624
- setMessages([])
727
+ updateMessages([])
625
728
  setError(null)
626
729
  setLastRequestDebug(null)
627
730
  setLastResponseDebug(null)
628
- clearPersistedSession(agent)
731
+ clearPersistedSession(agent, persistKey)
629
732
  mintedConversationIdRef.current = makeConversationId()
630
- }, [agent, cancel])
733
+ }, [agent, cancel, persistKey, updateMessages])
631
734
 
632
735
  const sendMessage = React.useCallback(
633
736
  async (textInput: string, files?: AiChatMessageFile[]) => {
@@ -652,9 +755,10 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
652
755
  const assistantId = assistantMessage.id
653
756
  // Snapshot prior messages for request payload so the dispatcher sees the
654
757
  // full turn history including the just-added user message.
655
- const outgoingHistory = [...messages, userMessage]
656
- setMessages([...outgoingHistory, assistantMessage])
758
+ const outgoingHistory = [...latestMessagesRef.current, userMessage]
759
+ updateMessages([...outgoingHistory, assistantMessage], { persistWhenUnmounted: true })
657
760
  setStatus('submitting')
761
+ writeSessionStreamState(sessionStorageKey, true)
658
762
 
659
763
  const controller = new AbortController()
660
764
  abortRef.current = controller
@@ -670,7 +774,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
670
774
  debug,
671
775
  conversationId: effectiveConversationId,
672
776
  }
673
- setLastRequestDebug({ url, body })
777
+ if (mountedRef.current) {
778
+ setLastRequestDebug({ url, body })
779
+ }
674
780
 
675
781
  let response: Response
676
782
  try {
@@ -685,6 +791,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
685
791
  })
686
792
  } catch (requestError) {
687
793
  if ((requestError as { name?: string })?.name === 'AbortError') {
794
+ writeSessionStreamState(sessionStorageKey, false)
688
795
  setStatus('idle')
689
796
  abortRef.current = null
690
797
  return
@@ -694,6 +801,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
694
801
  ? requestError.message
695
802
  : 'Network request failed.'
696
803
  emitError({ message })
804
+ writeSessionStreamState(sessionStorageKey, false)
697
805
  setStatus('idle')
698
806
  abortRef.current = null
699
807
  return
@@ -701,17 +809,25 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
701
809
 
702
810
  if (!response.ok) {
703
811
  const envelope = await readErrorEnvelope(response)
704
- setLastResponseDebug({ status: response.status, text: envelope.message })
812
+ if (mountedRef.current) {
813
+ setLastResponseDebug({ status: response.status, text: envelope.message })
814
+ }
705
815
  emitError(envelope)
816
+ writeSessionStreamState(sessionStorageKey, false)
706
817
  setStatus('idle')
707
- setMessages((current) => current.filter((entry) => entry.id !== assistantId))
818
+ updateMessages((current) => current.filter((entry) => entry.id !== assistantId), {
819
+ persistWhenUnmounted: true,
820
+ })
708
821
  abortRef.current = null
709
822
  return
710
823
  }
711
824
 
712
825
  const bodyStream = response.body
713
826
  if (!bodyStream) {
714
- setLastResponseDebug({ status: response.status, text: '' })
827
+ if (mountedRef.current) {
828
+ setLastResponseDebug({ status: response.status, text: '' })
829
+ }
830
+ writeSessionStreamState(sessionStorageKey, false)
715
831
  setStatus('idle')
716
832
  abortRef.current = null
717
833
  return
@@ -771,12 +887,14 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
771
887
  builder = { ...builder, text: streamedRaw }
772
888
  }
773
889
  const snapshotBuilder = builder
774
- setMessages((current) =>
775
- current.map((entry) =>
776
- entry.id === assistantId
777
- ? mergeAssistantMessage(entry, snapshotBuilder)
778
- : entry,
779
- ),
890
+ updateMessages(
891
+ (current) =>
892
+ current.map((entry) =>
893
+ entry.id === assistantId
894
+ ? mergeAssistantMessage(entry, snapshotBuilder)
895
+ : entry,
896
+ ),
897
+ { persistWhenUnmounted: true },
780
898
  )
781
899
  }
782
900
  const tail = decoder.decode()
@@ -793,14 +911,18 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
793
911
  }
794
912
  builder = { ...builder, reasoningStreaming: false }
795
913
  const finalSnapshot = builder
796
- setMessages((current) =>
797
- current.map((entry) =>
798
- entry.id === assistantId
799
- ? mergeAssistantMessage(entry, finalSnapshot)
800
- : entry,
801
- ),
914
+ updateMessages(
915
+ (current) =>
916
+ current.map((entry) =>
917
+ entry.id === assistantId
918
+ ? mergeAssistantMessage(entry, finalSnapshot)
919
+ : entry,
920
+ ),
921
+ { persistWhenUnmounted: true },
802
922
  )
803
- setLastResponseDebug({ status: response.status, text: streamedRaw })
923
+ if (mountedRef.current) {
924
+ setLastResponseDebug({ status: response.status, text: streamedRaw })
925
+ }
804
926
  const isEmpty =
805
927
  !builder.text.trim() && builder.toolCalls.length === 0 && !builder.reasoning
806
928
  if (isEmpty) {
@@ -809,7 +931,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
809
931
  message:
810
932
  'The AI agent returned an empty response. This usually means the LLM provider rejected the request (invalid API key, rate limit, or model error). Check your server logs for details.',
811
933
  })
812
- setMessages((current) => current.filter((entry) => entry.id !== assistantId))
934
+ updateMessages((current) => current.filter((entry) => entry.id !== assistantId), {
935
+ persistWhenUnmounted: true,
936
+ })
813
937
  }
814
938
  } catch (streamError) {
815
939
  if ((streamError as { name?: string })?.name === 'AbortError') {
@@ -829,27 +953,79 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
829
953
  emitError({ code: 'stream_error', message })
830
954
  // Remove the empty assistant placeholder so the error alert is
831
955
  // the only visible feedback.
832
- setMessages((current) => current.filter((entry) => entry.id !== assistantId))
956
+ updateMessages((current) => current.filter((entry) => entry.id !== assistantId), {
957
+ persistWhenUnmounted: true,
958
+ })
833
959
  }
834
960
  } finally {
835
961
  reader.releaseLock()
836
962
  if (abortRef.current === controller) {
837
963
  abortRef.current = null
838
964
  }
965
+ writeSessionStreamState(sessionStorageKey, false)
839
966
  setStatus('idle')
840
967
  }
841
968
  },
842
- [agent, apiPath, attachmentIds, debug, effectiveConversationId, emitError, messages, pageContext],
969
+ [
970
+ agent,
971
+ apiPath,
972
+ attachmentIds,
973
+ debug,
974
+ effectiveConversationId,
975
+ emitError,
976
+ pageContext,
977
+ sessionStorageKey,
978
+ setStatus,
979
+ updateMessages,
980
+ ],
843
981
  )
844
982
 
845
983
  React.useEffect(() => {
984
+ if (typeof window === 'undefined') return
985
+ const handleSessionUpdate = (event: Event) => {
986
+ const detail = (event as CustomEvent<{ key?: string }>).detail
987
+ if (!detail || detail.key !== sessionStorageKey) return
988
+ const next = readPersistedSession(agent, persistKey)
989
+ if (!next) return
990
+ if (next.conversationId !== effectiveConversationId) return
991
+ updateMessages(next.messages)
992
+ }
993
+ window.addEventListener(SESSION_UPDATED_EVENT, handleSessionUpdate)
846
994
  return () => {
847
- if (abortRef.current) {
848
- abortRef.current.abort()
849
- abortRef.current = null
995
+ window.removeEventListener(SESSION_UPDATED_EVENT, handleSessionUpdate)
996
+ }
997
+ }, [agent, effectiveConversationId, persistKey, sessionStorageKey, updateMessages])
998
+
999
+ React.useEffect(() => {
1000
+ if (isSessionStreamActive(sessionStorageKey)) {
1001
+ setStatusInternal('streaming')
1002
+ }
1003
+ if (typeof window === 'undefined') return
1004
+ const handleStreamState = (event: Event) => {
1005
+ const detail = (event as CustomEvent<{ key?: string; active?: boolean }>).detail
1006
+ if (!detail || detail.key !== sessionStorageKey) return
1007
+ setStatusInternal(detail.active ? 'streaming' : 'idle')
1008
+ }
1009
+ window.addEventListener(SESSION_STREAM_STATE_EVENT, handleStreamState)
1010
+ return () => {
1011
+ window.removeEventListener(SESSION_STREAM_STATE_EVENT, handleStreamState)
1012
+ }
1013
+ }, [sessionStorageKey])
1014
+
1015
+ React.useEffect(() => {
1016
+ mountedRef.current = true
1017
+ return () => {
1018
+ // Flush the latest snapshot — including any in-flight assistant
1019
+ // content — and let the request keep running in the background.
1020
+ // Background stream updates continue writing to this same storage slot
1021
+ // and notify any reopened chat for the conversation (issue #1816).
1022
+ const finalMessages = latestMessagesRef.current
1023
+ if (finalMessages.length > 0) {
1024
+ persistSnapshot(finalMessages, true)
850
1025
  }
1026
+ mountedRef.current = false
851
1027
  }
852
- }, [])
1028
+ }, [persistSnapshot])
853
1029
 
854
1030
  return {
855
1031
  messages,