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:
co-authored by
Claude Opus 4.6
parent
2f27cb86c4
commit
aab55ef9ca
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user