Files
archy/packages/app/src/composables/useContentPanel.ts
T
DorianandClaude Opus 4.6 982a0c6aa6 fix(app): news tab shows eagerly for news queries, populates async
The news tab was missing because hasNews evaluated to false when web
search results hadn't arrived yet. Now shows the tab eagerly for news
queries — results populate when they arrive via the deep watcher.
Also fixes RSS late-insertion to place before the Prompt tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 18:14:27 +00:00

529 lines
22 KiB
TypeScript

import { ref } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
import { fetchRssFromUrls } from '@/composables/useRssFetch'
import {
extractAllFilms, extractAllSongs, extractAllPodcasts, extractAllBooks,
extractAllTVSeries, extractAllImages, extractAllPlaces,
extractMagazineSections, extractMagazineHeroImage,
extractMarkdownLinks, extractBoldDomainLinks, extractBareDomainLinks, mergeNewsResults,
extractFilmIds, extractSongIds, extractPodcastIds,
extractApps, extractCodeBlocks,
stripFilmTags, stripSongTags, stripPodcastTags, stripContentTags, stripMarkdownLinks,
} from './contentExtraction'
import type { AppEntry, CodeBlock } from './contentExtraction'
import {
isNewsQuery, isNewsLikeResponse, isTVQuery,
isNostrQuery, isNostrLikeResponse,
isCodeQuery, isCodeLikeResponse,
filterTabsByContext, extractQueryContext,
} from './contentFiltering'
export type { ContentTab, MagazineSection } from './contentFiltering'
import type { ContentTab, MagazineSection } from './contentFiltering'
const panelOpen = ref(false)
const panelFilms = ref<Film[]>([])
const panelBooks = ref<Book[]>([])
const panelTVSeries = ref<TVSeries[]>([])
const panelWebResults = ref<WebSearchResult[]>([])
const panelRssArticles = ref<WebSearchResult[]>([])
const panelWebsites = ref<WebSearchResult[]>([])
const panelMagazineSections = ref<MagazineSection[]>([])
const panelMagazineHeroImage = ref<string | null>(null)
const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([])
const panelImages = ref<ImageItem[]>([])
const panelPlaces = ref<Place[]>([])
const panelApps = ref<AppEntry[]>([])
const panelCodeBlocks = ref<CodeBlock[]>([])
const selectedFilm = ref<Film | null>(null)
const selectedBook = ref<Book | null>(null)
const selectedTVSeries = ref<TVSeries | null>(null)
const selectedSong = ref<Song | null>(null)
const selectedPodcast = ref<Podcast | null>(null)
const selectedArticle = ref<WebSearchResult | null>(null)
const selectedImage = ref<ImageItem | null>(null)
const selectedPlace = ref<Place | null>(null)
const selectedWebsite = ref<WebSearchResult | null>(null)
const selectedApp = ref<AppEntry | null>(null)
const selectedMagazineSection = ref<MagazineSection | null>(null)
const magazineSectionIndex = ref(0)
const panelTitle = ref('Recommended Films')
const panelQuery = ref('')
const panelResponseText = ref('')
const contentType = ref<'film' | 'song' | 'podcast'>('film')
const activeTab = ref<ContentTab>('film')
const availableTabs = ref<ContentTab[]>([])
const selectedDesignSystemItem = ref<DesignSystemItem | null>(null)
const longFormArticle = ref<{ content: string; title?: string } | null>(null)
const pdfUrl = ref<{ url: string; title?: string } | null>(null)
const mapPlaces = ref<Place[]>([])
export interface DesignSystemItem {
id: string
name: string
category: 'colors' | 'typography' | 'spacing' | 'atoms' | 'molecules' | 'organisms'
description: string
code: string
preview?: 'inline'
usedIn?: string
}
export function useContentPanel() {
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
panelQuery.value = userQuery.trim()
panelResponseText.value = text
const songs = extractAllSongs(text, userQuery)
let films = extractAllFilms(text)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, userQuery)
const tvSeries = extractAllTVSeries(text, userQuery)
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
films = films.filter(f => !f.id.startsWith('ext-'))
}
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const bareDomains = extractBareDomainLinks(text)
panelRssArticles.value = []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0 || bareDomains.length > 0
const websitesFromMarkdown = hasLinkableContent ? fromMarkdown : []
const mergedWebsites = mergeNewsResults(mergeNewsResults(websitesFromMarkdown, boldDomains), bareDomains)
const hasWebsites = mergedWebsites.length > 0
// App extraction
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
// Code block extraction
const codeBlocks = extractCodeBlocks(text)
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
// Nostr detection
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
// Magazine = bullet-style sections (- **Title**: Content)
const magazineSections = extractMagazineSections(text)
const hasAnyOtherContent = films.length > 0 || songs.length > 0 || podcasts.length > 0 ||
books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0
const hasMagazine = magazineSections.length >= 1 && (
isNewsQuery(userQuery) || isNewsLikeResponse(text) ||
/sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) ||
(!hasAnyOtherContent && !hasWebsites && magazineSections.length >= 2)
)
// News = actual articles (web search + RSS from website domains)
const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text)
// Show news tab eagerly when query is news-like — results may arrive async
const hasNews = newsContext && (webResults.length > 0 || mergedWebsites.length > 0 || isNewsQuery(userQuery))
const mergedNews = mergeNewsResults(webResults, panelRssArticles.value)
if (mergedWebsites.length > 0 && newsContext) {
const urls = mergedWebsites.map((w) => w.url)
fetchRssFromUrls(urls).then((articles) => {
if (articles.length === 0) return
panelRssArticles.value = articles
const combined = mergeNewsResults(panelWebResults.value, articles)
panelWebResults.value = combined
if (!availableTabs.value.includes('news')) {
// Insert before 'prompt' (which is always last)
const promptIdx = availableTabs.value.indexOf('prompt')
if (promptIdx >= 0) {
availableTabs.value = [...availableTabs.value.slice(0, promptIdx), 'news', ...availableTabs.value.slice(promptIdx)]
} else {
availableTabs.value = ['news', ...availableTabs.value]
}
activeTab.value = 'news'
}
const ctx = extractQueryContext(panelQuery.value)
panelTitle.value = ctx ? `${ctx}${combined.length} articles` : `${combined.length} Articles`
})
}
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode)
// Always append Prompt tab as the rightmost tab
const tabsWithPrompt = tabs.length > 0 ? [...tabs, 'prompt' as const] : ['prompt' as const]
availableTabs.value = tabsWithPrompt
activeTab.value = tabs[0] ?? 'prompt'
const showFilms = tabs.includes('film')
const showBooks = tabs.includes('book')
const showTVSeries = tabs.includes('tvshow')
const showImages = tabs.includes('image')
const showPlaces = tabs.includes('place')
const showSongs = tabs.includes('song')
const showPodcasts = tabs.includes('podcast')
const showNews = tabs.includes('news')
const showWebsitesTab = tabs.includes('websites')
const showMagazine = tabs.includes('magazine')
const showApps = tabs.includes('app')
const showCode = tabs.includes('code')
const visibleFilms = showFilms ? films : []
const visibleBooks = showBooks ? books : []
const visibleTVSeries = showTVSeries ? tvSeries : []
const visibleImages = showImages ? images : []
const visiblePlaces = showPlaces ? places : []
const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : []
const visibleWebsites = showWebsitesTab ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
const visibleApps = showApps ? apps : []
const visibleCodeBlocks = showCode ? codeBlocks : []
panelFilms.value = visibleFilms
panelBooks.value = visibleBooks
panelTVSeries.value = visibleTVSeries
panelImages.value = visibleImages
panelPlaces.value = visiblePlaces
panelSongs.value = visibleSongs
panelPodcasts.value = visiblePodcasts
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
panelApps.value = visibleApps
panelCodeBlocks.value = visibleCodeBlocks
panelMagazineSections.value = visibleMagazineSections
panelMagazineHeroImage.value = showMagazine
? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null)
: null
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedApp.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
if (visibleFilms.length > 0) contentType.value = 'film'
else if (visibleBooks.length > 0) contentType.value = 'film'
else if (visibleTVSeries.length > 0) contentType.value = 'film'
else if (visibleSongs.length > 0) contentType.value = 'song'
else if (visiblePodcasts.length > 0) contentType.value = 'podcast'
else if (visibleNews.length > 0) contentType.value = 'film'
else contentType.value = 'film'
// Title follows the primary (first) tab
const primary = tabs[0]
if (primary === 'place' && visiblePlaces.length > 0) {
panelTitle.value = visiblePlaces.length === 1 ? visiblePlaces[0].name : `${visiblePlaces.length} Places`
} else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
} else if (primary === 'book' && visibleBooks.length > 0) {
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
} else if (primary === 'image' && visibleImages.length > 0) {
panelTitle.value = `${visibleImages.length} Images`
} else if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films`
else if (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title
else if (visibleBooks.length > 1) panelTitle.value = `${visibleBooks.length} Books`
else if (visibleTVSeries.length === 1) panelTitle.value = visibleTVSeries[0].title
else if (visibleTVSeries.length > 1) panelTitle.value = `${visibleTVSeries.length} TV Series`
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
else if (visiblePlaces.length === 1) panelTitle.value = visiblePlaces[0].name
else if (visiblePlaces.length > 1) panelTitle.value = `${visiblePlaces.length} Places`
else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title
else if (visiblePodcasts.length > 1) panelTitle.value = `${visiblePodcasts.length} Podcasts`
else if (visibleNews.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx}${visibleNews.length} articles` : `${visibleNews.length} Articles`
}
else if (visibleMagazineSections.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx} — Brief` : 'AI Brief'
}
else if (visibleCodeBlocks.length > 0) panelTitle.value = `${visibleCodeBlocks.length} Code Blocks`
else if (visibleApps.length > 0) panelTitle.value = `${visibleApps.length} Apps`
else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites`
else if (hasNostr) panelTitle.value = 'Nostr'
else panelTitle.value = 'Content'
panelOpen.value = tabs.length > 0
}
function setActiveTab(tab: ContentTab) {
if (availableTabs.value.includes(tab)) activeTab.value = tab
}
/** Contextual content for inline cards (prompt index badges) */
function getContextualInlineContent(text: string, userQuery: string, webResults: WebSearchResult[] = []) {
let films = extractAllFilms(text)
const songs = extractAllSongs(text, userQuery)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, userQuery)
const tvSeries = extractAllTVSeries(text, userQuery)
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
films = films.filter(f => !f.id.startsWith('ext-'))
}
const magazineSections = extractMagazineSections(text)
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const bareDomains = extractBareDomainLinks(text)
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
const newsLinks = hasNews ? webResults : []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0 || bareDomains.length > 0
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
const websitesLinks = mergeNewsResults(mergeNewsResults(websitesFromMd, boldDomains), bareDomains)
const hasWebsites = websitesLinks.length > 0
const apps = extractApps(text, userQuery)
const hasApps = apps.length > 0
const codeBlocks = extractCodeBlocks(text)
const hasCode = codeBlocks.length > 0 && (isCodeQuery(userQuery) || isCodeLikeResponse(text))
const hasNostr = isNostrQuery(userQuery) || isNostrLikeResponse(text)
const images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
const hasAnyOtherInline = films.length > 0 || songs.length > 0 || podcasts.length > 0 ||
books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0
const hasMagazine = magazineSections.length >= 1 && (
isNewsQuery(userQuery) || isNewsLikeResponse(text) ||
/sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) ||
(!hasAnyOtherInline && !hasWebsites && magazineSections.length >= 2)
)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine, hasNostr, hasApps, hasCode)
return {
films: tabs.includes('film') ? films : [],
books: tabs.includes('book') ? books : [],
tvSeries: tabs.includes('tvshow') ? tvSeries : [],
images: tabs.includes('image') ? images : [],
places: tabs.includes('place') ? places : [],
songs: tabs.includes('song') ? songs : [],
podcasts: tabs.includes('podcast') ? podcasts : [],
newsLinks: tabs.includes('news') ? newsLinks : [],
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
magazineSections: tabs.includes('magazine') ? magazineSections : [],
apps: tabs.includes('app') ? apps : [],
codeBlocks: tabs.includes('code') ? codeBlocks : [],
hasNostr,
}
}
// ─── Detail open/close ────────────────────────────────────────
function clearAllSelections() {
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedApp.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
selectedDesignSystemItem.value = null
longFormArticle.value = null
pdfUrl.value = null
mapPlaces.value = []
}
function openFilmDetail(film: Film) { clearAllSelections(); selectedFilm.value = film }
function closeFilmDetail() { selectedFilm.value = null }
function openBookDetail(book: Book) { clearAllSelections(); selectedBook.value = book }
function closeBookDetail() { selectedBook.value = null }
function openSongDetail(song: Song) { clearAllSelections(); selectedSong.value = song }
function closeSongDetail() { selectedSong.value = null }
function openPodcastDetail(podcast: Podcast) { clearAllSelections(); selectedPodcast.value = podcast }
function closePodcastDetail() { selectedPodcast.value = null }
function openArticleDetail(article: WebSearchResult) { clearAllSelections(); selectedArticle.value = article; panelOpen.value = true }
function closeArticleDetail() { selectedArticle.value = null }
function openWebsiteDetail(website: WebSearchResult) { clearAllSelections(); selectedWebsite.value = website; panelOpen.value = true }
function closeWebsiteDetail() { selectedWebsite.value = null }
function openLongFormArticle(content: string, title?: string) { clearAllSelections(); longFormArticle.value = { content, title }; panelOpen.value = true }
function closeLongFormArticle() { longFormArticle.value = null }
function openPdfViewer(url: string, title?: string) { clearAllSelections(); pdfUrl.value = { url, title }; panelOpen.value = true }
function closePdfViewer() { pdfUrl.value = null }
function openMapView(places: Place[]) { clearAllSelections(); mapPlaces.value = places; panelOpen.value = true }
function closeMapView() { mapPlaces.value = [] }
function openMagazineSectionDetail(section: MagazineSection, index: number) {
clearAllSelections()
selectedMagazineSection.value = section
magazineSectionIndex.value = index
}
function closeMagazineSectionDetail() { selectedMagazineSection.value = null }
function navigateMagazineSection(direction: 'prev' | 'next') {
const sections = panelMagazineSections.value
if (!sections.length) return
let idx = magazineSectionIndex.value
idx += direction === 'next' ? 1 : -1
if (idx < 0) idx = sections.length - 1
if (idx >= sections.length) idx = 0
magazineSectionIndex.value = idx
selectedMagazineSection.value = sections[idx]
}
function openTVSeriesDetail(series: TVSeries) { clearAllSelections(); selectedTVSeries.value = series }
function closeTVSeriesDetail() { selectedTVSeries.value = null }
function openImageDetail(image: ImageItem) { clearAllSelections(); selectedImage.value = image }
function closeImageDetail() { selectedImage.value = null }
function openPlaceDetail(place: Place) { clearAllSelections(); selectedPlace.value = place }
function closePlaceDetail() { selectedPlace.value = null }
function openAppDetail(app: AppEntry) { clearAllSelections(); selectedApp.value = app }
function closeAppDetail() { selectedApp.value = null }
function openDesignSystemItem(item: DesignSystemItem) { clearAllSelections(); selectedDesignSystemItem.value = item }
function closeDesignSystemItem() { selectedDesignSystemItem.value = null }
function enterDesignSystemMode() {
panelOpen.value = true
activeTab.value = 'design-system'
availableTabs.value = ['design-system']
panelTitle.value = 'Design System'
clearAllSelections()
}
function closePanel() {
panelOpen.value = false
clearAllSelections()
activeTab.value = 'film'
availableTabs.value = []
}
function showAllFilms() {
panelFilms.value = [...mockFilms]
panelSongs.value = []
panelPodcasts.value = []
panelTitle.value = 'Your Film Library'
contentType.value = 'film'
panelOpen.value = true
clearAllSelections()
}
function showAllSongs() {
panelFilms.value = []
panelSongs.value = [...mockSongs]
panelPodcasts.value = []
panelTitle.value = 'Your Song Library'
contentType.value = 'song'
panelOpen.value = true
clearAllSelections()
}
function showAllPodcasts() {
panelFilms.value = []
panelSongs.value = []
panelPodcasts.value = [...mockPodcasts]
panelTitle.value = 'Your Podcast Library'
contentType.value = 'podcast'
panelOpen.value = true
clearAllSelections()
}
return {
panelOpen,
panelFilms,
panelBooks,
panelTVSeries,
panelImages,
panelPlaces,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelApps,
panelCodeBlocks,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
selectedApp,
selectedWebsite,
selectedMagazineSection,
magazineSectionIndex,
panelTitle,
panelQuery,
panelResponseText,
contentType,
activeTab,
availableTabs,
setActiveTab,
extractFilmIds,
extractAllFilms,
extractAllBooks,
extractAllTVSeries,
extractSongIds,
extractAllSongs,
extractPodcastIds,
extractAllPodcasts,
extractCodeBlocks,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripContentTags,
stripMarkdownLinks,
openFilmDetail,
closeFilmDetail,
openBookDetail,
closeBookDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
openWebsiteDetail,
closeWebsiteDetail,
openAppDetail,
closeAppDetail,
openMagazineSectionDetail,
closeMagazineSectionDetail,
navigateMagazineSection,
openTVSeriesDetail,
closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
selectedDesignSystemItem,
openDesignSystemItem,
closeDesignSystemItem,
longFormArticle,
openLongFormArticle,
closeLongFormArticle,
pdfUrl,
openPdfViewer,
closePdfViewer,
mapPlaces,
openMapView,
closeMapView,
enterDesignSystemMode,
closePanel,
showAllFilms,
showAllSongs,
showAllPodcasts,
}
}