fix: flow step picker layout and single hover/keyboard highlight (#10488)

* fix: keep flow step picker rows on one line and highlight only one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restore hover on standalone picker rows and drop phantom ai slots

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop inert picker resize and align hub rows with workspace rows

The step picker popover carried `!resize` but computes `overflow: visible`, so CSS
`resize` never applied and the handle did nothing. Dropping it also pins the inner
height at 464px, keeping `displayPath` off everywhere except the content-sized
trigger picker.

Hub rows there rendered summary and path inside a fixed 28px button; give them the
same `h-auto min-h-7 py-1` the workspace rows got. Guard `hover:bg-transparent` on
`onHover` in both pickers so all three agree, and drop the unconditional `title` on
TopLevelNode, which put a native tooltip on every kind button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep GenAiQuick's CSS hover when it is not wired into the shared index

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-08-05 09:28:13 +02:00
committed by GitHub
parent 055a9c2690
commit aa91619bb6
10 changed files with 125 additions and 59 deletions
@@ -217,6 +217,13 @@
}
}
// Mouse and keyboard share this one index, so rows report hover on mousemove rather than
// mouseenter: arrow keys scroll the list, which slides a row under a stationary cursor and
// fires mouseenter, which would otherwise hijack the selection mid-navigation.
function hover(index: number) {
selectedByKeyboard = index
}
onMount(() => {
$insertButtonOpen = true
})
@@ -247,9 +254,24 @@
preFilter
untrack(() => onPrefilterChange(preFilter))
})
let aiLength = $derived(
funcDesc?.length > 0 && !disableAi && selectedKind != 'flow' && preFilter == 'all' ? 2 : 0
// Gates the two AI rows and their slots in the index space; both must agree or arrow keys land
// on indices that render nothing.
let showAiRows = $derived(
!disableAi &&
funcDesc?.length > 0 &&
kind != 'failure' &&
kind != 'preprocessor' &&
(selectedKind == 'script' || selectedKind == 'trigger') &&
preFilter == 'all'
)
let aiLength = $derived(showAiRows ? 2 : 0)
// Every result row lives in one keyboard index space, and hovering a row moves that index, so
// mouse and keyboard can never highlight two different rows. Offsets follow the render order.
let inlineOffset = $derived(topLevelNodes.length)
let aiOffset = $derived(inlineOffset + (inlineScripts?.length ?? 0))
let workspaceOffset = $derived(aiOffset + aiLength)
let hubOffset = $derived(workspaceOffset + (filteredWorkspaceItems?.length ?? 0))
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -281,8 +303,9 @@
icon: owner.startsWith('f/') ? Folder : User,
props: { width: 14, height: 14 }
}}
title={owner.slice(2)}
>
{owner.slice(2)}
<span class="truncate">{owner.slice(2)}</span>
</Button>
</div>
{/each}
@@ -355,6 +378,7 @@
}}
{label}
selected={selectedByKeyboard === i}
onHover={() => hover(i)}
/>
{/each}
{/if}
@@ -395,7 +419,8 @@
{#each inlineScripts as [label, lang], i (lang)}
<FlowScriptPickerQuick
eeRestricted={!$enterpriseLicense && enterpriseLangs.includes(lang)}
selected={selectedByKeyboard === i + topLevelNodes.length}
selected={selectedByKeyboard === i + inlineOffset}
onHover={() => hover(i + inlineOffset)}
{enterpriseLangs}
{label}
lang={lang == 'docker' ? 'bash' : lang}
@@ -419,13 +444,14 @@
{/each}
{/if}
{#if !disableAi && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && (selectedKind == 'script' || selectedKind == 'trigger') && preFilter == 'all'}
{#if showAiRows}
<ul class="transition-all">
<li
><GenAiQuick
{funcDesc}
lang="TypeScript"
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length}
selected={selectedByKeyboard === aiOffset}
onHover={() => hover(aiOffset)}
on:click={() => {
lang = 'bun'
onGenerate()
@@ -436,7 +462,8 @@
<GenAiQuick
{funcDesc}
lang="Python"
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length + 1}
selected={selectedByKeyboard === aiOffset + 1}
onHover={() => hover(aiOffset + 1)}
on:click={() => {
lang = 'python3'
onGenerate()
@@ -463,11 +490,12 @@
bind:filteredWithOwner={filteredWorkspaceItems}
{filter}
kind={selectedKind}
selected={selectedByKeyboard - inlineScripts?.length - aiLength - topLevelNodes.length}
selected={selectedByKeyboard - workspaceOffset}
on:pickScript
on:pickFlow
{displayPath}
{refreshCount}
onHover={(i) => hover(workspaceOffset + i)}
/>
{/await}
<div class="pb-1"></div>
@@ -511,15 +539,12 @@
}
appFilter={selected?.name}
kind={selectedKind}
selected={selectedByKeyboard -
inlineScripts?.length -
aiLength -
filteredWorkspaceItems?.length -
topLevelNodes.length}
selected={selectedByKeyboard - hubOffset}
on:pickScript
bind:loading
{displayPath}
{refreshCount}
onHover={(i) => hover(hubOffset + i)}
/>
{/await}
{/if}
@@ -1,17 +1,18 @@
<script lang="ts">
import { createBubbler } from 'svelte/legacy';
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler();
const bubble = createBubbler()
import { Wand2 } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
interface Props {
funcDesc: string;
selected: boolean;
lang: string;
funcDesc: string
selected: boolean
lang: string
onHover?: () => void
}
let { funcDesc, selected, lang }: Props = $props();
let { funcDesc, selected, lang, onHover = undefined }: Props = $props()
const dispatch = createEventDispatcher()
const onKeyDown = (e: KeyboardEvent) => {
@@ -24,10 +25,13 @@
<svelte:window onkeydown={onKeyDown} />
<button
class="px-3 py-2 gap-2 w-full text-left hover:bg-surface-hover flex flex-row items-center transition-all rounded-md {selected
class="px-3 py-2 gap-2 w-full text-left flex flex-row items-center transition-all rounded-md {selected
? 'bg-surface-hover'
: ''}"
: onHover
? ''
: 'hover:bg-surface-hover'}"
onclick={bubble('click')}
onmousemove={() => onHover?.()}
>
<Wand2 size={14} class="text-ai" />
@@ -43,10 +43,12 @@
let loading = $state(false)
let small = $derived(kind === 'preprocessor' || kind === 'failure')
let width = $state(0)
let height = $state(0)
let owners = $state([])
let displayPath = $derived(width > 650 || height > 400)
// Only the content-sized host (TriggersWrapper) grows past this. The fixed-height hosts top out
// at 464px and must stay under the threshold, or every workspace row goes two-line in the step
// picker.
let displayPath = $derived(height > 480)
</script>
<div
@@ -58,7 +60,6 @@
e.stopPropagation()
}}
role="none"
bind:clientWidth={width}
bind:clientHeight={height}
>
<div class="flex flex-row items-center gap-2">
@@ -84,7 +85,7 @@
<div class="flex flex-row grow min-h-0 gap-2">
{#if kind === 'script'}
<div class="flex-none flex flex-col text-xs text-primary overflow-auto gap-1">
<div class="flex-none w-40 flex flex-col text-xs text-primary overflow-auto gap-1">
<TopLevelNode
label="Action"
selected={selectedKind === 'script'}
@@ -188,14 +189,14 @@
/>
{/if}
{#if customUi?.aiSandbox != false}
<TopLevelNode
label="AI Sandbox"
selected={selectedKind === 'aisandbox'}
onSelect={() => {
selectedKind = 'aisandbox'
}}
/>
{/if}
<TopLevelNode
label="AI Sandbox"
selected={selectedKind === 'aisandbox'}
onSelect={() => {
selectedKind = 'aisandbox'
}}
/>
{/if}
{/if}
</div>
{/if}
@@ -36,7 +36,7 @@
<Popover
bind:this={popover}
portal={portalTarget()}
contentClasses="p-2 max-w-lg h-[400px] !resize bg-surface"
contentClasses="p-2 max-w-lg h-[480px] bg-surface"
class="inline-block"
usePointerDownOutside
floatingConfig={{
@@ -11,6 +11,7 @@
selected?: boolean
eeRestricted: boolean
enterpriseLangs?: string[]
onHover?: () => void
}
let {
@@ -18,7 +19,8 @@
lang = undefined,
selected = false,
eeRestricted,
enterpriseLangs = []
enterpriseLangs = [],
onHover = undefined
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -44,12 +46,12 @@
<Button
id={`flow-editor-new-${lang}`}
{selected}
onClick={click}
onmousemove={() => onHover?.()}
role="menuitem"
variant="subtle"
unifiedSize="sm"
btnClasses="justify-start"
btnClasses="justify-start {selected ? 'bg-surface-hover' : onHover ? 'hover:bg-transparent' : ''}"
>
{#if lang}
<LanguageIcon {lang} width={13} height={13} />
@@ -3,11 +3,12 @@
import TopLevelNode from './TopLevelNode.svelte'
interface Props {
label: string;
selected?: boolean;
label: string
selected?: boolean
onHover?: () => void
}
let { label, selected = false }: Props = $props();
let { label, selected = false, onHover = undefined }: Props = $props()
const dispatch = createEventDispatcher()
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & Window }) {
if (selected && event.key === 'Enter') {
@@ -23,4 +24,4 @@
<svelte:window onkeydown={handleKeydown} />
<TopLevelNode {label} {selected} returnIcon onSelect={click} />
<TopLevelNode {label} {selected} returnIcon neutral onSelect={click} {onHover} />
@@ -74,6 +74,7 @@
displayPath?: boolean
apps?: string[]
refreshCount?: number
onHover?: (index: number) => void
}
let {
@@ -85,7 +86,8 @@
items = $bindable([]),
displayPath = false,
apps = $bindable([]),
refreshCount = 0
refreshCount = 0,
onHover = undefined
}: Props = $props()
let allApps: string[] = $state([])
@@ -199,7 +201,14 @@
<ul class="gap-1 flex flex-col">
{#each items as item, index (item.path)}
<li class="w-full">
<Popover class="w-full" placement="right" forceOpen={index === selected}>
<!-- Only the selected row may show a tooltip: the Popover opens on its own hover too, and a
row scrolled under a stationary cursor would otherwise open a second one. -->
<Popover
class="w-full"
placement="right"
forceOpen={index === selected}
disablePopup={index !== selected}
>
{#snippet text()}
<div class="flex flex-col">
<div class="text-left text-xs font-normal leading-tight py-0"
@@ -211,10 +220,14 @@
</div>
{/snippet}
<Button
selected={selected === index}
variant="subtle"
unifiedSize="sm"
btnClasses="justify-start"
btnClasses="justify-start h-auto min-h-7 py-1 {selected === index
? 'bg-surface-hover'
: onHover
? 'hover:bg-transparent'
: ''}"
onmousemove={() => onHover?.(index)}
onClick={() => handlePickScript(item)}
>
<div class={classNames('flex justify-center items-center')}>
@@ -229,11 +242,11 @@
</div>
<div class="flex flex-col grow min-w-0">
<div class="grow truncate text-left font-normal leading-tight py-0.5"
<div class="min-w-0 truncate text-left font-normal leading-tight"
>{item.summary ?? ''}</div
>
{#if displayPath && item.path}
<div class="grow truncate text-left text-2xs font-thin">
<div class="min-w-0 truncate text-left text-2xs font-thin leading-tight">
{item.path}
</div>
{/if}
@@ -19,10 +19,14 @@
label: string
selected?: boolean
returnIcon?: boolean
/** Highlight with the neutral hover surface instead of the accent, for transient
* (hover/keyboard) selection rather than the persistent category selection. */
neutral?: boolean
onSelect: () => void
onHover?: () => void
}
let { label, selected, returnIcon, onSelect }: Props = $props()
let { label, selected, returnIcon, neutral = false, onSelect, onHover }: Props = $props()
interface IconConfig {
icon: ComponentType
@@ -51,21 +55,23 @@
</script>
{#snippet iconWithText(icon: ComponentType, showChevron = false, iconClass = '')}
{label}
<span class="truncate">{label}</span>
{#if showChevron}
<ChevronRight size={12} class="ml-auto text-secondary" />
<ChevronRight size={12} class="ml-auto shrink-0 text-secondary" />
{/if}
{/snippet}
<Button
id={`flow-editor-flow-kind-${label.replaceAll(' ', '-').toLowerCase()}`}
{selected}
selected={neutral ? false : selected}
onClick={onSelect}
onmousemove={() => onHover?.()}
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: config?.icon }}
btnClasses={neutral ? (selected ? 'bg-surface-hover' : 'hover:bg-transparent') : ''}
>
<span class="grow flex items-center gap-2">
<span class="grow min-w-0 flex items-center gap-2">
{#if config}
{@render iconWithText(config.icon, config.showChevron, config.iconClass ?? '')}
{/if}
@@ -80,6 +80,7 @@
| { kind: 'inline' | 'owner' | 'integrations'; name: string | undefined }
| undefined
refreshCount?: number
onHover?: (index: number) => void
}
let {
@@ -91,7 +92,8 @@
filter = '',
owners = $bindable([]),
ownerFilter = $bindable(undefined),
refreshCount = 0
refreshCount = 0,
onHover = undefined
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -163,7 +165,14 @@
<ul class="gap-1 flex flex-col">
{#each filteredWithOwner ?? [] as { path, hash, summary, marked }, index}
<li class="w-full">
<Popover class="w-full " placement="right" forceOpen={index === selected}>
<!-- Only the selected row may show a tooltip: the Popover opens on its own hover too, and a
row scrolled under a stationary cursor would otherwise open a second one. -->
<Popover
class="w-full "
placement="right"
forceOpen={index === selected}
disablePopup={index !== selected}
>
{#snippet text()}
<div class="flex flex-col">
<div class="text-left text-xs font-normal leading-tight py-0">{summary ?? ''}</div>
@@ -173,10 +182,14 @@
</div>
{/snippet}
<Button
selected={selected === index}
variant="subtle"
unifiedSize="sm"
btnClasses="justify-start transition-all"
btnClasses="justify-start transition-all h-auto min-h-7 py-1 {selected === index
? 'bg-surface-hover'
: onHover
? 'hover:bg-transparent'
: ''}"
onmousemove={() => onHover?.(index)}
onClick={() => {
if (kind == 'flow') {
dispatch('pickFlow', { path: path })
@@ -189,7 +202,7 @@
}}
>
<div class="flex flex-col grow min-w-0">
<div class="grow min-w-0 truncate text-left">
<div class="min-w-0 truncate text-left leading-tight">
{#if marked}
{@html marked}
{:else}
@@ -197,7 +210,7 @@
{/if}
</div>
{#if displayPath && path}
<div class="grow min-w-0 truncate text-left text-2xs font-thin">
<div class="min-w-0 truncate text-left text-2xs font-thin leading-tight">
{path}
</div>
{/if}
@@ -49,8 +49,9 @@
startIcon={icon}
unifiedSize="sm"
btnClasses="justify-start"
title={filter}
>
{filter}
<span class="truncate">{filter}</span>
</Button>
</div>
{/each}