feat(content): add places, code mode, mobile context tab, and detail views

- Add Places/Restaurants content type with PlaceCard, PlaceDetail, PlaceGrid
- Add WebsiteDetail and MagazineSectionDetail views for Context panel
- Enhance MagazineGrid hero with background image and 3x taller header
- Add mobile 3-tab layout (Chat, Content, Context) with detail navigation
- Add /code command system: useCodeContext composable, ProjectGrid, FileTreeNode,
  CodeDetail for IDE-style code viewing across all three panels
- Fix /code bubble and prompt index clicks to re-activate code mode
- Fix updatePanelFromText overwriting code tab by skipping command messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 09:31:18 +00:00
co-authored by Claude Opus 4.6
parent 48dd7a9c68
commit e8fc54cade
22 changed files with 1901 additions and 107 deletions
+228 -68
View File
@@ -10,20 +10,22 @@
>
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout: side-by-side -->
<!-- Desktop layout -->
<div class="flex-1 flex h-full p-3 md:p-4 gap-3 md:gap-4" :class="isMobile ? 'hidden' : ''">
<!-- Content surface (main area) -->
<main
class="flex-1 min-w-0 path-glass-card overflow-hidden flex relative"
:class="[
panelSide === 'left' ? 'order-last' : 'order-first',
hasDetailOpen && !hasGridContent && 'detail-active'
isWideDesktop
? (panelSide === 'left' ? 'order-2' : 'order-1')
: (panelSide === 'left' ? 'order-last' : 'order-first'),
!isWideDesktop && hasDetailOpen && !hasGridContent && 'detail-active'
]"
>
<!-- Grid/list panel -->
<!-- Grid/list panel (on wide desktop: always show grid; on regular: show when no detail) -->
<div
v-if="panelOpen && hasGridContent && !hasDetailOpen"
v-if="panelOpen && hasGridContent && (isWideDesktop || !hasDetailOpen)"
class="flex-1 min-w-0 flex flex-col"
>
<CloseButton @click="closePanel" />
@@ -56,6 +58,7 @@
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
:panel-images="panelImages"
:panel-places="panelPlaces"
:panel-songs="panelSongs"
:panel-podcasts="panelPodcasts"
:panel-web-results="panelWebResults"
@@ -68,61 +71,9 @@
/>
</div>
<!-- Side-by-side: grid + detail on wide screens -->
<template v-else-if="panelOpen && hasGridContent && hasDetailOpen">
<div class="w-[45%] min-w-0 flex flex-col shrink-0"
:style="isDark
? 'border-right: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-right: 1px solid rgba(0, 0, 0, 0.06)'">
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center gap-2 px-4 py-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex flex-wrap gap-1.5 flex-1 min-w-0 justify-center">
<button
v-for="tab in availableTabs"
:key="tab"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeTab === tab
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
</div>
<ContentGridView
:active-tab="activeTab"
:panel-films="panelFilms"
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
:panel-images="panelImages"
: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"
/>
</div>
<div class="flex-1 min-w-0 flex flex-col">
<CloseButton @click="closeAllDetails" />
<DetailView />
</div>
</template>
<!-- Detail only (no grid content e.g. article overlay) -->
<div v-else-if="panelOpen && hasDetailOpen" class="flex-1 min-w-0 flex flex-col detail-active">
<CloseButton @click="closePanel" />
<!-- 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 />
</div>
@@ -156,10 +107,39 @@
</div>
</main>
<!-- Detail panel (wide desktop only persistent, no close/back) -->
<div
v-if="isWideDesktop"
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col order-3 detail-persistent"
>
<template v-if="hasDetailOpen">
<DetailView />
</template>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-4 max-w-[200px] px-4">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<div>
<h2 class="text-sm font-semibold mb-1"
:class="isDark ? 'text-white/60' : 'text-gray-600'">
Awaiting Context
</h2>
<p class="text-xs leading-relaxed"
:class="isDark ? 'text-white/25' : 'text-gray-400'">
Select an item to see details here.
</p>
</div>
</div>
</div>
</div>
<!-- Chat panel -->
<aside
class="relative z-[100] w-80 xl:w-96 shrink-0 flex flex-col path-glass-card overflow-visible"
:class="panelSide === 'left' ? 'order-first' : 'order-last'"
:class="isWideDesktop
? (panelSide === 'left' ? 'order-1' : 'order-2')
: (panelSide === 'left' ? 'order-first' : 'order-last')"
>
<ChatWindow
:side="panelSide"
@@ -183,10 +163,7 @@
<!-- Content view -->
<div v-show="mobileTab === 'content'" 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">
<template v-if="panelOpen && hasDetailOpen">
<DetailView />
</template>
<template v-else-if="panelOpen">
<template v-if="panelOpen">
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center gap-2 px-3 pt-2 pb-1 overflow-x-auto scrollbar-hide"
@@ -211,6 +188,7 @@
:panel-books="panelBooks"
:panelTVSeries="panelTVSeries"
:panel-images="panelImages"
:panel-places="panelPlaces"
:panel-songs="panelSongs"
:panel-podcasts="panelPodcasts"
:panel-web-results="panelWebResults"
@@ -238,6 +216,67 @@
</div>
</div>
<!-- Context view (detail panel) -->
<div v-show="mobileTab === 'context'" 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">
<template v-if="hasDetailOpen">
<div class="flex-1 min-h-0 flex flex-col">
<DetailView />
</div>
<!-- Navigation bar (skip for magazine sections which have their own) -->
<div
v-if="!selectedMagazineSection && contextItems.length > 1"
class="shrink-0 flex items-center justify-between px-4 py-2"
:style="isDark
? 'border-top: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-top: 1px solid rgba(0, 0, 0, 0.06)'"
>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="navigateContext('prev')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Prev
</button>
<span class="text-[10px] font-mono tabular-nums"
:class="isDark ? 'text-white/25' : 'text-black/25'">
{{ currentContextIndex + 1 }}/{{ contextItems.length }}
</span>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-colors"
:class="isDark
? 'text-white/50 hover:text-white/80 hover:bg-white/5'
: 'text-black/40 hover:text-black/70 hover:bg-black/5'"
@click="navigateContext('next')"
>
Next
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</template>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center space-y-3 px-6">
<div class="empty-state-icon w-16 h-16 rounded-2xl path-glass-icon flex items-center justify-center mx-auto overflow-hidden">
<svg class="w-7 h-7" :class="isDark ? 'text-white/30' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Tap an item in Content to view details here.
</p>
</div>
</div>
</div>
</div>
<!-- Bottom tab bar -->
<div
class="shrink-0 flex items-center px-2 py-1.5 gap-1"
@@ -270,6 +309,23 @@
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
<button
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150 relative"
:class="mobileTab === 'context'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'context'"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Context
<span
v-if="hasDetailOpen && mobileTab !== 'context'"
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
</div>
</div>
@@ -290,8 +346,10 @@ import CloseButton from '@/components/content/CloseButton.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import { usePlayer } from '@/composables/usePlayer'
import { useCodeContext } from '@/composables/useCodeContext'
const chatStore = useChatStore()
const { activeFile: codeActiveFile, isCodeMode } = useCodeContext()
const { hasTrack } = usePlayer()
const { isDark } = useTheme()
@@ -303,6 +361,7 @@ const {
panelBooks,
panelTVSeries,
panelImages,
panelPlaces,
panelSongs,
panelPodcasts,
panelWebResults,
@@ -319,17 +378,31 @@ const {
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
selectedWebsite,
selectedMagazineSection,
closeFilmDetail,
closeBookDetail,
closeTVSeriesDetail,
closeImageDetail,
closePlaceDetail,
closeSongDetail,
closePodcastDetail,
closeArticleDetail,
closeWebsiteDetail,
closeMagazineSectionDetail,
closePanel,
openFilmDetail,
openBookDetail,
openTVSeriesDetail,
openImageDetail,
openPlaceDetail,
openSongDetail,
openPodcastDetail,
openMagazineSectionDetail,
} = useContentPanel()
const panelSide = computed(() => chatStore.panelSide)
@@ -337,22 +410,28 @@ const panelSide = computed(() => chatStore.panelSide)
// Mobile detection
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const mobileTab = ref<'chat' | 'content'>('chat')
const isWideDesktop = computed(() => windowWidth.value >= 1440)
const mobileTab = ref<'chat' | 'content' | 'context'>('chat')
function onResize() { windowWidth.value = window.innerWidth }
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
// Auto-switch to content tab on mobile when panel opens
// Auto-switch to content tab on mobile when panel opens or detail is selected
watch(panelOpen, (open) => {
if (open && isMobile.value) mobileTab.value = 'content'
})
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value ||
selectedImage.value || selectedSong.value || selectedPodcast.value || selectedArticle.value)
selectedImage.value || selectedPlace.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedWebsite.value || selectedMagazineSection.value ||
(isCodeMode.value && codeActiveFile.value))
)
watch(hasDetailOpen, (open) => {
if (open && isMobile.value) mobileTab.value = 'context'
})
const hasGridContent = computed(() =>
panelOpen.value && availableTabs.value.length > 0
)
@@ -362,9 +441,83 @@ function closeAllDetails() {
closeBookDetail()
closeTVSeriesDetail()
closeImageDetail()
closePlaceDetail()
closeSongDetail()
closePodcastDetail()
closeArticleDetail()
closeWebsiteDetail()
closeMagazineSectionDetail()
}
// Context navigation — flat list of all detail-openable items
interface ContextNavItem {
open: () => void
}
const contextItems = computed<ContextNavItem[]>(() => {
const items: ContextNavItem[] = []
panelFilms.value.forEach(f => items.push({ open: () => openFilmDetail(f) }))
panelBooks.value.forEach(b => items.push({ open: () => openBookDetail(b) }))
panelTVSeries.value.forEach(s => items.push({ open: () => openTVSeriesDetail(s) }))
panelSongs.value.forEach(s => items.push({ open: () => openSongDetail(s) }))
panelPodcasts.value.forEach(p => items.push({ open: () => openPodcastDetail(p) }))
panelImages.value.forEach(img => items.push({ open: () => openImageDetail(img) }))
panelPlaces.value.forEach(p => items.push({ open: () => openPlaceDetail(p) }))
panelMagazineSections.value.forEach((s, i) => items.push({ open: () => openMagazineSectionDetail(s, i) }))
return items
})
const currentContextIndex = computed(() => {
// Determine which item is currently selected
if (selectedFilm.value) {
const idx = panelFilms.value.indexOf(selectedFilm.value)
return idx >= 0 ? idx : 0
}
if (selectedBook.value) {
const idx = panelBooks.value.indexOf(selectedBook.value)
return idx >= 0 ? panelFilms.value.length + idx : 0
}
if (selectedTVSeries.value) {
const idx = panelTVSeries.value.indexOf(selectedTVSeries.value)
return idx >= 0 ? panelFilms.value.length + panelBooks.value.length + idx : 0
}
if (selectedSong.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length
const idx = panelSongs.value.indexOf(selectedSong.value)
return idx >= 0 ? base + idx : 0
}
if (selectedPodcast.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length
const idx = panelPodcasts.value.indexOf(selectedPodcast.value)
return idx >= 0 ? base + idx : 0
}
if (selectedImage.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length
const idx = panelImages.value.indexOf(selectedImage.value)
return idx >= 0 ? base + idx : 0
}
if (selectedPlace.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length + panelImages.value.length
const idx = panelPlaces.value.indexOf(selectedPlace.value)
return idx >= 0 ? base + idx : 0
}
if (selectedMagazineSection.value) {
const base = panelFilms.value.length + panelBooks.value.length + panelTVSeries.value.length + panelSongs.value.length + panelPodcasts.value.length + panelImages.value.length + panelPlaces.value.length
const idx = panelMagazineSections.value.indexOf(selectedMagazineSection.value)
return idx >= 0 ? base + idx : 0
}
return 0
})
function navigateContext(direction: 'prev' | 'next') {
const items = contextItems.value
if (!items.length) return
let idx = currentContextIndex.value
idx += direction === 'next' ? 1 : -1
if (idx < 0) idx = items.length - 1
if (idx >= items.length) idx = 0
closeAllDetails()
items[idx].open()
}
const TAB_LABELS: Record<ContentTab, string> = {
@@ -372,11 +525,13 @@ const TAB_LABELS: Record<ContentTab, string> = {
book: 'Books',
tvshow: 'TV',
image: 'Images',
place: 'Places',
song: 'Songs',
podcast: 'Podcasts',
news: 'News',
websites: 'Websites',
magazine: 'Brief',
code: 'Code',
}
function tabLabel(tab: ContentTab): string {
@@ -391,6 +546,7 @@ const loaderContextType = computed(() => {
if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book'
if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow'
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|bar|pub|brunch|lunch|dinner)\b/.test(q)) return 'film'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news'
if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine'
@@ -411,4 +567,8 @@ const loaderContextType = computed(() => {
.detail-active {
border-color: transparent !important;
}
/* Hide back buttons in persistent detail panel on wide desktops */
.detail-persistent :deep(button[class*="absolute"][class*="top-3"][class*="left-3"]) {
display: none !important;
}
</style>