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>
|
||||
Reference in New Issue
Block a user