Files
windmill/frontend/src/lib/components/DedicatedWorkersSelector.svelte
T
946848feef fix: limit multi-runnable dedicated workers to one job at a time (#8782)
* feat: thread concurrency semaphore through dedicated worker executors

Pass the concurrency_semaphore parameter through bun, deno, and python
start_worker functions to handle_dedicated_process. Also fix the
DedicatedWorkersSelector to use listWorkspacesAsSuperAdmin (so all
workspaces including admins are visible) and skip loading when disabled.

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

* chore: update ee-repo-ref to limit-workers-one-job branch

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

* chore: update ee-repo-ref to 54037e77cdd37777560755fef7075d35906c96d8

This commit updates the EE repository reference after PR #523 was merged in windmill-ee-private.

Previous ee-repo-ref: 56890ea8fca2c1c44a1338a27011b4dd1137d9c9

New ee-repo-ref: 54037e77cdd37777560755fef7075d35906c96d8

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-10 14:35:13 +00:00

900 lines
27 KiB
Svelte

<script lang="ts">
import {
ScriptService,
FlowService,
WorkspaceService,
ConfigService,
type FlowModule
} from '$lib/gen'
import {
Check,
X,
RefreshCcw,
ChevronDown,
ChevronRight,
CodeXml,
ExternalLink,
TriangleAlert,
Layers
} from 'lucide-svelte'
import { Button } from './common'
import Select from './select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import Badge from './common/badge/Badge.svelte'
import { SvelteMap } from 'svelte/reactivity'
import { untrack } from 'svelte'
import BarsStaggered from './icons/BarsStaggered.svelte'
import { parseTag } from './dedicated_worker'
import Tooltip from './Tooltip.svelte'
// A "Runnable" is a script or flow with dedicated_worker=true
interface Runnable {
tag: string // workspace:path or workspace:flow/path
displayName: string
language: string
type: 'script' | 'flow'
path: string
selected: boolean
// For flows, the actual runners (steps) that will be spawned
runners?: FlowRunner[]
loadingRunners?: boolean
expanded?: boolean
// Workspace dependency names from annotations (scripts only)
workspaceDeps?: string[]
}
// A "FlowRunner" is an individual step within a flow that will get a dedicated worker
interface FlowRunner {
stepId: string
stepSummary?: string
language?: string
scriptPath?: string
isInline: boolean
}
interface Props {
selectedTags: string[]
disabled?: boolean
onchange?: (tags: string[]) => void
}
let { selectedTags = $bindable([]), disabled = false, onchange }: Props = $props()
let selectedWorkspace: string | undefined = $state(undefined)
let runnables: Runnable[] = $state([])
let loading = $state(false)
let workspaces: { id: string; name: string }[] = $state([])
let workspacesLoading = $state(true)
let selectorExpanded = $state(false)
// Map of dep name → set of workspaces that have it (for cross-workspace validation)
let existingDeps: Map<string, Set<string>> = $state(new Map())
// Track detailed info for each selected tag (for displaying in summary)
interface SelectedTagInfo {
tag: string
workspace: string
type: 'script' | 'flow'
path: string
language?: string
runners?: FlowRunner[]
expanded?: boolean
loading?: boolean
workspaceDeps?: string[]
}
let selectedTagsInfo: SvelteMap<string, SelectedTagInfo> = $state(new SvelteMap())
// Languages that support dedicated workers
const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'bunnative', 'deno']
// Resolve workspace script languages and filter to supported languages
async function resolveAndFilterRunners(
workspace: string,
preliminaryRunners: FlowRunner[]
): Promise<FlowRunner[]> {
const runnersWithLanguage = await Promise.all(
preliminaryRunners.map(async (runner) => {
if (!runner.isInline && runner.scriptPath) {
try {
const script = await ScriptService.getScriptByPath({
workspace,
path: runner.scriptPath
})
return { ...runner, language: script.language }
} catch (e) {
console.error(`Failed to fetch script ${runner.scriptPath}`, e)
return { ...runner, language: undefined }
}
}
return runner
})
)
// Filter to only supported languages
return runnersWithLanguage.filter(
(runner) => runner.language && DEDICATED_WORKER_LANGUAGES.includes(runner.language)
)
}
// Load detailed info for all selected tags
async function loadSelectedTagsInfo(tags: string[]) {
if (tags.length === 0) {
selectedTagsInfo = new SvelteMap()
return
}
// Capture current state without tracking to avoid infinite loops
const currentInfo = untrack(() => selectedTagsInfo)
const currentRunnables = untrack(() => runnables)
const currentExistingDeps = untrack(() => existingDeps)
const newInfo = new SvelteMap<string, SelectedTagInfo>()
// Fetch dep info only when not disabled (requires devops role)
let needsFetch = !disabled && currentExistingDeps.size === 0
if (!needsFetch && !disabled) {
for (const tag of tags) {
const existing = currentInfo.get(tag)
const existingRunnable = currentRunnables.find((r) => r.tag === tag)
if (!existing?.workspaceDeps && !existingRunnable?.workspaceDeps) {
const parsed = parseTag(tag)
if (parsed?.type === 'script') {
needsFetch = true
break
}
}
}
}
// Fetch dep info across all workspaces (requires devops role)
const depsPerWorkspace = new Map<string, Map<string, { deps: string[]; language: string }>>()
if (needsFetch) {
const [allDedicatedDeps, allWsDeps] = await Promise.all([
ConfigService.listAllDedicatedWithDeps().catch(() => []),
ConfigService.listAllWorkspaceDependencies().catch(() => [])
])
for (const d of allDedicatedDeps) {
if (d.workspace_dep_names.length > 0) {
let wsMap = depsPerWorkspace.get(d.workspace_id)
if (!wsMap) {
wsMap = new Map()
depsPerWorkspace.set(d.workspace_id, wsMap)
}
wsMap.set(d.path, {
deps: d.workspace_dep_names,
language: d.language
})
}
}
for (const d of allWsDeps) {
if (d.name) {
let wsSet = currentExistingDeps.get(d.name)
if (!wsSet) {
wsSet = new Set()
currentExistingDeps.set(d.name, wsSet)
}
wsSet.add(d.workspace_id)
}
}
existingDeps = new Map(currentExistingDeps)
}
try {
await Promise.all(
tags.map(async (tag) => {
// Check if we already have this info cached
const existing = currentInfo.get(tag)
if (existing && (existing.type === 'script' || existing.runners !== undefined)) {
// Backfill workspaceDeps and language if missing
if (existing.type === 'script' && !existing.workspaceDeps) {
const depInfo = depsPerWorkspace.get(existing.workspace)?.get(existing.path)
if (depInfo) {
existing.workspaceDeps = depInfo.deps
existing.language = depInfo.language
}
}
newInfo.set(tag, existing)
return
}
// Check if we have it loaded in runnables
const existingRunnable = currentRunnables.find((r) => r.tag === tag)
if (existingRunnable) {
const ws = tag.substring(0, tag.indexOf(':'))
const depInfo = depsPerWorkspace.get(ws)?.get(existingRunnable.path)
newInfo.set(tag, {
tag,
workspace: ws,
type: existingRunnable.type,
path: existingRunnable.path,
language: existingRunnable.language,
runners: existingRunnable.runners,
expanded: existing?.expanded ?? false,
workspaceDeps: existingRunnable.workspaceDeps ?? depInfo?.deps
})
return
}
// Parse and fetch
const parsed = parseTag(tag)
if (!parsed) return
if (disabled) {
// Read-only: just show path info, skip API calls
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: parsed.type,
path: parsed.path
})
return
}
if (parsed.type === 'script') {
const depInfo = depsPerWorkspace.get(parsed.workspace)?.get(parsed.path)
let scriptLang = depInfo?.language
if (!scriptLang) {
try {
const script = await ScriptService.getScriptByPath({
workspace: parsed.workspace,
path: parsed.path
})
scriptLang = script.language
} catch {}
}
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: 'script',
path: parsed.path,
language: scriptLang,
workspaceDeps: depInfo?.deps
})
} else {
// Flows need to fetch to get runners
try {
const flow = await FlowService.getFlowByPath({
workspace: parsed.workspace,
path: parsed.path
})
const preliminaryRunners = flow.value?.modules
? extractRunnersFromModules(flow.value.modules)
: []
const runners = await resolveAndFilterRunners(parsed.workspace, preliminaryRunners)
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: 'flow',
path: parsed.path,
runners,
expanded: existing?.expanded ?? false
})
} catch (e) {
console.error(`Failed to load flow ${parsed.path}`, e)
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: 'flow',
path: parsed.path,
runners: []
})
}
}
})
)
} finally {
selectedTagsInfo = newInfo
}
}
function toggleSelectedTagExpanded(tag: string) {
const info = selectedTagsInfo.get(tag)
if (info) {
// Need to set the whole object to trigger reactivity
selectedTagsInfo.set(tag, { ...info, expanded: !info.expanded })
}
}
// Auto-expand selector if no tags selected
$effect(() => {
if (selectedTags.length === 0) {
selectorExpanded = true
}
})
// Load selected tags info when selectedTags change
$effect(() => {
loadSelectedTagsInfo(selectedTags)
})
$effect(() => {
if (!disabled) {
loadWorkspaces()
}
})
async function loadWorkspaces() {
try {
workspacesLoading = true
const ws = await WorkspaceService.listWorkspacesAsSuperAdmin()
workspaces = ws.map((w) => ({ id: w.id, name: w.name }))
} catch (e) {
console.error('Failed to load workspaces', e)
sendUserToast('Failed to load workspaces', true)
} finally {
workspacesLoading = false
}
}
// Extract runners from flow modules recursively
// Returns runners with language info for inline scripts, and scriptPath for workspace scripts
function extractRunnersFromModules(modules: FlowModule[]): FlowRunner[] {
const runners: FlowRunner[] = []
for (const module of modules) {
const value = module.value
switch (value.type) {
case 'rawscript':
if (DEDICATED_WORKER_LANGUAGES.includes(value.language)) {
runners.push({
stepId: module.id,
stepSummary: module.summary,
language: value.language,
scriptPath: value.path,
isInline: true
})
}
break
case 'script':
// For workspace script references, we'll resolve the language later
runners.push({
stepId: module.id,
stepSummary: module.summary,
language: undefined, // Will be resolved by fetching the script
scriptPath: value.path,
isInline: false
})
break
case 'forloopflow':
runners.push(...extractRunnersFromModules(value.modules))
break
case 'whileloopflow':
runners.push(...extractRunnersFromModules(value.modules))
break
case 'branchone':
for (const branch of value.branches) {
runners.push(...extractRunnersFromModules(branch.modules))
}
runners.push(...extractRunnersFromModules(value.default))
break
case 'branchall':
for (const branch of value.branches) {
runners.push(...extractRunnersFromModules(branch.modules))
}
break
}
}
return runners
}
async function loadFlowRunners(runnable: Runnable) {
if (!selectedWorkspace || runnable.type !== 'flow') return
try {
runnable.loadingRunners = true
const flow = await FlowService.getFlowByPath({
workspace: selectedWorkspace,
path: runnable.path
})
if (flow.value?.modules) {
const preliminaryRunners = extractRunnersFromModules(flow.value.modules)
runnable.runners = await resolveAndFilterRunners(selectedWorkspace, preliminaryRunners)
} else {
runnable.runners = []
}
} catch (e) {
console.error('Failed to load flow runners', e)
runnable.runners = []
} finally {
runnable.loadingRunners = false
}
}
async function loadRunnables(workspaceId: string) {
try {
loading = true
runnables = []
const [scripts, flows, dedicatedDeps, allWsDeps] = await Promise.all([
ScriptService.listScripts({
workspace: workspaceId,
dedicatedWorker: true
}),
FlowService.listFlows({
workspace: workspaceId,
dedicatedWorker: true
}),
disabled
? Promise.resolve([])
: ScriptService.listDedicatedWithDeps({
workspace: workspaceId
}).catch(() => []),
disabled
? Promise.resolve([])
: ConfigService.listAllWorkspaceDependencies().catch(() => [])
])
// Track existing workspace dep names with their workspaces for validation
const newDeps = new Map<string, Set<string>>()
for (const d of allWsDeps) {
if (d.name) {
let wsSet = newDeps.get(d.name)
if (!wsSet) {
wsSet = new Set()
newDeps.set(d.name, wsSet)
}
wsSet.add(d.workspace_id)
}
}
existingDeps = newDeps
// Build a map from path -> workspace dep names
const depsMap = new Map<string, string[]>()
for (const d of dedicatedDeps) {
if (d.workspace_dep_names.length > 0) {
depsMap.set(d.path, d.workspace_dep_names)
}
}
const newRunnables: Runnable[] = []
// Add scripts with supported languages
for (const script of scripts) {
if (DEDICATED_WORKER_LANGUAGES.includes(script.language ?? '')) {
const tag = `${workspaceId}:${script.path}`
newRunnables.push({
tag,
displayName: script.path,
language: script.language ?? 'unknown',
type: 'script',
path: script.path,
selected: selectedTags.includes(tag),
workspaceDeps: depsMap.get(script.path)
})
}
}
// Add flows
for (const flow of flows) {
const tag = `${workspaceId}:flow/${flow.path}`
newRunnables.push({
tag,
displayName: flow.path,
language: 'flow',
type: 'flow',
path: flow.path,
selected: selectedTags.includes(tag),
runners: undefined,
loadingRunners: false,
expanded: false
})
}
runnables = newRunnables
// Load runners for all flows in parallel
await Promise.all(runnables.filter((r) => r.type === 'flow').map((r) => loadFlowRunners(r)))
} catch (e) {
console.error('Failed to load runnables', e)
sendUserToast('Failed to load scripts/flows', true)
} finally {
loading = false
}
}
function toggleRunnable(runnable: Runnable) {
runnable.selected = !runnable.selected
updateSelectedTags()
}
function toggleExpanded(runnable: Runnable) {
runnable.expanded = !runnable.expanded
}
function selectAll() {
for (const runnable of runnables) {
runnable.selected = true
}
updateSelectedTags()
}
function deselectAll() {
for (const runnable of runnables) {
runnable.selected = false
}
updateSelectedTags()
}
function updateSelectedTags() {
// Keep tags from other workspaces, update only the currently visible ones
const visibleTags = new Set(runnables.map((r) => r.tag))
const otherTags = selectedTags.filter((t) => !visibleTags.has(t))
const newVisibleTags = runnables.filter((r) => r.selected).map((r) => r.tag)
selectedTags = [...otherTags, ...newVisibleTags]
onchange?.(selectedTags)
}
function removeTag(tag: string) {
selectedTags = selectedTags.filter((t) => t !== tag)
// Also update runnable state if visible
const runnable = runnables.find((r) => r.tag === tag)
if (runnable) {
runnable.selected = false
}
onchange?.(selectedTags)
}
$effect(() => {
if (selectedWorkspace) {
loadRunnables(selectedWorkspace)
}
})
let selectedCount = $derived(runnables.filter((r) => r.selected).length)
// Compute shared runner groups: scripts sharing a (workspace, dep_name, language) tuple
interface RunnerGroup {
workspace: string
depName: string
language: string
tags: string[]
}
let runnerGroups: RunnerGroup[] = $derived.by(() => {
const groupMap = new Map<string, RunnerGroup>()
for (const tag of selectedTags) {
const info = selectedTagsInfo.get(tag)
if (info?.type === 'script' && info.workspaceDeps) {
const lang = info.language ?? runnables.find((r) => r.tag === tag)?.language ?? 'unknown'
for (const dep of info.workspaceDeps) {
const key = `${info.workspace}:${dep}:${lang}`
const existing = groupMap.get(key)
if (existing) {
existing.tags.push(tag)
} else {
groupMap.set(key, {
workspace: info.workspace,
depName: dep,
language: lang,
tags: [tag]
})
}
}
}
}
// Only return groups with 2+ scripts
return Array.from(groupMap.values()).filter((g) => g.tags.length >= 2)
})
// Map tag → runner group it belongs to
let tagRunnerGroup: Map<string, RunnerGroup> = $derived.by(() => {
const map = new Map<string, RunnerGroup>()
for (const group of runnerGroups) {
for (const tag of group.tags) {
map.set(tag, group)
}
}
return map
})
// Tags not in any runner group (standalone dedicated workers)
let standaloneTags: string[] = $derived(selectedTags.filter((tag) => !tagRunnerGroup.has(tag)))
</script>
{#snippet depBadge(dep: string, workspace: string | undefined)}
{@const depWorkspaces = existingDeps.get(dep)}
{@const existsInWorkspace = depWorkspaces?.has(workspace ?? '')}
{@const existsElsewhere = depWorkspaces && depWorkspaces.size > 0 && !existsInWorkspace}
{#if existsInWorkspace}
<Badge color="indigo" small href="/workspace_settings?tab=dependencies&workspace={workspace}">
{dep}
<ExternalLink class="h-2.5 w-2.5" />
</Badge>
{:else if existsElsewhere}
<Tooltip small>
Workspace dependency '{dep}' exists in {[...(depWorkspaces ?? [])].join(', ')} but not in '{workspace}'.
Each workspace needs its own dependency.
</Tooltip>
<Badge color="yellow" small>
<TriangleAlert class="h-2.5 w-2.5" />
{dep} (not in {workspace})
</Badge>
{:else}
<Tooltip small>
Workspace dependency '{dep}' not found in any workspace. Create it in workspace settings to
enable shared runners.
</Tooltip>
<Badge color="yellow" small>
<TriangleAlert class="h-2.5 w-2.5" />
{dep}
</Badge>
{/if}
{/snippet}
{#snippet tagRow(tag: string, info: SelectedTagInfo | undefined, standalone: boolean)}
{@const hasChevron = info?.type === 'flow' && info.runners && info.runners.length > 0}
<div>
<div
class="flex items-center {standalone
? (hasChevron ? 'pr-3' : 'px-3') + ' py-1.5 bg-surface-secondary'
: ''}"
>
{#if hasChevron}
<button
class="p-2 hover:bg-surface-hover transition-colors"
onclick={(e) => {
e.stopPropagation()
toggleSelectedTagExpanded(tag)
}}
>
{#if info.expanded}
<ChevronDown class="h-3 w-3 text-tertiary" />
{:else}
<ChevronRight class="h-3 w-3 text-tertiary" />
{/if}
</button>
{:else if !standalone}
<div class="w-7"></div>
{/if}
<div class="flex-1 flex items-center gap-2 {standalone ? '' : 'px-2 py-1.5'} min-w-0">
{#if info}
{#if info.type === 'flow'}
<BarsStaggered size={14} class="flex-shrink-0 text-secondary" />
{:else}
<CodeXml size={14} class="flex-shrink-0 text-secondary" />
{/if}
<span class="text-xs truncate min-w-0">{info.path}</span>
<span class="text-xs text-tertiary flex-shrink-0">({info.workspace})</span>
<span class="flex-1"></span>
{#if !tagRunnerGroup.has(tag)}
{#if info.workspaceDeps}
{#each info.workspaceDeps as dep}
{@render depBadge(dep, info?.workspace)}
{/each}
{/if}
{#if info.type === 'flow' && info.runners && info.runners.length > 0}
<Badge color="indigo" small>
{info.runners.length} runner{info.runners.length !== 1 ? 's' : ''}
</Badge>
{:else if info.language}
<Badge color="gray" small>{info.language}</Badge>
{/if}
{/if}
{:else}
<span class="text-xs text-tertiary truncate">{tag}</span>
{/if}
</div>
{#if !disabled}
<button
class="p-2 hover:text-red-500 transition-colors"
onclick={(e) => {
e.stopPropagation()
removeTag(tag)
}}
>
<X class="h-3 w-3" />
</button>
{/if}
</div>
{#if info?.type === 'flow' && info.expanded && info.runners}
<div class="bg-surface-secondary border-t">
{#each info.runners as runner (runner.stepId)}
<div class="flex items-center gap-2 px-9 py-1 text-xs border-t first:border-t-0 min-w-0">
<span class="font-mono text-tertiary flex-shrink-0">{runner.stepId}</span>
{#if runner.stepSummary}
<span class="text-secondary truncate flex-1 min-w-0">{runner.stepSummary}</span>
{/if}
<Badge color="gray" small>
{runner.isInline ? runner.language : runner.scriptPath}
</Badge>
</div>
{/each}
</div>
{/if}
</div>
{/snippet}
<div class="flex flex-col gap-3">
{#if selectedTags.length > 0}
<div class="flex flex-col gap-2">
<div class="border rounded-md bg-surface divide-y">
<!-- Shared runner groups -->
{#each runnerGroups as group (`${group.workspace}:${group.depName}:${group.language}`)}
<div>
<div class="flex items-center gap-2 px-3 py-1.5 bg-surface-secondary border-b">
<Layers size={12} class="flex-shrink-0 text-secondary" />
<span class="text-xs font-medium text-emphasis">Shared runner</span>
<span class="flex-1"></span>
{@render depBadge(group.depName, group.workspace)}
<Badge color="gray" small>{group.language}</Badge>
</div>
<div class="divide-y">
{#each group.tags as tag (tag)}
{@const info = selectedTagsInfo.get(tag)}
{@render tagRow(tag, info, false)}
{/each}
</div>
</div>
{/each}
<!-- Standalone scripts/flows -->
{#each standaloneTags as tag (tag)}
{@const info = selectedTagsInfo.get(tag)}
{@render tagRow(tag, info, true)}
{/each}
</div>
</div>
{/if}
<!-- Collapsible selector section (hidden for read-only users) -->
{#if !disabled}
<div class="border rounded-md">
<button
class="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-hover transition-colors"
onclick={() => (selectorExpanded = !selectorExpanded)}
>
{#if selectorExpanded}
<ChevronDown class="h-4 w-4 text-secondary" />
{:else}
<ChevronRight class="h-4 w-4 text-secondary" />
{/if}
<span class="text-sm">
{selectedTags.length > 0 ? 'Add more scripts/flows' : 'Select scripts/flows'}
</span>
</button>
{#if selectorExpanded}
<div class="border-t px-3 py-3 flex flex-col gap-3">
<!-- Workspace selector -->
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">Workspace</span>
<Select
bind:value={selectedWorkspace}
items={workspaces.map((w) => ({ value: w.id, label: `${w.name} (${w.id})` }))}
placeholder="Select workspace..."
disabled={disabled || workspacesLoading}
/>
</div>
<!-- Scripts/flows list -->
{#if selectedWorkspace}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs text-secondary"
>Scripts/flows with dedicated worker enabled</span
>
{#if !loading && runnables.length > 0}
<div class="flex gap-1">
<Button size="xs2" color="light" on:click={selectAll} {disabled}>All</Button>
<Button size="xs2" color="light" on:click={deselectAll} {disabled}>None</Button>
<Button
size="xs2"
color="light"
iconOnly
startIcon={{ icon: RefreshCcw }}
on:click={() => selectedWorkspace && loadRunnables(selectedWorkspace)}
{disabled}
/>
</div>
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center py-4">
<RefreshCcw class="animate-spin h-4 w-4 text-secondary" />
<span class="ml-2 text-xs text-secondary">Loading...</span>
</div>
{:else if runnables.length === 0}
<div class="text-xs text-tertiary py-3 text-center">
No scripts or flows with dedicated worker enabled found.
</div>
{:else}
<div class="border rounded-md divide-y max-h-64 overflow-y-auto bg-surface">
{#each runnables as runnable (runnable.tag)}
<div>
<div class="flex items-center">
{#if runnable.type === 'flow' && runnable.runners && runnable.runners.length > 0}
<button
class="p-2 hover:bg-surface-hover transition-colors"
onclick={(e) => {
e.stopPropagation()
toggleExpanded(runnable)
}}
{disabled}
>
{#if runnable.expanded}
<ChevronDown class="h-3 w-3 text-tertiary" />
{:else}
<ChevronRight class="h-3 w-3 text-tertiary" />
{/if}
</button>
{/if}
<button
class="flex-1 flex items-center gap-2 px-2 py-1.5 hover:bg-surface-hover transition-colors text-left min-w-0"
onclick={(e) => {
e.stopPropagation()
if (!disabled) toggleRunnable(runnable)
}}
{disabled}
>
<div
class="w-4 h-4 border rounded flex items-center justify-center flex-shrink-0"
class:bg-blue-500={runnable.selected}
class:border-blue-500={runnable.selected}
>
{#if runnable.selected}
<Check class="h-3 w-3 text-white" />
{/if}
</div>
<span class="flex-1 text-xs truncate min-w-0">{runnable.displayName}</span
>
{#if runnable.type === 'flow' && runnable.runners}
<span class="text-xs text-tertiary flex-shrink-0">
{runnable.runners.length}
</span>
{/if}
{#if runnable.workspaceDeps}
{#each runnable.workspaceDeps as dep}
{@render depBadge(dep, selectedWorkspace)}
{/each}
{/if}
<Badge color={runnable.type === 'flow' ? 'indigo' : 'gray'} small>
{runnable.type === 'flow' ? 'flow' : runnable.language}
</Badge>
</button>
</div>
{#if runnable.type === 'flow' && runnable.expanded && runnable.runners}
<div class="bg-surface-secondary border-t">
{#if runnable.runners.length === 0}
<div class="px-9 py-1.5 text-xs text-tertiary italic">
No eligible steps (python3/bun/bunnative/deno)
</div>
{:else}
{#each runnable.runners as runner (runner.stepId)}
<div
class="flex items-center gap-2 px-9 py-1 text-xs border-t first:border-t-0 min-w-0"
>
<span class="font-mono text-tertiary flex-shrink-0"
>{runner.stepId}</span
>
{#if runner.stepSummary}
<span class="text-secondary truncate flex-1 min-w-0">
{runner.stepSummary}
</span>
{/if}
<Badge color="gray" small>
{runner.isInline ? runner.language : runner.scriptPath}
</Badge>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="text-xs text-tertiary">
{selectedCount} selected
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>