Files
windmill/frontend/src/lib/components/flows/content/AgentEditorModal.svelte
T
hugocasaandClaude Opus 5.5 8c7dcbbda5 feat: agents as a standalone kind with home listing, detail and editor pages (#11332)
* feat: list agents on the home page and create them from the new menu

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

* fix: keep runnables out of the agents view and anchor a new agent once saved

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

* fix: autosave a new agent's first edit and list agents past one page

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

* feat: add agent detail and editor pages

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

* fix: explain when an agent cannot be run and drop the broken agent move

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

* fix: keep query params and a unique path when creating an agent

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

* fix: lead the home list with agents and keep rows while the agent view loads

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

* fix: treat a loading agent as undeployed when leaving the editor page

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

* feat: agent detail page config panel, run page on form runs, chat badge

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

* feat: agent configuration modal and draft paths like other new items

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

* fix: keep a new agent draft-free until the first input, land agents with runnables

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

* feat: place AI agent after apps in the new menu and describe chat and flow use

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

* feat: new agents start with managed memory, editor form says how to turn it off

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

* fix: clearer managed memory hint in the agent editor form

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

* refactor: share the agent editor's pane notice as a PaneNotice component

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

* fix: name a new agent after a path no resource holds

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

* fix: tell repeated tool names apart and hide permissions on draft-only agents

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

* feat: agent configuration beside the model in the chat composer and above the form

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

* feat: center the agent run form, configuration beside its Run button

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

* fix: icon-only agent configuration button beside Run

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

* feat: agents run on behalf of their deployer through a run-by-path endpoint

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

* feat: agents keep their run-as identity in their value, preserved by the CLI

The identity an agent runs as lives in `value.on_behalf_of` instead of a column. Every write
of an agent resolves it server-side as a flow's is: the writer's own, unless an admin or
wm_deployers member asks to keep it, and a folder default on create. Retyping a resource into
an agent resolves its value the same way. Export leaves it out; the run endpoint reads it and
drops it from the step's inputs.

The CLI follows the app model: a pushed agent never takes its identity from the tracked file,
an unchanged agent compares equal to the deployed one, and an admin or deployer push claims
the deployed identity back. The owner-change pre-check lists agents.

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

* feat: deploying an agent draft from review keeps its deployed identity

As for an app: an agent draft carries no identity, so the review page claims the deployed one
back, which the backend honours for an admin or wm_deployers member. The draft diff leaves the
deployed identity out, since a draft never holds one.

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

* feat: deploying an agent to another workspace offers the run-as choice

An agent deployed to prod/staging, merged from a fork or promoted with `wmill workspace merge`
gets the same identity choice as a flow or an app: the target's current one, the deployer, or
a picked user, sent as a principal the way a trigger's is. The source workspace's principal is
never copied, and a difference in identity alone is not a change in the workspace compare or
its diff.

The frontend consumes the published windmill-utils-internal, so its deploy provider carries the
same rewrite until that version ships.

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

* fix: agent identity on fork, agent-scoped job reads, and the run license gate

A fork re-points an agent's identity at its creator when they may not preserve someone else's,
and at the creator when it names nobody in the fork, as it does an app's. A token scoped to
`jobs:run:agents:<path>` reads back the runs it starts, chat turns included. The agent run
endpoint checks the enterprise license like every other run entrypoint.

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

* fix: a CLI push decides agent identity handling by the tracked file's type

An agent retyped into another resource by a push kept neither its value's `on_behalf_of` as the
file stated it nor clear of the old agent's identity. The file's type now decides, and only a
deployed agent's identity is claimed back.

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

* refactor: agents run as the caller, with a note on what a shared one needs

Drops the agent run-as identity: its storage in the agent value, the backfill, the resolution
on every write, and its handling in export, the CLI, draft and cross-workspace deploys, forks
and the workspace compare. The run endpoint runs as the caller, so an operator or reader runs an
agent with their own access. The editor tells the author of a folder agent that anyone running
it needs access to its AI resource and to what its tools use.

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

* docs: agent editor comments describe runs as the caller

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

* refactor: agent editor pane notes use Alert

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

* refactor: agent editor pane notes render as Alert

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

* fix: agent editor notes as regular Alerts, not banners

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

* feat: agent path as its own editor level, and evals on the agent page

The path leaves the agent form: the editor dialog opens it as a level, as it does evals, and the
editor page in a drawer. The agent page gains Evals, in a dialog. The page supplies the run
form, keeping it out of the editor the flow editor reaches.

The draft edit gate no longer throws when a focused, changed field is removed: the `change` that
removal fires lands mid-teardown, so the gate opens just after instead.

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

* feat: agent settings as the resource editor's header fields, behind a cog

The agent's own settings (path, labels, workspace specific, description) open from a cog as
the flow and script editors' do, laid out as the top of the resource editor. The folder note is
gone.

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

* feat: agent settings fields and cog, completing the rename

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

* feat: evals open as a dialog over the agent editor page

The editor page opens an agent's evals over itself, as the agent page does, with the unsaved
edits offered to a run; the editor dialog keeps evals as a level of its own. The two pages share
the dialog.

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

* fix: the unreachable model provider note reads like the unreachable agent one

Same warning level and wording as the note above it, with the path inline rather than in
parentheses that lost their spaces.

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

* fix: the unreachable model provider note offers editing the agent too

Editing the agent to use a provider the reader can access is often the simpler way out; offered
when the reader can write the agent, with Unlink and asking for access.

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

* fix: provider note lists asking for access as its own option

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

* fix: shorter provider note, without the header's buttons repeated

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

* fix: evals only for those who can edit the agent

Evaluating builds datasets and runs against the agent, which is authoring: the agent page and the
editor offer it only with write access to the agent.

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

* fix: agent page actions ordered as the script and flow pages

The menu leads and Edit comes last, as DetailPageHeader lays them out.

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

* fix: the agent editor dialog's levels slide in built on the first visit

Warmed, as the evals pane's levels are, so settings and evals are mounted before the first
navigation rather than inside its transition. Evals are only in the strip where they can be
opened.

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

* fix: the agent's settings report path errors, and hold nothing a reader can edit

The path field reads its error back, so it shows it and keeps deploy blocked on it.
Labels are shown rather than editable without write access. Evals wait for the load
to know they can be opened, and the page layout builds no levels it never shows.

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

* refactor: one builder for an agent's run flow, and the agent page on the shared header

The run endpoint and evals build the agent's one-step flow through the same function.
The agent page uses DetailPageHeader, whose error handler, tag and trigger context are
now optional, and whose menu items keep their disabled state. The home row and the
page share the agent's menu and delete.

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

* fix: the agent page's menu is built from its current path

Deploy settings are the workspace's, so they load once and the menu is derived from
them rather than fetched per path, where a superseded fetch could land after a
navigation. The shared run-flow builder lives with agent runs rather than evals.

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

* fix: the scoped-read comment names the run-flow builder as it is

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:40:05 +02:00

562 lines
22 KiB
Svelte

<script lang="ts">
import {
ChevronRight,
FlaskConical,
Settings,
FormInput,
History,
MessageSquare,
Save
} from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { onDestroy, untrack } from 'svelte'
import { resource } from 'runed'
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import { clearPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte'
import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils'
import { ResourceService, type AgentDraft } from '$lib/gen'
import {
agentEditorTarget,
agentWriteCount,
type AgentEditorTarget,
closeAgentEditor,
markAgentWritten,
openAgentEditor,
showAgentEditorTool,
showAgentEditorView
} from '../agentEditorStore.svelte'
import { agentConfigToInputTransforms } from '../agentResourceUtils'
import { publishLinkedAgentTools } from '../flowState'
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import AgentEditorHost from './AgentEditorHost.svelte'
import AgentSettings from './AgentSettings.svelte'
import AgentEvalsModal from './AgentEvalsModal.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
enableAi?: boolean
/** Which targets this mount is responsible for. The target is module-global while several
* flow editors can be alive at once (a session retains every tab it has visited), so without
* this each of them would build a whole editor — fetching, inferring tool schemas, and
* running its own two-way sync against the one draft row. Required rather than defaulted: a
* mount that guesses wrong renders nothing, and silence is a poor way to find that out. */
owns: (target: AgentEditorTarget) => boolean
/** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface
* that opened the editor: a flow's own steps, a page's URL. */
onRenamed?: (from: string, to: string) => void
/** `page` fills its container with the editor's header and body instead of opening a dialog. */
layout?: 'modal' | 'page'
/** Where closing leads, for a layout that has no dialog to dismiss. `deployed` is false for an
* agent that exists only as a draft, which has no page of its own to return to. */
onClose?: (deployed: boolean) => void
/** Ran after every successful deploy, with the path written. */
onDeployed?: (path: string) => void
}
let {
enableAi = false,
owns,
onRenamed = undefined,
layout = 'modal',
onClose = undefined,
onDeployed = undefined
}: Props = $props()
// Every target names the surface that opened it, and only a flow step or a resource row can:
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
// to link it to a saved agent of its own. So `owns` decides alone — nothing here has to adopt a
// target opened from inside another editor.
let target = $derived.by(() => {
const t = agentEditorTarget()
return t && owns(t) ? t : undefined
})
// The last agent this dialog showed. The version pane keeps working after `onRestore` returns —
// it reloads its list keyed on the path it was given — while the restore handler has already
// closed the dialog and dropped the target. Reading the live one there throws, and the throw
// lands in the global unhandled-rejection handler as a bare toast with no stack.
let lastShownAgent = $state<string | undefined>(undefined)
$effect(() => {
const p = target?.path
if (p) untrack(() => (lastShownAgent = p))
})
let ws = $derived(target?.workspace ?? $operatingWorkspace)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let settingsDrawer: Drawer | undefined = $state(undefined)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
let saving = $state(false)
// Counted per agent, so a deploy that leaves the editor on the same agent still refetches the
// version it just minted. Shared, so the step card behind the dialog refetches on the same signal.
let writes = $derived(agentWriteCount(ws, target?.path))
let versionResource = resource(
() => ({ ws, path: target?.path, writes }),
async ({ ws, path, writes }) => {
if (!ws || !path) return { ws, path, writes }
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
return { ws, path, writes, version: history.versions?.[0]?.version }
}
)
// `writes` as well as the identity: runed neither aborts nor tags a superseded request, so a
// response from before a deploy can land after the one for it and relabel the badge with the
// version that deploy replaced.
let version = $derived.by(() => {
const loaded = versionResource.current
return loaded !== undefined &&
loaded.ws === ws &&
loaded.path === target?.path &&
loaded.writes === writes
? loaded.version
: undefined
})
let draft = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
// the dialog carries only the refusal the host renders.
let refused = $derived(draft?.refusal != null)
let inEvals = $derived(target?.view === 'evals' && !refused)
// A level of its own in the dialog; the page layout opens the same settings in a drawer instead.
let inSettings = $derived(target?.view === 'settings' && !refused)
let readOnly = $derived(draft ? !draft.canWrite : false)
let draftOnly = $derived(draft?.noDeployed ?? false)
// Evals run against the deployed agent, and a draft-only one has none: the backend's
// `require_agent` would reject every run. Evaluating is authoring, so it is for those who can
// edit the agent. Unknown until the load answers, and warming the pane loads it.
let canEvaluate = $derived(
draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused
)
// A never-deployed agent is stored at a minted `draft_<uuid>` path, so it is named by the path
// its first deploy will create, as the home list names it.
let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path)
// Where the evals pane is within itself, so its levels extend this dialog's trail rather than
// opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on
// one, and never that it is back at its root.
let evalsLocation = $state<EvalsLocation | undefined>(undefined)
let evalsKey = $derived(`${ws}:${target?.path}:${target?.view}`)
$effect(() => {
evalsKey
untrack(() => (evalsLocation = undefined))
})
const AGENT_DESCRIPTION = 'Changes here update the saved agent, and every flow that links to it.'
let root = $derived<ModalTrailSegment>({
label: shownPath ?? 'Agent',
onclick: inEvals || inSettings ? () => showAgentEditorView(undefined) : undefined
})
let trail = $derived<ModalTrailSegment[]>(
inEvals
? [
root,
{ label: 'Evals', onclick: evalsLocation ? evalsLocation.back : undefined },
...(evalsLocation ? [{ label: evalsLocation.label }] : [])
]
: inSettings
? [root, { label: 'Settings' }]
: [root]
)
// The root's alone: below it the header's second line is the way back, and what a level is for
// belongs to that level rather than to the dialog's own name.
let description = $derived(inEvals || inSettings || refused ? undefined : AGENT_DESCRIPTION)
// The dialog's levels are its pages; a page has none, so evals open over it as a dialog.
function openEvals() {
if (layout === 'modal') showAgentEditorView('evals')
else evalsModal?.openModal()
}
function openSettings() {
if (layout === 'modal') showAgentEditorView('settings')
else settingsDrawer?.openDrawer()
}
/** The unsaved edits, in the shape the server builds from a deployed config
* (`ai_evals/run.rs` `config_to_draft`), so a draft run's hash can be recognised as equal to
* the version it is later deployed as. */
function editedConfig(): AgentDraft {
const args = draft?.state?.args
return {
input_transforms: agentConfigToInputTransforms(args ?? {}) as Record<string, unknown>,
tools: (args?.tools ?? []) as unknown[] as Record<string, unknown>[]
}
}
/** A resources-page row anchors itself in the hash on the way in, so closing has to clear it or
* the URL keeps claiming the agent is open and a refresh reopens it. Anchored to that page, so
* this is a no-op when the editor was opened from a flow. */
function close() {
// Unknown until the load answers, and a detail page that does not exist is the worse guess.
const deployed = draft != undefined && !draft.loading && !draftOnly && !refused
closeAgentEditor()
if (layout === 'modal') void clearPageDrawerAnchor(RESOURCES_PATH)
onClose?.(deployed)
}
// The target is module-global and outlives this component: navigating away takes the mount that
// owns it without passing through `close()`, and what it leaves behind reopens the dialog on the
// way back and tells the flow editor a dialog is over its graph. Only its own, so a mount going
// down never drops a target another one is showing.
onDestroy(() => {
const t = agentEditorTarget()
if (t && owns(t)) closeAgentEditor()
})
/** What the flow behind a write has to be told about it, captured before the request rather than
* read after: the dialog stays closable while one is in flight and can be pointed at another
* agent by the time it lands, and the reactive `ws`/`target` would then name that one. */
type WriteTarget = { ws: string | undefined; path: string; host: AgentEditorTarget['host'] }
function currentWriteTarget(): WriteTarget | undefined {
const t = target
return t ? { ws, path: t.path, host: t.host } : undefined
}
/** Reconciling is best-effort: it refreshes what a write changed, and the write itself has
* already landed. A rejection here must not surface as an unhandled one — the global handler
* turns those into a bare toast with no stack, which says nothing about where it came from. */
function reconcileQuietly(at: WriteTarget | undefined, path: string) {
try {
void Promise.resolve(reconcile(at, path)).catch((err) => {
console.error('agent editor: could not refresh what links to', path, err)
})
} catch (err) {
console.error('agent editor: could not refresh what links to', path, err)
}
}
function reconcile(at: WriteTarget | undefined, path: string) {
if (!at?.ws) return
markAgentWritten(at.ws, path)
if (!at.host) return
// The host graph resolves a linked agent's tool nodes from the resource, so it has to re-read
// what the write just changed. Every step of that flow linking this agent, not only the one
// the editor was opened from: they all show tools the write may have moved. Looked up under
// the path the editor opened, since a rename is about to move those steps off it.
const scope = linkedToolsScope(at.ws, at.host.flowPath)
const moduleIds = new Set(linkedModulesForAgent(scope, at.path))
moduleIds.add(at.host.moduleId)
return Promise.all(
// With the draft: a deploy leaves none, but a version restore leaves the draft standing and
// it is still what a test of the host step would run.
[...moduleIds].map((moduleId) => publishLinkedAgentTools(path, at.ws, scope, moduleId, true))
)
}
let deployingFor = $state<WriteTarget | undefined>(undefined)
async function onDeploy() {
saving = true
deployingFor = currentWriteTarget()
try {
await host?.deploy()
} finally {
saving = false
deployingFor = undefined
}
}
/** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename
* moves off the one the editor opened. */
async function onSaved(savedPath: string) {
const at = deployingFor ?? currentWriteTarget()
// Before the rename is announced: it finds the steps to refresh under the old path.
const reconciled = reconcile(at, savedPath)
// A new agent's first deploy is announced like a rename even at the same path: the surface
// has anchored nothing for it yet, and reopening drops `isNew` now that it exists.
if (at && (savedPath !== at.path || target?.isNew)) {
onRenamed?.(at.path, savedPath)
// The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it
// still shows the one deployed: it can be closed or pointed elsewhere mid-request.
if (target?.path === at.path) {
openAgentEditor({ path: savedPath, workspace: target.workspace, host: target.host })
}
}
await reconciled
onDeployed?.(savedPath)
}
</script>
{#if target}
{#key `${ws}:${target.path}`}
<!-- Bound rather than held open: the store is what decides whether this is mounted, so every
way out has to reach it. The close button and the backdrop only set `open`, and a
dialog left closed over a target still set could never be opened again.
`paginated` throughout: the body is one strip of pages (the agent form, then evals), so
the header keeps its height and only the pages move. -->
{#if layout === 'modal'}
<Modal
bind:open={() => true, (open) => !open && close()}
kind="X"
fillHeight
enterConfirms={false}
paginated
title={shownPath ?? target.path}
{trail}
{description}
{titleBadge}
{levelBadge}
{settings}
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
>
{@render body()}
</Modal>
{:else}
<div class="h-full min-h-0 flex flex-col">
<div class="flex items-center gap-2 px-4 py-2 border-b shrink-0">
<div class="min-w-0 flex flex-col">
<div class="flex items-center gap-1 min-w-0">
{#each trail as segment, i (i)}
{#if i > 0}
<ChevronRight size={14} class="text-tertiary shrink-0" />
{/if}
{#if segment.onclick}
<Button variant="subtle" unifiedSize="sm" onClick={segment.onclick}>
{segment.label}
</Button>
{:else}
<span class="text-sm font-semibold text-emphasis truncate">{segment.label}</span>
{/if}
{#if i === 0}
{@render titleBadge()}
{/if}
{/each}
{@render levelBadge()}
</div>
{#if description}
<span class="text-2xs text-tertiary truncate">{description}</span>
{/if}
</div>
<div class="grow"></div>
{@render settings()}
</div>
<div class="flex-1 min-h-0 px-4 sm:px-6">
{@render body()}
</div>
</div>
{/if}
{/key}
{#snippet titleBadge()}
<!-- Against the agent's own name wherever it appears, as the linked-agent card in the
step panel has it. -->
{#if version != undefined && !refused}
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
v{version}
</Badge>
{/if}
{/snippet}
{#snippet levelBadge()}
<!-- Marks evals, not the agent, so it sits against that level's own name. Dropped a
level deeper, where it would read as marking the run rather than the feature it
belongs to. -->
{#if inEvals && !evalsLocation}
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
{/if}
{/snippet}
<!-- Evals carries none of the editor's actions: nothing there edits the agent, and the run
dialog states for itself whether a run is against the deployed version or the edits. -->
{#snippet settings()}
<div class="flex flex-row items-center gap-2 shrink-0">
{#if !inEvals && !refused}
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
loaded and the pane has picked its first mode. -->
{#if testPane?.mode && !inSettings}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="sm"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="sm"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if readOnly}
<Badge color="gray" class="shrink-0" title="You do not have write access to this agent">
Read only
</Badge>
{/if}
{#if !inSettings}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Settings }}
iconOnly
title="Settings"
on:click={openSettings}
/>
{/if}
{#if canEvaluate}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FlaskConical }}
title="Run this agent against a dataset of cases"
on:click={openEvals}
>
Evals
</Button>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: History }}
iconOnly
title="Version history"
on:click={() => versionDrawer?.openDrawer()}
/>
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Save }}
loading={saving}
disabled={readOnly}
title={readOnly ? 'You do not have write access to this agent' : undefined}
on:click={onDeploy}
>
Deploy
</Button>
{/if}
</div>
{/snippet}
{#snippet body()}
<div class="h-full min-h-0 flex flex-col">
<!-- Full-bleed, as a banner is everywhere else: the body's own horizontal padding is
cancelled so it spans it. -->
<div class="-mx-4 sm:-mx-6 shrink-0 {inEvals ? 'hidden' : ''}">
<LocalDraftBanner
show={draft?.sync.hasDraft ?? false}
reserveSpace={draft?.sync.hasBaseline ?? false}
getDeployed={() => draft?.deployed}
getCurrent={() => draft?.state}
onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')}
title="Deployed <> Unsaved agent changes"
/>
</div>
<!-- One strip of pages, so a level slides in from the right the way evals' own levels do.
No `onNavigate`: the arrow keys belong to whichever pane is on screen, and evals answers
them for its own levels. Warmed, as those levels are: a page built on its first visit
lands inside the transition and arrives empty. The page layout has no levels: it opens
settings in a drawer and evals in a dialog, so warming them there would build them twice. -->
<PagedContent
warm
class="flex-1 min-h-0"
current={inEvals ? 'evals' : inSettings ? 'settings' : 'agent'}
pages={[
{ key: 'agent', content: agentPage },
...(layout === 'modal' ? [{ key: 'settings', content: settingsPage }] : []),
...(layout === 'modal' && canEvaluate ? [{ key: 'evals', content: evalsPage }] : [])
]}
/>
</div>
{/snippet}
<!-- Mounted under the evals level too: it holds the draft the header's banner and Deploy act on,
and the config a draft run is offered on. The target is read optionally: a deploy that
navigates away clears it while these are still rendered. -->
{#snippet agentPage()}
<AgentEditorHost
bind:this={host}
path={target?.path ?? ''}
workspace={ws}
{enableAi}
toolId={target?.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
{onSaved}
isNew={target?.isNew}
/>
{/snippet}
{#snippet settingsFields()}
{#if draft}
<AgentSettings
{draft}
path={target?.path ?? ''}
workspace={ws}
bind:error={() => host?.pathError(), (error) => host?.setPathError(error)}
/>
{/if}
{/snippet}
{#snippet settingsPage()}
<div class="max-w-2xl py-6">{@render settingsFields()}</div>
{/snippet}
{#snippet evalsPage()}
<EvalsPane
agentPath={target?.path ?? ''}
opWorkspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
bind:location={evalsLocation}
active={inEvals}
/>
{/snippet}
{#if layout === 'page' && ws}
<AgentEvalsModal
bind:this={evalsModal}
agentPath={target?.path ?? ''}
workspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
/>
{/if}
<Drawer bind:this={settingsDrawer} size="600px">
<DrawerContent title="Settings" on:close={() => settingsDrawer?.closeDrawer()}>
{@render settingsFields()}
</DrawerContent>
</Drawer>
<Drawer bind:this={versionDrawer} size="1200px">
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
<ResourceVersionHistory
path={target?.path ?? lastShownAgent ?? ''}
workspace={ws}
canRestore={!readOnly}
onRestore={() => {
versionDrawer?.closeDrawer()
// A restore writes the resource as a deploy does, so the flow behind has to be told
// the same way. Captured before closing, which drops the target this reads.
const at = currentWriteTarget()
// Close the editor too, as the generic resource editor does on a restore: it holds
// a baseline captured before the restore, and any local draft on top of it, so
// deploying from it afterwards would write the pre-restore value straight back over
// the version just restored.
close()
if (at) reconcileQuietly(at, at.path)
}}
/>
</DrawerContent>
</Drawer>
{/if}