This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/ai-history-and-enhancement
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit fec4c8b370139c71472fc4d8e7396ba562cc6b6c
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 11:24:14 2026 +0800

    fix(ai): client chat history now actually persists to IndexedDB
    
    Live testing surfaced three bugs that blocked every write:
    - DataCloneError: conversations are Vue reactive proxies, which IndexedDB's
      structured clone can't serialize — unwrap to plain objects (JSON 
round-trip)
      before put, the same unwrap the old localStorage impl got for free.
    - Transaction auto-commit: an IndexedDB transaction commits the instant you
      await a resolved request inside it, so the subsequent put/delete threw —
      read existing keys in a separate read tx, then write in a fresh tx with no
      await between the ops (save/remove/clear).
    - /ai never re-hydrated: the standalone full-page route hydrated once on 
mount
      with no auth-change retry, so history stayed unloaded — re-load when auth
      settles, mirroring the launcher.
---
 apps/ui/src/ai/AiFullPageView.vue | 14 ++++++++++----
 apps/ui/src/ai/historyStore.ts    | 24 ++++++++++++++++++------
 2 files changed, 28 insertions(+), 10 deletions(-)

diff --git a/apps/ui/src/ai/AiFullPageView.vue 
b/apps/ui/src/ai/AiFullPageView.vue
index 57655f6..c513cd5 100644
--- a/apps/ui/src/ai/AiFullPageView.vue
+++ b/apps/ui/src/ai/AiFullPageView.vue
@@ -24,6 +24,7 @@ import Icon from '@/components/icons/Icon.vue';
 import ChatTranscript from './ChatTranscript.vue';
 import ChatComposer from './ChatComposer.vue';
 import ChatScopeBar from './ChatScopeBar.vue';
+import { useAuthStore } from '@/state/auth';
 import { useAiChat } from './useAiChat';
 import { useAiConversations } from './useAiConversations';
 import { useChatScroll } from './useChatScroll';
@@ -31,11 +32,14 @@ import type { Conversation } from './types';
 
 const { t } = useI18n({ useScope: 'global' });
 const router = useRouter();
+const auth = useAuthStore();
 const chat = useAiChat();
 const conv = useAiConversations();
-// This route is fullscreen (outside AppShell, so the launcher isn't mounted) —
-// load the AI config + this user's history here too.
-onMounted(() => {
+// This route is fullscreen (outside AppShell, so the launcher — which owns the
+// config probe + hydrate — isn't mounted). Load config + this user's history
+// here on mount AND whenever auth settles, so a reload before the session has
+// bootstrapped still hydrates history once the user is known.
+function loadHistory(): void {
   void chat
     .ensureConfig()
     .then(() => conv.hydrate())
@@ -43,7 +47,9 @@ onMounted(() => {
       savingHistory.value = conv.historyEnabled();
       refreshUsage();
     });
-});
+}
+onMounted(loadHistory);
+watch(() => auth.isAuthenticated, loadHistory);
 
 const ordered = computed<Conversation[]>(() => 
[...conv.conversations.value].sort((a, b) => b.updatedAt - a.updatedAt));
 
diff --git a/apps/ui/src/ai/historyStore.ts b/apps/ui/src/ai/historyStore.ts
index 06c8ccc..3f81a46 100644
--- a/apps/ui/src/ai/historyStore.ts
+++ b/apps/ui/src/ai/historyStore.ts
@@ -122,12 +122,20 @@ export function indexedDbHistory(maxBytes: number): 
HistoryStore {
       const db = await openDb();
       if (!db) return kept;
       try {
+        // Read existing keys in a SEPARATE read tx first: an IndexedDB tx
+        // auto-commits the instant you await a resolved request inside it, so
+        // the write tx below must do its delete+put with NO await between 
them.
+        const existing = await reqToPromise<IDBValidKey[]>(
+          db.transaction(STORE, 
'readonly').objectStore(STORE).index('owner').getAllKeys(owner),
+        );
         const keepIds = new Set(kept.map((c) => c.id));
+        // IndexedDB's structured clone can't clone Vue reactive proxies — 
unwrap
+        // to plain objects first (the JSON round-trip the localStorage impl 
used).
+        const plain = JSON.parse(JSON.stringify(kept)) as Conversation[];
         const tx = db.transaction(STORE, 'readwrite');
         const s = tx.objectStore(STORE);
-        const existing = await 
reqToPromise<IDBValidKey[]>(s.index('owner').getAllKeys(owner));
         for (const id of existing) if (!keepIds.has(String(id))) s.delete(id);
-        for (const c of kept) s.put({ ...c, owner });
+        for (const c of plain) s.put({ ...c, owner });
         await txDone(tx);
       } catch {
         /* degrade: this write just won't persist */
@@ -138,10 +146,12 @@ export function indexedDbHistory(maxBytes: number): 
HistoryStore {
       const db = await openDb();
       if (!db) return;
       try {
+        const rec = await reqToPromise<StoredConversation | undefined>(
+          db.transaction(STORE, 'readonly').objectStore(STORE).get(id),
+        );
+        if (!rec || rec.owner !== owner) return;
         const tx = db.transaction(STORE, 'readwrite');
-        const s = tx.objectStore(STORE);
-        const rec = await reqToPromise<StoredConversation | 
undefined>(s.get(id));
-        if (rec && rec.owner === owner) s.delete(id);
+        tx.objectStore(STORE).delete(id);
         await txDone(tx);
       } catch {
         /* ignore */
@@ -151,9 +161,11 @@ export function indexedDbHistory(maxBytes: number): 
HistoryStore {
       const db = await openDb();
       if (!db) return;
       try {
+        const ids = await reqToPromise<IDBValidKey[]>(
+          db.transaction(STORE, 
'readonly').objectStore(STORE).index('owner').getAllKeys(owner),
+        );
         const tx = db.transaction(STORE, 'readwrite');
         const s = tx.objectStore(STORE);
-        const ids = await 
reqToPromise<IDBValidKey[]>(s.index('owner').getAllKeys(owner));
         for (const id of ids) s.delete(id);
         await txDone(tx);
       } catch {

Reply via email to