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 {

View file

@ -7,7 +7,7 @@ import {
PRIOR_RECOMMENDATIONS_MAX_TRACKS,
QUERY_MAX_LENGTH,
} from '../lib/constants'
import { formatMessage, messages } from '../lib/messages'
import { messages } from '../lib/messages'
import type { AssistantTurn, ChatTurn, EventLogEntry, TransportFailure } from '../lib/models'
import { EMPTY_PLAYLIST_STATE } from '../lib/models'
import {
@ -101,9 +101,7 @@ function reduceEvent(turn: AssistantTurn, event: StreamEvent): AssistantTurn {
}
function playlistName(query: string): string {
return formatMessage('useChatStreamPlaylistName', { query })
.slice(0, PLAYLIST_NAME_MAX_LENGTH)
.trim()
return query.slice(0, PLAYLIST_NAME_MAX_LENGTH).trim()
}
function createTurnId(): string {

View file

@ -2,6 +2,7 @@
export const messages = {
appHeaderBrandPrefix: 'discovery',
appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat',
appHeaderBrandAccent: '-by-',
appHeaderBrandSuffix: 'llm',
appHeaderKicker: 'proof of concept',
@ -90,10 +91,13 @@ export const messages = {
resultActionsConnectSpotify: 'Connect Spotify to save',
resultActionsSummary: '{count} verified tracks ready for a private playlist',
resultSetRecommendedTracksLabel: 'Recommended tracks',
resultSetRecommendedTrackLabel: '{count} recommended track',
resultSetRecommendedTracksLabel: '{count} recommended tracks',
streamErrorRetry: 'Edit and retry',
suggestionChipsRegionLabel: 'Listening suggestions',
trackCardArtworkAlt: '{album} album artwork',
trackCardOpenSpotify: 'Open in Spotify',
@ -110,7 +114,6 @@ export const messages = {
useApiPlaylistCreationFailed: 'Playlist creation returned {status}.',
useChatStreamTrackCountMismatch: 'The final track count did not match the streamed results.',
useChatStreamPlaylistName: '[discovery-by-llm] {query}',
useChatStreamRequestReplaced: 'This request was replaced by a newer request.',
useChatStreamUnexpectedFailure: 'The recommendation request failed unexpectedly.',
useChatStreamTransportEvent: 'transport',

View file

@ -36,6 +36,7 @@ function parseLine(line: string): StreamEvent {
function advancePhase(phase: StreamPhase, event: StreamEvent): StreamPhase {
if (phase === 'metadata') {
if (event.type === 'metadata') return 'events'
if (event.type === 'error') return 'terminal'
throw new StreamTransportError('protocol', messages.recommendationStreamMissingMetadata)
}
if (phase === 'events') {

View file

@ -1,4 +1,8 @@
import { createApp } from 'vue'
import '@fontsource/bricolage-grotesque/600.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/public-sans/400.css'
import '@fontsource/public-sans/500.css'
import './style.css'
import App from './App.vue'

View file

@ -74,6 +74,11 @@
--shadow-drawer: -24px 0 60px rgba(0, 0, 0, 0.45);
--ease: cubic-bezier(0.2, 0.8, 0.3, 1);
--dur-fast: 0.16s;
--dur-card: 0.4s;
--dur-card-stagger: 0.08s;
--dur-reduced-motion: 0.001s;
color-scheme: dark;
color: var(--c-text);
background: var(--c-bg);
@ -151,7 +156,9 @@ a:hover {
*::before,
*::after {
scroll-behavior: auto !important;
animation-delay: var(--dur-reduced-motion) !important;
animation-duration: var(--dur-reduced-motion) !important;
transition-delay: var(--dur-reduced-motion) !important;
transition-duration: var(--dur-reduced-motion) !important;
}
}