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

@ -273,3 +273,48 @@ Waarom:
- Machine-geconverteerde UI-code krijgt dezelfde behandeling als de - Machine-geconverteerde UI-code krijgt dezelfde behandeling als de
backend: een review-pass met concrete findings en een test-suite die de backend: een review-pass met concrete findings en een test-suite die de
contractkritische randen vastzet, voordat het richting main gaat. contractkritische randen vastzet, voordat het richting main gaat.
## Dag 3 - avond
### Live eval run
Wat ik deed:
- Alle 8 scenario's in 1 run live gedraaid, plus de baseline arm (bare
search) voor de comparison table.
- Het Spotify-dagquotum was eerder al een keer vol; daarom vooraf
maatregelen genomen: scenario's sequentieel, geen retries, de server-boot
zelf als quota-check, en live meegekeken op 429's (niet gehit).
- Uitkomst: 4 scenario's pass, 3 vallen alleen op een te strenge
synonym-check in de eval zelf (gefixt), en discover-new faalt echt:
call 1 verzint bij familiarity=new titels die niet bestaan, dus er
overleeft niets de grounding. Fix volgt in de intent prompt.
- De refinement turn deed live 0 Spotify calls: pool reuse werkt.
Waarom:
- De comparison table in de README wil ik op echte metingen baseren, en het
quotum maakt herhalen duur.
Wat ik heb laten vallen of uitgesteld:
- discover-new opnieuw opnemen wacht op de prompt fix.
### Frontend: presentatie
Wat ik deed:
- Cards komen rustig gestaggerd binnen (met reduced-motion pad), results
en chips scrollen in eigen panelen zodat de summary zichtbaar blijft,
klik op het logo start een nieuwe chat, fonts self-hosted.
- Een error die voor de metadata binnenkomt is nu een echte foutmelding
in de chat, geen transport failure. Playlist-namen hadden een dubbele
prefix; de frontend stuurt nu de kale query. 10 nieuwe tests.
Waarom:
- Dit is wat de reviewer als eerste ziet; rustige presentatie en eerlijke
foutmeldingen gaan voor extra features.
Wat ik heb laten vallen of uitgesteld:
- Verdere styling; de tijd gaat naar eval, demo mode en de README.

View file

@ -8,6 +8,9 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/public-sans": "^5.3.0",
"vue": "^3.5.40" "vue": "^3.5.40"
}, },
"devDependencies": { "devDependencies": {
@ -156,6 +159,30 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@fontsource/bricolage-grotesque": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/public-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.3.0.tgz",
"integrity": "sha512-kjODI0S3zdv0mBYCIQ8TbBayaiqszpc2UbhJiO3bjIqVVXzcWfHSt2o3WBCLOY3juaGaQoy4MoWCcgmfI5hCuA==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",

View file

@ -13,6 +13,9 @@
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json" "typecheck": "vue-tsc --noEmit -p tsconfig.app.json"
}, },
"dependencies": { "dependencies": {
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/public-sans": "^5.3.0",
"vue": "^3.5.40" "vue": "^3.5.40"
}, },
"devDependencies": { "devDependencies": {

View file

@ -9,7 +9,7 @@ const props = defineProps<{
mode: AppMode | null mode: AppMode | null
healthFailed: boolean healthFailed: boolean
}>() }>()
const emit = defineEmits<{ logout: []; toggleDev: [opener: HTMLElement] }>() const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
function openDevPanel(event: MouseEvent): void { function openDevPanel(event: MouseEvent): void {
if (event.currentTarget instanceof HTMLElement) { if (event.currentTarget instanceof HTMLElement) {
@ -29,11 +29,18 @@ const modeLabel = computed(() => {
<header class="header"> <header class="header">
<div class="inner"> <div class="inner">
<div class="brand"> <div class="brand">
<button
class="brand-control"
type="button"
:aria-label="messages.appHeaderBrandResetLabel"
@click="$emit('reset')"
>
<span class="wordmark" <span class="wordmark"
>{{ messages.appHeaderBrandPrefix >{{ messages.appHeaderBrandPrefix
}}<span class="accent">{{ messages.appHeaderBrandAccent }}</span }}<span class="accent">{{ messages.appHeaderBrandAccent }}</span
>{{ messages.appHeaderBrandSuffix }}</span >{{ messages.appHeaderBrandSuffix }}</span
> >
</button>
<span class="kicker">{{ messages.appHeaderKicker }}</span> <span class="kicker">{{ messages.appHeaderKicker }}</span>
</div> </div>
<div class="controls"> <div class="controls">
@ -78,6 +85,20 @@ const modeLabel = computed(() => {
min-width: 0; 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 { .wordmark {
font-family: var(--f-display); font-family: var(--f-display);
font-size: var(--t-brand); font-size: var(--t-brand);

View file

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

View file

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

View file

@ -69,7 +69,13 @@ async function scrollToLatest(): Promise<void> {
if (!pinned.value) return if (!pinned.value) return
await nextTick() await nextTick()
requestAnimationFrame(() => { 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"> <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 type { TrackEvent } from '../lib/types'
import TrackCard from './TrackCard.vue' 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> </script>
<template> <template>
<div class="set" :aria-label="messages.resultSetRecommendedTracksLabel"> <div class="set" role="region" :aria-label="regionLabel" tabindex="0">
<TrackCard v-for="event in tracks" :key="event.track.id" :event="event" /> <TrackCard
v-for="(event, index) in tracks"
:key="event.track.id"
:event="event"
:style="`--card-order: ${index}`"
/>
</div> </div>
</template> </template>
@ -17,5 +31,18 @@ defineProps<{ tracks: TrackEvent[] }>()
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s-2); 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> </style>

View file

@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { messages } from '../lib/messages'
defineProps<{ suggestions: readonly string[] }>() defineProps<{ suggestions: readonly string[] }>()
defineEmits<{ pick: [suggestion: string] }>() defineEmits<{ pick: [suggestion: string] }>()
</script> </script>
<template> <template>
<div class="chips"> <div class="frame">
<div class="chips" role="region" :aria-label="messages.suggestionChipsRegionLabel" tabindex="0">
<button <button
v-for="suggestion in suggestions" v-for="suggestion in suggestions"
:key="suggestion" :key="suggestion"
@ -15,13 +18,43 @@ defineEmits<{ pick: [suggestion: string] }>()
{{ suggestion }} {{ suggestion }}
</button> </button>
</div> </div>
</div>
</template> </template>
<style scoped> <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 { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: var(--s-3); 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 { .chip {
@ -31,7 +64,7 @@ defineEmits<{ pick: [suggestion: string] }>()
font-size: var(--t-small); font-size: var(--t-small);
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
background: var(--c-surface-raised); background: var(--c-surface);
border: 1px solid var(--c-line); border: 1px solid var(--c-line);
border-radius: var(--r-pill); border-radius: var(--r-pill);
transition: all var(--dur-fast) ease; transition: all var(--dur-fast) ease;
@ -42,18 +75,4 @@ defineEmits<{ pick: [suggestion: string] }>()
border-color: var(--c-accent); border-color: var(--c-accent);
transform: translateY(-1px); 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> </style>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,8 @@
import { createApp } from 'vue' 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 './style.css'
import App from './App.vue' import App from './App.vue'

View file

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

View file

@ -128,8 +128,23 @@ describe('streamRecommendations', () => {
expect(wasCancelled).toBe(true) expect(wasCancelled).toBe(true)
}) })
it('accepts a terminal error as the only event', async () => {
const error = { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' }
stubResponse(streamResponse([encoder.encode(`${JSON.stringify(error)}\n`)]))
const events: StreamEvent[] = []
await streamRecommendations(
{ schema_version: 1, query: 'focus', history: [], prior_recommendations: [] },
new AbortController().signal,
(event) => events.push(event),
)
expect(events).toEqual([error])
})
it.each([ it.each([
['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]], ['an event before metadata', [{ type: 'warning', code: 'early', message: 'No metadata.' }]],
['done before metadata', [{ type: 'done', track_count: 0, total_ms: 4 }]],
[ [
'duplicate metadata', 'duplicate metadata',
[ [

View file

@ -0,0 +1,186 @@
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
import type { Component } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AppHeader from '../src/components/AppHeader.vue'
import AssistantMessage from '../src/components/AssistantMessage.vue'
import MessageList from '../src/components/MessageList.vue'
import SuggestionChips from '../src/components/SuggestionChips.vue'
import type { AssistantTurn, ChatTurn } from '../src/lib/models'
import type { TrackEvent } from '../src/lib/types'
const unmountCallbacks: Array<() => void> = []
function track(rank: number): TrackEvent {
return {
type: 'track',
rank,
track: {
id: `track-${rank}`,
uri: `spotify:track:${rank}`,
title: `Track ${rank}`,
artists: ['Artist'],
album_name: 'Album',
album_art_url: null,
external_url: null,
},
justification: 'It fits.',
}
}
function doneTurn(): AssistantTurn {
return {
id: 'assistant-1',
role: 'assistant',
query: 'Focused listening',
status: 'done',
requestId: 'request-1',
intentSummary: 'Calm music for focused work.',
candidateCount: 20,
tracks: [track(1), track(2)],
warnings: [{ type: 'warning', code: 'limited_pool', message: 'The pool was limited.' }],
error: null,
transportFailure: null,
completion: { type: 'done', track_count: 2, total_ms: 12 },
playlist: { status: 'idle', name: null, url: null, message: null },
}
}
function mountComponent(component: Component, props: Record<string, unknown>): HTMLElement {
const root = document.createElement('div')
document.body.append(root)
const app = createApp(
defineComponent({
render: () => h(component, props),
}),
)
app.mount(root)
unmountCallbacks.push(() => {
app.unmount()
root.remove()
})
return root
}
afterEach(() => {
while (unmountCallbacks.length) unmountCallbacks.pop()?.()
vi.restoreAllMocks()
})
describe('result presentation', () => {
it('uses the app title as a fresh-chat control', () => {
const onReset = vi.fn()
const root = mountComponent(AppHeader, {
auth: { status: 'anonymous', user: null, message: null },
mode: 'demo',
healthFailed: false,
onReset,
})
const button = root.querySelector<HTMLButtonElement>(
'button[aria-label="discovery-by-llm: start a new chat"]',
)
expect(button?.type).toBe('button')
expect(button?.textContent).toContain('discovery-by-llm')
button?.click()
expect(onReset).toHaveBeenCalledOnce()
})
it('keeps completion context outside the focusable track region', () => {
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
const region = root.querySelector<HTMLElement>(
'[role="region"][aria-label="2 recommended tracks"]',
)
const summary = root.querySelector<HTMLElement>('.intent')
const warning = root.querySelector<HTMLElement>('[data-warning-code="limited_pool"]')
const action = root.querySelector<HTMLButtonElement>('button')
expect(region?.tabIndex).toBe(0)
expect(region?.querySelectorAll('article')).toHaveLength(2)
expect(summary?.textContent).toContain('Calm music')
expect(warning?.textContent).toContain('pool was limited')
expect(action?.textContent).toContain('Save as playlist')
expect(region?.contains(summary ?? null)).toBe(false)
expect(region?.contains(warning ?? null)).toBe(false)
expect(region?.contains(action ?? null)).toBe(false)
})
it('assigns a deliberate stagger order to arriving cards', () => {
const root = mountComponent(AssistantMessage, { turn: doneTurn(), canSave: true })
const cards = root.querySelectorAll<HTMLElement>('article')
expect(cards[0]?.style.getPropertyValue('--card-order')).toBe('0')
expect(cards[1]?.style.getPropertyValue('--card-order')).toBe('1')
})
it('anchors a completed response at the top of the conversation viewport', async () => {
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => undefined)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0)
return 1
})
const complete = doneTurn()
const turns = ref<ChatTurn[]>([
{ id: 'user-1', role: 'user', text: 'Focused listening' },
{ ...complete, status: 'streaming', completion: null },
])
const root = document.createElement('div')
document.body.append(root)
const app = createApp(
defineComponent({
render: () =>
h(MessageList, {
turns: turns.value,
suggestions: [],
canSave: true,
}),
}),
)
app.mount(root)
unmountCallbacks.push(() => {
app.unmount()
root.remove()
})
turns.value = [turns.value[0] as ChatTurn, complete]
await nextTick()
await nextTick()
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' })
})
it('keeps suggestions in a labeled focusable row', () => {
const onPick = vi.fn()
const suggestions = ['Focus', 'Energy', 'Surprise']
const root = mountComponent(SuggestionChips, { suggestions, onPick })
const region = root.querySelector<HTMLElement>('[aria-label="Listening suggestions"]')
const buttons = root.querySelectorAll<HTMLButtonElement>('button')
expect(region?.tabIndex).toBe(0)
expect(buttons).toHaveLength(suggestions.length)
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(suggestions)
buttons[0]?.click()
expect(onPick).toHaveBeenCalledWith('Focus')
})
it('renders a first-stage stream error with its code and message', () => {
const turn: AssistantTurn = {
...doneTurn(),
status: 'error',
requestId: null,
intentSummary: '',
tracks: [],
warnings: [],
error: { type: 'error', code: 'first_stage_failed', message: 'Planning failed.' },
transportFailure: null,
completion: null,
}
const root = mountComponent(AssistantMessage, { turn, canSave: false })
const alert = root.querySelector<HTMLElement>('[role="alert"]')
expect(alert?.dataset.errorCode).toBe('first_stage_failed')
expect(alert?.textContent).toContain('Planning failed.')
})
})

View file

@ -13,14 +13,16 @@ vi.mock('../src/lib/recommendationStream', async (importOriginal) => {
const streamMock = vi.mocked(streamRecommendations) const streamMock = vi.mocked(streamRecommendations)
const unmountCallbacks: Array<() => void> = [] const unmountCallbacks: Array<() => void> = []
function mountChat() { function mountChat(
createPlaylist: Parameters<typeof useChatStream>[0] = vi.fn(async () => ({ url: null })),
) {
let chat: ReturnType<typeof useChatStream> | undefined let chat: ReturnType<typeof useChatStream> | undefined
const root = document.createElement('div') const root = document.createElement('div')
document.body.append(root) document.body.append(root)
const app = createApp( const app = createApp(
defineComponent({ defineComponent({
setup() { setup() {
chat = useChatStream(vi.fn(async () => ({ url: null }))) chat = useChatStream(createPlaylist)
return () => h('div') return () => h('div')
}, },
}), }),
@ -106,6 +108,22 @@ describe('useChatStream', () => {
expect(turn?.transportFailure).toBeNull() expect(turn?.transportFailure).toBeNull()
}) })
it('stores a metadata-free terminal error as a normal stream error', async () => {
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
onEvent({ type: 'error', code: 'first_stage_failed', message: 'Planning failed.' })
})
const chat = mountChat()
await chat.send('error')
expect(assistantTurns(chat)[0]).toMatchObject({
status: 'error',
requestId: null,
error: { code: 'first_stage_failed', message: 'Planning failed.' },
transportFailure: null,
})
})
it('stores an incomplete stream as a transport failure', async () => { it('stores an incomplete stream as a transport failure', async () => {
streamMock.mockRejectedValue( streamMock.mockRejectedValue(
new StreamTransportError('unexpected_eof', 'The stream ended early.'), new StreamTransportError('unexpected_eof', 'The stream ended early.'),
@ -239,4 +257,26 @@ describe('useChatStream', () => {
expect(receivedQuery).toHaveLength(1000) expect(receivedQuery).toHaveLength(1000)
}) })
it('uses the bare query as the capped playlist name', async () => {
const createPlaylist = vi.fn(async () => ({ url: null }))
streamMock.mockImplementation(async (_request, _signal, onEvent) => {
onEvent(metadata('playlist'))
onEvent(track(1))
onEvent({ type: 'done', track_count: 1, total_ms: 5 })
})
const chat = mountChat(createPlaylist)
const query = `Late-night instrumental focus ${'x'.repeat(100)}`
await chat.send(query)
const turn = assistantTurns(chat)[0]
if (!turn) throw new Error('Assistant turn was not created.')
await chat.savePlaylist(turn.id)
expect(createPlaylist).toHaveBeenCalledWith({
schema_version: 1,
name: query.slice(0, 100),
track_uris: ['spotify:track:1'],
})
})
}) })