feat(chat): add collapsible prompt index and polish magazine layout

Add a toggle in the chat header to collapse messages into a compact
prompt index showing user queries with content-type badges. Clicking
a prompt surfaces the corresponding content panel. Also fixes magazine
section extraction (### headers, emoji stripping, author regex) and
improves tile layout with editorial rhythm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 01:16:02 +00:00
co-authored by Claude Opus 4.6
parent 2f27cb86c4
commit aab55ef9ca
6 changed files with 242 additions and 29 deletions
@@ -33,6 +33,25 @@
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="chatStore.chatCollapsed
? 'text-accent'
: isDark
? 'text-white/70 hover:text-white'
: 'text-gray-500 hover:text-gray-900'"
:title="chatStore.chatCollapsed ? 'Expand chat' : 'Collapse to prompts'"
aria-label="Toggle prompt index"
@click="chatStore.toggleChatCollapse()"
>
<svg v-if="!chatStore.chatCollapsed" 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="M4 6h16M4 12h10M4 18h16" />
</svg>
<svg v-else 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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="isDark
@@ -10,7 +10,16 @@
@close="$emit('close')"
/>
<!-- Collapsed: prompt index -->
<PromptIndex
v-if="chatCollapsed"
:messages="messages"
@select="handlePromptSelect"
/>
<!-- Expanded: full message list -->
<div
v-else
ref="messageListRef"
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide p-4 space-y-3"
>
@@ -54,6 +63,8 @@ import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
import StreamingDots from './StreamingDots.vue'
import PromptIndex from './PromptIndex.vue'
import type { Message } from '@aiui/core/types/message'
withDefaults(
defineProps<{
@@ -81,6 +92,7 @@ const messageListRef = ref<HTMLElement | null>(null)
const messages = computed(() => chatStore.messages)
const isStreaming = computed(() => chatStore.isStreaming)
const chatCollapsed = computed(() => chatStore.chatCollapsed)
const title = computed(
() => chatStore.activeConversation?.title ?? 'New Chat'
@@ -112,6 +124,12 @@ async function handleSend(text: string) {
await sendMessage(text)
}
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
if (assistantMsg?.content) {
updatePanelFromText(assistantMsg.content, _userMsg.content, assistantMsg.webResults ?? [])
}
}
watch(
() => messages.value.length,
() => {
@@ -0,0 +1,111 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hide p-3 space-y-1">
<div v-if="promptPairs.length === 0" class="flex items-center justify-center h-full">
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No prompts yet
</p>
</div>
<button
v-for="(pair, i) in promptPairs"
:key="pair.userMsg.id"
class="w-full text-left px-3 py-2.5 rounded-xl transition-all duration-150 group"
:class="[
activeIndex === i
? 'path-glass-bubble-user'
: isDark
? 'hover:bg-white/5'
: 'hover:bg-black/3'
]"
@click="selectPrompt(pair, i)"
>
<p class="text-sm leading-snug truncate"
:class="isDark ? 'text-white/90' : 'text-gray-800'">
{{ pair.userMsg.content }}
</p>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px]"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ formatTime(pair.userMsg.timestamp) }}
</span>
<span
v-for="badge in pair.badges"
:key="badge"
class="text-[9px] px-1.5 py-0.5 rounded-md"
:class="isDark
? 'bg-white/8 text-white/40'
: 'bg-black/5 text-gray-500'"
>
{{ badge }}
</span>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Message } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
interface PromptPair {
userMsg: Message
assistantMsg: Message | null
badges: string[]
}
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
select: [userMsg: Message, assistantMsg: Message | null]
}>()
const { isDark } = useTheme()
const { getContextualInlineContent } = useContentPanel()
const activeIndex = ref<number | null>(null)
const promptPairs = computed<PromptPair[]>(() => {
const pairs: PromptPair[] = []
const msgs = props.messages
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].role !== 'user') continue
const userMsg = msgs[i]
const assistantMsg = (i + 1 < msgs.length && msgs[i + 1].role === 'assistant')
? msgs[i + 1]
: null
const badges: string[] = []
if (assistantMsg && assistantMsg.content) {
const content = getContextualInlineContent(
assistantMsg.content,
userMsg.content,
assistantMsg.webResults ?? [],
)
if (content.films.length > 0) badges.push('Films')
if (content.songs.length > 0) badges.push('Music')
if (content.podcasts.length > 0) badges.push('Podcasts')
if (content.magazineSections.length > 0) badges.push('Magazine')
if ((content.newsLinks?.length ?? 0) > 0) badges.push('News')
if ((content.websitesLinks?.length ?? 0) > 0) badges.push('Web')
}
pairs.push({ userMsg, assistantMsg, badges })
}
return pairs
})
function selectPrompt(pair: PromptPair, index: number) {
activeIndex.value = index
emit('select', pair.userMsg, pair.assistantMsg)
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
@@ -15,7 +15,7 @@
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- Query context -->
<div v-if="headlineText" class="px-5 pt-4 pb-1">
<div v-if="headlineText" class="px-5 pt-4 pb-3">
<p class="text-[9px] uppercase tracking-[0.3em] font-medium"
:class="isDark ? 'text-white/30' : 'text-black/40'">
In response to
@@ -24,18 +24,19 @@
:class="isDark ? 'text-white/60' : 'text-black/50'">
{{ headlineText }}
</p>
<div class="mt-3 h-px" :class="isDark ? 'bg-white/8' : 'bg-black/6'" />
</div>
<!-- Tile grid -->
<div class="px-3 py-3">
<div class="grid grid-cols-2 gap-[1px]"
:class="isDark ? 'bg-white/8' : 'bg-black/8'">
<div class="px-3 pt-2 pb-8">
<div class="grid grid-cols-2 gap-px"
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
<template v-for="(tile, i) in tiles" :key="i">
<!-- Banner tile: full width with icon -->
<div v-if="tile.type === 'banner'"
class="col-span-2 flex flex-col items-center justify-center py-6 px-5"
class="col-span-2 flex flex-col items-center justify-center py-8 px-5"
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]'">
<svg class="w-6 h-6 mb-2" :class="isDark ? 'text-white/20' : 'text-black/15'"
<svg class="w-5 h-5 mb-2.5" :class="isDark ? 'text-white/20' : 'text-black/15'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="tile.icon === 'compass'" stroke-linecap="round" stroke-linejoin="round"
d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v2m0 16v2m10-10h-2M4 12H2m15.07-5.07l-1.41 1.41M8.34 15.66l-1.41 1.41m0-11.14l1.41 1.41m7.32 7.32l1.41 1.41" />
@@ -96,12 +97,16 @@
:class="isDark ? 'text-white/25' : 'text-black/30'">
{{ tile.label }}
</p>
<h3 class="font-serif text-sm font-bold leading-snug mb-1"
<h3 v-if="tile.title"
class="font-serif text-sm font-bold leading-snug mb-1"
:class="isDark ? 'text-white/90' : 'text-black/85'">
{{ tile.title }}
</h3>
<p class="font-serif text-xs leading-relaxed flex-1"
:class="isDark ? 'text-white/55' : 'text-black/50'">
:class="[
isDark ? 'text-white/55' : 'text-black/50',
!tile.title ? 'italic' : ''
]">
{{ tile.text }}
</p>
</button>
@@ -186,6 +191,10 @@ const tiles = computed<Tile[]>(() => {
for (const c of (props.query || 'brief')) seed = ((seed << 5) - seed + c.charCodeAt(0)) | 0
const rand = () => { seed = (seed * 16807 + 0) % 2147483647; return (seed & 0x7fffffff) / 2147483647 }
// Layout rhythm: wide → half pair → banner → wide → half pair → ...
// This creates the New Yorker editorial cadence
let layoutPhase = 0 // 0=wide, 1=half-pair, 2=banner
secs.forEach((section, i) => {
const bullets = splitIntoBullets(section.content)
@@ -199,11 +208,12 @@ const tiles = computed<Tile[]>(() => {
author: section.author,
section,
})
layoutPhase = 1
return
}
// Insert a banner between groups to break up content
if (i % 3 === 0 && i > 0) {
// Insert banner to break up content
if (layoutPhase === 2) {
const bIdx = Math.floor(rand() * bannerIcons.length)
result.push({
type: 'banner',
@@ -212,11 +222,11 @@ const tiles = computed<Tile[]>(() => {
icon: bannerIcons[bIdx],
label: bannerLabels[bIdx],
})
layoutPhase = 0
}
// If the section has multiple bullets, split into individual tiles
if (bullets.length >= 2) {
// Section header as wide tile
if (bullets.length >= 3) {
// Multi-bullet section: wide header + half tiles for bullets
result.push({
type: 'wide',
title: section.title,
@@ -224,35 +234,79 @@ const tiles = computed<Tile[]>(() => {
label: section.author ? `By ${section.author}` : undefined,
section,
})
// Remaining bullets as half-width tiles in pairs
for (let b = 1; b < bullets.length; b++) {
const isOdd = rand() > 0.7
const variant = rand() > 0.6 ? 'dark' : 'half'
result.push({
type: isOdd ? 'dark' : 'half',
type: variant,
title: extractBulletTitle(bullets[b]) || section.title,
text: truncate(cleanBulletTitle(bullets[b]), 100),
section,
})
}
} else {
// Single-content section: alternate between wide and half
const useWide = rand() > 0.5 || section.content.length > 200
layoutPhase = 2
} else if (layoutPhase === 0) {
// Wide tile phase
result.push({
type: useWide ? 'wide' : 'half',
type: 'wide',
title: section.title,
text: truncate(section.content, useWide ? 200 : 100),
text: truncate(section.content, 180),
author: section.author,
section,
})
// If half, add a companion dark tile for visual pairing
if (!useWide) {
layoutPhase = 1
} else {
// Half-width tile phase: split content at a sentence boundary
const cleaned = cleanText(section.content)
// Only split if content is long enough for two meaningful tiles
if (cleaned.length < 120) {
// Too short to split — use as a half tile with a decorative companion
result.push({
type: 'half',
title: section.title,
text: truncate(section.content, 120),
section,
})
result.push({
type: 'dark',
title: '',
text: '',
})
} else {
// Find a sentence boundary (. or — or ;) after first ~40% of content
const target = Math.floor(cleaned.length * 0.4)
let splitAt = -1
for (const sep of ['. ', ' — ', '; ']) {
const idx = cleaned.indexOf(sep, target)
if (idx > 0 && idx < cleaned.length * 0.7) {
splitAt = idx + sep.length
break
}
}
// Fallback: find a word boundary near the middle
if (splitAt < 0) {
const mid = Math.floor(cleaned.length / 2)
const spaceIdx = cleaned.indexOf(' ', mid)
splitAt = spaceIdx > 0 ? spaceIdx + 1 : mid
}
const firstHalf = cleaned.slice(0, splitAt).trim()
const secondHalf = cleaned.slice(splitAt).trim()
result.push({
type: 'half',
title: section.title,
text: truncate(section.content.slice(100), 80),
text: firstHalf.length > 110 ? firstHalf.slice(0, 107) + '\u2009...' : firstHalf,
section,
})
result.push({
type: 'dark',
title: '',
text: secondHalf.length > 110 ? secondHalf.slice(0, 107) + '\u2009...' : secondHalf,
section,
})
}
layoutPhase = 2
}
})
@@ -79,16 +79,16 @@ function extractUrlFromText(text: string): string | undefined {
function extractAuthorFromText(text: string): string | undefined {
const patterns = [
/(?:analyst|according to)\s+\*{0,2}([^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/i,
/\bby\s+\*{0,2}([^*\n]+?)\*{0,2}(?:\s|$|\.|,)/i,
/(?:source|—)\s*:?\s*\*{0,2}([^*\n]+?)\*{0,2}(?:\s|$|\.|,)/i,
/\*\*([^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/,
/(?:analyst|according to)\s+\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/,
/\bby\s+\*{0,2}([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\*{0,2}/,
/(?:source|—)\s*:?\s*\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s|$|\.|,)/,
/\*\*([A-Z][^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/,
]
for (const re of patterns) {
const m = re.exec(text)
if (m) {
const name = m[1].trim().slice(0, 60)
if (name.length > 2 && name.length < 50) return name
if (name.length > 3 && name.length < 50) return name
}
}
return undefined
+11
View File
@@ -53,6 +53,7 @@ export const useChatStore = defineStore('chat', () => {
const panelSide = ref<'left' | 'right'>(savedSide ?? 'left')
const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false')
const chatCollapsed = ref(localStorage.getItem('aiui-chat-collapsed') === 'true')
// Load chats from server on startup — _loaded gate prevents the watcher
// from overwriting the file with empty data before the load completes
@@ -81,6 +82,10 @@ export const useChatStore = defineStore('chat', () => {
localStorage.setItem('aiui-web-search', String(val))
})
watch(chatCollapsed, (val) => {
localStorage.setItem('aiui-chat-collapsed', String(val))
})
watch(
[conversations, activeConversationId],
([conv, active]) => {
@@ -151,6 +156,10 @@ export const useChatStore = defineStore('chat', () => {
panelSide.value = panelSide.value === 'right' ? 'left' : 'right'
}
function toggleChatCollapse() {
chatCollapsed.value = !chatCollapsed.value
}
function setActiveConversation(id: string) {
if (conversations.value.has(id)) {
activeConversationId.value = id
@@ -175,11 +184,13 @@ export const useChatStore = defineStore('chat', () => {
loaded,
panelSide,
webSearchEnabled,
chatCollapsed,
createConversation,
addMessage,
appendToLastMessage,
setMessageWebResults,
switchSide,
toggleChatCollapse,
setActiveConversation,
deleteConversation,
}