Files
windmill/frontend/src/lib/components/common/confirmationModal/DraftChangesConfirmationModal.svelte
T
hugocasaandClaude Opus 5 7feaf619cf feat: run a linked AI agent's draft when testing a flow, and offer to deploy it (#10993)
* feat(frontend): run a linked agent's draft when testing a flow, and offer to deploy it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): settle an agent's autosave before reading it, and refresh its card on a draft save

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): deploy the agent draft that was validated, and make the draft-tools flag explicit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a stale agent deploy, and warn when a never-deployed agent is kept as a draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* docs: record what inlining an agent draft puts in a preview job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): name the draft-changes dialog after what it lists

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a draft deploy when the draft row is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): apply the missing-draft refusal to raw apps too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): stop reading a deployed resource row as a draft on deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): do not mistake an outage or a vanished draft for a deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse an agent read whose pending draft save failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): space the trigger badges and right-align the agent actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): surface a failed agent-draft read instead of dropping it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the agent draft delete a baseline so a newer edit survives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): drop the agent draft cell locally instead of deleting twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pass the withDraft flag the guard tests were missing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* refactor(frontend): deploy agent drafts the way Review & Deploy does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the read-only flow graph its own linked-tools bucket

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): base the resource draft delete on the read that promoted it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refresh every step linking an agent when its draft is saved

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): write nothing at all when a resource draft has gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pin that the resource draft delete follows its baseline seed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:04:38 +02:00

324 lines
12 KiB
Svelte

<script lang="ts">
import ConfirmationModal from './ConfirmationModal.svelte'
import { createEventDispatcher, untrack } from 'svelte'
import type { Trigger } from '$lib/components/triggers/utils'
import DataTable from '$lib/components/table/DataTable.svelte'
import { twMerge } from 'tailwind-merge'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { Bot, Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
import Badge from '../badge/Badge.svelte'
import type { LinkedAgentDraft } from '$lib/components/flows/linkedAgentDrafts'
interface Props {
open?: boolean
draftTriggers?: Trigger[]
/** Saved agents this flow links to that have an unsaved draft. Scripts pass none: only a
* flow step can link an agent. */
draftAgents?: LinkedAgentDraft[]
/** Whether this user may write each listed agent's resource, keyed by path. */
agentCanWrite?: Record<string, boolean>
/** Why an agent cannot be deployed, keyed by path, from the same rule the agent editor's own
* Deploy button follows — so this dialog cannot offer a write that would be rejected. Decided
* by the caller: this is a generic dialog the script editor mounts too, and agent validation
* has no business in its bundle. */
agentRefusal?: Record<string, string | undefined>
isFlow?: boolean
}
let {
open = $bindable(false),
draftTriggers = [],
draftAgents = [],
agentCanWrite = {},
agentRefusal = {},
isFlow = false
}: Props = $props()
let selectedTriggers: Trigger[] = $state(untrack(() => draftTriggers))
let selectedAgents: LinkedAgentDraft[] = $state([])
const dispatch = createEventDispatcher<{
canceled: void
confirmed: { selectedTriggers: Trigger[]; selectedAgents: LinkedAgentDraft[] }
}>()
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
if (trigger.isDraft) {
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
} else {
selectedTriggers = selectedTriggers.filter(
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
)
}
} else if (!isSelected(selectedTriggers, trigger)) {
selectedTriggers = [...selectedTriggers, trigger]
}
}
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
if (trigger.isDraft) {
return triggers.some((t) => t.id === trigger.id)
} else {
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
}
}
function toggleAgent(agent: LinkedAgentDraft, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
selectedAgents = selectedAgents.filter((a) => a.path !== agent.path)
} else if (!selectedAgents.some((a) => a.path === agent.path)) {
selectedAgents = [...selectedAgents, agent]
}
}
function checkSavePermissions(trigger: Trigger) {
// Creating http trigger is forbidden for non-admin users
const adminOnly =
trigger.type === 'http' &&
!$userStore?.is_admin &&
!$userStore?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
}
function checkAgentPermissions(agent: LinkedAgentDraft): {
state: 'deploy' | 'read-only' | 'invalid-config'
reason?: string
} {
if (agentCanWrite[agent.path] === false) {
return { state: 'read-only' }
}
const refusal = agentRefusal[agent.path]
return refusal ? { state: 'invalid-config', reason: refusal } : { state: 'deploy' }
}
$effect(() => {
if (!open) return
selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy')
selectedAgents = [...draftAgents].filter((a) => checkAgentPermissions(a).state === 'deploy')
})
const runnable = $derived(isFlow ? 'flow' : 'script')
// Named after what is actually listed, so the title is not a second, vaguer copy of the section
// headings below it. "Unsaved changes detected" is taken by the leave-the-page guard, which means
// the opposite of this dialog: there, unlisted work is about to be lost.
const title = $derived.by(() => {
const triggers = draftTriggers.length > 0
const agents = draftAgents.length > 0
if (triggers && agents) return 'Draft triggers and agents detected'
if (agents) return 'Draft agents detected'
return 'Draft triggers detected'
})
</script>
<ConfirmationModal
{open}
{title}
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
type="reload"
showIcon={false}
on:canceled={() => dispatch('canceled')}
on:confirmed={() => dispatch('confirmed', { selectedTriggers, selectedAgents })}
>
<div class="flex flex-col w-full gap-8 pb-4">
{#if draftTriggers.length > 0}
<div class="flex flex-col gap-2">
<div class="text-secondary text-sm">
{`Your ${runnable} has draft triggers. Select which draft triggers to deploy with the ${runnable}. Undeployed draft triggers will be permanently deleted.`}
</div>
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Triggers to deploy</th>
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftTriggers as trigger}
{@const SvelteComponent = triggerIconMap[trigger.type]}
{@const permission = checkSavePermissions(trigger)}
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
<tr
class={twMerge(
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<td class={twMerge('text-center py-1 px-4')}>
<div class="flex flex-row items-center gap-2">
<div class="relative flex justify-center items-center">
<SvelteComponent
size={14}
class={isSelectedTrigger ? 'text-accent' : 'text-hint'}
/>
{#if trigger.isPrimary}
<Star size={8} class="absolute -mt-3 ml-3 text-accent" />
{/if}
</div>
<!-- `TriggerLabel` emits its badges as bare siblings, so the gap between them
is the container's to set, as the other call sites do. -->
<div class="flex grow min-w-0 items-center gap-2 text-left">
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
</div>
</div>
</td>
<td class="text-left py-1">
{#if permission === 'deploy'}
<div class="flex justify-start">
<ToggleButtonGroup
class="w-fit h-fit"
selected={isSelectedTrigger ? 'deploy' : 'discard'}
on:selected={(e) => toggleTrigger(trigger, e.detail)}
>
{#snippet children({ item })}
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
value={'discard'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{:else if permission === 'admin-only'}
<Badge color="red">Admin only</Badge>
{:else if permission === 'invalid-config'}
<Badge color="red">Invalid config</Badge>
{/if}
</td>
</tr>
{/each}
</tbody>
</DataTable>
</div>
</div>
{/if}
{#if draftAgents.length > 0}
<div class="flex flex-col gap-2">
<div class="text-secondary text-sm">
Saved agents this flow uses have unsaved changes. Select which ones to deploy with the
flow. An agent kept as a draft stays editable, and the flow runs the agent as currently
deployed.
</div>
<div class={draftAgents.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Agents to deploy</th>
<!-- Wider than the trigger table's: "Keep as draft" does not fit its w-32. -->
<th class="w-48 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftAgents as agent (agent.path)}
{@const permission = checkAgentPermissions(agent)}
{@const isSelectedAgent = selectedAgents.some((a) => a.path === agent.path)}
<tr
class={twMerge(
'transition-colors border-t border-gray-200 dark:border-gray-700',
permission.state === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<!-- No fixed height, unlike the trigger table's `h-12` row: a never-deployed agent
carries a second line of warning under the path, so the row grows with it. -->
<td class="py-2 px-4">
<div class="flex flex-row items-center gap-2">
<Bot size={14} class={isSelectedAgent ? 'text-accent' : 'text-hint'} />
<div class="flex grow min-w-0 flex-col text-left">
<div class="flex items-center gap-2 min-w-0">
<!-- Dimmed rather than struck through: keeping a draft destroys nothing, so the
trigger table's deletion styling would say the wrong thing. -->
<span
class={twMerge(
'truncate text-xs',
isSelectedAgent ? '' : 'text-tertiary'
)}
title={agent.path}
>
{agent.path}
</span>
{#if agent.noDeployed}
<Badge small color="indigo">Never deployed</Badge>
{/if}
</div>
{#if agent.noDeployed && !isSelectedAgent}
<!-- The section's promise — the flow falls back to the deployed agent — does not
hold for one that has never been deployed. There is nothing to fall back to,
so the flow would land naming a path that does not resolve. -->
<span class="text-xs text-red-600 dark:text-red-400 whitespace-normal">
Never deployed, so the flow will not run until this agent is deployed.
</span>
{/if}
</div>
</div>
</td>
<!-- Right-aligned, unlike the trigger column: this one is `w-48` to fit "Keep as
draft", so left-aligned content would float in the slack instead of lining up
with the trigger rows above it. -->
<td class="text-right py-1 pr-2">
{#if permission.state === 'deploy'}
<div class="flex justify-end">
<ToggleButtonGroup
class="w-fit h-fit"
selected={isSelectedAgent ? 'deploy' : 'discard'}
on:selected={(e) => toggleAgent(agent, e.detail)}
>
{#snippet children({ item })}
<ToggleButton
label="Keep as draft"
value={'discard'}
{item}
small
class="justify-center"
/>
<ToggleButton
label={agent.noDeployed ? 'Deploy' : 'Update'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{:else if permission.state === 'read-only'}
<Badge color="red" title="You do not have write access to this agent">
Read-only
</Badge>
{:else}
<Badge color="red" title={permission.reason}>Invalid config</Badge>
{/if}
</td>
</tr>
{/each}
</tbody>
</DataTable>
</div>
</div>
{/if}
</div>
</ConfirmationModal>