Files
windmill/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte
T
Ruben FiszelandClaude Fable 5.1 fdd3b36423 feat: workspace setting to hide the AI assistant, agent steps unaffected (#10941)
* feat: workspace setting to hide the AI assistant, agent steps unaffected

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: load workspace AI config on cold /sessions load and say hidden, not disabled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: follow workspace switches on /sessions gate and drop deprecated button size

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: key the /sessions hidden-assistant gate on the acting workspace's own config

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: tag the /sessions hidden-assistant verdict with its workspace and drop superseded reads

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: overlay the /sessions hidden-assistant gate so warm sessions survive workspace switches

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: hide the pipeline insert menu AI prompt and refuse chat turns where the assistant is hidden

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: shrink the home Build with AI / CLI / Hub line to a flush hint row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

* fix: frame the workspace toggle as hide AI sessions at the bottom of the AI settings

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011eNweUugVqerex6MLxjbeL

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:21:10 +02:00

307 lines
9.5 KiB
Svelte

<script lang="ts" module>
import type { ComponentType } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
export type PipelineInsertKind = {
// Machine-readable id; drives which right-column panel renders.
id: string
label: string
description?: string
icon?: ComponentType
}
export type PipelineInsertPick = {
kindId: string
language?: SupportedLanguage
path?: string
// Picked output asset kind. Optional because some menu instances
// (those without `pickOutputKind`) skip that stage entirely.
outputKind?: string
// Optional natural-language prompt entered on the path stage.
// When set, the caller is expected to bootstrap the script body
// from this prompt via AI (using language + outputKind + the
// upstream input as context) instead of using the seeded template.
aiPrompt?: string
}
</script>
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { copilotInfo } from '$lib/aiStore'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import { twMerge } from 'tailwind-merge'
import {
compatibleOutputKinds,
PIPELINE_OUTPUT_KINDS,
type PipelineOutputKind
} from './pipelineTemplates'
import type { ScriptLang } from '$lib/gen'
import Label from '$lib/components/Label.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { CornerDownLeft, Sparkles } from 'lucide-svelte'
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
import { selectAndAdvanceTo } from '$lib/attachments/selectAndAdvanceTo'
interface Props {
kinds: PipelineInsertKind[]
languages?: Array<{ label: string; lang: SupportedLanguage }>
pathPrefix?: string
defaultPathSuffix?: string
onPick: (pick: PipelineInsertPick) => void
trigger: import('svelte').Snippet
placement?: 'bottom' | 'top' | 'left' | 'right'
}
let {
kinds,
languages = [],
pathPrefix = '',
trigger: triggerSnippet,
placement = 'bottom',
defaultPathSuffix,
onPick
}: Props = $props()
// When there's only one trigger kind, hide the Trigger column entirely
// and pre-select it so the user lands directly on the Language picker.
const singleKind = $derived(kinds.length === 1 ? kinds[0] : undefined)
const buildEmptySelected = () => ({
triggerId: singleKind?.id,
language: undefined as undefined | ScriptLang,
outputId: undefined as undefined | PipelineOutputKind,
scriptPath: `${defaultPathSuffix ?? 'pipeline_script'}_${shortSlug()}`,
aiPrompt: undefined as undefined | string
})
let selected = $state(buildEmptySelected())
// Refs to the column containers so Enter inside one column can hand
// focus off to the first tabbable in the next column.
let languageEl: HTMLElement | undefined
let outputEl: HTMLElement | undefined
// Refs into the bottom panel for the Output → Path → AI Prompt → Save chain. The AI
// prompt is absent where the workspace hid the assistant, so Path advances to Save.
let pathEl: HTMLElement | undefined
let aiPromptEl: HTMLElement | undefined
let saveEl: HTMLElement | undefined
let compatibleKinds = $derived.by<PipelineOutputKind[]>(() => {
if (!selected.language) return []
return compatibleOutputKinds(selected.language)
})
let visibleOutputKinds = $derived(
PIPELINE_OUTPUT_KINDS.filter((k) => compatibleKinds.includes(k.id))
)
let showBottomPanel = $derived(selected.triggerId && selected.language && selected.outputId)
function shortSlug(len = 4): string {
const a = 'abcdefghijklmnopqrstuvwxyz0123456789'
let out = ''
for (let i = 0; i < len; i++) out += a[Math.floor(Math.random() * a.length)]
return out
}
function confirm(close: () => void) {
const suffix = selected.scriptPath.trim()
if (!suffix || !selected.triggerId || !selected.language || !selected.outputId) return
const trimmedPrompt = selected.aiPrompt?.trim()
onPick({
kindId: selected.triggerId,
language: selected.language,
path: pathPrefix + suffix,
outputKind: selected.outputId,
aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined
})
close()
}
</script>
<Popover
enableFlyTransition
contentClasses={twMerge(
'p-0 bg-surface overflow-hidden relative transition-height',
showBottomPanel ? 'h-[26rem]' : 'h-[22rem]'
)}
class="inline-block"
usePointerDownOutside
floatingConfig={{
placement,
strategy: 'absolute',
gutter: 8,
overflowPadding: 16,
flip: true,
fitViewport: true,
overlap: false
}}
onClose={() => (selected = buildEmptySelected())}
>
{#snippet trigger()}
{@render triggerSnippet?.()}
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
</div>
</div>
{/snippet}
</Popover>
{#snippet topSection()}
{#if !singleKind}
<div
class={twMerge('flex flex-col gap-1 p-2 w-56 shrink-0 overflow-auto')}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => languageEl) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Trigger</div>
{#each kinds as k}
{@const isSelected = selected.triggerId == k.id}
<Button
variant="subtle"
btnClasses={'text-left'}
onClick={() => (selected.triggerId = k.id)}
selected={isSelected}
>
{#if k.icon}
{@const Icon = k.icon}
<Icon
size={14}
class={twMerge(
'shrink-0 my-auto mr-1.5',
isSelected ? 'text-accent' : 'text-secondary'
)}
/>
{/if}
<span class="flex flex-col items-start flex-1 min-w-0">
<span class="text-xs font-normal leading-tight">{k.label}</span>
{#if k.description}
<span
class={twMerge(
'text-2xs font-normal leading-snug mt-0.5',
isSelected ? 'text-accent/80' : 'text-hint'
)}
>
{k.description}
</span>
{/if}
</span>
</Button>
{/each}
</div>
{/if}
<div
bind:this={languageEl}
class={twMerge(
'flex flex-col gap-1 p-2 overflow-auto transition-opacity w-48',
selected.triggerId ? '' : 'opacity-20'
)}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => outputEl) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Language</div>
{#each languages as l}
{@const isSelected = selected.language === l.lang}
<Button
variant="subtle"
unifiedSize="sm"
btnClasses="justify-start"
selected={isSelected}
onClick={() => {
selected.language = l.lang
const _compatibleOutputKinds = compatibleOutputKinds(l.lang)
if (selected.outputId && !_compatibleOutputKinds.includes(selected.outputId)) {
selected.outputId = _compatibleOutputKinds[0]
}
}}
>
<LanguageIcon lang={l.lang} width={14} height={14} />
<span class="grow truncate text-left text-xs">{l.label}</span>
</Button>
{/each}
</div>
<div
bind:this={outputEl}
class={twMerge(
'flex flex-col gap-1 p-2 grow w-80 overflow-auto transition-opacity',
selected.triggerId && selected.language ? '' : 'opacity-20'
)}
{@attach arrowTabNav({ onKeyDown: selectAndAdvanceTo(() => pathEl, { timeout: 50 }) })}
>
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Output asset</div>
{#each visibleOutputKinds.length ? visibleOutputKinds : PIPELINE_OUTPUT_KINDS as k}
{@const isSelected = selected.outputId === k.id}
<Button variant="subtle" selected={isSelected} onClick={() => (selected.outputId = k.id)}>
<span class="flex flex-col items-start flex-1 min-w-0 text-left">
<span class="text-xs font-normal leading-tight">{k.label}</span>
{#if k.description}
<span
class={twMerge(
'text-2xs font-normal leading-snug mt-0.5',
isSelected ? 'text-accent/80' : 'text-hint'
)}
>
{k.description}
</span>
{/if}
</span>
</Button>
{/each}
</div>
{/snippet}
{#snippet bottomSection(close: () => void)}
<Label label="Path">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={pathEl} class="flex" onkeydown={selectAndAdvanceTo(() => aiPromptEl ?? saveEl)}>
<div
class="border rounded-md rounded-r-none border-r-0 text-xs w-fit shrink-0 whitespace-nowrap flex items-center px-2 text-secondary bg-surface-input"
>
{pathPrefix}
</div>
<TextInput bind:value={selected.scriptPath} class="rounded-l-none" />
</div>
</Label>
{#if !$copilotInfo.workspaceDisabled}
<Label label="AI Prompt (optional)">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={aiPromptEl}
onkeydown={(e) =>
(e.metaKey || e.ctrlKey) && e.key === 'Enter' && (confirm(close), e.stopPropagation())}
>
<TextInput
class="resize-none h-12 !max-h-12"
underlyingInputEl="textarea"
inputProps={{
placeholder: 'Describe what the script should do — leave empty to use a template'
}}
bind:value={selected.aiPrompt}
/>
</div>
</Label>
{/if}
{@const hasAiPrompt = !!selected.aiPrompt?.trim()}
<div class="ml-auto" bind:this={saveEl}>
<Button
variant="accent"
btnClasses="w-fit"
disabled={!selected.scriptPath.trim()}
onClick={() => confirm(close)}
startIcon={hasAiPrompt ? { icon: Sparkles } : undefined}
shortCut={{ Icon: CornerDownLeft }}>{hasAiPrompt ? 'Generate' : 'Create'}</Button
>
</div>
{/snippet}