fix(frontend): refine streamed presentation and resolve stream edge cases

This commit is contained in:
Justin Visser 2026-08-10 21:38:14 +02:00
parent a3af8f45cc
commit 2cc33a721c
18 changed files with 450 additions and 46 deletions

View file

@ -9,7 +9,7 @@ const props = defineProps<{
mode: AppMode | null
healthFailed: boolean
}>()
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>()
const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
function openDevPanel(event: MouseEvent): void {
if (event.currentTarget instanceof HTMLElement) {
@ -29,11 +29,18 @@ const modeLabel = computed(() => {
<header class="header">
<div class="inner">
<div class="brand">
<span class="wordmark"
>{{ messages.appHeaderBrandPrefix
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
>{{ messages.appHeaderBrandSuffix }}</span
<button
class="brand-control"
type="button"
:aria-label="messages.appHeaderBrandResetLabel"
@click="$emit('reset')"
>
<span class="wordmark"
>{{ messages.appHeaderBrandPrefix
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
>{{ messages.appHeaderBrandSuffix }}</span
>
</button>
<span class="kicker">{{ messages.appHeaderKicker }}</span>
</div>
<div class="controls">
@ -78,6 +85,20 @@ const modeLabel = computed(() => {
min-width: 0;
}
.brand-control {
padding: 0;
color: inherit;
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--r-sm);
}
.brand-control:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 4px;
}
.wordmark {
font-family: var(--f-display);
font-size: var(--t-brand);

View file

@ -42,8 +42,6 @@ const isEmptyResult = computed(
<div class="column">
<p v-if="turn.intentSummary" class="intent">{{ turn.intentSummary }}</p>
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
<ThinkingIndicator v-if="turn.status === 'streaming'" :label="thinkingLabel" />
<StreamWarning
@ -85,6 +83,8 @@ const isEmptyResult = computed(
:url="turn.playlist.url"
:track-count="turn.tracks.length"
/>
<ResultSet v-if="turn.tracks.length" :tracks="turn.tracks" />
</div>
</div>
</template>

View file

@ -112,6 +112,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
:mode="mode"
:health-failed="health.status === 'failed'"
@logout="logout"
@reset="reset"
@toggle-dev="openDevPanel"
/>

View file

@ -69,7 +69,13 @@ async function scrollToLatest(): Promise<void> {
if (!pinned.value) return
await nextTick()
requestAnimationFrame(() => {
if (list.value) list.value.scrollTop = list.value.scrollHeight
const element = list.value
if (!element) return
if (latestAssistant.value?.status === 'done') {
element.querySelector<HTMLElement>('.turn:last-child')?.scrollIntoView({ block: 'start' })
return
}
element.scrollTop = element.scrollHeight
})
}

View file

@ -1,14 +1,28 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
import { computed } from 'vue'
import { formatMessage } from '../lib/messages'
import type { TrackEvent } from '../lib/types'
import TrackCard from './TrackCard.vue'
defineProps<{ tracks: TrackEvent[] }>()
const props = defineProps<{ tracks: TrackEvent[] }>()
const regionLabel = computed(() =>
formatMessage(
props.tracks.length === 1
? 'resultSetRecommendedTrackLabel'
: 'resultSetRecommendedTracksLabel',
{ count: props.tracks.length },
),
)
</script>
<template>
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel">
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" />
<div class="set" role="region" :aria-label="regionLabel" tabindex="0">
<TrackCard
v-for="(event, index) in tracks"
:key="event.track.id"
:event="event"
:style="`--card-order: ${index}`"
/>
</div>
</template>
@ -17,5 +31,18 @@ defineProps<{ tracks: TrackEvent[] }>()
display: flex;
flex-direction: column;
gap: var(--s-2);
max-height: min(42dvh, 480px);
padding: var(--s-2) var(--s-3) var(--s-2) 0;
overflow-y: auto;
overscroll-behavior-y: contain;
border-block: 1px solid var(--c-line);
scrollbar-color: var(--c-line-strong) transparent;
scrollbar-width: thin;
}
@media (max-width: 560px) {
.set {
max-height: min(34dvh, 360px);
}
}
</style>

View file

@ -1,27 +1,60 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
defineProps<{ suggestions: readonly string[] }>()
defineEmits<{ pick: [suggestion: string] }>()
</script>
<template>
<div class="chips">
<button
v-for="suggestion in suggestions"
:key="suggestion"
class="chip"
type="button"
@click="$emit('pick', suggestion)"
>
{{ suggestion }}
</button>
<div class="frame">
<div class="chips" role="region" :aria-label="messages.suggestionChipsRegionLabel" tabindex="0">
<button
v-for="suggestion in suggestions"
:key="suggestion"
class="chip"
type="button"
@click="$emit('pick', suggestion)"
>
{{ suggestion }}
</button>
</div>
</div>
</template>
<style scoped>
.frame {
position: relative;
min-width: 0;
overflow: hidden;
background: var(--c-surface-raised);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.frame::after {
position: absolute;
top: 1px;
right: 1px;
bottom: var(--s-3);
width: var(--s-10);
pointer-events: none;
content: '';
background: linear-gradient(to right, transparent, var(--c-surface-raised));
}
.chips {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: var(--s-3);
padding: var(--s-3) var(--s-10) var(--s-3) var(--s-3);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-color: var(--c-line-strong) transparent;
scrollbar-width: thin;
}
.chips:focus-visible {
outline-offset: -3px;
}
.chip {
@ -31,7 +64,7 @@ defineEmits<{ pick: [suggestion: string] }>()
font-size: var(--t-small);
white-space: nowrap;
cursor: pointer;
background: var(--c-surface-raised);
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-pill);
transition: all var(--dur-fast) ease;
@ -42,18 +75,4 @@ defineEmits<{ pick: [suggestion: string] }>()
border-color: var(--c-accent);
transform: translateY(-1px);
}
@media (max-width: 560px) {
.chips {
flex-wrap: nowrap;
padding: 0 var(--gutter);
margin: 0 calc(-1 * var(--gutter));
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
}
</style>

View file

@ -53,6 +53,7 @@ const artworkAlt = computed(() =>
border-color var(--dur-fast) ease,
background var(--dur-fast) ease;
animation: card-in var(--dur-card) var(--ease) both;
animation-delay: calc(var(--card-order, 0) * var(--dur-card-stagger));
}
.card:hover {