feat(frontend): assemble accessible application shell
This commit is contained in:
parent
96c69507ec
commit
036ef3cc6f
27 changed files with 1852 additions and 13 deletions
130
frontend/src/components/MessageList.vue
Normal file
130
frontend/src/components/MessageList.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
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)
|
||||
const fingerprint = computed(() =>
|
||||
props.turns
|
||||
.map((turn) =>
|
||||
turn.role === 'user'
|
||||
? turn.id
|
||||
: `${turn.id}:${turn.tracks.length}:${turn.warnings.length}:${turn.status}`,
|
||||
)
|
||||
.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 announcement = computed(() => {
|
||||
const turn = latestAssistant.value
|
||||
if (!turn) return ''
|
||||
if (turn.status === 'error') return 'The recommendation request failed.'
|
||||
if (turn.status === 'done') {
|
||||
return `${turn.completion?.track_count ?? turn.tracks.length} tracks ready.`
|
||||
}
|
||||
if (turn.requestId === null) return 'Reading the request.'
|
||||
if (turn.tracks.length === 0) return 'Checking recommendation candidates.'
|
||||
return `${turn.tracks.length} verified tracks received.`
|
||||
})
|
||||
|
||||
function onScroll(): void {
|
||||
const element = list.value
|
||||
if (!element) return
|
||||
pinned.value = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
||||
}
|
||||
|
||||
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="Conversation" @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 0.3s 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue