mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
* 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>
193 lines
6.6 KiB
Svelte
193 lines
6.6 KiB
Svelte
<script module lang="ts">
|
||
export function inputBorderClass({
|
||
error,
|
||
forceFocus
|
||
}: {
|
||
error?: boolean
|
||
forceFocus?: boolean
|
||
} = {}) {
|
||
return twMerge(
|
||
'transition-colors border border-border-light',
|
||
forceFocus
|
||
? '!border-border-selected'
|
||
: '!border-border-light hover:!border-border-selected/50 focus:!border-border-selected',
|
||
error
|
||
? '!border-red-300 dark:!border-red-400/45 focus:!border-red-400 hover:!border-red-500 dark:hover:!border-red-400/75'
|
||
: ''
|
||
)
|
||
}
|
||
|
||
export const inputBaseClass =
|
||
'rounded-md focus:ring-0 no-default-style text-xs text-primary font-normal !bg-surface-input disabled:!bg-surface-disabled disabled:!border-transparent disabled:!text-disabled disabled:cursor-not-allowed shadow-none !placeholder-hint'
|
||
|
||
import autosize from '$lib/autosize'
|
||
import { ButtonType } from '$lib/components/common/button/model'
|
||
|
||
export const inputSizeClasses = {
|
||
'2xs': twMerge(
|
||
ButtonType.UnifiedSizingClasses['2xs'],
|
||
ButtonType.UnifiedMinHeightClasses['2xs'],
|
||
'px-1 !py-0.5'
|
||
),
|
||
xs: twMerge(
|
||
ButtonType.UnifiedSizingClasses.xs,
|
||
ButtonType.UnifiedMinHeightClasses.xs,
|
||
'px-1 !py-0.5'
|
||
),
|
||
sm: twMerge(
|
||
ButtonType.UnifiedSizingClasses.sm,
|
||
ButtonType.UnifiedMinHeightClasses.sm,
|
||
'px-2 !py-0.5'
|
||
),
|
||
md: twMerge(ButtonType.UnifiedSizingClasses.md, ButtonType.UnifiedMinHeightClasses.md, 'px-2'),
|
||
lg: twMerge(ButtonType.UnifiedSizingClasses.lg, ButtonType.UnifiedMinHeightClasses.lg, 'px-2')
|
||
}
|
||
|
||
// Base leading == (unified height − vertical padding) so a single-line
|
||
// contenteditable div centers its text the way a native <input> does.
|
||
//
|
||
// In "large mode" (viewport ≥ 1760px, where app.css bumps :root to 18px →
|
||
// font 13.5px) headless-Chromium ink measurement showed the text sitting
|
||
// ~1px low with the base leading. The residual is a fixed ~2px of line box,
|
||
// so the exact centered value there is (content-box height − 2px). Scoped to
|
||
// the same 1760px breakpoint as the font-size bump; small mode is unchanged.
|
||
export const inputLeadingClasses: Record<ButtonType.UnifiedSize, string> = {
|
||
'2xs': 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem
|
||
xs: 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem
|
||
sm: 'leading-6 min-[1760px]:leading-[calc(1.5rem_-_2px)]', // h-7 − py-0.5 → 1.5rem
|
||
md: 'leading-8 min-[1760px]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem
|
||
lg: 'leading-10 min-[1760px]:leading-[calc(2.5rem_-_2px)]' // h-10, no py → 2.5rem
|
||
}
|
||
</script>
|
||
|
||
<script lang="ts" generics="UnderlyingInputElT extends 'input' | 'textarea' | 'div' = 'input'">
|
||
import type { HTMLAttributes, HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
|
||
import { twMerge } from 'tailwind-merge'
|
||
|
||
type DivInputProps = HTMLAttributes<HTMLDivElement> & {
|
||
disabled?: boolean
|
||
placeholder?: string
|
||
}
|
||
|
||
type Props<UnderlyingInputElT extends 'input' | 'textarea' | 'div'> = {
|
||
inputProps?: UnderlyingInputElT extends 'input'
|
||
? HTMLInputAttributes
|
||
: UnderlyingInputElT extends 'textarea'
|
||
? HTMLTextareaAttributes
|
||
: DivInputProps
|
||
value?: string | number
|
||
class?: string
|
||
error?: string | boolean
|
||
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() {
|
||
inputEl?.focus()
|
||
}
|
||
|
||
export function select() {
|
||
if (inputEl instanceof HTMLDivElement) {
|
||
const range = document.createRange()
|
||
range.selectNodeContents(inputEl)
|
||
const sel = window.getSelection()
|
||
sel?.removeAllRanges()
|
||
sel?.addRange(range)
|
||
} else {
|
||
inputEl?.select()
|
||
}
|
||
}
|
||
|
||
let inputEl: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement | undefined = $state()
|
||
|
||
let {
|
||
inputProps: _inputProps,
|
||
value = $bindable(),
|
||
class: className = '',
|
||
error,
|
||
size = 'md',
|
||
unifiedHeight = true,
|
||
underlyingInputEl: _underlyingInputEl,
|
||
autosizeParams
|
||
}: Props<UnderlyingInputElT> = $props()
|
||
|
||
let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const))
|
||
let inputProps = $derived(_inputProps as any)
|
||
let isDiv = $derived(underlyingInputEl === 'div')
|
||
let divDisabled = $derived(isDiv && Boolean(inputProps?.disabled))
|
||
|
||
let fullClassName = $derived(
|
||
twMerge(
|
||
inputBaseClass,
|
||
inputSizeClasses[size],
|
||
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
|
||
inputBorderClass({ error: !!error }),
|
||
unifiedHeight ? ButtonType.UnifiedHeightClasses[size] : '',
|
||
'w-full',
|
||
isDiv &&
|
||
`whitespace-pre overflow-hidden ${inputLeadingClasses[size]} focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 empty:before:content-[attr(data-placeholder)] empty:before:text-hint`,
|
||
divDisabled && '!bg-surface-disabled !border-transparent !text-disabled cursor-not-allowed',
|
||
className
|
||
)
|
||
)
|
||
|
||
// contenteditable is mutated by the browser as the user types — a templated
|
||
// `>{value}</div>` would race with that and produce duplicated text. Sync
|
||
// imperatively, with an equality guard so user input doesn't echo back.
|
||
$effect(() => {
|
||
if (underlyingInputEl !== 'div' || !inputEl) return
|
||
const target = value == null ? '' : String(value)
|
||
if (inputEl.textContent !== target) {
|
||
inputEl.textContent = target
|
||
}
|
||
})
|
||
</script>
|
||
|
||
{#if underlyingInputEl === 'textarea'}
|
||
<!-- We hardcode the py value because since textareas can be multiline, we can't rely on
|
||
the fact that it auto-centers -->
|
||
<textarea
|
||
class={twMerge('py-[0.45rem]', fullClassName)}
|
||
{...inputProps}
|
||
onpointerdown={(e) => e.stopImmediatePropagation()}
|
||
bind:this={inputEl}
|
||
bind:value
|
||
use:autosize={autosizeParams}
|
||
></textarea>
|
||
{:else if underlyingInputEl === 'input'}
|
||
<input
|
||
{...inputProps}
|
||
class={fullClassName}
|
||
onpointerdown={(e) => e.stopImmediatePropagation()}
|
||
bind:this={inputEl}
|
||
bind:value
|
||
/>
|
||
{:else if underlyingInputEl === 'div'}
|
||
{@const { disabled, placeholder, ...divProps } = (inputProps ?? {}) as DivInputProps}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div
|
||
role="textbox"
|
||
aria-disabled={disabled}
|
||
tabindex={disabled ? -1 : 0}
|
||
contenteditable={!disabled}
|
||
{...divProps}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') e.preventDefault()
|
||
divProps?.onkeydown?.(e)
|
||
}}
|
||
class={fullClassName}
|
||
data-placeholder={placeholder ?? ''}
|
||
onpointerdown={(e) => e.stopImmediatePropagation()}
|
||
oninput={(e) => {
|
||
value = e.currentTarget.textContent ?? ''
|
||
}}
|
||
bind:this={inputEl}
|
||
></div>
|
||
{/if}
|