Files
windmill/frontend/src/lib/components/common/EditableTextarea.svelte
T
GuilhemandClaude Opus 5 1462f17643 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>
2026-08-31 19:30:26 +02:00

174 lines
6.5 KiB
Svelte

<!--
@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}