Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/ui/client/src/components/CaseMetricsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -521,10 +521,11 @@ const formatOf = (name: string, value: number) => formatColumnValue(value, metri
<span class="text-xs muted font-normal">every case against its recorded metrics</span>
</h2>
<StateView :error="metrics.error.value" :loading="metrics.isPending.value" :empty="table.rows.length === 0" empty-text="No cases yet" @retry="metrics.refetch()">
<div :class="['grid gap-3 p-3']">
<!-- `minmax(0,1fr)`: a chart wider than the card scrolls inside its own wrapper instead of widening the grid. -->
<div :class="['grid grid-cols-[minmax(0,1fr)] gap-3 p-3']">
<!-- One toolbar row: the axes behind a button, the color, and what the selection holds. -->
<div :class="['flex flex-wrap items-center gap-x-2 gap-y-2 text-xs select-none']">
<SegmentedControl v-if="views.length > 0" v-model="view" :items="views" label="View" size="md" />
<SegmentedControl v-if="views.length > 0" v-model="view" :items="views" label="View" :size="isDesktop ? 'md' : 'sm'" />
<AxisPicker v-model:metrics="numericAxes" v-model:categories="categoryAxes" :definition-of="name => metricDefinition(name, definitions)" :label-of="labelOf" :separation="separation" :table="table" :values-of="valuesOf" />
<DropdownMenuRoot @update:open="open => { if (!open) highlightedColor = undefined }">
<DropdownMenuTrigger :class="['option-trigger max-w-60']" data-testid="color-by">
Expand Down
63 changes: 63 additions & 0 deletions packages/ui/client/src/components/InfoButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useMediaQuery } from '@vueuse/core'
import { PopoverArrow, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { shallowRef } from 'vue'

import AnimatedContent from './AnimatedContent.vue'

/**
* An "ⓘ" icon button that opens secondary facts the page does not print inline (raw identifiers, how a number is
* computed). A popover on desktop, a bottom drawer on phones, with the same body from the default slot.
*
* Use when:
* - a fact helps when asked for but would crowd the layout, especially on phones
*/
const props = defineProps<{
/** What the facts are about (`Run details`): the button's accessible name and the drawer's title. */
label: string
}>()

const isDesktop = useMediaQuery('(min-width: 768px)')
const open = shallowRef(false)
const trigger = ['icon-btn', 'size-6! text-sm! muted']
</script>

<template>
<PopoverRoot v-if="isDesktop" v-model:open="open">
<PopoverTrigger :class="trigger" :aria-label="props.label" :title="props.label" data-testid="info-button">
<span class="i-mingcute-information-line" aria-hidden="true" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent as-child align="start" :side-offset="6" :collision-padding="12" :class="['floating p-3 text-xs font-normal select-text outline-none']">
<AnimatedContent>
<!-- The content scrolls inside: `AnimatedContent` clips its own root. -->
<div :class="['w-96 max-w-[calc(100vw-2rem)] max-h-[min(32rem,var(--reka-popover-content-available-height))] overflow-y-auto']">
<slot />
</div>
<PopoverArrow :class="['floating-arrow']" />
</AnimatedContent>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<DrawerRoot v-else v-model:open="open">
<button type="button" :class="trigger" :aria-label="props.label" :title="props.label" data-testid="info-button" @click="open = true">
<span class="i-mingcute-information-line" aria-hidden="true" />
</button>
<DrawerPortal>
<DrawerOverlay :class="['fixed inset-0 z-50 bg-black/40']" />
<DrawerContent :class="['floating fixed inset-x-0 bottom-0 max-h-[85dvh] flex flex-col gap-3 rounded-b-none! rounded-t-2xl! border-b-0! px-4 pt-3 pb-6 text-sm font-normal outline-none']">
<DrawerHandle :class="['bg-neutral-300! dark:bg-neutral-600!']" />
<DrawerTitle :class="['text-sm font-semibold']">
{{ props.label }}
</DrawerTitle>
<DrawerDescription :class="['sr-only']">
{{ props.label }}
</DrawerDescription>
<div :class="['overflow-y-auto min-h-0']">
<slot />
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
4 changes: 3 additions & 1 deletion packages/ui/client/src/components/LineChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ const bounds = computed(() => {
const x = extent(props.points.map(point => point.x))
const y = extent(props.points.map(point => point.y))
const minY = Math.min(0, y.min)
const maxY = Math.max(y.max, minY + 1e-9)
// A constant series (every value 0, say) has no range: one unit above the floor keeps the axis readable (`0`–`1`)
// instead of labelling both ends `0`.
const maxY = y.max > minY ? y.max : minY + 1
return { maxX: x.max, maxY, minX: x.min, minY }
})
function sx(x: number) {
Expand Down
19 changes: 12 additions & 7 deletions packages/ui/client/src/components/PageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,12 @@ const siblings = computed(() => {
<template>
<header :class="['flex flex-col gap-2 pb-3 mb-3 border-b border-neutral-200/50 dark:border-neutral-800/50']">
<Breadcrumbs :items="crumbs" :siblings="siblings" />
<!-- With page actions, phones give the title its own line and wrap the actions below it instead of squeezing the
title; the "⋯" menu alone stays next to the title. -->
<!-- Phones keep the "⋯" menu next to the title and wrap the page actions onto their own line below it instead of
squeezing the title; wider screens put the actions and the menu after the title, and wrap them below once the
title would get narrower than 24rem. -->
<div :class="['flex items-start gap-x-3 gap-y-2 flex-wrap']">
<div :class="['min-w-0 flex-1', $slots.actions && 'max-md:basis-full']">
<h1 :class="['flex items-start gap-2 text-lg font-semibold break-all leading-7']">
<div :class="['min-w-0 flex-1 md:basis-96']">
<h1 :class="['flex items-start gap-2 text-lg font-semibold [overflow-wrap:anywhere] leading-7']">
<span v-if="props.level" :class="[levelIcons[props.level], 'shrink-0 mt-1 muted']" role="img" :aria-label="levelLabels[props.level]" :title="levelLabels[props.level]" />
<span v-if="props.loading" :class="['skeleton h-6 my-0.5 w-56 max-w-full']" data-testid="page-header-skeleton" />
<slot v-else name="title">
Expand All @@ -75,11 +76,15 @@ const siblings = computed(() => {
</slot>
</div>
</div>
<!-- As tall as the title's first line, so actions line up with the title wherever the header wraps. -->
<div :class="['flex items-center gap-2 flex-wrap min-h-7']">
<!-- As tall as the title's first line on wide screens, so actions line up with the title. On phones the line
collapses when every action hides itself there (the experiment trend). -->
<div v-if="$slots.actions" :class="['flex items-center gap-2 flex-wrap md:min-h-7', 'max-md:(order-2 basis-full)']">
<slot name="actions" />
<IdMenu v-if="props.id && props.level" :id="props.id" :kind="levelLabels[props.level].toLowerCase()" />
</div>
<!-- IdMenu renders no element of its own; the wrapper carries the order and the alignment. -->
<span v-if="props.id && props.level" :class="['flex items-center min-h-7 max-md:order-1']">
<IdMenu :id="props.id" :kind="levelLabels[props.level].toLowerCase()" />
</span>
</div>
</header>
</template>
10 changes: 7 additions & 3 deletions packages/ui/client/src/components/ProgressPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { LiveConnection } from '../composables/useRunLive'
import { useIntervalFn, useTimestamp } from '@vueuse/core'
import { computed } from 'vue'

import InfoButton from './InfoButton.vue'
import ProgressBar from './ProgressBar.vue'
import ProgressChart from './ProgressChart.vue'
import StateBadge from './StateBadge.vue'
Expand Down Expand Up @@ -40,6 +41,8 @@ const basisText = computed(() => ({
'ended': 'run ended',
'unknown': 'no case finished yet: no estimate',
})[progress.value.basis])
/** How the remaining time is estimated: printed under the numbers on desktop, behind an info button on phones. */
const etaBasis = computed(() => `ETA basis: ${basisText.value}${live.value && progress.value.lowerBound ? '; auto attempts were seen, so more rounds may follow (lower bound)' : ''}. Rule: elapsed ÷ settled cases × estimated cases − elapsed, and at least as long as the longest running case has run.`)
const connectionText = computed(() => ({
closed: 'not connected',
connecting: 'connecting…',
Expand Down Expand Up @@ -122,8 +125,9 @@ const connectionText = computed(() => ({
</dd>
</div>
<div>
<dt class="text-xs muted">
<dt :class="['flex items-center gap-1 text-xs muted']">
{{ live ? 'Estimated remaining' : 'Total duration' }}
<span :class="['flex md:hidden']"><InfoButton label="How the ETA is estimated"><p>{{ etaBasis }}</p></InfoButton></span>
</dt>
<dd :class="['text-xl font-semibold tabular-nums']" data-testid="progress-eta">
<template v-if="live">
Expand All @@ -143,8 +147,8 @@ const connectionText = computed(() => ({
</dd>
</div>
</dl>
<p :class="['text-xs muted max-md:order-1']">
{{ `ETA basis: ${basisText}${live && progress.lowerBound ? '; auto attempts were seen, so more rounds may follow (lower bound)' : ''}. Rule: elapsed ÷ settled cases × estimated cases − elapsed, and at least as long as the longest running case has run.` }}
<p :class="['text-xs muted max-md:hidden']">
{{ etaBasis }}
</p>

<div v-if="props.snapshot.current.running.length > 0 || props.snapshot.current.waiting.length > 0" :class="['max-md:order-1']" data-testid="current-position">
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/client/src/components/RunDetails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ describe('runDetails', () => {
* @example
* expect(screen.getByText('Report root')).toBeVisible()
*/
it('keeps the raw run identifiers collapsed until "Details" is opened', async () => {
it('keeps the raw run identifiers behind the "Run details" info button', async () => {
const screen = await render(RunDetails, { props: { reportPath: 'default-workspace/osworld/exp/attempt-1/run-1', reportRoot: '/data/.vieval/reports', runId: 'run-1' } })

expect(screen.getByTestId('run-details').query()).toBeNull()

await screen.getByRole('button', { name: 'Details' }).click()
await screen.getByRole('button', { name: 'Run details' }).click()

await expect.element(screen.getByText('Report root')).toBeVisible()
await expect.element(screen.getByText('/data/.vieval/reports')).toBeVisible()
Expand All @@ -28,7 +28,7 @@ describe('runDetails', () => {
it('leaves out identifiers a run without a report does not have', async () => {
const screen = await render(RunDetails, { props: { reportPath: null, reportRoot: null, runId: 'run-2' } })

await screen.getByRole('button', { name: 'Details' }).click()
await screen.getByRole('button', { name: 'Run details' }).click()

await expect.element(screen.getByText('run-2')).toBeVisible()
expect(screen.getByText('Report path').query()).toBeNull()
Expand Down
46 changes: 21 additions & 25 deletions packages/ui/client/src/components/RunDetails.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { computed, shallowRef } from 'vue'

import InfoButton from './InfoButton.vue'

/**
* Collapsed "Details" of a run page header: the raw identifiers (run id, report path, report root) that the header
* no longer prints, each with an explicit "Copy" action.
* "Run details" of a run page header, behind an info button next to the title: the raw identifiers (run id, report
* path, report root) that the header no longer prints, each with an explicit "Copy" action.
*/
const props = defineProps<{
reportPath?: null | string
Expand All @@ -29,26 +30,21 @@ function copyRow(row: { label: string, value: string }): void {
</script>

<template>
<CollapsibleRoot v-slot="{ open }" :class="['mt-1']">
<CollapsibleTrigger :class="['btn', 'px-1.5! py-0.5! text-xs muted']">
<span :class="[open ? 'i-mingcute-down-small-line' : 'i-mingcute-right-small-line']" aria-hidden="true" />Details
</CollapsibleTrigger>
<CollapsibleContent>
<dl :class="['mt-1 grid gap-x-3 gap-y-1 text-xs', 'grid-cols-[max-content_1fr_max-content] items-baseline']" data-testid="run-details">
<template v-for="row in rows" :key="row.label">
<dt :class="['muted']">
{{ row.label }}
</dt>
<dd :class="['mono break-all text-neutral-700 dark:text-neutral-300']">
{{ row.value }}
</dd>
<dd>
<button type="button" :class="['btn', 'px-1.5! py-0.5! text-xs']" @click="copyRow(row)">
{{ copied && copiedLabel === row.label ? 'Copied' : 'Copy' }}
</button>
</dd>
</template>
</dl>
</CollapsibleContent>
</CollapsibleRoot>
<InfoButton label="Run details">
<dl :class="['grid gap-x-3 gap-y-1.5 text-xs', 'grid-cols-[max-content_1fr_max-content] items-baseline']" data-testid="run-details">
<template v-for="row in rows" :key="row.label">
<dt :class="['muted']">
{{ row.label }}
</dt>
<dd :class="['mono break-all text-neutral-700 dark:text-neutral-300']">
{{ row.value }}
</dd>
<dd>
<button type="button" :class="['btn', 'px-1.5! py-0.5! text-xs']" @click="copyRow(row)">
{{ copied && copiedLabel === row.label ? 'Copied' : 'Copy' }}
</button>
</dd>
</template>
</dl>
</InfoButton>
</template>
30 changes: 21 additions & 9 deletions packages/ui/client/src/components/ScoresPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ watch(() => props.refreshKey, () => {
})

const matrixKeys = computed(() => [...new Set(props.snapshot.tasks.flatMap(task => Object.keys({ ...task.matrix?.run, ...task.matrix?.eval })))])
/**
* Normalizes a task's display name for the matrix rows table, whose axis columns already show the matrix values that
* `vieval run` appends in brackets (`formatTaskDisplayName` in `packages/vieval/src/cli/run.ts`).
*
* Before:
* - "en [tts=official, asr=gemini-3.8-flash]"
*
* After:
* - "en"
*/
const taskName = (displayName: string) => displayName.replace(/ \[[^\]]*\]$/, '')
const scoreDefinitions = computed(() => scores.data.value?.definitions ?? props.snapshot.scoreDefinitions)
/** Every score of the run in the order the summary lists them; the series of the bar charts and the table columns. */
const scoreNames = computed(() => (scores.data.value?.overall.scores ?? []).map(summary => summary.name))
Expand Down Expand Up @@ -176,7 +187,7 @@ const numeric = computed(() => (metrics.data.value?.numeric ?? []).map(metric =>
</thead>
<tbody>
<tr v-for="row in scoreRows" :key="row.summary.name" :class="['border-b border-neutral-100/70 dark:border-neutral-900']" data-testid="score-row">
<td :class="['td text-sm whitespace-nowrap']">
<td :class="['td text-sm max-md:text-xs whitespace-nowrap']">
<MetricInfo :name="row.summary.name" :definition="row.column" :values="[]" :summary="row.summary">
<span :class="['cursor-help', row.column?.label == null && row.column != null && 'mono']">{{ row.label }}</span>
</MetricInfo>
Expand All @@ -191,10 +202,10 @@ const numeric = computed(() => (metrics.data.value?.numeric ?? []).map(metric =>
<td :class="['td text-xs muted']">
{{ unscoredText(row.summary.unscored) || '—' }}
</td>
<td :class="['td text-xs']">
<td :class="['td text-xs min-w-64']">
{{ row.column?.formula ?? '—' }}
</td>
<td :class="['td text-xs mono']">
<td :class="['td text-xs mono whitespace-nowrap']">
{{ row.grader ?? '—' }}
</td>
</tr>
Expand Down Expand Up @@ -244,10 +255,10 @@ const numeric = computed(() => (metrics.data.value?.numeric ?? []).map(metric =>
</thead>
<tbody>
<tr v-for="task in props.snapshot.tasks" :key="task.id" :class="['border-b border-neutral-100/70 dark:border-neutral-900']">
<td class="td">
<a :class="['link']" :href="href({ name: 'task', runKey, taskId: task.id })">{{ task.displayName }}</a>
<td :class="['td whitespace-nowrap']">
<a :class="['link']" :href="href({ name: 'task', runKey, taskId: task.id })" :title="task.displayName">{{ matrixKeys.length > 0 ? taskName(task.displayName) : task.displayName }}</a>
</td>
<td v-for="key in matrixKeys" :key="key" :class="['td mono text-xs']">
<td v-for="key in matrixKeys" :key="key" :class="['td mono text-xs whitespace-nowrap']">
{{ task.matrix?.run[key] ?? task.matrix?.eval[key] ?? '—' }}
</td>
<td :class="['td min-w-48']">
Expand Down Expand Up @@ -355,9 +366,10 @@ const numeric = computed(() => (metrics.data.value?.numeric ?? []).map(metric =>
</li>
</ul>
</section>
<section v-if="metrics.data.value && metrics.data.value.inference.requests > 0" :class="['card p-3 text-sm']">
Inference: {{ metrics.data.value.inference.requests }} requests · {{ metrics.data.value.inference.responses }} responses · {{ metrics.data.value.inference.failed }} failed
<span v-for="(value, key) in metrics.data.value.inference.totals" :key="key" class="ml-3 tabular-nums">{{ key }}={{ value }}</span>
<!-- The totals are one unbreakable `key=value` token each; the flex row wraps between them on narrow screens. -->
<section v-if="metrics.data.value && metrics.data.value.inference.requests > 0" :class="['card p-3 text-sm', 'flex flex-wrap items-baseline gap-x-3 gap-y-1']">
<span>Inference: {{ metrics.data.value.inference.requests }} requests · {{ metrics.data.value.inference.responses }} responses · {{ metrics.data.value.inference.failed }} failed</span>
<span v-for="(value, key) in metrics.data.value.inference.totals" :key="key" :class="['tabular-nums break-all']">{{ key }}={{ value }}</span>
</section>
</StateView>
</div>
Expand Down
Loading
Loading