feat(app): add error boundaries to mobile content and detail views
Wrap mobile ContentGridView and DetailView with ErrorBoundary components to prevent cascading failures on mobile. Fix prefer-const lint error in chat store. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c6843fca1a
commit
72f5656e02
@@ -51,31 +51,35 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContentGridView
|
||||
:active-tab="activeTab"
|
||||
:is-wide-desktop="isWideDesktop"
|
||||
:is-mobile="isMobile"
|
||||
:panel-films="panelFilms"
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-places="panelPlaces"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
:panel-websites="panelWebsites"
|
||||
:panel-magazine-sections="panelMagazineSections"
|
||||
:panel-magazine-hero-image="panelMagazineHeroImage"
|
||||
:panel-title="panelTitle"
|
||||
:panel-query="panelQuery"
|
||||
@close="closePanel"
|
||||
/>
|
||||
<ErrorBoundary title="Content failed to load">
|
||||
<ContentGridView
|
||||
:active-tab="activeTab"
|
||||
:is-wide-desktop="isWideDesktop"
|
||||
:is-mobile="isMobile"
|
||||
:panel-films="panelFilms"
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-places="panelPlaces"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
:panel-websites="panelWebsites"
|
||||
:panel-magazine-sections="panelMagazineSections"
|
||||
:panel-magazine-hero-image="panelMagazineHeroImage"
|
||||
:panel-title="panelTitle"
|
||||
:panel-query="panelQuery"
|
||||
@close="closePanel"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
<!-- Detail replaces grid on regular desktops -->
|
||||
<div v-else-if="!isWideDesktop && panelOpen && hasDetailOpen" class="flex-1 min-w-0 flex flex-col">
|
||||
<CloseButton @click="hasGridContent ? closeAllDetails() : closePanel()" />
|
||||
<DetailView />
|
||||
<ErrorBoundary title="Detail view error">
|
||||
<DetailView />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -114,7 +118,9 @@
|
||||
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col order-3 detail-persistent"
|
||||
>
|
||||
<template v-if="hasDetailOpen">
|
||||
<DetailView />
|
||||
<ErrorBoundary title="Detail view error">
|
||||
<DetailView />
|
||||
</ErrorBoundary>
|
||||
</template>
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center space-y-4 max-w-[200px] px-4">
|
||||
@@ -142,10 +148,12 @@
|
||||
? (panelSide === 'left' ? 'order-1' : 'order-2')
|
||||
: (panelSide === 'left' ? 'order-first' : 'order-last')"
|
||||
>
|
||||
<ChatWindow
|
||||
:side="panelSide"
|
||||
@switch-side="chatStore.switchSide()"
|
||||
/>
|
||||
<ErrorBoundary title="Chat error">
|
||||
<ChatWindow
|
||||
:side="panelSide"
|
||||
@switch-side="chatStore.switchSide()"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -154,10 +162,12 @@
|
||||
<!-- Chat view -->
|
||||
<div v-show="mobileTab === 'chat'" class="flex-1 min-h-0 flex flex-col p-2 pb-0">
|
||||
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
|
||||
<ChatWindow
|
||||
variant="standalone"
|
||||
:show-close="false"
|
||||
/>
|
||||
<ErrorBoundary title="Chat error">
|
||||
<ChatWindow
|
||||
variant="standalone"
|
||||
:show-close="false"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,25 +201,27 @@
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
</div>
|
||||
<ContentGridView
|
||||
:active-tab="activeTab"
|
||||
:is-wide-desktop="isWideDesktop"
|
||||
:is-mobile="isMobile"
|
||||
:panel-films="panelFilms"
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-places="panelPlaces"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
:panel-websites="panelWebsites"
|
||||
:panel-magazine-sections="panelMagazineSections"
|
||||
:panel-magazine-hero-image="panelMagazineHeroImage"
|
||||
:panel-title="panelTitle"
|
||||
:panel-query="panelQuery"
|
||||
@close="closePanel"
|
||||
/>
|
||||
<ErrorBoundary title="Content failed to load">
|
||||
<ContentGridView
|
||||
:active-tab="activeTab"
|
||||
:is-wide-desktop="isWideDesktop"
|
||||
:is-mobile="isMobile"
|
||||
:panel-films="panelFilms"
|
||||
:panel-books="panelBooks"
|
||||
:panelTVSeries="panelTVSeries"
|
||||
:panel-images="panelImages"
|
||||
:panel-places="panelPlaces"
|
||||
:panel-songs="panelSongs"
|
||||
:panel-podcasts="panelPodcasts"
|
||||
:panel-web-results="panelWebResults"
|
||||
:panel-websites="panelWebsites"
|
||||
:panel-magazine-sections="panelMagazineSections"
|
||||
:panel-magazine-hero-image="panelMagazineHeroImage"
|
||||
:panel-title="panelTitle"
|
||||
:panel-query="panelQuery"
|
||||
@close="closePanel"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</template>
|
||||
<template v-else-if="chatStore.isStreaming">
|
||||
<ContextLoader :context-type="loaderContextType" />
|
||||
@@ -232,7 +244,9 @@
|
||||
<div class="flex-1 min-h-0 path-glass-card flex flex-col rounded-2xl overflow-hidden">
|
||||
<template v-if="hasDetailOpen">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<DetailView />
|
||||
<ErrorBoundary title="Detail view error">
|
||||
<DetailView />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<!-- Navigation bar (skip for magazine sections which have their own) -->
|
||||
<div
|
||||
@@ -356,6 +370,7 @@ import DetailView from '@/components/content/DetailView.vue'
|
||||
import CloseButton from '@/components/content/CloseButton.vue'
|
||||
import ContextLoader from '@/components/content/ContextLoader.vue'
|
||||
import PlayerBar from '@/components/player/PlayerBar.vue'
|
||||
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
||||
import { usePlayer } from '@/composables/usePlayer'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Message, Conversation, WebSearchResult } from '@aiui/core/types/message'
|
||||
import {
|
||||
saveConversation as idbSave,
|
||||
loadAllConversations as idbLoadAll,
|
||||
deleteConversation as idbDelete,
|
||||
isIDBAvailable,
|
||||
} from '@/utils/idb-storage'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const useIDB = isIDBAvailable()
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const SAVE_DEBOUNCE = 800
|
||||
|
||||
// Server-side persistence via Vite dev middleware
|
||||
// Server-side persistence via Vite dev middleware (fallback for dev mode without IDB)
|
||||
async function loadServerChats(): Promise<{ conversations: Map<string, Conversation>; activeId: string | null }> {
|
||||
const empty = { conversations: new Map<string, Conversation>(), activeId: null }
|
||||
if (!isDev) return empty
|
||||
@@ -43,6 +51,19 @@ function saveServerChats(conversations: Map<string, Conversation>, activeId: str
|
||||
}, SAVE_DEBOUNCE)
|
||||
}
|
||||
|
||||
// Debounced IDB save for a single conversation
|
||||
const idbSaveTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function debouncedIDBSave(conv: Conversation) {
|
||||
if (!useIDB) return
|
||||
const existing = idbSaveTimers.get(conv.id)
|
||||
if (existing) clearTimeout(existing)
|
||||
idbSaveTimers.set(conv.id, setTimeout(() => {
|
||||
idbSave(conv).catch(() => {})
|
||||
idbSaveTimers.delete(conv.id)
|
||||
}, SAVE_DEBOUNCE))
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref<Map<string, Conversation>>(new Map())
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
@@ -55,25 +76,55 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false')
|
||||
const chatCollapsed = ref(localStorage.getItem('aiui-chat-collapsed') !== 'false')
|
||||
|
||||
// Load chats from server on startup — _loaded gate prevents the watcher
|
||||
// from overwriting the file with empty data before the load completes
|
||||
if (isDev) {
|
||||
loadServerChats().then((data) => {
|
||||
// Load chats: try IndexedDB first, fall back to dev-chats middleware
|
||||
async function loadChats() {
|
||||
if (useIDB) {
|
||||
try {
|
||||
const idbConversations = await idbLoadAll()
|
||||
if (idbConversations.size > 0) {
|
||||
conversations.value = idbConversations
|
||||
const savedActiveId = localStorage.getItem('aiui-active-conversation')
|
||||
activeConversationId.value =
|
||||
savedActiveId && idbConversations.has(savedActiveId)
|
||||
? savedActiveId
|
||||
: [...idbConversations.keys()].pop() ?? null
|
||||
loaded.value = true
|
||||
_loaded = true
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// IDB failed, fall through to dev-chats
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
const data = await loadServerChats()
|
||||
if (data.conversations.size > 0) {
|
||||
conversations.value = data.conversations
|
||||
activeConversationId.value =
|
||||
data.activeId && data.conversations.has(data.activeId)
|
||||
? data.activeId
|
||||
: [...data.conversations.keys()][0] ?? null
|
||||
// Migrate existing dev-chats to IndexedDB
|
||||
if (useIDB) {
|
||||
for (const conv of data.conversations.values()) {
|
||||
idbSave(conv).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded.value = true
|
||||
_loaded = true
|
||||
})
|
||||
} else {
|
||||
}
|
||||
loaded.value = true
|
||||
_loaded = true
|
||||
}
|
||||
|
||||
loadChats()
|
||||
|
||||
// Persist active conversation ID to localStorage
|
||||
watch(activeConversationId, (id) => {
|
||||
if (id) localStorage.setItem('aiui-active-conversation', id)
|
||||
else localStorage.removeItem('aiui-active-conversation')
|
||||
})
|
||||
|
||||
watch(panelSide, (val) => {
|
||||
localStorage.setItem('aiui-panel-side', val)
|
||||
})
|
||||
@@ -89,6 +140,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
watch(
|
||||
[conversations, activeConversationId],
|
||||
([conv, active]) => {
|
||||
// Dev-chats middleware fallback
|
||||
saveServerChats(conv as Map<string, Conversation>, active as string | null)
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -116,6 +168,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
conversations.value.set(id, conversation)
|
||||
activeConversationId.value = id
|
||||
debouncedIDBSave(conversation)
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -135,6 +188,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
conv.title = message.content.slice(0, 60) + (message.content.length > 60 ? '...' : '')
|
||||
}
|
||||
|
||||
debouncedIDBSave(conv)
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -143,13 +197,17 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (!conv || conv.messages.length === 0) return
|
||||
const last = conv.messages[conv.messages.length - 1]
|
||||
last.content += text
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
|
||||
function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) {
|
||||
const conv = conversations.value.get(conversationId)
|
||||
if (!conv) return
|
||||
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
|
||||
if (msg) msg.webResults = results
|
||||
if (msg) {
|
||||
msg.webResults = results
|
||||
debouncedIDBSave(conv)
|
||||
}
|
||||
}
|
||||
|
||||
function switchSide() {
|
||||
@@ -168,6 +226,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
conversations.value.delete(id)
|
||||
if (useIDB) idbDelete(id).catch(() => {})
|
||||
if (activeConversationId.value === id) {
|
||||
const remaining = conversationList.value
|
||||
activeConversationId.value = remaining.length > 0 ? remaining[0].id : null
|
||||
|
||||
Reference in New Issue
Block a user