fix(frontend): harden the streaming ui and extract copy and constants
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run

This commit is contained in:
Justin Visser 2026-08-10 15:42:18 +02:00
parent 036ef3cc6f
commit a3af8f45cc
36 changed files with 1662 additions and 226 deletions

View file

@ -1,5 +1,7 @@
<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'
@ -18,37 +20,49 @@ defineEmits<{
const list = ref<HTMLElement | null>(null)
const pinned = ref(true)
const fingerprint = computed(() =>
props.turns
.map((turn) =>
turn.role === 'user'
? turn.id
: `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`,
)
.join('|'),
)
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(() =>
[...props.turns].reverse().find((turn): turn is AssistantTurn => turn.role === 'assistant'),
)
const latestAssistant = computed(findLatestAssistant)
const announcement = computed(() => {
const turn = latestAssistant.value
if (!turn) return ''
if (turn.status === 'error') return 'The recommendation request failed.'
if (turn.status === 'error') return messages.messageListRequestFailed
if (turn.status === 'done') {
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
const trackCount = turn.completion?.track_count ?? turn.tracks.length
return formatMessage('messageListTracksReady', { count: trackCount })
}
if (turn.requestId === null) return 'Reading the request.'
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
return `${turn.tracks.length} verified tracks received.`
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
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight
pinned.value = distanceFromBottom < MESSAGE_LIST_PIN_THRESHOLD_PX
}
async function scrollToLatest(): Promise<void> {
@ -66,7 +80,12 @@ watch(streaming, (value) => {
</script>
<template>
<main ref="list" class="list" aria-label="Conversation" @scroll="onScroll">
<main
ref="list"
class="list"
:aria-label="messages.messageListConversationLabel"
@scroll="onScroll"
>
<p class="visually-hidden" aria-live="polite" aria-atomic="true">
{{ announcement }}
</p>
@ -107,7 +126,7 @@ watch(streaming, (value) => {
}
.turn {
animation: message-in 0.3s ease both;
animation: message-in var(--dur-message) ease both;
}
@keyframes message-in {