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:
Dorian
2026-03-03 20:13:08 +00:00
co-authored by Claude Opus 4.6
parent c6843fca1a
commit 72f5656e02
2 changed files with 133 additions and 59 deletions
+64 -49
View File
@@ -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'
+69 -10
View File
@@ -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