discovery-by-llm/frontend/src/components/MessageList.vue
Justin Visser a3af8f45cc
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
fix(frontend): harden the streaming ui and extract copy and constants
2026-08-10 15:42:18 +02:00

149 lines
3.9 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { MESSAGE_LIST_PIN_THRESHOLD_PX } from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import type { AssistantTurn, ChatTurn } from '../lib/models'
import AssistantMessage from './AssistantMessage.vue'
import EmptyState from './EmptyState.vue'
import UserMessage from './UserMessage.vue'
const props = defineProps<{
turns: ChatTurn[]
suggestions: readonly string[]
canSave: boolean
}>()
defineEmits<{
pick: [suggestion: string]
save: [turnId: string]
retry: [query: string]
}>()
const list = ref<HTMLElement | null>(null)
const pinned = ref(true)
function turnFingerprint(turn: ChatTurn): string {
if (turn.role === 'user') return turn.id
return `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`
}
function findLatestAssistant(): AssistantTurn | undefined {
for (let index = props.turns.length - 1; index >= 0; index -= 1) {
const turn = props.turns[index]
if (turn?.role === 'assistant') return turn
}
return undefined
}
const fingerprint = computed(() => props.turns.map(turnFingerprint).join('|'))
const streaming = computed(() =>
props.turns.some((turn) => turn.role === 'assistant' && turn.status === 'streaming'),
)
const latestAssistant = computed(findLatestAssistant)
const announcement = computed(() => {
const turn = latestAssistant.value
if (!turn) return ''
if (turn.status === 'error') return messages.messageListRequestFailed
if (turn.status === 'done') {
const trackCount = turn.completion?.track_count ?? turn.tracks.length
return formatMessage('messageListTracksReady', { count: trackCount })
}
const warning = turn.warnings.at(-1)
if (warning) {
return formatMessage('messageListWarning', {
count: turn.warnings.length,
message: warning.message,
})
}
if (turn.requestId === null) return messages.messageListReadingRequest
if (turn.tracks.length === 0) return messages.messageListCheckingCandidates
return formatMessage('messageListVerifiedTracksReceived', { count: turn.tracks.length })
})
function onScroll(): void {
const element = list.value
if (!element) return
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
}
async function scrollToLatest(): Promise<void> {
if (!pinned.value) return
await nextTick()
requestAnimationFrame(() => {
if (list.value) list.value.scrollTop = list.value.scrollHeight
})
}
watch(fingerprint, scrollToLatest, { flush: 'post' })
watch(streaming, (value) => {
if (value) pinned.value = true
})
</script>
<template>
<main
ref="list"
class="list"
:aria-label="messages.messageListConversationLabel"
@scroll="onScroll"
>
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
{{ announcement }}
</p>
<div class="column">
<EmptyState
v-if="turns.length === 0"
:suggestions="suggestions"
@pick="$emit('pick', $event)"
/>
<div v-for="turn in turns" :key="turn.id" class="turn">
<UserMessage v-if="turn.role === 'user'" :text="turn.text" />
<AssistantMessage
v-else
:turn="turn"
:can-save="canSave"
@save="$emit('save', turn.id)"
@retry="$emit('retry', $event)"
/>
</div>
</div>
</main>
</template>
<style scoped>
.list {
flex: 1 1 auto;
min-height: 0;
padding: 32px var(--gutter) 28px;
overflow-y: auto;
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-9);
max-width: var(--measure);
margin: 0 auto;
}
.turn {
animation: message-in var(--dur-message) ease both;
}
@keyframes message-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 560px) {
.list {
padding-top: 24px;
}
}
</style>