mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
feat: rework the evals dataset drawer and run navigation (#10884)
* fix: create eval datasets from the run dialog, not the empty table Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * feat: paginate the evals dialog and show live run progress Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * feat: rework the evals dataset drawer and run navigation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: take the write lock on the empty-state add-a-case action Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: paginate the cases editor and add keyboard page navigation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: wire cases pagination and select runs from the keyboard Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: one highlight for pointer and keyboard, and guard keys on the topmost overlay Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: restore page navigation and answer arrows outside the pages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: scope eval keyboard navigation to the active topmost surface Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: leave Enter to focused controls and declare topmost from drawers too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: highlight with the hover surface and open the highlighted run Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: restore the run's dataset on the arrow-right fallback Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: keep the chosen comparison when reopening the same run Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * docs: state the Modal Enter caveat on EditableTextarea Modal handles Enter at `window` in the capture phase and stops propagation, so inside a dialog the key confirms the dialog rather than committing the edit. The docstring already carried this caveat for Escape; it now covers both keys and names `enterConfirms={false}` as the opt-out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: render the case result with the chat's prose stack GfmMarkdown falls back to the legacy `!prose-xs` when no `prose` is given, so the case result read differently from a chat answer. Pass `sm`, the stack AssistantMessage renders with, and drop the wrapper whose `text-xs text-secondary` competed with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * refactor: drop the narrating comment on the case result render Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: restore focus to the arriving page on keyboard navigation The restore checked `document.activeElement === document.body` one frame after the page changed, but the inert-driven reset lands after that frame: it read the element the user was about to lose and returned. Ask whether focus was inside the pages before navigating, then focus the arriving page unconditionally, which removes the race rather than re-timing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: restore focus for any keyboard-driven page change Arming only from the arrow handler missed Enter, which opens the highlighted run from the page itself and never reaches this component. Record whether the last interaction was a key pressed with focus inside the pages — cleared on pointerdown — so any caller-driven keyboard navigation restores focus too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: track focus position rather than the key that navigated Arming from a keydown listener cannot work for Escape: Modal handles it at `window` in the capture phase, registered before this component, and steps a level back from there — Svelte flushes this component's effects inside that handler, before our listener runs. Track whether focus sits in the pages as it moves, so the answer is already settled when the page changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: take focus back when a navigation leaves it nowhere The trail's back button sits outside the pages and is removed as the level it returns from closes, so activating it from the keyboard left focus on a dead element. Claim the arriving page when focus was in the pages, or when it has ended up on the body — never off a control that outlives the navigation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ * fix: type the TextInput binding as the textarea it renders `TextInput` is generic over its underlying element and defaults to `'input'`, so binding the textarea instance to a bare `TextInput` failed svelte-check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BamH3sRkmn5nP7wo9iKYPJ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,18 +27,14 @@
|
||||
let trail = $derived(
|
||||
location ? [{ label: TITLE, onclick: location.back }, { label: location.label }] : undefined
|
||||
)
|
||||
let description = $derived(
|
||||
location
|
||||
? undefined
|
||||
: 'Each run answers a dataset of cases with this agent and scores the answers, so runs can be compared.'
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- The `sm:` widths are what actually win: the dialog's own are breakpoint-prefixed. -->
|
||||
<Modal
|
||||
title={TITLE}
|
||||
{trail}
|
||||
{description}
|
||||
paginated
|
||||
enterConfirms={false}
|
||||
bind:open
|
||||
kind="X"
|
||||
class="w-[90vw] sm:w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[85vh]"
|
||||
|
||||
@@ -1,82 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { createGrid, type GridApi } from 'ag-grid-community'
|
||||
import 'ag-grid-community/styles/ag-grid.css'
|
||||
import 'ag-grid-community/styles/ag-theme-alpine.css'
|
||||
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
|
||||
import { transformColumnDefs } from '$lib/components/apps/components/display/table/utils'
|
||||
import { multilineCellColDef } from '$lib/components/apps/components/display/table/multilineCellEditor'
|
||||
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import EditableTextarea from '$lib/components/common/EditableTextarea.svelte'
|
||||
import { Button, EmptyState } from '$lib/components/common'
|
||||
import { ListPlus, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import type { CaseDraft } from './evalUtils'
|
||||
|
||||
let {
|
||||
cases = $bindable(),
|
||||
onRemove,
|
||||
locked = false,
|
||||
onEditingChange
|
||||
onAdd,
|
||||
focusCaseId = undefined,
|
||||
locked = false
|
||||
}: {
|
||||
/** The drawer's working copy. Edits land here as they are made; the drawer writes them. */
|
||||
cases: CaseDraft[]
|
||||
/** Asked before a row goes, since a stored case has runs that executed it. */
|
||||
onRemove: (c: CaseDraft) => void
|
||||
/** Adds a case. The empty table offers this itself, where the first row would be. */
|
||||
onAdd: () => void
|
||||
/** A case to open for typing as soon as it appears — the one just added, so a new row is
|
||||
* ready to be filled in rather than waiting to be clicked. */
|
||||
focusCaseId?: string
|
||||
/** The cases are being written: an edit made now is one the request already left behind. */
|
||||
locked?: boolean
|
||||
/** A cell opened or closed. Reported up so Save can be pressed for an edit the list does not
|
||||
* hold yet: the press is what commits the cell, so it has to reach a live button. */
|
||||
onEditingChange?: (editing: boolean) => void
|
||||
} = $props()
|
||||
|
||||
type Row = { id: string; question: string; expected: string }
|
||||
/** A dataset is capped at 1000 cases and every row here mounts two editors and a button, so the
|
||||
* whole set at once is thousands of components for a table you read a screenful of. ag-grid
|
||||
* virtualised its rows; `DataTable` paginates instead, which is the primitive already here. */
|
||||
const CASES_PER_PAGE = 25
|
||||
let currentPage = $state(1)
|
||||
/** Bound to `DataTable`'s own per-page selector, so changing it there re-slices here rather
|
||||
* than only relabelling the footer. */
|
||||
let perPage = $state(CASES_PER_PAGE)
|
||||
let lastPage = $derived(Math.max(1, Math.ceil(cases.length / perPage)))
|
||||
let pageCases = $derived(cases.slice((currentPage - 1) * perPage, currentPage * perPage))
|
||||
// A case removed from the last page leaves it empty; step back rather than show nothing.
|
||||
$effect(() => {
|
||||
if (currentPage > lastPage) currentPage = lastPage
|
||||
})
|
||||
|
||||
/** `expected` is whatever the case holds: a bare string, or a value shown as JSON. Read back
|
||||
* the same way, so a case that held an object keeps holding one. */
|
||||
function expectedToText(value: unknown): string {
|
||||
if (value == undefined) return ''
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function toRow(c: CaseDraft): Row {
|
||||
return {
|
||||
id: c.id ?? '',
|
||||
question: c.input?.user_message ?? '',
|
||||
expected: expectedToText(c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
let api: GridApi<any> | undefined = $state()
|
||||
let eGui: HTMLDivElement | undefined = $state()
|
||||
let darkMode = $state(false)
|
||||
|
||||
const defaultColDef = {
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
...multilineCellColDef
|
||||
}
|
||||
|
||||
$effect(() => eGui && untrack(() => mountGrid()))
|
||||
function mountGrid() {
|
||||
if (!eGui || api) return
|
||||
createGrid(eGui, {
|
||||
rowData: untrack(() => cases.map(toRow)),
|
||||
columnDefs: columnDefs(),
|
||||
defaultColDef: { ...defaultColDef, editable: untrack(() => !locked) },
|
||||
onCellValueChanged: (e) => {
|
||||
const target = cases.find((c) => c.id === (e.data as Row).id)
|
||||
if (!target) return
|
||||
if (e.colDef.field === 'question') {
|
||||
target.input = { ...target.input, user_message: e.newValue ?? '' }
|
||||
} else if (e.colDef.field === 'expected') {
|
||||
setExpected(target, e.newValue ?? '')
|
||||
}
|
||||
},
|
||||
onCellEditingStarted: () => onEditingChange?.(true),
|
||||
onCellEditingStopped: () => onEditingChange?.(false),
|
||||
suppressColumnMoveAnimation: true,
|
||||
suppressDragLeaveHidesColumns: true,
|
||||
onGridReady: (e) => (api = e.api)
|
||||
/** The question editors, by case id, so a newly added row can be opened. */
|
||||
let questionEditors = $state<Record<string, EditableTextarea | undefined>>({})
|
||||
$effect(() => {
|
||||
const id = focusCaseId
|
||||
if (!id) return
|
||||
untrack(() => {
|
||||
// Onto the page the case is on before reaching for its editor: paginated, a case added to
|
||||
// a full page is not rendered yet, and there would be nothing to open.
|
||||
const at = cases.findIndex((c) => c.id === id)
|
||||
if (at < 0) return
|
||||
currentPage = Math.floor(at / perPage) + 1
|
||||
// After the row has been rendered and registered itself.
|
||||
requestAnimationFrame(() => questionEditors[id]?.edit())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** Text that parses as JSON is stored as JSON, so a structured answer can be written by hand. */
|
||||
function setExpected(c: CaseDraft, text: string) {
|
||||
// Cleared means the case has no expected answer, which is not the same as expecting the
|
||||
// empty string: a scorer reads `undefined` as "nothing to measure here" and leaves the case
|
||||
// out of its mean, where `''` scores it a hard zero.
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
c.expected = undefined
|
||||
@@ -88,45 +82,93 @@
|
||||
c.expected = text
|
||||
}
|
||||
}
|
||||
|
||||
function columnDefs() {
|
||||
return transformColumnDefs({
|
||||
columnDefs: [
|
||||
{ field: 'question', headerName: 'Question', flex: 3 },
|
||||
{ field: 'expected', headerName: 'Expected', flex: 2 }
|
||||
] as any,
|
||||
onDelete: (values) => {
|
||||
if (locked) return
|
||||
const target = cases.find((c) => c.id === (values as Row).id)
|
||||
if (target) onRemove(target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Keyed on which cases are in the list rather than on what is in them: pushing rows back on
|
||||
// each keystroke would reset the cell being typed in.
|
||||
let rowKey = $derived(cases.map((c) => c.id).join(','))
|
||||
$effect(() => {
|
||||
rowKey
|
||||
untrack(() => api?.updateGridOptions({ rowData: cases.map(toRow) }))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const editable = !locked
|
||||
untrack(() => api?.updateGridOptions({ defaultColDef: { ...defaultColDef, editable } }))
|
||||
})
|
||||
|
||||
/** Commit whatever cell is open into `cases`, and wait for it to land there: the drawer reads
|
||||
* the list to save it, and a cell still being typed in is an edit that press is saving. */
|
||||
export async function flush() {
|
||||
api?.stopEditing()
|
||||
// The commit reaches `cases` through the grid's own event queue, a macrotask away: reading in
|
||||
// the same turn, or after a microtask, reads the list as it was before the cell was typed in.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div bind:this={eGui} class="h-full w-full ag-theme-alpine {darkMode ? 'ag-theme-alpine-dark' : ''}"
|
||||
></div>
|
||||
{#if cases.length === 0}
|
||||
<EmptyState
|
||||
icon={ListPlus}
|
||||
title="No cases yet"
|
||||
description="A case is one question this agent is asked, and the answer it is measured against. A dataset needs at least one before it can be saved or run."
|
||||
action={{
|
||||
label: 'Add a case',
|
||||
icon: Plus,
|
||||
onClick: onAdd,
|
||||
// The same lock the header's own button takes: a save reads the case list before it is
|
||||
// sent, so a case added while one is in flight would be dropped when it lands.
|
||||
disabled: locked,
|
||||
// The only live call to action here: Save stays disabled until a case exists.
|
||||
variant: 'accent'
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<DataTable
|
||||
size="xs"
|
||||
tableFixed
|
||||
containerClass="bg-surface-tertiary"
|
||||
paginated={cases.length > perPage}
|
||||
bind:currentPage
|
||||
bind:perPage
|
||||
rowCount={cases.length}
|
||||
hasMore={currentPage < lastPage}
|
||||
showPrev={currentPage > 1}
|
||||
on:next={() => (currentPage = Math.min(currentPage + 1, lastPage))}
|
||||
on:previous={() => (currentPage = Math.max(currentPage - 1, 1))}
|
||||
>
|
||||
<colgroup>
|
||||
<col style="width: 55%" />
|
||||
<col />
|
||||
<col style="width: 3rem" />
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Question</Cell>
|
||||
<Cell head>Expected</Cell>
|
||||
<Cell head last></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each pageCases as c (c.id)}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<!-- `commitOnInput`: the drawer's Save reads this list, so an edit has to be in it by
|
||||
the time the button is pressed rather than waiting on a blur that the press
|
||||
itself would cause. -->
|
||||
<EditableTextarea
|
||||
bind:this={questionEditors[c.id ?? '']}
|
||||
value={c.input?.user_message ?? ''}
|
||||
placeholder="Question"
|
||||
editable={!locked}
|
||||
commitOnInput
|
||||
class="w-full"
|
||||
textClass="text-xs font-normal"
|
||||
onSave={(v) => (c.input = { ...(c.input ?? {}), user_message: v })}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<EditableTextarea
|
||||
value={expectedToText(c.expected)}
|
||||
placeholder="Expected"
|
||||
editable={!locked}
|
||||
commitOnInput
|
||||
class="w-full"
|
||||
textClass="text-xs font-normal"
|
||||
onSave={(v) => setExpected(c, v)}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
destructive
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Delete case"
|
||||
disabled={locked}
|
||||
onclick={() => onRemove(c)}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
onRenamed,
|
||||
onDeleted,
|
||||
onCasesChanged,
|
||||
onScorersChanged,
|
||||
onClosed
|
||||
onScorersChanged
|
||||
}: {
|
||||
workspace: string | undefined
|
||||
/** The agent the dataset is named after and belongs to. */
|
||||
@@ -45,8 +44,6 @@
|
||||
onDeleted: (path: string) => void | Promise<void>
|
||||
onCasesChanged: () => void | Promise<void>
|
||||
onScorersChanged: () => void | Promise<void>
|
||||
/** The drawer is done, whether it saved anything or not. */
|
||||
onClosed?: () => void
|
||||
} = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
@@ -92,11 +89,6 @@
|
||||
let storedIds = $state<Set<string>>(new Set())
|
||||
|
||||
let removingCase = $state<CaseDraft | undefined>(undefined)
|
||||
/** The grid, so an open cell can be committed before the list is read to write it. */
|
||||
let casesGrid: EvalCasesGrid | undefined = $state()
|
||||
/** A cell is open in the grid: an edit the list does not hold yet, so Save stays live for the
|
||||
* press that commits it. */
|
||||
let casesEditing = $state(false)
|
||||
|
||||
/** The next free `<agent>_datasetN`, which is what a dataset is called until it is named. */
|
||||
function nextDatasetIndex(): number {
|
||||
@@ -109,9 +101,9 @@
|
||||
export function openDrawer(next: 'new' | 'edit') {
|
||||
mode = next
|
||||
pathError = ''
|
||||
// The grid is rebuilt per open and reports afresh; a cell left open when the drawer closed
|
||||
// would otherwise leave Save live over a set nothing has changed.
|
||||
casesEditing = false
|
||||
// Or the case added last time opens itself when the drawer is next opened on this dataset:
|
||||
// ids survive `fromStoredCase`, so the id would still match a row.
|
||||
focusCaseId = undefined
|
||||
// What was collected for a dataset that was never created belongs to that attempt.
|
||||
pendingScorers = []
|
||||
workingCases = next === 'edit' ? cases.map((c) => fromStoredCase(c) as CaseDraft) : []
|
||||
@@ -145,7 +137,6 @@
|
||||
|
||||
async function createDataset() {
|
||||
if (!workspace || !path || pathError) return
|
||||
await casesGrid?.flush()
|
||||
creating = true
|
||||
const created = path
|
||||
const submittedSummary = summary || undefined
|
||||
@@ -164,9 +155,9 @@
|
||||
creating = false
|
||||
return
|
||||
}
|
||||
// The pane moves onto the created dataset before the drawer closes: closing reopens the Run
|
||||
// dialog, which reads the pane's selection as it opens. A refresh that fails must still not
|
||||
// read as a create that failed, or the retry hits "already exists".
|
||||
// The pane moves onto the created dataset while the drawer is still open — the Run dialog
|
||||
// stands underneath and follows that selection. A refresh that fails must still not read as
|
||||
// a create that failed, or the retry hits "already exists".
|
||||
try {
|
||||
await onCreated(created)
|
||||
} catch (e) {
|
||||
@@ -193,8 +184,6 @@
|
||||
* refuses the case edits with it instead of leaving them written under the old name. */
|
||||
async function saveDataset() {
|
||||
if (!workspace || !datasetPath || !dataset || !path || pathError) return
|
||||
// Before anything reads the list: a cell still open is an edit that must go in with it.
|
||||
await casesGrid?.flush()
|
||||
// Read once, so every step of the save agrees on what was submitted: the fields are locked
|
||||
// while it runs, and the one the drawer navigates to afterwards must be the one written.
|
||||
// `summary` always travels: the server keeps the stored one when it is absent, so clearing
|
||||
@@ -233,8 +222,13 @@
|
||||
saving = false
|
||||
}
|
||||
|
||||
/** The case the grid should open for typing: the one just added. */
|
||||
let focusCaseId = $state<string | undefined>(undefined)
|
||||
|
||||
function addCase() {
|
||||
workingCases = [...workingCases, { ...emptyCase(), id: randomUUID() }]
|
||||
const id = randomUUID()
|
||||
workingCases = [...workingCases, { ...emptyCase(), id }]
|
||||
focusCaseId = id
|
||||
}
|
||||
|
||||
function deleteCase(id: string) {
|
||||
@@ -263,9 +257,15 @@
|
||||
)
|
||||
})
|
||||
let nothingToSave = $derived(metadataUnchanged && !casesChanged)
|
||||
/** A dataset with no cases cannot be run — the server refuses it (`run.rs`, "has no case to
|
||||
* run") — so there is no point creating one. Only creation is blocked: a saved dataset must
|
||||
* stay editable down to its last case, or removing that case, or renaming while it is the only
|
||||
* one left, would have no way to be saved. */
|
||||
let noCases = $derived(workingCases.length === 0)
|
||||
let noCasesTitle = $derived(noCases ? 'Add at least one case first' : undefined)
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="900px" on:close={() => onClosed?.()}>
|
||||
<Drawer bind:this={drawer} size="900px">
|
||||
<!-- Inside the drawer, not beside it. The drawer is portalled to the body and stacked above the
|
||||
pane that opened it, so a modal rendered from here is otherwise trapped under it. -->
|
||||
<ConfirmationModal
|
||||
@@ -354,7 +354,10 @@
|
||||
onWriting={(w) => (scorersWriting = w)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow min-h-0">
|
||||
<!-- Sized by its rows, not by what is left of the drawer: a dataset with one case should
|
||||
not be followed by an empty half-screen of table. The drawer body scrolls when the
|
||||
list outgrows it. -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Cases</span>
|
||||
<span class="text-2xs text-tertiary">{workingCases.length}</span>
|
||||
@@ -369,13 +372,13 @@
|
||||
Add a case
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grow min-h-0">
|
||||
<div>
|
||||
<EvalCasesGrid
|
||||
bind:this={casesGrid}
|
||||
bind:cases={workingCases}
|
||||
onRemove={removeCase}
|
||||
onAdd={addCase}
|
||||
{focusCaseId}
|
||||
locked={writing}
|
||||
onEditingChange={(v) => (casesEditing = v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +400,7 @@
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
loading={saving}
|
||||
disabled={writing || !path || !!pathError || (nothingToSave && !casesEditing)}
|
||||
disabled={writing || !path || !!pathError || nothingToSave}
|
||||
onclick={saveDataset}
|
||||
>
|
||||
Save
|
||||
@@ -408,7 +411,8 @@
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
loading={creating}
|
||||
disabled={creating || !path || !!pathError}
|
||||
disabled={creating || !path || !!pathError || noCases}
|
||||
title={noCasesTitle}
|
||||
onclick={createDataset}
|
||||
>
|
||||
Create dataset
|
||||
|
||||
@@ -47,16 +47,6 @@
|
||||
let hasDraft = $derived(editedConfig !== undefined)
|
||||
let dataset = $state<string | undefined>(undefined)
|
||||
let hoveringDataset = $state(false)
|
||||
/** Set while the dialog stands aside for the dataset drawer, holding the pane's dataset as it
|
||||
* was on the way out: coming back onto a different one is the detour's answer, and adopted. */
|
||||
let steppedAside = $state<{ dataset: string | undefined } | undefined>(undefined)
|
||||
|
||||
/** Hands the screen to the dataset drawer, to be given back when it closes. */
|
||||
function stepAside(go: () => void) {
|
||||
steppedAside = { dataset: defaultDataset }
|
||||
open = false
|
||||
go()
|
||||
}
|
||||
|
||||
/** The agent's versions, for pinning one. Loaded when the dialog opens rather than held: a
|
||||
* version list goes stale the moment the agent is saved again. */
|
||||
@@ -76,21 +66,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the dialog was already open on the previous run of the effect below, which reads it
|
||||
* to tell an open from the pane's dataset moving underneath. */
|
||||
let wasOpen = false
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return
|
||||
const isOpen = open
|
||||
const pane = defaultDataset
|
||||
untrack(() => {
|
||||
loadVersions()
|
||||
const aside = steppedAside
|
||||
steppedAside = undefined
|
||||
if (aside) {
|
||||
// Back from the drawer: a dataset created or edited there moves the pane onto it, and
|
||||
// anything else leaves the field as it was left.
|
||||
if (defaultDataset !== aside.dataset) dataset = defaultDataset
|
||||
if (!isOpen) {
|
||||
wasOpen = false
|
||||
return
|
||||
}
|
||||
// Seeded on every open: the dataset last worked in, and the state of the agent there is
|
||||
// most reason to measure.
|
||||
dataset = defaultDataset
|
||||
// Followed while the dialog stands, not only read at open: the dataset drawer opens over
|
||||
// this dialog rather than in place of it, so creating, renaming or deleting a dataset
|
||||
// there moves the pane's selection with the dialog still up. Nothing else moves it then.
|
||||
dataset = pane
|
||||
if (wasOpen) return
|
||||
wasOpen = true
|
||||
loadVersions()
|
||||
// The state of the agent there is most reason to measure.
|
||||
choice = hasDraft ? 'draft' : 'deployed'
|
||||
})
|
||||
})
|
||||
@@ -184,7 +179,7 @@
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => stepAside(onNewDataset)}
|
||||
onclick={onNewDataset}
|
||||
>
|
||||
New dataset
|
||||
</Button>
|
||||
@@ -215,7 +210,7 @@
|
||||
title="Edit this dataset"
|
||||
on:click={() => {
|
||||
close()
|
||||
stepAside(() => onEditDataset(item.value ?? ''))
|
||||
onEditDataset(item.value ?? '')
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -228,7 +223,7 @@
|
||||
btnClasses="w-full !h-auto !justify-start !rounded-none flex items-center gap-2 px-3 py-2 text-xs !font-normal text-secondary hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
close()
|
||||
stepAside(onNewDataset)
|
||||
onNewDataset()
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
@@ -247,7 +242,7 @@
|
||||
startIcon={{ icon: Pencil }}
|
||||
iconOnly
|
||||
title="Edit this dataset"
|
||||
on:click={() => stepAside(() => onEditDataset(dataset ?? ''))}
|
||||
on:click={() => onEditDataset(dataset ?? '')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,17 +7,21 @@
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Bot, Code2, Loader2, Plus } from 'lucide-svelte'
|
||||
import { Bot, ChevronRight, Code2, Loader2, Plus } from 'lucide-svelte'
|
||||
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
|
||||
import type { EvalDataset, EvalExperiment, ExperimentScore } from '$lib/gen'
|
||||
import { datasetSummary, experimentName, formatScore, subjectLabel } from './evalUtils'
|
||||
|
||||
let {
|
||||
experiments,
|
||||
datasets,
|
||||
caseProgress,
|
||||
loaded,
|
||||
active = false,
|
||||
deployedHash = undefined,
|
||||
currentVersion = undefined,
|
||||
onOpen,
|
||||
onHighlight,
|
||||
onEditDataset,
|
||||
onNew
|
||||
}: {
|
||||
@@ -25,17 +29,87 @@
|
||||
experiments: EvalExperiment[]
|
||||
/** Whether the list has been read: an empty table is a statement about the agent. */
|
||||
loaded: boolean
|
||||
/** Whether this list is the page on screen. The keyboard is only answered while it is: the
|
||||
* run page keeps its own rows, and both would otherwise move on one press. */
|
||||
active?: boolean
|
||||
/** The workspace's datasets, for naming the one a run is of by what it is for. */
|
||||
datasets: EvalDataset[]
|
||||
/** How many cases each still-running run has finished, keyed by run id. A run the flow has
|
||||
* not been read for yet is at none of them rather than absent: the count is on the row from
|
||||
* the moment it appears, so it never arrives late and shifts the column. */
|
||||
caseProgress: Record<string, number>
|
||||
/** What the agent hashes to as deployed, and the version it is on: they resolve a run of
|
||||
* edits that were later saved, so a run is labelled here as the run picker labels it. */
|
||||
deployedHash?: string
|
||||
currentVersion?: number
|
||||
onOpen: (experiment: EvalExperiment) => void
|
||||
/** The highlighted run, reported up so the surface can act on it — arrowing into the run
|
||||
* page opens the run under the highlight rather than whichever was opened last. */
|
||||
onHighlight?: (id: string | undefined) => void
|
||||
onEditDataset: (dataset: string) => void
|
||||
onNew: () => void
|
||||
} = $props()
|
||||
|
||||
/** The highlighted run, by id. One state for both the pointer and the keyboard, as a melt menu
|
||||
* does it: hovering a row moves the highlight to it, so the arrows carry on from wherever the
|
||||
* pointer left off instead of running a second, invisible cursor of their own. It says where
|
||||
* the highlight is, not what is chosen — a run is not opened until Enter.
|
||||
*
|
||||
* By id and not by index: the list is newest-first and the poll prepends to it, so an index
|
||||
* would quietly come to mean a different run and Enter would open the wrong one. */
|
||||
let cursorId = $state<string | undefined>(undefined)
|
||||
let cursor = $derived(
|
||||
cursorId === undefined ? -1 : experiments.findIndex((e) => e.id === cursorId)
|
||||
)
|
||||
let body: HTMLTableSectionElement | undefined = $state()
|
||||
|
||||
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
|
||||
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
|
||||
// still the one on top, rather than under a drawer or a dialog opened since.
|
||||
const hostActive = overlayHostActive()
|
||||
const onTop = topmostSurface()
|
||||
const listening = () => hostActive() && onTop()
|
||||
|
||||
$effect(() => {
|
||||
onHighlight?.(cursorId)
|
||||
})
|
||||
|
||||
// A highlight on a run that has since gone, and the highlight itself when the list is not the
|
||||
// page on screen.
|
||||
$effect(() => {
|
||||
if (!active || (cursorId !== undefined && cursor < 0)) cursorId = undefined
|
||||
})
|
||||
|
||||
function move(by: number) {
|
||||
if (experiments.length === 0) return
|
||||
const from = cursor < 0 ? (by > 0 ? -1 : experiments.length) : cursor
|
||||
const at = Math.max(0, Math.min(experiments.length - 1, from + by))
|
||||
cursorId = experiments[at]?.id
|
||||
// `nearest`, so arrowing through a long list scrolls by a row rather than jumping the table.
|
||||
requestAnimationFrame(() =>
|
||||
body?.querySelectorAll('tr')[at]?.scrollIntoView({ block: 'nearest' })
|
||||
)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!active || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
const el = event.target as HTMLElement | null
|
||||
if (el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
} else if (event.key === 'Enter' && experiments[cursor]) {
|
||||
// Enter belongs to whatever is focused if that thing does something with it. A highlighted
|
||||
// row is not a reason to swallow the press on `New evaluation` or a row's dataset button.
|
||||
if (el?.closest?.('button, a[href], [role="button"], summary')) return
|
||||
event.preventDefault()
|
||||
onOpen(experiments[cursor])
|
||||
}
|
||||
}
|
||||
|
||||
/** The one number a column reports: a pass rate where it has a line to pass, the mean where it
|
||||
* does not. */
|
||||
function headline(score: ExperimentScore): string | undefined {
|
||||
@@ -44,13 +118,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<DataTable size="sm" tableFixed>
|
||||
<colgroup>
|
||||
<col style="width: 20%" />
|
||||
<col style="width: 22%" />
|
||||
<col style="width: 5rem" />
|
||||
<col style="width: 6rem" />
|
||||
<col />
|
||||
<col style="width: 7rem" />
|
||||
<col style="width: 2rem" />
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
@@ -58,12 +135,20 @@
|
||||
<Cell head>Dataset</Cell>
|
||||
<Cell head numeric>Cases</Cell>
|
||||
<Cell head>Scores</Cell>
|
||||
<Cell head last numeric>When</Cell>
|
||||
<Cell head numeric>When</Cell>
|
||||
<Cell head last></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each experiments as experiment (experiment.id)}
|
||||
<Row hoverable on:click={() => onOpen(experiment)}>
|
||||
<tbody class="divide-y" bind:this={body}>
|
||||
{#each experiments as experiment, i (experiment.id)}
|
||||
<!-- No `hoverable`, and not `selected` either: the first is a second hover tint competing
|
||||
with this highlight, the second is the blue that means "chosen". Nothing here is
|
||||
chosen until Enter, so the highlight wears the ordinary hover surface. -->
|
||||
<Row
|
||||
class="cursor-pointer {i === cursor ? 'bg-surface-hover' : ''}"
|
||||
on:click={() => onOpen(experiment)}
|
||||
on:hover={(e) => e.detail && (cursorId = experiment.id)}
|
||||
>
|
||||
<Cell first>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
@@ -99,7 +184,18 @@
|
||||
</Button>
|
||||
</Cell>
|
||||
<Cell numeric>
|
||||
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
|
||||
{#if experiment.running}
|
||||
<!-- The run's only spinner. One per score badge instead read as the judge being
|
||||
slow, when what is unfinished is the run. -->
|
||||
<span
|
||||
class="inline-flex items-center gap-1 tabular-nums text-secondary whitespace-nowrap"
|
||||
>
|
||||
<Loader2 size={11} class="animate-spin text-blue-500 shrink-0" />
|
||||
{caseProgress[experiment.id] ?? 0}/{experiment.case_count}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-wrap gap-1 min-w-0">
|
||||
@@ -117,8 +213,6 @@
|
||||
<span class="tabular-nums font-semibold text-emphasis">{value}</span>
|
||||
{:else if score.failed > 0}
|
||||
<span class="text-red-500">failed</span>
|
||||
{:else if experiment.running}
|
||||
<Loader2 size={11} class="animate-spin text-blue-500 self-center" />
|
||||
{:else}
|
||||
<span class="text-tertiary">—</span>
|
||||
{/if}
|
||||
@@ -126,33 +220,34 @@
|
||||
</Badge>
|
||||
{/each}
|
||||
{#if (experiment.scores ?? []).length === 0}
|
||||
{#if experiment.running}
|
||||
<span class="text-2xs text-tertiary inline-flex items-center gap-1">
|
||||
<Loader2 size={11} class="animate-spin text-blue-500" />
|
||||
scoring
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-2xs text-tertiary">not scored</span>
|
||||
{/if}
|
||||
<!-- Not a pending state: a scorer's column is on the run from the moment it is
|
||||
created, with or without a number in it, so nothing arrives here later. -->
|
||||
<span class="text-2xs text-tertiary">not scored</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<Cell numeric>
|
||||
<span class="text-2xs text-tertiary whitespace-nowrap">
|
||||
<TimeAgo date={experiment.created_at} agoOnlyIfRecent />
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
class="text-tertiary transition-opacity {i === cursor ? 'opacity-100' : 'opacity-0'}"
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{#if experiments.length === 0 && !loaded}
|
||||
<tr>
|
||||
<td colspan="5" class="p-3">
|
||||
<td colspan="6" class="p-3">
|
||||
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
|
||||
</td>
|
||||
</tr>
|
||||
{:else if experiments.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="p-6">
|
||||
<td colspan="6" class="p-6">
|
||||
<div class="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<span class="text-sm text-emphasis">No runs yet</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
|
||||
@@ -196,26 +196,31 @@
|
||||
</DropdownV2>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md">
|
||||
<div>
|
||||
{#if scorers.length === 0}
|
||||
<div class="p-3 text-2xs text-tertiary">
|
||||
<div class="border rounded-md p-3 text-2xs text-tertiary">
|
||||
A scorer reads one run and returns a number. Every run of this dataset is measured by all of
|
||||
them, which is what makes two runs comparable.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y">
|
||||
<!-- Laid out as `RadioCard` is — same padding, icon size, and label-over-description type
|
||||
scale — so a scorer reads as a card of the same family. Not that component: nothing
|
||||
here is being selected. -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md border border-border-light bg-surface-tertiary p-3"
|
||||
>
|
||||
{#if scorer.kind === 'agent'}
|
||||
<Bot size={13} class="text-tertiary shrink-0" />
|
||||
<Bot size={16} class="text-secondary shrink-0" />
|
||||
{:else}
|
||||
<Code2 size={13} class="text-tertiary shrink-0" />
|
||||
<Code2 size={16} class="text-secondary shrink-0" />
|
||||
{/if}
|
||||
<div class="flex flex-col min-w-0 grow">
|
||||
<span class="text-xs text-emphasis truncate leading-tight">
|
||||
<span class="text-xs font-semibold text-emphasis truncate">
|
||||
{scorerLabel(scorer)}
|
||||
</span>
|
||||
<span class="text-2xs text-tertiary truncate leading-tight">{scorer.path}</span>
|
||||
<span class="text-xs font-normal text-secondary truncate mt-0.5">{scorer.path}</span>
|
||||
</div>
|
||||
{#if scorer.pass_if != undefined}
|
||||
<span class="text-2xs text-tertiary shrink-0" title="Pass threshold">
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { Splitpanes, Pane } from 'svelte-splitpanes'
|
||||
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
|
||||
import {
|
||||
type AgentDraft,
|
||||
AiEvalsService,
|
||||
JobService,
|
||||
type EvalCase,
|
||||
type EvalDataset,
|
||||
type EvalExperiment,
|
||||
@@ -35,9 +37,11 @@
|
||||
Code2,
|
||||
ExternalLink
|
||||
} from 'lucide-svelte'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import EvalDatasetDrawer from './EvalDatasetDrawer.svelte'
|
||||
import EvalRunsList from './EvalRunsList.svelte'
|
||||
import EvalRunDialog from './EvalRunDialog.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import {
|
||||
caseLabel,
|
||||
@@ -52,6 +56,10 @@
|
||||
/** A dataset is capped at this many cases, so one page holds the whole set. */
|
||||
const CASE_PAGE_SIZE = 1000
|
||||
|
||||
/** The id the run's flow gives the loop over its cases (`CASES_NODE_ID` in `ai_evals/run.rs`).
|
||||
* Looked up by id rather than by position: the flow has a step after the loop too. */
|
||||
const CASES_MODULE_ID = 'cases'
|
||||
|
||||
let {
|
||||
agentPath,
|
||||
opWorkspace = undefined,
|
||||
@@ -91,12 +99,56 @@
|
||||
/** What the agent hashes to as deployed: a run of edits carrying it ran what was then saved. */
|
||||
let deployedHash = $state<string | undefined>(undefined)
|
||||
let running = $state(false)
|
||||
let scorers = $derived(dataset?.scorers ?? [])
|
||||
/** The run on screen belongs to a dataset still being read. Until it arrives the rows and the
|
||||
* scorer columns would both be built from the *previous* dataset, so both are held back. */
|
||||
let datasetLoading = $state(false)
|
||||
let scorers = $derived(datasetLoading ? [] : (dataset?.scorers ?? []))
|
||||
let selectedCaseId = $state<string | undefined>(undefined)
|
||||
|
||||
let datasetDrawer: EvalDatasetDrawer | undefined = $state()
|
||||
let runDialogOpen = $state(false)
|
||||
let resumeRunDialog = $state(false)
|
||||
/** The run the list has highlighted, so arrowing into the run page opens that one. Without it
|
||||
* the arrow could only fall back to whichever run was opened last, which on a dialog just
|
||||
* opened is none at all. */
|
||||
let highlightedRunId = $state<string | undefined>(undefined)
|
||||
|
||||
/** How many cases each still-running run has finished, keyed by run id. Read from the flow
|
||||
* executing the run: the list carries the case total, and counting the finished ones there
|
||||
* would be a per-case query for every run listed. The flow already records it — one slot per
|
||||
* case in `flow_jobs_success`, null until that case's iteration is over. */
|
||||
let caseProgress = $state<Record<string, number>>({})
|
||||
|
||||
async function readCaseProgress() {
|
||||
const workspace = ws
|
||||
const live = experiments.filter((e) => e.running)
|
||||
if (!workspace || live.length === 0) {
|
||||
if (Object.keys(caseProgress).length > 0) caseProgress = {}
|
||||
return
|
||||
}
|
||||
const read = await Promise.all(
|
||||
live.map(async (e) => {
|
||||
try {
|
||||
const update = await JobService.getJobUpdates({
|
||||
workspace,
|
||||
id: e.run_job_id,
|
||||
running: true,
|
||||
noLogs: true
|
||||
})
|
||||
const cases = update.flow_status?.modules?.find((m) => m.id === CASES_MODULE_ID)
|
||||
if (!cases) return undefined
|
||||
return [
|
||||
e.id,
|
||||
(cases.flow_jobs_success ?? []).filter((s) => s != undefined).length
|
||||
] as const
|
||||
} catch {
|
||||
// Left out of the map, so the row reads `0/total` until a later poll answers. A
|
||||
// flow that cannot be read is already the list's problem to report, not this one's.
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
caseProgress = Object.fromEntries(read.filter((e) => e !== undefined))
|
||||
}
|
||||
|
||||
let experiment = $derived(experiments.find((e) => e.id === experimentId))
|
||||
|
||||
@@ -120,6 +172,7 @@
|
||||
runsLoadError = false
|
||||
try {
|
||||
experiments = await listSubjectExperiments()
|
||||
await readCaseProgress()
|
||||
} catch (e) {
|
||||
runsLoadError = true
|
||||
sendUserToast(`Failed to load the runs: ${e}`, true)
|
||||
@@ -183,6 +236,8 @@
|
||||
// Switching datasets leaves the previous request in flight; only the newest may write, or a
|
||||
// slow response for the dataset you just left replaces the one you are looking at.
|
||||
let loadGeneration = 0
|
||||
/** Which run the pane is opening; only the newest may clear `datasetLoading`. */
|
||||
let openGeneration = 0
|
||||
|
||||
async function loadDataset(path: string | undefined): Promise<boolean> {
|
||||
const generation = ++loadGeneration
|
||||
@@ -345,6 +400,7 @@
|
||||
await loadResults()
|
||||
} else {
|
||||
experiments = await listSubjectExperiments()
|
||||
await readCaseProgress()
|
||||
}
|
||||
} finally {
|
||||
refreshing = false
|
||||
@@ -354,27 +410,46 @@
|
||||
/** Opens a run, bringing its dataset with it and offering the run before it as the baseline.
|
||||
* Reading the cells is left to the effect on the selection, so every way in opens one alike. */
|
||||
async function openRun(id: string) {
|
||||
// Against the dataset that is loaded, not the one that is selected: skipping on the selection
|
||||
// alone would leave a run open over a dataset whose cases and scorers were never read.
|
||||
const target = experiments.find((e) => e.id === id)
|
||||
if (target && target.dataset !== dataset?.path) {
|
||||
await useDataset(target.dataset)
|
||||
// Only when the run itself changes: re-showing the one already open — arrowing back into it
|
||||
// from the list — must keep whatever comparison the user picked.
|
||||
if (id !== experimentId) {
|
||||
const index = experiments.findIndex((e) => e.id === id)
|
||||
// The run before it *of the same dataset*: the list spans datasets, and a run of another
|
||||
// set of cases is not a baseline for this one.
|
||||
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
|
||||
}
|
||||
const index = experiments.findIndex((e) => e.id === id)
|
||||
// The run before it *of the same dataset*: the list spans datasets, and a run of another set
|
||||
// of cases is not a baseline for this one.
|
||||
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
|
||||
experimentId = id
|
||||
viewingRun = true
|
||||
selectedCaseId = undefined
|
||||
// Opened first, read second: the dataset is a request, and waiting on it here is a click
|
||||
// that does nothing at all until the network answers. The page carries the wait instead.
|
||||
//
|
||||
// Against what is *selected* as well as what is loaded. `selectedDataset` moves the moment a
|
||||
// read starts, so a load still in flight for another dataset shows up here: without that
|
||||
// test, opening a run of the dataset already committed would skip `useDataset` entirely and
|
||||
// leave the in-flight one free to commit its cases under this run.
|
||||
const needsDataset =
|
||||
!!target && (target.dataset !== dataset?.path || target.dataset !== selectedDataset)
|
||||
datasetLoading = needsDataset
|
||||
viewingRun = true
|
||||
if (needsDataset) {
|
||||
// Numbered like `loadDataset`'s own read, and for the same reason: opening a second run
|
||||
// while the first is still loading leaves two `finally`s racing, and the loser clearing
|
||||
// the flag would uncover the table with neither dataset in hand.
|
||||
const generation = ++openGeneration
|
||||
try {
|
||||
await useDataset(target!.dataset)
|
||||
} finally {
|
||||
if (generation === openGeneration) datasetLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(runSubject: EvalSubject, path: string): Promise<boolean> {
|
||||
if (!ws || !path) return false
|
||||
running = true
|
||||
let id: string
|
||||
try {
|
||||
id = await AiEvalsService.runExperiment({
|
||||
await AiEvalsService.runExperiment({
|
||||
workspace: ws,
|
||||
requestBody: { dataset: path, subject: runSubject }
|
||||
})
|
||||
@@ -386,9 +461,10 @@
|
||||
// From here the run exists and is billing: what can still fail is reading it back, and
|
||||
// saying "failed to run" to that invites a second, duplicate run.
|
||||
try {
|
||||
// Onto the list rather than into the run: a run that has just started has no answers and
|
||||
// no scores, and the list already fills its row in as they land. Reading it is a click.
|
||||
if (path !== dataset?.path) await useDataset(path)
|
||||
await loadRuns()
|
||||
await openRun(id)
|
||||
} catch (e) {
|
||||
sendUserToast(
|
||||
`The run started but could not be read back: ${e}. Reload the runs list to see it.`,
|
||||
@@ -407,8 +483,6 @@
|
||||
|
||||
/** The dataset is gone and every run of it with it: back to the list, on no dataset. */
|
||||
async function datasetDeleted(path: string) {
|
||||
// A run dialog waiting behind the drawer has nothing to come back to.
|
||||
resumeRunDialog = false
|
||||
if (selectedDataset === path) {
|
||||
viewingRun = false
|
||||
selectedCaseId = undefined
|
||||
@@ -442,6 +516,13 @@
|
||||
}
|
||||
|
||||
let selectedRow = $derived(displayRows.find((row) => row.case_id === selectedCaseId))
|
||||
/** The case the side panel is showing. Held rather than read straight off the selection: the
|
||||
* pane animates shut over a few hundred milliseconds, and the selection is gone on the first
|
||||
* of them, which would empty the panel before it had finished closing. */
|
||||
let openRow = $state<ExperimentRow | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (selectedRow) openRow = selectedRow
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!ws) return
|
||||
@@ -570,34 +651,52 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
{#if loaded && loadError}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">Could not load evals</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
The datasets or runs could not be read. Check your access to this agent and reload.
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Warmed: the run page is a table, a splitter and two selects, and building it on the first
|
||||
click lands that work inside the transition — only the first navigation stutters, which
|
||||
reads as the animation being unreliable rather than as a cost. -->
|
||||
<PagedContent
|
||||
warm
|
||||
class="grow min-h-0"
|
||||
current={!viewingRun || !loaded ? 'list' : 'run'}
|
||||
onNavigate={(key) => {
|
||||
// Right opens the run under the highlight, falling back to whichever was open before;
|
||||
// left is the way back, the same as the breadcrumb.
|
||||
if (key === 'run') {
|
||||
// Both branches go through `openRun`: it is what brings the run's own dataset back,
|
||||
// and the fallback run may be of a dataset the list has since moved off.
|
||||
const id = highlightedRunId ?? experimentId
|
||||
if (id) openRun(id)
|
||||
} else if (key === 'list') {
|
||||
viewingRun = false
|
||||
selectedCaseId = undefined
|
||||
}
|
||||
}}
|
||||
pages={[
|
||||
{ key: 'list', content: listPage },
|
||||
{ key: 'run', content: runPage }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet listPage()}
|
||||
<!-- Carried by the level rather than by the dialog: as the dialog's own `description` it
|
||||
vanished the moment a run opened, shrinking the header and jolting the page under it. -->
|
||||
<p class="text-xs text-secondary">
|
||||
Each run answers a dataset of cases with this agent and scores the answers, so runs can be
|
||||
compared.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-2 py-2">
|
||||
{#if viewingRun}
|
||||
<Label label="Run" class="w-52 shrink">
|
||||
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
|
||||
</Label>
|
||||
<Label label="Compare to" class="w-48 shrink">
|
||||
<Select
|
||||
items={experimentItems.filter((i) => i.value !== experimentId)}
|
||||
bind:value={baselineId}
|
||||
placeholder="No comparison"
|
||||
clearable
|
||||
disabled={experiments.length < 2}
|
||||
class="text-xs"
|
||||
/>
|
||||
</Label>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
{#if viewingRun && experiment?.run_job_id}
|
||||
<a
|
||||
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
|
||||
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
{#if !viewingRun && loaded && datasets.length > 0}
|
||||
{#if loaded && datasets.length > 0}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
@@ -622,98 +721,115 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grow min-h-0 overflow-auto">
|
||||
<EvalRunsList
|
||||
{experiments}
|
||||
{datasets}
|
||||
{caseProgress}
|
||||
{loaded}
|
||||
active={!viewingRun}
|
||||
{deployedHash}
|
||||
{currentVersion}
|
||||
onOpen={(e) => openRun(e.id)}
|
||||
onHighlight={(id) => (highlightedRunId = id)}
|
||||
onEditDataset={async (path) => {
|
||||
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
|
||||
}}
|
||||
onNew={() => (runDialogOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet runPage()}
|
||||
<div class="flex flex-wrap items-end gap-2 py-2">
|
||||
<Label label="Run" class="w-52 shrink">
|
||||
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
|
||||
</Label>
|
||||
<Label label="Compare to" class="w-48 shrink">
|
||||
<Select
|
||||
items={experimentItems.filter((i) => i.value !== experimentId)}
|
||||
bind:value={baselineId}
|
||||
placeholder="No comparison"
|
||||
clearable
|
||||
disabled={experiments.length < 2}
|
||||
class="text-xs"
|
||||
/>
|
||||
</Label>
|
||||
<div class="grow"></div>
|
||||
{#if experiment?.run_job_id}
|
||||
<a
|
||||
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
|
||||
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grow min-h-0">
|
||||
<Splitpanes class="h-full">
|
||||
<Pane size={selectedRow ? 60 : 100} minSize={35}>
|
||||
<Splitpanes class="h-full splitter-hidden">
|
||||
<Pane minSize={35}>
|
||||
<div class="h-full overflow-auto">
|
||||
{#if loaded && loadError}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">Could not load evals</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
The datasets or runs could not be read. Check your access to this agent and reload.
|
||||
</span>
|
||||
</div>
|
||||
{:else if loaded && datasets.length === 0}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">No dataset yet</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
A dataset is the set of cases this agent is measured on. Runs are of a dataset, so
|
||||
it is the first thing to make.
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => datasetDrawer?.openDrawer('new')}
|
||||
>
|
||||
New dataset
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !viewingRun || !loaded}
|
||||
<EvalRunsList
|
||||
{experiments}
|
||||
{datasets}
|
||||
{loaded}
|
||||
{deployedHash}
|
||||
{currentVersion}
|
||||
onOpen={(e) => openRun(e.id)}
|
||||
onEditDataset={async (path) => {
|
||||
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
|
||||
}}
|
||||
onNew={() => (runDialogOpen = true)}
|
||||
/>
|
||||
{:else}
|
||||
<DataTable size="sm" tableFixed rounded={!selectedRow}>
|
||||
<colgroup>
|
||||
<col style="width: 24%" />
|
||||
<col style="width: 32%" />
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
<col style="width: 9rem" />
|
||||
{/each}
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Case</Cell>
|
||||
<Cell head last={scorers.length === 0}>Answer</Cell>
|
||||
{#each scorers as scorer, index (scorer.id)}
|
||||
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
|
||||
{@const headline = columnHeadline(scorer, mean)}
|
||||
<Cell head numeric last={index === scorers.length - 1}>
|
||||
<!-- The second row keeps its height while there is nothing in it, so the table
|
||||
does not move when the first score lands. -->
|
||||
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
|
||||
<span
|
||||
class="flex items-center gap-1 min-w-0 max-w-full"
|
||||
title={columnTitle(scorer, mean)}
|
||||
>
|
||||
{#if scorer.kind === 'agent'}
|
||||
<Bot size={13} class="text-tertiary shrink-0" />
|
||||
{:else}
|
||||
<Code2 size={13} class="text-tertiary shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
|
||||
</span>
|
||||
<span class="h-4 flex items-baseline gap-1.5 font-normal">
|
||||
{#if headline}
|
||||
<span class="tabular-nums text-emphasis font-semibold">
|
||||
{headline.value}
|
||||
<DataTable size="sm" tableFixed>
|
||||
<colgroup>
|
||||
<col style="width: 24%" />
|
||||
<col style="width: 32%" />
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
<col style="width: 9rem" />
|
||||
{/each}
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Case</Cell>
|
||||
<Cell head last={scorers.length === 0}>Answer</Cell>
|
||||
{#each scorers as scorer, index (scorer.id)}
|
||||
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
|
||||
{@const headline = columnHeadline(scorer, mean)}
|
||||
<Cell head numeric last={index === scorers.length - 1}>
|
||||
<!-- The second row keeps its height while there is nothing in it, so the table
|
||||
does not move when the first score lands. -->
|
||||
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
|
||||
<span
|
||||
class="flex items-center gap-1 min-w-0 max-w-full"
|
||||
title={columnTitle(scorer, mean)}
|
||||
>
|
||||
{#if scorer.kind === 'agent'}
|
||||
<Bot size={13} class="text-tertiary shrink-0" />
|
||||
{:else}
|
||||
<Code2 size={13} class="text-tertiary shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
|
||||
</span>
|
||||
<span class="h-4 flex items-baseline gap-1.5 font-normal">
|
||||
{#if headline}
|
||||
<span class="tabular-nums text-emphasis font-semibold">
|
||||
{headline.value}
|
||||
</span>
|
||||
{#if headline.delta && headline.direction !== 0}
|
||||
<span
|
||||
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
|
||||
>
|
||||
{headline.delta}
|
||||
</span>
|
||||
{#if headline.delta && headline.direction !== 0}
|
||||
<span
|
||||
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
|
||||
>
|
||||
{headline.delta}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</Cell>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</Cell>
|
||||
{/each}
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#if datasetLoading}
|
||||
<!-- A run whose *results* are loading keeps its rows instead: `displayRows` already
|
||||
names every case from the dataset in hand, which beats a skeleton. -->
|
||||
<tr>
|
||||
<td colspan={2 + scorers.length} class="p-3">
|
||||
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
|
||||
</td>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{:else}
|
||||
{#each displayRows as row (row.case_id)}
|
||||
{@const status = statusOf(row.status)}
|
||||
<Row
|
||||
@@ -813,52 +929,74 @@
|
||||
{/each}
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</Pane>
|
||||
{#if selectedRow}
|
||||
{@const openRow = selectedRow}
|
||||
<Pane size={40} minSize={25}>
|
||||
<!-- Animated rather than mounted and unmounted: the pane's own width is what moves, so the
|
||||
cells table reflows alongside it instead of being shoved aside. -->
|
||||
<AnimatedPane size={40} minSize={25} duration={180} opened={!!selectedRow}>
|
||||
{#if openRow}
|
||||
<div class="h-full overflow-auto flex flex-col">
|
||||
<div class="flex items-start gap-2 px-3 py-2 border-b">
|
||||
<span class="text-xs font-semibold text-emphasis break-words">
|
||||
<!-- The title and the actions are different type sizes, so nothing lines them up on
|
||||
its own. `leading-7` gives the title's first line the same box height as the
|
||||
close button, and the actions centre in a row of that height beside it — so a
|
||||
title that wraps grows downwards and leaves the row where it is. -->
|
||||
<div class="flex items-start gap-2 px-3 py-2">
|
||||
<span
|
||||
class="text-xs font-semibold text-emphasis break-words leading-7 flex-1 min-w-0"
|
||||
>
|
||||
{openRow.input?.user_message ?? caseLabel(openRow)}
|
||||
</span>
|
||||
<div class="grow"></div>
|
||||
{#if openRow.job_id}
|
||||
<a
|
||||
class="text-2xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 mt-0.5"
|
||||
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the case job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: X }}
|
||||
iconOnly
|
||||
title="Close"
|
||||
onclick={() => (selectedCaseId = undefined)}
|
||||
/>
|
||||
<div class="flex items-center gap-2 shrink-0 h-7">
|
||||
{#if openRow.job_id}
|
||||
<a
|
||||
class="text-2xs text-accent hover:underline inline-flex items-center gap-1"
|
||||
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the case job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: X }}
|
||||
iconOnly
|
||||
title="Close"
|
||||
onclick={() => (selectedCaseId = undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col gap-4">
|
||||
<!-- One card per thing the case has, rather than sections divided by rules: each
|
||||
stands on its own and the panel reads as a stack. -->
|
||||
<div class="px-3 pb-3 flex flex-col gap-2">
|
||||
{#if openRow.expected != undefined && openRow.expected !== ''}
|
||||
<Label label="Expected">
|
||||
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
|
||||
{typeof openRow.expected === 'string'
|
||||
? openRow.expected
|
||||
: JSON.stringify(openRow.expected, null, 2)}
|
||||
</span>
|
||||
</Label>
|
||||
<div class="rounded-md border border-light overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
|
||||
>
|
||||
<span class="text-2xs font-semibold text-secondary truncate">Expected</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
|
||||
{typeof openRow.expected === 'string'
|
||||
? openRow.expected
|
||||
: JSON.stringify(openRow.expected, null, 2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if scorers.length > 0 && openRow.scores.length > 0}
|
||||
<Label label="Scores">
|
||||
<div class="flex flex-col divide-y border rounded-md">
|
||||
<div class="rounded-md border border-light overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
|
||||
>
|
||||
<span class="text-2xs font-semibold text-secondary truncate">Scores</span>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-light">
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
{@const cell = openRow.scores.find((s) => s.scorer_id === scorer.id)}
|
||||
<div class="flex flex-col gap-1 px-2 py-1.5">
|
||||
@@ -899,7 +1037,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if experiment && (openRow.job_id || openRow.output != undefined)}
|
||||
<div class="rounded-md border border-light overflow-hidden">
|
||||
@@ -912,9 +1050,7 @@
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{#if openRow.output != undefined}
|
||||
<div class="text-xs text-secondary break-words">
|
||||
<GfmMarkdown md={openRow.output} noPadding />
|
||||
</div>
|
||||
<GfmMarkdown md={openRow.output} prose="sm" noPadding />
|
||||
{:else if openRow.status === 'running'}
|
||||
<span class="text-xs text-tertiary inline-flex items-center gap-1.5">
|
||||
<Loader2 size={12} class="animate-spin text-blue-500" />
|
||||
@@ -928,11 +1064,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
{/if}
|
||||
</AnimatedPane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<EvalRunDialog
|
||||
bind:open={runDialogOpen}
|
||||
@@ -944,15 +1080,9 @@
|
||||
{running}
|
||||
onRun={runAll}
|
||||
onEditDataset={async (path) => {
|
||||
if (await useDataset(path)) {
|
||||
resumeRunDialog = true
|
||||
datasetDrawer?.openDrawer('edit')
|
||||
}
|
||||
}}
|
||||
onNewDataset={() => {
|
||||
resumeRunDialog = true
|
||||
datasetDrawer?.openDrawer('new')
|
||||
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
|
||||
}}
|
||||
onNewDataset={() => datasetDrawer?.openDrawer('new')}
|
||||
/>
|
||||
|
||||
<EvalDatasetDrawer
|
||||
@@ -968,11 +1098,14 @@
|
||||
onDeleted={datasetDeleted}
|
||||
onCasesChanged={casesChanged}
|
||||
onScorersChanged={scorersChanged}
|
||||
onClosed={() => {
|
||||
if (!resumeRunDialog) return
|
||||
resumeRunDialog = false
|
||||
// On the dataset the drawer was just in: the dialog opens on the pane's own, which
|
||||
// creating or editing one has already moved to it.
|
||||
runDialogOpen = true
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Direct child only, so the rule cannot reach a Splitpanes nested inside a page. Transparent
|
||||
rather than `opacity: 0`: the gutter stays there to be dragged, it just stops drawing a line
|
||||
between the cells and the case beside them. */
|
||||
:global(.splitter-hidden > .splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/* MultilineCellEditor: a popup positioned over the cell, so it has to paint the cell's own frame
|
||||
rather than inherit it. */
|
||||
.ag-theme-alpine .wm-multiline-cell-editor,
|
||||
.ag-theme-alpine-dark .wm-multiline-cell-editor {
|
||||
background-color: var(--ag-background-color);
|
||||
}
|
||||
.ag-theme-alpine .wm-multiline-cell-editor textarea,
|
||||
.ag-theme-alpine-dark .wm-multiline-cell-editor textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
/* Horizontal only: the vertical padding is set by the editor, which knows the height of the row
|
||||
it is replacing. `line-height` here is what it computes against. */
|
||||
padding: 0 calc(var(--ag-cell-horizontal-padding) - 1px);
|
||||
border: 1px solid var(--ag-input-focus-border-color);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
/* Past this it scrolls rather than growing. */
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
background-color: var(--ag-background-color);
|
||||
color: var(--ag-foreground-color);
|
||||
font: inherit;
|
||||
line-height: 20px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { ColDef, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'
|
||||
// Beside the editor rather than in the AgGrid theme: that file is the vendored theme, and a rule
|
||||
// added to it is one the next copy of it drops.
|
||||
import './multilineCellEditor.css'
|
||||
|
||||
/** Kept in step with the `line-height` the stylesheet gives the textarea. */
|
||||
const LINE_HEIGHT = 20
|
||||
|
||||
/**
|
||||
* A text cell editor that starts the height of the cell and grows as lines are added, for columns
|
||||
* holding prose rather than a value. Enter commits, Shift+Enter adds a line, Escape cancels.
|
||||
*
|
||||
* Rendered as a popup positioned over the cell: an in-cell editor is clipped to the row height, so
|
||||
* growing is only visible if the editor is allowed to paint outside it.
|
||||
*/
|
||||
export class MultilineCellEditor implements ICellEditorComp {
|
||||
private eGui!: HTMLDivElement
|
||||
private textarea!: HTMLTextAreaElement
|
||||
private params!: ICellEditorParams
|
||||
private wasEmpty = false
|
||||
|
||||
init(params: ICellEditorParams) {
|
||||
this.params = params
|
||||
this.eGui = document.createElement('div')
|
||||
this.eGui.className = 'wm-multiline-cell-editor'
|
||||
|
||||
this.wasEmpty = params.value == undefined
|
||||
|
||||
this.textarea = document.createElement('textarea')
|
||||
this.textarea.rows = 1
|
||||
// A keystroke that opened the edit replaces the value, as it does in every other cell; F2
|
||||
// and double-click keep it to be edited.
|
||||
this.textarea.value = params.eventKey?.length === 1 ? params.eventKey : (params.value ?? '')
|
||||
this.textarea.style.width = `${params.column.getActualWidth() - 2}px`
|
||||
// Padded so one line fills the cell it replaces and a second costs a line rather than a row.
|
||||
// From the row rather than from `--ag-row-height`, which is the theme's figure and not
|
||||
// necessarily this grid's.
|
||||
const rowHeight = params.node.rowHeight ?? 28
|
||||
const padding = Math.max(0, (rowHeight - LINE_HEIGHT - 2) / 2)
|
||||
this.textarea.style.paddingTop = `${padding}px`
|
||||
this.textarea.style.paddingBottom = `${padding}px`
|
||||
|
||||
this.textarea.addEventListener('input', () => this.resize())
|
||||
this.textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Kept from whatever is around the grid: a grid in a drawer or a dialog is under a
|
||||
// surface that closes on Escape, and leaving an edit is not asking to leave that.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.params.api.stopEditing(true)
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Enter' || e.isComposing) return
|
||||
// Both branches keep the key from the grid, which ends the edit on Enter whether or not
|
||||
// Shift is held: Shift+Enter falls through to the textarea's own newline, and plain Enter
|
||||
// ends the edit here instead.
|
||||
e.stopPropagation()
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault()
|
||||
this.params.stopEditing()
|
||||
}
|
||||
})
|
||||
this.eGui.appendChild(this.textarea)
|
||||
}
|
||||
|
||||
private resize() {
|
||||
this.textarea.style.height = 'auto'
|
||||
this.textarea.style.height = `${this.textarea.scrollHeight}px`
|
||||
}
|
||||
|
||||
getGui() {
|
||||
return this.eGui
|
||||
}
|
||||
|
||||
afterGuiAttached() {
|
||||
this.resize()
|
||||
this.textarea.focus()
|
||||
// At the end rather than selected: a selection is a keystroke away from erasing the cell.
|
||||
const end = this.textarea.value.length
|
||||
this.textarea.setSelectionRange(end, end)
|
||||
}
|
||||
|
||||
getValue() {
|
||||
// Nothing typed into a cell that held nothing is not an edit: returning '' here would write
|
||||
// an empty string over a null, which the grid would see as a change and commit.
|
||||
if (this.wasEmpty && this.textarea.value === '') return this.params.value
|
||||
return this.textarea.value
|
||||
}
|
||||
|
||||
isPopup() {
|
||||
return true
|
||||
}
|
||||
|
||||
getPopupPosition(): 'over' | 'under' {
|
||||
return 'over'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a column of prose needs, ready to spread into a colDef. `suppressKeyboardEvent` as well as
|
||||
* the editor: the grid ends an edit on Enter from a handler a popup editor's DOM does not sit
|
||||
* under, so the editor cannot keep Shift+Enter for itself on its own.
|
||||
*/
|
||||
export const multilineCellColDef: Pick<ColDef, 'cellEditor' | 'suppressKeyboardEvent'> = {
|
||||
cellEditor: MultilineCellEditor,
|
||||
suppressKeyboardEvent: (p) =>
|
||||
p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<!--
|
||||
@component
|
||||
Inline-editable prose. The multiline counterpart to `EditableInput`: idle renders
|
||||
the whole text, wrapped, as a clickable button; clicking swaps to an auto-growing
|
||||
`textarea`. `Enter` or `blur` commits via `onSave`, `Shift+Enter` adds a line,
|
||||
`Escape` discards — except under `commitOnInput`, where every keystroke has
|
||||
already been propagated and there is nothing left to discard.
|
||||
|
||||
Escape is stopped from propagating, which keeps a surrounding `Drawer` open. A `Modal`
|
||||
is the exception: it takes both Enter and Escape on `window` in the capture phase and
|
||||
stops them there, so neither reaches this. Inside one, pass `enterConfirms={false}` to
|
||||
the `Modal` for Enter to commit here rather than confirm the dialog; Escape stays the
|
||||
dialog's either way, so expect it to leave the edit and close the dialog together.
|
||||
|
||||
Use it where a cell or a field holds a sentence rather than a label — a prompt, a
|
||||
description, an expected answer. For a one-line label, use `EditableInput`.
|
||||
|
||||
```svelte
|
||||
<EditableTextarea
|
||||
value={question}
|
||||
placeholder="Question"
|
||||
onSave={(v) => (question = v)}
|
||||
textClass="text-xs font-normal"
|
||||
/>
|
||||
```
|
||||
|
||||
Like `EditableInput`, the value isn't bound: `onSave` fires whenever the draft
|
||||
differs from the prior `value`, including `''` when cleared. Unlike it, the draft
|
||||
goes back verbatim — whitespace a caller stored on purpose survives a focus and a
|
||||
blur. The parent owns the canonical state.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Current value shown in idle mode and pre-filled when entering edit mode. */
|
||||
value: string
|
||||
/** Shown when `value` is empty, in both idle and editing modes. */
|
||||
placeholder?: string
|
||||
/**
|
||||
* Called when the user commits a changed value (Enter or blur, or every keystroke when
|
||||
* {@link commitOnInput} is set). Fires with the draft exactly as typed, including `''` if the
|
||||
* field was cleared. Not called on Escape, or when the draft matches the prior `value`.
|
||||
*/
|
||||
onSave?: (newValue: string) => void
|
||||
/**
|
||||
* Fire `onSave` on every keystroke instead of only on Enter/blur. Use when the parent
|
||||
* autosaves, or when a control outside this one reads the value on click — the click blurs
|
||||
* the textarea, and a commit-on-blur would land after the read. Escape no longer discards
|
||||
* with this on: the live commits have already propagated.
|
||||
*/
|
||||
commitOnInput?: boolean
|
||||
/** When false, renders as plain text and is not clickable. */
|
||||
editable?: boolean
|
||||
/** Textarea size in editing mode. Idle mode is text only and unaffected. */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
/** Wrapper classes. Layout only — margin, width, alignment — not text styling. */
|
||||
class?: string
|
||||
/** Extra classes on the `<textarea>`. Background, border and shadow are reset over these. */
|
||||
inputClass?: string
|
||||
/**
|
||||
* Text styling applied to *both* the idle text and the textarea, so the two render
|
||||
* identically and clicking in does not shift the text.
|
||||
*/
|
||||
textClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
value,
|
||||
placeholder = '',
|
||||
onSave,
|
||||
commitOnInput = false,
|
||||
editable = true,
|
||||
size = 'sm',
|
||||
class: className = '',
|
||||
inputClass = '',
|
||||
textClass = ''
|
||||
}: Props = $props()
|
||||
|
||||
let editing = $state(false)
|
||||
let draft = $state('')
|
||||
// `TextInput` is generic over its underlying element and defaults to `'input'`; this one renders
|
||||
// a textarea, so the binding has to say so or it is typed as the wrong component.
|
||||
let textInputComponent: TextInput<'textarea'> | undefined = $state(undefined)
|
||||
|
||||
function startEditing() {
|
||||
if (!editable) return
|
||||
editing = true
|
||||
draft = value ?? ''
|
||||
requestAnimationFrame(() => {
|
||||
textInputComponent?.focus()
|
||||
textInputComponent?.select()
|
||||
})
|
||||
}
|
||||
|
||||
/** Open the editor from outside — a row that was just added, say, so the caller does not have
|
||||
* to click into it. */
|
||||
export function edit() {
|
||||
startEditing()
|
||||
}
|
||||
|
||||
function save() {
|
||||
// Re-entry guard: Enter calls `save()` and clears `editing`, which unmounts the textarea and
|
||||
// synchronously fires its `blur` handler — also `save()`. Without this, `onSave` fires twice.
|
||||
if (!editing) return
|
||||
editing = false
|
||||
// Verbatim, not trimmed: a caller's value may hold whitespace that means something, and
|
||||
// merely focusing and blurring a field must never count as an edit.
|
||||
if (draft !== (value ?? '')) {
|
||||
onSave?.(draft)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
// Kept from a surrounding drawer, which would otherwise close on it. A `Modal` takes
|
||||
// Escape at `window` in the capture phase and is past us before this runs.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
editing = false
|
||||
return
|
||||
}
|
||||
// Shift+Enter falls through to the textarea's own newline; plain Enter commits, which is
|
||||
// what makes this usable in a table where Enter means "done with this cell".
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
function handleLiveInput(e: Event) {
|
||||
const next = (e.currentTarget as HTMLTextAreaElement).value
|
||||
if (next !== (value ?? '')) onSave?.(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
<!-- Every override below exists so the two states are the same size with the text in the same
|
||||
place, and clicking in moves nothing: `!min-h-0` (TextInput floors it at 28px), `!block` (an
|
||||
inline-block textarea baseline-aligns and the idle button does not), and never `!h-auto` —
|
||||
`use:autosize` grows the field with an inline `style.height` an `!important` class beats. -->
|
||||
<TextInput
|
||||
bind:this={textInputComponent}
|
||||
bind:value={draft}
|
||||
{size}
|
||||
underlyingInputEl="textarea"
|
||||
autosizeParams={{ minHeight: 0 }}
|
||||
class="!block !bg-transparent !border-0 !shadow-none !m-0 !min-w-0 !min-h-0 !py-0 {textClass} {inputClass} {className}"
|
||||
inputProps={{
|
||||
placeholder,
|
||||
rows: 1,
|
||||
onblur: save,
|
||||
onkeydown: handleKeydown,
|
||||
oninput: commitOnInput ? handleLiveInput : undefined,
|
||||
spellcheck: false,
|
||||
style: 'padding: 2px !important; resize: none'
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Padding 2px over, 4px under: the same 22px box as the textarea *and* the same 2px above the
|
||||
first line. An even 3px gets the box right and leaves the text a pixel low. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEditing}
|
||||
disabled={!editable}
|
||||
aria-label={editable ? `Edit ${placeholder.toLowerCase() || 'value'}` : undefined}
|
||||
class="w-full text-left whitespace-pre-wrap break-words rounded px-0.5 pt-[2px] pb-[4px] {editable
|
||||
? 'cursor-text hover:bg-surface-hover'
|
||||
: 'cursor-default'} {textClass} {className}"
|
||||
>
|
||||
{value || placeholder}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { onMount, createEventDispatcher, setContext, untrack } from 'svelte'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import Disposable from './Disposable.svelte'
|
||||
import { setTopmostSurface } from '$lib/components/common/overlayHost.svelte'
|
||||
import ConditionalPortal from './ConditionalPortal.svelte'
|
||||
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
@@ -51,6 +52,11 @@
|
||||
|
||||
let disposable: Disposable | undefined = $state(undefined)
|
||||
|
||||
// A drawer stacks like a dialog does, so content inside it gets the same answer about whether
|
||||
// its keys are meant for it. Without this, a drawer opened over a dialog would inherit the
|
||||
// dialog's answer — false, because the drawer itself is now on top — and go deaf.
|
||||
setTopmostSurface(() => disposable?.isTopmost() ?? true)
|
||||
|
||||
let reducedMotion = useReducedMotion()
|
||||
let duration = $derived(reducedMotion.val ? 0 : _duration)
|
||||
let durationMs = $derived(duration * 1000)
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
label: string
|
||||
icon?: any
|
||||
onClick: () => void
|
||||
/**
|
||||
* `default` unless the surface has no other live call to action. Accent is for the case
|
||||
* where this button is the only thing to press — a form whose submit is disabled until
|
||||
* this is done, say — so it is not competing with one.
|
||||
*/
|
||||
variant?: 'default' | 'accent'
|
||||
/** Same write lock the surface's other controls take. An empty state is still a live
|
||||
* control: without this it stays clickable while a request that has already read the
|
||||
* empty list is in flight, and whatever it adds is discarded when that request lands. */
|
||||
disabled?: boolean
|
||||
aiId?: string
|
||||
aiDescription?: string
|
||||
}
|
||||
@@ -32,11 +42,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if action}
|
||||
<!-- `default`, not `accent`: the page header already carries the accent CTA for
|
||||
this same action, and one accent per view is the rule. -->
|
||||
<!-- `default` by default: the page header usually carries the accent CTA for this same
|
||||
action, and one accent per view is the rule. A caller whose other CTA is disabled until
|
||||
this is done opts into `accent`. -->
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
variant={action.variant ?? 'default'}
|
||||
disabled={action.disabled}
|
||||
startIcon={action.icon ? { icon: action.icon } : undefined}
|
||||
onClick={action.onClick}
|
||||
aiId={action.aiId}
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler, stopPropagation } from 'svelte/legacy'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
import { getOverlayHost, overlayHostActive } from '$lib/components/common/overlayHost.svelte'
|
||||
import {
|
||||
getOverlayHost,
|
||||
overlayHostActive,
|
||||
setTopmostSurface
|
||||
} from '$lib/components/common/overlayHost.svelte'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
@@ -38,6 +42,18 @@
|
||||
/** A line under the title saying what the dialog is for; in the header so it does not
|
||||
* scroll away with the body. */
|
||||
description?: string
|
||||
/** The body holds pages that are laid over each other rather than stacked, so the header
|
||||
* keeps its own height and only the pages move. Requires `fillHeight`: pages are absolutely
|
||||
* positioned and need a definite height to fill. Pair with `PagedContent`, which does the
|
||||
* laying over; this only makes room for it. */
|
||||
paginated?: boolean
|
||||
/**
|
||||
* Whether Enter confirms the dialog. On by default, which is right for a form. Turn it off
|
||||
* where the body is a surface with its own meaning for Enter: the handler runs at `window`
|
||||
* in the capture phase and stops propagation, so while it is on nothing inside the dialog
|
||||
* can see the key at all.
|
||||
*/
|
||||
enterConfirms?: boolean
|
||||
/** Make the dialog fill the height it is anchored to and lay its body out as a flex
|
||||
* column, so content can size itself with `h-full` / `flex-1 min-h-0`. Off by default:
|
||||
* the dialog otherwise hugs its content, and percentage heights inside it do not
|
||||
@@ -65,6 +81,8 @@
|
||||
trail = undefined,
|
||||
description = undefined,
|
||||
fillHeight = false,
|
||||
paginated = false,
|
||||
enterConfirms = true,
|
||||
minZIndex: minZIndexProp = undefined,
|
||||
titleBadge,
|
||||
settings,
|
||||
@@ -95,6 +113,10 @@
|
||||
// 1200 to stack above.
|
||||
const minZIndex = $derived(minZIndexProp ?? (chatState.size > 0 ? zIndexes.aiChat + 1 : 0))
|
||||
|
||||
// So content in the body can tell a key meant for this dialog from one meant for whatever was
|
||||
// opened over it. Read lazily: `disposable` is bound after this runs.
|
||||
setTopmostSurface(() => disposable?.isTopmost() ?? true)
|
||||
|
||||
// Both `bind:open` and this $effect are needed: bind:open syncs the
|
||||
// boolean, while the effect calls openDrawer/closeDrawer to register
|
||||
// the disposable in the stacking system (same pattern as Drawer.svelte).
|
||||
@@ -114,6 +136,7 @@
|
||||
if (open) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (!enterConfirms) break
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
dispatch('confirmed')
|
||||
@@ -231,7 +254,9 @@
|
||||
{@render titleBadge?.()}
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<ChevronRight size={18} class="text-tertiary shrink-0" />
|
||||
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
|
||||
<ChevronRight size={18} class="text-tertiary shrink-0" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if i === 0}
|
||||
<!-- flex: an inline child holding an icon sits on the baseline and adds
|
||||
@@ -250,7 +275,11 @@
|
||||
{:else if segment.onclick}
|
||||
{@render crumb(segment, i === crumbs.length - 2)}
|
||||
{:else}
|
||||
<span class="text-emphasis truncate" aria-current="page">
|
||||
<span
|
||||
class="text-emphasis truncate"
|
||||
aria-current="page"
|
||||
in:fade={{ duration: 150 }}
|
||||
>
|
||||
{segment.label}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -276,7 +305,13 @@
|
||||
<p class="mt-1 text-xs text-secondary">{description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 text-sm text-primary {fillHeight ? 'flex-1 min-h-0' : ''}">
|
||||
<!-- `mt-1` when paginated: a page carries its own description as its first line,
|
||||
and it belongs where the dialog's own sat, right under the title. -->
|
||||
<div
|
||||
class="{paginated ? 'mt-1' : 'mt-4'} text-sm text-primary {fillHeight
|
||||
? 'flex-1 min-h-0'
|
||||
: ''}"
|
||||
>
|
||||
{@render children_render?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts" module>
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
/** One level of a paginated dialog. Order is the order given: the page on screen sits at rest
|
||||
* and every other waits off the side it is listed on, so a deeper page arrives from the right
|
||||
* and the way back arrives from the left without anyone naming a direction. */
|
||||
export type ModalPage = { key: string; content: Snippet }
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
|
||||
|
||||
let {
|
||||
pages,
|
||||
current,
|
||||
onNavigate,
|
||||
warm = false,
|
||||
class: c = ''
|
||||
}: {
|
||||
pages: ModalPage[]
|
||||
/** The `key` of the page on screen. */
|
||||
current: string
|
||||
/**
|
||||
* Go to a page. Given, the component answers the left and right arrow keys, stepping between
|
||||
* pages the way the surface's own controls do. Escape is deliberately not taken: the dialog
|
||||
* around this owns it, and a page component that swallowed it would stop the dialog from
|
||||
* closing. Without this prop the pages are still navigable, just not from the keyboard —
|
||||
* the caller owns `current` either way.
|
||||
*/
|
||||
onNavigate?: (key: string) => void
|
||||
/**
|
||||
* Build every page once this one has settled, rather than each on its first visit. Turn it
|
||||
* on where a page is heavy enough that building it costs a frame: otherwise that build
|
||||
* lands on the first click, inside the transition, and only the first navigation stutters.
|
||||
* The cost moves to a moment when nothing is animating and nobody is waiting.
|
||||
*/
|
||||
warm?: boolean
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
/** A page is mounted the first time it is opened and never unmounted again, which is the whole
|
||||
* point of this over an `{#if}`: a page keeps its state, its loaded data and its scroll
|
||||
* position while another is on screen, and returning to it costs nothing. */
|
||||
let visited = $state<string[]>([])
|
||||
$effect(() => {
|
||||
if (!visited.includes(current)) visited = [...visited, current]
|
||||
})
|
||||
|
||||
/** Whether the rest of the pages have been built. Deferred a frame past mount rather than done
|
||||
* outright: the page being opened *now* is what the user is waiting on, and building its
|
||||
* neighbours in the same frame would make opening the dialog pay for them. */
|
||||
let warmed = $state(false)
|
||||
$effect(() => {
|
||||
if (!warm || warmed) return
|
||||
const frame = requestAnimationFrame(() => (warmed = true))
|
||||
return () => cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
let box: HTMLDivElement | undefined = $state()
|
||||
|
||||
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
|
||||
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
|
||||
// still the one on top, rather than under a drawer or a dialog opened since.
|
||||
const hostActive = overlayHostActive()
|
||||
const onTop = topmostSurface()
|
||||
const listening = () => hostActive() && onTop()
|
||||
|
||||
/** Whether the keyboard is ours to answer.
|
||||
*
|
||||
* Deliberately not "focus is inside the pages". Navigating leaves focus wherever the page that
|
||||
* was left put it — the body, once `inert` lands on it, or a button in the surrounding dialog
|
||||
* chrome — so a containment test answers the first press and refuses every one after it. What
|
||||
* is actually being asked is whether the key is meant for something else, and only a control
|
||||
* that reads arrows itself qualifies. */
|
||||
function ownsKeyboard(target: EventTarget | null): boolean {
|
||||
if (!box || !box.isConnected) return false
|
||||
const el = target as HTMLElement | null
|
||||
return !el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
if (!ownsKeyboard(event.target)) return
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (step === 0) return
|
||||
const next = pages[index + step]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
onNavigate(next.key)
|
||||
}
|
||||
|
||||
/** Whether focus is inside the pages right now. Tracked as focus moves, not read when the page
|
||||
* changes: a surrounding `Modal` handles Escape at `window` in the capture phase and steps a
|
||||
* level back from there, and Svelte flushes this component's effects inside that handler —
|
||||
* before a keydown listener of ours would ever run. Only a value already settled survives that.
|
||||
* Plain state, not `$state`: the effect below is keyed on the page, and reading this must not
|
||||
* key it too. */
|
||||
let focusInside = false
|
||||
$effect(() => {
|
||||
if (!box) return
|
||||
const enter = () => (focusInside = true)
|
||||
const leave = (event: FocusEvent) => {
|
||||
focusInside = !!event.relatedTarget && !!box?.contains(event.relatedTarget as Node)
|
||||
}
|
||||
box.addEventListener('focusin', enter)
|
||||
box.addEventListener('focusout', leave)
|
||||
return () => {
|
||||
box?.removeEventListener('focusin', enter)
|
||||
box?.removeEventListener('focusout', leave)
|
||||
}
|
||||
})
|
||||
|
||||
/** Focus follows the page, or a keyboard user is dropped out of the dialog: `inert` on the page
|
||||
* being left resets focus to the body, and so does the trail's back button, which is removed
|
||||
* as the level it returns from closes. Focus is taken when it was in the pages, or when it has
|
||||
* ended up nowhere — never off a control the user is actually on, such as dialog chrome that
|
||||
* outlives the navigation. */
|
||||
let previous = current
|
||||
$effect(() => {
|
||||
const next = current
|
||||
if (next === previous) return
|
||||
const wasInside = focusInside
|
||||
previous = next
|
||||
// Claimed over a few frames rather than one: the browser runs its own fixup for the page
|
||||
// that just became inert, and it lands after this on some paths.
|
||||
let tries = 3
|
||||
let frame = 0
|
||||
const claim = () => {
|
||||
const page = box?.querySelector<HTMLElement>('.paged-content-page:not(.is-hidden)')
|
||||
if (!page) return
|
||||
if ((wasInside && tries === 3) || document.activeElement === document.body) {
|
||||
page.focus({ preventScroll: true })
|
||||
}
|
||||
if (--tries > 0) frame = requestAnimationFrame(claim)
|
||||
}
|
||||
frame = requestAnimationFrame(claim)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
/** How far a page travels, as a share of the box. Deliberately a fraction and not the whole
|
||||
* width: at 100% a page has left the box before its opacity has gone anywhere, so the fade is
|
||||
* spent off-screen and all that is left to see is a slide. */
|
||||
const TRAVEL_PERCENT = 24
|
||||
|
||||
let index = $derived(
|
||||
Math.max(
|
||||
pages.findIndex((p) => p.key === current),
|
||||
0
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- The clipper. `overflow` clips descendants and not an element's own transform, so the box that
|
||||
hides a page off the side can never be the page that travels. -->
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div bind:this={box} class="relative overflow-hidden {c}">
|
||||
{#each pages as page, i (page.key)}
|
||||
{#if visited.includes(page.key) || warmed}
|
||||
<!-- Pages are laid over each other rather than laid out, so the dialog cannot change
|
||||
height as one replaces another. That needs a definite height from the caller.
|
||||
`inert` and not just opacity: an off-screen page keeps its DOM, so without it the
|
||||
tab order and every screen reader still walk through it. -->
|
||||
<!-- `tabindex="-1"`: focusable programmatically, never a tab stop of its own. -->
|
||||
<div
|
||||
tabindex="-1"
|
||||
class="paged-content-page absolute inset-0 flex flex-col"
|
||||
class:is-hidden={i !== index}
|
||||
class:opacity-0={i !== index}
|
||||
class:pointer-events-none={i !== index}
|
||||
style="transform: translateX({(i - index) * TRAVEL_PERCENT}%)"
|
||||
inert={i !== index}
|
||||
>
|
||||
{@render page.content()}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Arriving: slides in and fades up. A transition reads the duration of the state it is moving
|
||||
*to*, which is what lets the two directions differ here. */
|
||||
.paged-content-page {
|
||||
transition-property: transform, opacity;
|
||||
transition-duration: 130ms, 90ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
/* Leaving: gone at once. Both pages share the box while one replaces the other, so an outgoing
|
||||
page is painted over the incoming one for as long as it still has any opacity — any fade at
|
||||
all is a ghost laid over the page you are trying to look at. It still travels, which costs
|
||||
nothing to watch because it is already invisible. */
|
||||
.paged-content-page.is-hidden {
|
||||
transition-duration: 130ms, 0s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.paged-content-page {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -72,3 +72,23 @@ export function overlayHostActive(): () => boolean {
|
||||
const host = getOverlayHost()
|
||||
return () => host?.active() ?? true
|
||||
}
|
||||
|
||||
const TOPMOST_SURFACE_KEY = 'topmostSurface'
|
||||
|
||||
/**
|
||||
* Declare whether the surface enclosing this subtree is the one on top. Set by whatever owns the
|
||||
* stacking — a dialog, a drawer — so content inside it can tell a key meant for itself from one
|
||||
* meant for something opened over it.
|
||||
*/
|
||||
export function setTopmostSurface(isTopmost: () => boolean) {
|
||||
setContext(TOPMOST_SURFACE_KEY, isTopmost)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the enclosing surface is on top. True when nothing declared otherwise, so content that
|
||||
* is not inside such a surface is not silently made deaf.
|
||||
*/
|
||||
export function topmostSurface(): () => boolean {
|
||||
const isTopmost = getContext<(() => boolean) | undefined>(TOPMOST_SURFACE_KEY)
|
||||
return () => isTopmost?.() ?? true
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
size?: ButtonType.UnifiedSize
|
||||
unifiedHeight?: boolean
|
||||
underlyingInputEl?: UnderlyingInputElT
|
||||
/**
|
||||
* Passed to the `autosize` action on the `textarea` variant. Chiefly `minHeight: 0`, for a
|
||||
* field that hugs one line instead of reserving the action's 30px floor.
|
||||
*/
|
||||
autosizeParams?: import('$lib/autosize').AutosizeParams
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
@@ -108,7 +113,8 @@
|
||||
error,
|
||||
size = 'md',
|
||||
unifiedHeight = true,
|
||||
underlyingInputEl: _underlyingInputEl
|
||||
underlyingInputEl: _underlyingInputEl,
|
||||
autosizeParams
|
||||
}: Props<UnderlyingInputElT> = $props()
|
||||
|
||||
let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const))
|
||||
@@ -152,7 +158,7 @@
|
||||
onpointerdown={(e) => e.stopImmediatePropagation()}
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
use:autosize
|
||||
use:autosize={autosizeParams}
|
||||
></textarea>
|
||||
{:else if underlyingInputEl === 'input'}
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user