541 lines
18 KiB
Vue
541 lines
18 KiB
Vue
<template>
|
|||
|
|
<div class="flex flex-col h-full rounded-2xl overflow-visible transition-all duration-300">
|
||
|
|
<ChatHeader
|
||
|
|
:title="title"
|
||
|
|
:conversation-id="displayId"
|
||
|
|
:side="side"
|
||
|
|
:show-close="showClose"
|
||
|
|
@switch-side="$emit('switchSide')"
|
||
|
|
@new-chat="handleNewChat"
|
||
|
|
@close="$emit('close')"
|
||
|
|
@open-settings="showSettings = true"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<BranchSwitcher />
|
||
|
|
|
||
|
|
<ChatSearch
|
||
|
|
ref="chatSearchRef"
|
||
|
|
:messages="messages"
|
||
|
|
@scroll-to-message="scrollToMessageIndex"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<ContextBar :messages="messages" :active-model="activeModel" />
|
||
|
|
|
||
|
|
<PersonaSelector />
|
||
|
|
<SettingsModal v-model:open="showSettings" />
|
||
|
|
|
||
|
|
<!-- History: full conversation list -->
|
||
|
|
<ChatHistory
|
||
|
|
v-if="showHistory"
|
||
|
|
@select="handleHistorySelect"
|
||
|
|
@new-chat="handleNewChat"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<!-- Collapsed: prompt index -->
|
||
|
|
<template v-else-if="chatCollapsed">
|
||
|
|
<PromptIndex
|
||
|
|
:messages="messages"
|
||
|
|
@select="handlePromptSelect"
|
||
|
|
/>
|
||
|
|
<div v-if="isStreaming" class="px-4 pb-3">
|
||
|
|
<StreamingDots />
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<!-- Expanded: full message list (virtualized) -->
|
||
|
|
<div
|
||
|
|
v-else
|
||
|
|
ref="messageListRef"
|
||
|
|
class="relative z-0 flex-1 min-h-0 overflow-y-auto scrollbar-hide"
|
||
|
|
>
|
||
|
|
<div v-if="messages.length === 0" class="flex items-center justify-center h-full p-4">
|
||
|
|
<div class="text-center space-y-4 animate-fade-up">
|
||
|
|
<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 text-[#fafafa]">✦</span>
|
||
|
|
</div>
|
||
|
|
<p class="text-sm text-white/30">
|
||
|
|
Start a conversation
|
||
|
|
</p>
|
||
|
|
<router-link
|
||
|
|
to="/guide"
|
||
|
|
class="inline-flex items-center gap-1.5 text-xs text-white/25 hover:text-white/50 transition-colors"
|
||
|
|
>
|
||
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||
|
|
</svg>
|
||
|
|
AIUI Guide
|
||
|
|
</router-link>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
v-else
|
||
|
|
:style="{ height: `${virtualizer.getTotalSize()}px`, width: '100%', position: 'relative' }"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
v-for="virtualRow in virtualizer.getVirtualItems()"
|
||
|
|
:key="messages[virtualRow.index].id"
|
||
|
|
:ref="(el) => el && virtualizer.measureElement(el as Element)"
|
||
|
|
:data-index="virtualRow.index"
|
||
|
|
:style="{
|
||
|
|
position: 'absolute',
|
||
|
|
top: 0,
|
||
|
|
left: 0,
|
||
|
|
width: '100%',
|
||
|
|
transform: `translateY(${virtualRow.start}px)`,
|
||
|
|
}"
|
||
|
|
:class="['px-4 py-2.5', virtualRow.index === 0 ? 'pt-6' : '']"
|
||
|
|
>
|
||
|
|
<ErrorBoundary title="Message failed to render">
|
||
|
|
<ChatMessage
|
||
|
|
:message="messages[virtualRow.index]"
|
||
|
|
:index="virtualRow.index"
|
||
|
|
:triggering-query="getTriggeringQuery(messages, virtualRow.index)"
|
||
|
|
@edit="handleEdit"
|
||
|
|
@regenerate="handleRegenerate"
|
||
|
|
@branch="handleBranch"
|
||
|
|
@reply="handleReply"
|
||
|
|
@feedback="handleFeedback"
|
||
|
|
/>
|
||
|
|
</ErrorBoundary>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="isStreaming && lastMessageEmpty" class="px-4 pb-4">
|
||
|
|
<StreamingDots />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Comparison mode split view -->
|
||
|
|
<ComparisonView
|
||
|
|
v-if="comparison.isComparing.value && (comparison.response1.value || comparison.response2.value)"
|
||
|
|
class="flex-1 min-h-0"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<!-- Code mode project indicator -->
|
||
|
|
<div
|
||
|
|
v-if="codeContext.isCodeMode.value && codeContext.activeProject.value"
|
||
|
|
class="px-3 pb-1 flex items-center gap-1.5"
|
||
|
|
>
|
||
|
|
<span class="text-xs px-2 py-0.5 rounded-md bg-accent/15 text-accent font-medium truncate max-w-[200px]">
|
||
|
|
{{ codeContext.activeProject.value.name }}
|
||
|
|
</span>
|
||
|
|
<button
|
||
|
|
class="text-white/30 hover:text-white/60 transition-colors p-0.5"
|
||
|
|
@click="codeContext.exitCodeMode()"
|
||
|
|
>
|
||
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ChatInput
|
||
|
|
:disabled="isStreaming || comparison.isAnyStreaming.value"
|
||
|
|
:streaming="isStreaming || comparison.isAnyStreaming.value"
|
||
|
|
:placeholder="activeTab === 'code' ? 'Code...' : isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||
|
|
:active-tab="activeTab"
|
||
|
|
:reply-to="replyTo"
|
||
|
|
@send="handleSend"
|
||
|
|
@extract="handleExtract"
|
||
|
|
@stop="handleStop"
|
||
|
|
@clear-reply="clearReply"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { computed, ref, watch, nextTick } from 'vue'
|
||
|
|
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||
|
|
import { useChatStore } from '@/stores/chat'
|
||
|
|
import { DEMO_CONTENT_ENABLED } from '@/utils/demoContent'
|
||
|
|
import { useAI, streamWithModel } from '@/composables/useAI'
|
||
|
|
import { useContentPanel } from '@/composables/useContentPanel'
|
||
|
|
import { useComparisonMode } from '@/composables/useComparisonMode'
|
||
|
|
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 ChatHistory from './ChatHistory.vue'
|
||
|
|
import BranchSwitcher from './BranchSwitcher.vue'
|
||
|
|
import ChatSearch from './ChatSearch.vue'
|
||
|
|
import ContextBar from './ContextBar.vue'
|
||
|
|
import ComparisonView from './ComparisonView.vue'
|
||
|
|
import PersonaSelector from './PersonaSelector.vue'
|
||
|
|
import SettingsModal from './SettingsModal.vue'
|
||
|
|
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
||
|
|
import type { Message, ImageAttachment } from '@aiui/core/types/message'
|
||
|
|
|
||
|
|
withDefaults(
|
||
|
|
defineProps<{
|
||
|
|
variant?: 'standalone' | 'modal'
|
||
|
|
side?: 'left' | 'right'
|
||
|
|
showClose?: boolean
|
||
|
|
}>(),
|
||
|
|
{
|
||
|
|
variant: 'standalone',
|
||
|
|
side: 'right',
|
||
|
|
showClose: false,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
defineEmits<{
|
||
|
|
switchSide: []
|
||
|
|
close: []
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const chatStore = useChatStore()
|
||
|
|
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI()
|
||
|
|
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
|
||
|
|
import { useCodeContext } from '@/composables/useCodeContext'
|
||
|
|
import { useVisualViewport } from '@/composables/useVisualViewport'
|
||
|
|
const codeContext = useCodeContext()
|
||
|
|
import { usePersonaStore } from '@/stores/personas'
|
||
|
|
const personaStore = usePersonaStore()
|
||
|
|
const comparison = useComparisonMode()
|
||
|
|
const messageListRef = ref<HTMLElement | null>(null)
|
||
|
|
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
|
||
|
|
const showSettings = ref(false)
|
||
|
|
|
||
|
|
// A send/regenerate/edit failure that looks like a missing or invalid API
|
||
|
|
// key (see useAI.ts's looksLikeMissingApiKey) opens Settings automatically
|
||
|
|
// instead of leaving the user stuck on a silent/dead error with no obvious
|
||
|
|
// next step. One-shot: reset immediately after acting so a later retry that
|
||
|
|
// fails the same way can re-trigger it (the user may have closed Settings
|
||
|
|
// without fixing anything).
|
||
|
|
watch(needsApiKey, (needs) => {
|
||
|
|
if (needs) {
|
||
|
|
showSettings.value = true
|
||
|
|
needsApiKey.value = false
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Scroll position memory per conversation
|
||
|
|
const scrollPositions = new Map<string, number>()
|
||
|
|
|
||
|
|
function saveScrollPosition() {
|
||
|
|
const el = messageListRef.value
|
||
|
|
const id = chatStore.activeConversationId
|
||
|
|
if (el && id) {
|
||
|
|
scrollPositions.set(id, el.scrollTop)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function restoreScrollPosition(convId: string) {
|
||
|
|
const saved = scrollPositions.get(convId)
|
||
|
|
if (saved !== undefined) {
|
||
|
|
nextTick(() => {
|
||
|
|
const el = messageListRef.value
|
||
|
|
if (el) el.scrollTop = saved
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrollToMessageIndex(index: number) {
|
||
|
|
virtualizer.value.scrollToIndex(index, { align: 'center' })
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reply-to threading state
|
||
|
|
const replyTo = ref<{ messageId: string; excerpt: string } | null>(null)
|
||
|
|
|
||
|
|
function handleReply(messageId: string, content: string) {
|
||
|
|
const excerpt = content.slice(0, 100) + (content.length > 100 ? '...' : '')
|
||
|
|
replyTo.value = { messageId, excerpt }
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearReply() {
|
||
|
|
replyTo.value = null
|
||
|
|
}
|
||
|
|
|
||
|
|
const messages = computed(() => chatStore.messages)
|
||
|
|
|
||
|
|
const virtualizer = useVirtualizer(computed(() => ({
|
||
|
|
count: messages.value.length,
|
||
|
|
getScrollElement: () => messageListRef.value,
|
||
|
|
estimateSize: (index: number) => {
|
||
|
|
// User messages are typically shorter
|
||
|
|
return messages.value[index]?.role === 'user' ? 60 : 200
|
||
|
|
},
|
||
|
|
overscan: 5,
|
||
|
|
})))
|
||
|
|
const isStreaming = computed(() => chatStore.isStreaming)
|
||
|
|
const chatCollapsed = computed(() => chatStore.chatCollapsed)
|
||
|
|
const showHistory = computed(() => chatStore.showHistory)
|
||
|
|
|
||
|
|
const title = computed(
|
||
|
|
() => chatStore.activeConversation?.title ?? 'New Chat'
|
||
|
|
)
|
||
|
|
|
||
|
|
const displayId = computed(
|
||
|
|
() => chatStore.activeConversationId?.slice(0, 8) ?? '—'
|
||
|
|
)
|
||
|
|
|
||
|
|
const lastMessageEmpty = computed(() => {
|
||
|
|
const msgs = messages.value
|
||
|
|
if (msgs.length === 0) return true
|
||
|
|
return msgs[msgs.length - 1].content === ''
|
||
|
|
})
|
||
|
|
|
||
|
|
function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
|
||
|
|
if (msgs[idx]?.role !== 'assistant') return ''
|
||
|
|
for (let i = idx - 1; i >= 0; i--) {
|
||
|
|
if (msgs[i]?.role === 'user') return msgs[i].content ?? ''
|
||
|
|
}
|
||
|
|
return ''
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleNewChat() {
|
||
|
|
chatStore.createConversation('New Chat', personaStore.defaultPersona?.id)
|
||
|
|
chatStore.showHistory = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleHistorySelect(id: string) {
|
||
|
|
chatStore.setActiveConversation(id)
|
||
|
|
chatStore.showHistory = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleStop() {
|
||
|
|
stopGeneration()
|
||
|
|
comparison.stopComparison()
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleEdit(messageId: string, newContent: string) {
|
||
|
|
await editAndResend(messageId, newContent)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleRegenerate() {
|
||
|
|
await regenerateLastResponse()
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleBranch(messageId: string) {
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (!convId) return
|
||
|
|
chatStore.branchFromMessage(convId, messageId)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleFeedback(messageId: string, value: 'up' | 'down' | undefined) {
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (!convId) return
|
||
|
|
chatStore.setMessageFeedback(convId, messageId, value)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleExtract(text: string) {
|
||
|
|
// Run content extraction without sending to AI
|
||
|
|
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
|
||
|
|
chatStore.addMessage(convId, { role: 'user', content: `[Extract] ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` })
|
||
|
|
chatStore.addMessage(convId, { role: 'assistant', content: text })
|
||
|
|
updatePanelFromText(text, '', [])
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSend(text: string, images: ImageAttachment[] = []) {
|
||
|
|
// Command handling
|
||
|
|
const trimmed = text.trim().toLowerCase()
|
||
|
|
if (trimmed === '/code') {
|
||
|
|
codeContext.enterCodeMode()
|
||
|
|
// Open the content panel with code tab
|
||
|
|
panelOpen.value = true
|
||
|
|
if (!availableTabs.value.includes('code')) {
|
||
|
|
availableTabs.value = [...availableTabs.value, 'code']
|
||
|
|
}
|
||
|
|
setActiveTab('code')
|
||
|
|
// Add system message to chat
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (convId) {
|
||
|
|
chatStore.addMessage(convId, {
|
||
|
|
role: 'user',
|
||
|
|
content: '/code',
|
||
|
|
})
|
||
|
|
chatStore.addMessage(convId, {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'Code mode activated. Select a project from the content panel to start coding.',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (trimmed === '/seed') {
|
||
|
|
// Demo-site/dev only (S7): the seed showcase is fabricated example
|
||
|
|
// content and must not ship in the node build.
|
||
|
|
if (!DEMO_CONTENT_ENABLED) {
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (convId) {
|
||
|
|
chatStore.addMessage(convId, {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'The `/seed` showcase is only available in demo builds.',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
await chatStore.loadSeedChats()
|
||
|
|
// loadSeedChats already switches to the seed conversation
|
||
|
|
// Collapse chat so user sees PromptIndex for quick picking
|
||
|
|
chatStore.chatCollapsed = true
|
||
|
|
chatStore.showHistory = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (trimmed === '/nostr') {
|
||
|
|
panelOpen.value = true
|
||
|
|
if (!availableTabs.value.includes('nostr')) {
|
||
|
|
availableTabs.value = [...availableTabs.value, 'nostr']
|
||
|
|
}
|
||
|
|
setActiveTab('nostr')
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (convId) {
|
||
|
|
chatStore.addMessage(convId, { role: 'user', content: '/nostr' })
|
||
|
|
chatStore.addMessage(convId, { role: 'assistant', content: 'Nostr feed opened. Browse notes, articles, and zaps from the network.' })
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (trimmed === '/design') {
|
||
|
|
enterDesignSystemMode()
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (convId) {
|
||
|
|
chatStore.addMessage(convId, { role: 'user', content: '/design' })
|
||
|
|
chatStore.addMessage(convId, { role: 'assistant', content: 'Design system viewer opened. Browse colors, typography, spacing, and components.' })
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (trimmed === '/freefilms') {
|
||
|
|
const { freeFilms } = await import('@/data/freeFilms')
|
||
|
|
panelFilms.value = freeFilms
|
||
|
|
panelTitle.value = 'Free Documentary Films'
|
||
|
|
panelOpen.value = true
|
||
|
|
availableTabs.value = ['film', 'prompt']
|
||
|
|
setActiveTab('film')
|
||
|
|
const convId = chatStore.activeConversationId
|
||
|
|
if (convId) {
|
||
|
|
chatStore.addMessage(convId, { role: 'user', content: '/freefilms' })
|
||
|
|
chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` })
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if (trimmed === '/code exit' || trimmed === '/exit') {
|
||
|
|
if (codeContext.isCodeMode.value) {
|
||
|
|
codeContext.exitCodeMode()
|
||
|
|
availableTabs.value = availableTabs.value.filter(t => t !== 'code')
|
||
|
|
const convId2 = chatStore.activeConversationId
|
||
|
|
if (convId2) {
|
||
|
|
chatStore.addMessage(convId2, {
|
||
|
|
role: 'assistant',
|
||
|
|
content: 'Code mode deactivated.',
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Prepend quote if replying to a message
|
||
|
|
let finalText = text
|
||
|
|
if (replyTo.value) {
|
||
|
|
const quoteLine = replyTo.value.excerpt.split('\n').map(l => `> ${l}`).join('\n')
|
||
|
|
finalText = `${quoteLine}\n\n${text}`
|
||
|
|
clearReply()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Comparison mode: stream to both models simultaneously
|
||
|
|
if (comparison.isComparing.value) {
|
||
|
|
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
|
||
|
|
chatStore.addMessage(convId, { role: 'user', content: finalText, images: images.length > 0 ? images : undefined })
|
||
|
|
const history = chatStore.messages.map(m => ({ role: m.role, content: m.content }))
|
||
|
|
await comparison.streamBothModels(streamWithModel, history)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
await sendMessage(finalText, images.length > 0 ? images : undefined)
|
||
|
|
}
|
||
|
|
|
||
|
|
function handlePromptSelect(_userMsg: Message, assistantMsg: Message | null) {
|
||
|
|
const userText = _userMsg.content?.trim().toLowerCase() ?? ''
|
||
|
|
if (userText === '/code') {
|
||
|
|
codeContext.enterCodeMode()
|
||
|
|
panelOpen.value = true
|
||
|
|
if (!availableTabs.value.includes('code')) {
|
||
|
|
availableTabs.value = [...availableTabs.value, 'code']
|
||
|
|
}
|
||
|
|
setActiveTab('code')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if (assistantMsg?.content) {
|
||
|
|
updatePanelFromText(assistantMsg.content, _userMsg.content, assistantMsg.webResults ?? [])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrollToBottom() {
|
||
|
|
// Container scroll ONLY — deliberately not virtualizer.scrollToIndex.
|
||
|
|
//
|
||
|
|
// Doing both is what produced "Failed to scroll to index N after 10
|
||
|
|
// attempts" in the console on every send. scrollToIndex runs a retry loop
|
||
|
|
// that nudges scrollTop toward the measured offset of the target row and
|
||
|
|
// re-checks, up to 10 times, because dynamically-measured rows move the
|
||
|
|
// target as they settle. Assigning scrollTop ourselves on the next tick
|
||
|
|
// overwrote each of those nudges, so the loop never saw itself converge and
|
||
|
|
// always exhausted its attempts — the warning was the two of us fighting
|
||
|
|
// over the same scrollTop, not a real failure.
|
||
|
|
//
|
||
|
|
// For "go to the end" the virtualizer's index-settling machinery buys
|
||
|
|
// nothing: scrollHeight is already the bottom, and the virtualizer renders
|
||
|
|
// whatever window that offset implies. It also keeps working while a
|
||
|
|
// response streams and the last row keeps growing, which is exactly the
|
||
|
|
// case the old fallback was added for. scrollToMessageIndex still uses
|
||
|
|
// scrollToIndex, which is the right tool for jumping to an arbitrary row.
|
||
|
|
nextTick(() => {
|
||
|
|
const el = messageListRef.value
|
||
|
|
if (el) el.scrollTop = el.scrollHeight
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Scroll to bottom when expanding from collapsed
|
||
|
|
watch(chatCollapsed, (collapsed, wasCollapsed) => {
|
||
|
|
if (!collapsed && wasCollapsed) {
|
||
|
|
scrollToBottom()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Scroll to bottom when mobile keyboard opens so latest messages + input stay visible
|
||
|
|
const { isKeyboardOpen } = useVisualViewport()
|
||
|
|
watch(isKeyboardOpen, (open) => {
|
||
|
|
if (open) scrollToBottom()
|
||
|
|
})
|
||
|
|
|
||
|
|
// Save/restore scroll position on conversation switch
|
||
|
|
watch(
|
||
|
|
() => chatStore.activeConversationId,
|
||
|
|
(newId, oldId) => {
|
||
|
|
if (oldId) saveScrollPosition()
|
||
|
|
if (newId) restoreScrollPosition(newId)
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => messages.value.length,
|
||
|
|
() => scrollToBottom()
|
||
|
|
)
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => {
|
||
|
|
const msgs = messages.value
|
||
|
|
const last = msgs[msgs.length - 1]
|
||
|
|
return last ? { content: last.content, webResults: last.webResults } : null
|
||
|
|
},
|
||
|
|
(val) => {
|
||
|
|
scrollToBottom()
|
||
|
|
if (val?.content) {
|
||
|
|
const msgs = messages.value
|
||
|
|
const lastMsg = msgs[msgs.length - 1]
|
||
|
|
const lastUser = [...msgs].reverse().find((m) => m.role === 'user')
|
||
|
|
// Skip panel updates for command messages (e.g. /code, /exit)
|
||
|
|
const userText = lastUser?.content?.trim() ?? ''
|
||
|
|
if (!userText.startsWith('/')) {
|
||
|
|
updatePanelFromText(val.content, userText, lastMsg?.webResults ?? [])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ deep: true, immediate: true }
|
||
|
|
)
|
||
|
|
</script>
|