feat(agents): mockup of agents as a standalone frontend kind

Frontend-only exploration: saved AI agents (`ai_agent` resources) appear as
their own kind, with pages of their own instead of the resources drawer.

- home page lists agents as a kind, with an Agents filter and a create entry
- /agents/edit/<path> hosts the existing agent editor with a Chat/Form toggle
- /agents/get/<path> is the deployed agent's page: chat or single-shot form
- /agent_chats lists every agent conversation, grouped by agent
- a new agent starts from a minted draft path, named in the editor itself

No backend change: a turn is still a preview of a one-step flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-22 09:05:36 +02:00
co-authored by Claude Opus 5
parent 96db2d8431
commit d4bd01ec04
25 changed files with 2113 additions and 105 deletions
@@ -53,12 +53,26 @@
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
{/if}
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate grow min-w-0 whitespace-nowrap text-left"
>
{item.displayName}
</p>
{#if item.subtitle}
<div class="flex flex-col grow min-w-0 text-left">
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate whitespace-nowrap"
>
{item.displayName}
</p>
<p title={item.subtitle} class="truncate whitespace-nowrap text-2xs text-secondary">
{item.subtitle}
</p>
</div>
{:else}
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate grow min-w-0 whitespace-nowrap text-left"
>
{item.displayName}
</p>
{/if}
{@render item.extra?.()}
{#if item.shortcut || item.selected || item.toggle !== undefined}
<!-- Single trailing group so `shortcut` and `selected` can coexist:
@@ -0,0 +1,201 @@
<script lang="ts">
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import DraftBadge from '$lib/components/DraftBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import { DraftService, ResourceService, type ListableResource } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { isDeployable } from '$lib/utils_deployable'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { createEventDispatcher } from 'svelte'
import { Boxes, FileUp, FolderInput, Pen, Shield, Trash } from 'lucide-svelte'
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
/**
* A home-page row for a saved agent. The agent is an `ai_agent` resource; the row links to the
* agent editor page rather than the resources drawer, and its menu carries the resource actions
* (permissions, move, deploy, delete) since that is what the item is underneath.
*/
interface Props {
agent: ListableResource & { canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
deploymentDrawer: DeployWorkspaceDrawer
deleteConfirmedCallback?: (() => void) | undefined
depth?: number
menuOpen?: boolean
showEditButton?: boolean
keyboardSelected?: boolean
}
let {
agent,
marked,
shareModal,
moveDrawer,
deploymentDrawer,
deleteConfirmedCallback = $bindable(),
depth = 0,
menuOpen = $bindable(false),
showEditButton = true,
keyboardSelected = false
}: Props = $props()
const dispatch = createEventDispatcher()
async function deleteAgent(path: string): Promise<void> {
try {
if (agent.draft_only) {
// Nothing deployed to delete: the row is the caller's own draft, which the draft
// endpoint discards on a null value.
await DraftService.updateDraft({
workspace: $workspaceStore!,
kind: 'resource',
path,
requestBody: { value: null, force: true }
})
} else {
await ResourceService.deleteResource({ workspace: $workspaceStore!, path })
}
sendUserToast(`Deleted agent ${path}`)
dispatch('change')
} catch (err) {
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
}
}
let editHref = $derived(`${base}/agents/edit/${agent.path}`)
// A draft-only agent has nothing deployed for a detail page to show, so it opens the editor.
let rowHref = $derived(agent.draft_only ? editHref : `${base}/agents/get/${agent.path}`)
</script>
<Row
href={rowHref}
kind="agent"
{keyboardSelected}
{marked}
path={agent.path}
summary={agent.is_draft ? `${agent.description || agent.path}*` : agent.description}
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
canFavorite={false}
{depth}
>
{#snippet badges()}
<Badge color="violet" baseClass="border border-violet-200 dark:border-violet-800">Agent</Badge>
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
<DraftBadge
is_draft={agent.is_draft}
draft_only={agent.draft_only}
currentUsername={$userStore?.username}
workspace={$workspaceStore ?? undefined}
itemKind="resource"
path={agent.path}
onMigrated={() => dispatch('change')}
/>
{#if agent.labels?.length}
<div class="flex items-center gap-0.5">
{#each agent.labels.slice(0, 3) as label (label)}
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
{/each}
{#if agent.labels.length > 3}
<Badge
color="blue"
small
class="px-1"
title={agent.labels
.slice(3)
.map((l) => 'Label: ' + l)
.join('\n')}>+{agent.labels.length - 3}</Badge
>
{/if}
</div>
{/if}
<InheritedLabels labels={agent.inherited_labels} />
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator && showEditButton && agent.canWrite}
<div>
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href={editHref}
>
Edit
</Button>
</div>
{/if}
</span>
<Dropdown
items={async () => {
const { path } = agent
const canEdit = agent.canWrite && showEditButton
return [
{
displayName: 'Open in resources',
icon: Boxes,
href: `${base}/resources#/resource/${path}`
},
{
displayName: 'Permissions',
icon: Shield,
disabled: !canEdit,
action: () => {
shareModal.openDrawer?.(path, 'resource')
}
},
{
displayName: 'Move/Rename',
icon: FolderInput,
disabled: !canEdit || Boolean(agent.draft_only),
action: () => {
moveDrawer.openDrawer(path, agent.description, 'resource')
}
},
...(!agent.ws_specific &&
!agent.draft_only &&
isDeployable('resource', path, await getDeployUiSettings())
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer.openDrawer(path, 'resource')
}
}
]
: []),
{
displayName: 'Delete',
icon: Trash,
type: 'delete' as const,
disabled: !canEdit,
action: (event) => {
if (event?.shiftKey) {
deleteAgent(path)
} else {
deleteConfirmedCallback = () => {
deleteAgent(path)
}
}
}
}
]
}}
on:open={() => {
menuOpen = true
}}
/>
{/snippet}
</Row>
@@ -39,6 +39,7 @@
| 'flow'
| 'app'
| 'raw_app'
| 'agent'
| 'resource'
| 'variable'
| 'resource_type'
@@ -8,6 +8,7 @@
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import {
Bot,
Boxes,
Calendar,
Code2,
@@ -29,6 +30,7 @@
| 'app'
| 'raw_app'
| 'raw_app_file'
| 'agent'
| 'resource'
| 'variable'
| 'resource_type'
@@ -99,6 +101,8 @@
<FileIcon name={path ?? ''} {size} />
{:else if effectiveKind === 'script'}
<Code2 {size} class="text-blue-500" />
{:else if effectiveKind === 'agent'}
<Bot {size} class="text-violet-500" />
{:else if effectiveKind === 'variable'}
<DollarSign {size} class="text-gray-400" />
{:else if effectiveKind === 'resource'}
@@ -0,0 +1,75 @@
import type { FlowModule, InputTransform, OpenFlow } from '$lib/gen'
import { agentStreamingEnabled } from './agentFormFields'
import type { AgentTool } from './agentToolUtils'
/** The id of the one step an agent chat flow holds. */
export const AGENT_CHAT_STEP_ID = '__wm_agent_root'
/** The step's configuration as a flow step carries it: the brain as input transforms, plus tools. */
export interface AgentStepShape {
input_transforms?: Record<string, InputTransform>
tools?: AgentTool[]
}
/**
* Whether the chat can run this agent. It keys the agent's history on the conversation, which
* the worker only reads back for `auto` memory: any other setting would show a transcript the
* model never sees.
*/
export function agentChatReady(step: AgentStepShape | undefined): boolean {
const memory = step?.input_transforms?.memory as { value?: { kind?: string } } | undefined
return memory?.value?.kind === 'auto'
}
/** `agentChatReady` over a saved agent's config, as a resource or draft holds it. */
export function agentArgsChatReady(args: Record<string, any> | undefined): boolean {
return (args?.memory as { kind?: string } | undefined)?.kind === 'auto'
}
export const AGENT_CHAT_BLOCKED_REASON = 'Set memory to auto to chat with this agent.'
/** Whether a turn streams, from the same settings a flow's agent step reads. */
export function agentChatStreams(step: AgentStepShape | undefined): boolean {
return agentStreamingEnabled(step as Record<string, any> | undefined)
}
/**
* An agent as the one-step chat flow a turn previews. The step reads the message and the
* attachments from the flow input, as a chat-enabled flow's agent step does, and the flow is
* marked chat-enabled so the server files the run under a conversation.
*/
export function agentChatFlow(step: AgentStepShape | undefined): OpenFlow {
const module: FlowModule = {
id: AGENT_CHAT_STEP_ID,
value: {
type: 'aiagent',
tools: Array.isArray(step?.tools) ? step.tools : [],
input_transforms: {
...(step?.input_transforms ?? {}),
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
}
} as any
}
return {
summary: '',
value: { modules: [module], chat_input_enabled: true } as OpenFlow['value'],
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
user_message: {
type: 'string',
description: 'The message sent to the agent as the user turn.'
},
user_attachments: {
type: 'array',
description: 'Images or PDFs sent with the message. Needs S3 storage on the workspace.',
items: { type: 'object', resourceType: 's3object' }
}
},
required: ['user_message'],
order: ['user_message', 'user_attachments']
}
}
}
@@ -6,6 +6,8 @@ import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { getUsernameForNamespace } from '$lib/userNamespace'
import { random_adj } from '$lib/components/random_positive_adjetive'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte'
import { logReusableAgentUsage } from './agentTelemetry'
@@ -83,6 +85,9 @@ export interface AgentResourceState {
resource_type?: string
labels?: string[]
wsSpecific: boolean
/** For an agent with nothing deployed, the path its first deploy creates. The draft itself
* stays at its storage path (`path`), as a new script's does, so renaming never moves it. */
draft_path?: string
}
/**
@@ -178,6 +183,9 @@ export interface AgentDraftOptions {
/** The `ai_agent` resource being edited. */
path: () => string | undefined
workspace: () => string | undefined
/** The path was minted for a new agent: a missing row is an empty agent to start from, not a
* load failure. Nothing is written until the first edit. */
isNew?: () => boolean
}
export interface AgentDraftHandle {
@@ -193,6 +201,8 @@ export interface AgentDraftHandle {
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
readonly refusal: string | undefined
readonly sync: TriggerDraftSync
/** Where the last successful deploy wrote: `draft_path` for a first deploy, else the path. */
readonly lastDeployedPath: string | undefined
/** Write the current state to the resource and drop the draft. */
deploy: () => Promise<boolean>
}
@@ -208,6 +218,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
let loading = $state(true)
let noDeployed = $state(false)
let canWriteResource = $state(true)
let lastDeployedPath = $state<string | undefined>(undefined)
/** Guards the load against a path that changed under a slow response. */
let loadedFor = $state<string | undefined>(undefined)
/** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that
@@ -323,10 +334,29 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
await sync.maybeRestore()
},
(err) => {
if (loadedFor !== key) return
// A minted path with no row yet is a new agent. Memory starts on `auto`: the
// editor's chat keys the history on the conversation and is off otherwise.
if (opts.isNew?.() && (err as { status?: number })?.status === 404) {
noDeployed = true
deployed = undefined
canWriteResource = true
state = {
path,
description: '',
args: { memory: { kind: 'auto', context_length: 10 } } as AIAgentConfig,
resource_type: 'ai_agent',
wsSpecific: false,
// The friendly temporary name a new script or flow starts with, under the
// author's own namespace; renamed from the editor's settings.
draft_path: `u/${getUsernameForNamespace()}/${random_adj()}_agent`
}
loading = false
return
}
// A failed load knows neither the resource's type nor its value, so it refuses:
// clearing `loading` alone would let the sync restore a persisted draft into a form
// that would then deploy over a resource nobody read.
if (loadedFor !== key) return
refuse(`Could not load agent ${path}: ${err}`)
}
)
@@ -342,12 +372,20 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
sendUserToast(refused, true)
return false
}
// A first deploy creates the agent where its author named it, not at the draft's storage
// path, which is a generated `draft_<uuid>` nobody would link to.
const target = noDeployed ? s.draft_path?.trim() : s.path
if (!target) {
sendUserToast('Pick a path for this agent before deploying.', true)
return false
}
// The form stays editable while the request is in flight, so everything below works from a
// snapshot taken now. Adopting the live state as `deployed` afterwards would count an edit
// made during the request as saved, and the banner would clear on a value the server never
// received; against the snapshot it stays a draft, which is what it is.
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
const written = await writeAgentResource(ws, submitted, noDeployed)
const firstDeploy = noDeployed
const written = await writeAgentResource(ws, { ...submitted, path: target }, noDeployed)
if (!written.ok) {
// A path that is no longer an agent tears this editor down; anything else is a plain error
// the user can retry from the form as it stands.
@@ -361,6 +399,14 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
// The counter the step card's write-back used to report, from the surface that now owns the
// write: a deploy here reaches every flow linking this agent.
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
lastDeployedPath = target
if (firstDeploy) {
// The agent now lives at `target`; the draft at the storage path has nothing left to
// describe, whatever was typed during the request.
sync.discard(opts.path()!, undefined)
sendUserToast(`Saved agent ${target}`)
return true
}
deployed = submitted
noDeployed = false
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
@@ -400,6 +446,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
get refusal() {
return refusal
},
get lastDeployedPath() {
return lastDeployedPath
},
sync,
deploy
}
@@ -31,8 +31,16 @@
} from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
import {
AGENT_CHAT_BLOCKED_REASON,
agentChatFlow,
agentChatReady,
agentChatStreams
} from '../agentChatFlow'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import { useAgentDraft } from '../agentDraft.svelte'
import { runFlowPreview } from '../utils.svelte'
import FlowChat from '../conversations/FlowChat.svelte'
interface Props {
/** The `ai_agent` resource being edited. */
@@ -44,6 +52,12 @@
onSelectTool?: (toolId: string | undefined) => void
/** Ran after a successful deploy, with the path actually written, which a rename can move. */
onSaved?: (path: string) => void | Promise<void>
/** Which way of trying the agent the right pane shows: a conversation, or the single-shot
* form for a run with explicit inputs. The chat needs `auto` memory
* (`agentArgsChatReady`); asked for without it, the pane says why instead. */
runPane?: 'chat' | 'form'
/** The path was minted for a new agent, so a missing row starts an empty one. */
isNew?: boolean
}
let {
@@ -52,7 +66,9 @@
enableAi = false,
toolId = undefined,
onSelectTool = undefined,
onSaved = undefined
onSaved = undefined,
runPane = 'chat',
isNew = false
}: Props = $props()
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
@@ -61,7 +77,11 @@
* schema to the root's. */
const AGENT_ID = '__wm_agent_root'
const draft = useAgentDraft({ path: () => path, workspace: () => workspace })
const draft = useAgentDraft({
path: () => path,
workspace: () => workspace,
isNew: () => isNew
})
/** Read access only. Everything that could write is blocked, down to the draft itself: an
* autosave the server rejects would look like a save and lose the edit. Running the agent,
@@ -291,6 +311,34 @@
let testIsLoading = $state(false)
let scriptProgress = $state(undefined)
let chatReady = $derived(agentChatReady(agentValue))
let useStreaming = $derived(agentChatStreams(agentValue))
/** The draft as the one-step chat flow a turn previews. */
function chatFlow(): OpenFlow {
return agentChatFlow(agentValue)
}
// What the chat reads of the flow shape (which inputs to promote, the composer's model
// control), derived once per draft change rather than rebuilt on every render.
let chatFlowShape = $derived(chatFlow())
async function runChatTurn(
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
): Promise<string | undefined> {
return await runFlowPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
chatFlow(),
path,
undefined,
conversationId,
undefined,
workspace
)
}
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
// tool it just created is the only place the click can land.
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
@@ -325,7 +373,7 @@
// The path this editor opened, not the draft's live one: `deploy` refuses a renaming draft,
// so the write always lands here, while the shared draft can be repointed by another tab
// mid-request and would send the reconciliation after a resource nobody wrote.
if (ok) await onSaved?.(path)
if (ok) await onSaved?.(draft.lastDeployedPath ?? path)
return ok
})
}
@@ -350,7 +398,7 @@
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
answer want opposite splits, and only the reader knows which they are on. -->
<Splitpanes class="h-full">
<Pane size={66} minSize={30}>
<Pane size={55} minSize={30}>
<div class="h-full min-h-0 overflow-auto">
<PropPickerWrapper
pickableProperties={stepPropPicker?.pickableProperties}
@@ -384,10 +432,33 @@
</Pane>
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<Pane size={34} minSize={20}>
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<Pane size={45} minSize={20}>
<!-- Which side shows is the page's choice, made in its header; the pane only fills
with it, so switching moves nothing here. -->
<div class="h-full min-h-0 flex flex-col">
{#if runPane === 'chat' && chatReady}
<!-- Test chats, as the flow editor's drawer files its own: each turn previews the
draft rather than the deployed agent. -->
<div class="flex-1 min-h-0 flex flex-col">
<FlowChat
{useStreaming}
onRunFlow={runChatTurn}
conversationKind="test"
frame="none"
{path}
identity={path}
inputSchema={chatFlowShape.schema}
flowModules={chatFlowShape.value.modules}
/>
</div>
{:else if runPane === 'chat'}
<div class="flex-1 flex items-center justify-center p-6 text-xs text-tertiary">
{AGENT_CHAT_BLOCKED_REASON}
</div>
{:else}
<!-- A column rather than a split: the inputs take the height they need, capped so
the result always has room, and the result gets the rest. -->
<div class="shrink-0 max-h-[45%] overflow-auto border-b">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
@@ -398,21 +469,21 @@
bind:scriptProgress
/>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</Pane>
</Splitpanes>
<div class="flex-1 min-h-0">
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
</div>
@@ -0,0 +1,164 @@
<script lang="ts">
import { setContext } from 'svelte'
import { writable } from 'svelte/store'
import type { Flow, FlowModule, Job, OpenFlow } from '$lib/gen'
import { emptySchema, type StateStore } from '$lib/utils'
import type { FlowInput } from '$lib/components/flows/types'
import type { FlowEditorContext, FlowInputEditorState, FlowPanelDetachContext } from '../types'
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
import { initFlowState, type FlowState } from '../flowState'
import { initHistory } from '$lib/history.svelte'
import { StepsInputArgs } from '../stepsInputArgs.svelte'
import { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import ModulePreview from '$lib/components/ModulePreview.svelte'
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import { flowLocalAgentSchema, type AIAgentConfig } from '../agentResourceUtils'
import { agentArgsToTransforms } from '../linkedAgentDrafts'
import { AGENT_EDITOR_RUN_INPUTS } from '../agentFormFields'
import type { AgentTool } from '../agentToolUtils'
/**
* Runs one agent configuration as a step test, the way the agent editor's test pane does, with
* nothing to edit around it: the config is whatever the caller hands over, deployed or not.
* The module test machinery reads a flow editor's context, so this builds a private one around
* a flow holding that single step.
*/
interface Props {
/** The path the preview runs under. Must be a real workspace path (`u/`, `f/`). */
path: string
workspace: string | undefined
config: AIAgentConfig
}
let { path, workspace, config }: Props = $props()
const AGENT_ID = '__wm_agent_root'
const flowStore = $state({
val: {
summary: '',
value: { modules: [] },
extra_perms: {},
schema: emptySchema()
} as OpenFlow
}) as StateStore<OpenFlow>
const flowStateStore = $state({ val: {} }) as StateStore<FlowState>
const previewArgs = $state({ val: {} })
const stepsInputArgs = new StepsInputArgs()
const modulesTestStates = new ModulesTestStates(() => {})
const selectionManager = new SelectionManager()
selectionManager.selectId(AGENT_ID)
const history = initHistory(flowStore.val)
const pathStore = writable(path)
setContext<FlowPanelDetachContext>('flowPanelDetach', {
claim: () => () => {},
modalOpen: () => false,
close: () => {},
enabled: () => false,
preference: () => 'auto',
setPreference: () => {}
})
setContext<FlowEditorContext>('FlowEditorContext', {
scriptEditorDrawer: writable(undefined),
workspaceScriptSettingsDrawer: writable(undefined),
flowEditorDrawer: writable(undefined),
initialPathStore: writable(''),
fakeInitialPath: '',
flowInputsStore: writable<FlowInput>({}),
customUi: {},
insertButtonOpen: writable(false),
executionCount: writable(0),
flowInputEditorState: writable<FlowInputEditorState>({
selectedTab: undefined,
editPanelSize: undefined,
payloadData: undefined
}),
currentEditor: writable(undefined),
outputPickerOpenFns: {},
preserveOnBehalfOf: writable(false),
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
flowStore,
flowStateStore,
previewArgs,
stepsInputArgs,
modulesTestStates,
selectionManager,
history,
pathStore,
opWorkspace: () => workspace,
agentEditorHost: () => path,
saveDraft: () => {}
} as FlowEditorContext)
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable(undefined),
inModalPanel: () => true
})
setContext<FlowCopilotContext>('FlowCopilotContext', {
shouldUpdatePropertyType: writable({}),
stepInputsLoading: writable(false),
generatedExprs: writable({}),
exprsToSet: writable({})
})
// The config as the one module of the private flow, built once: the caller keys this pane on
// what it hands over, so another config is another pane rather than a rebuild.
flowStore.val.value.modules = [
{
id: AGENT_ID,
value: {
type: 'aiagent',
tools: (Array.isArray(config.tools) ? config.tools : []) as AgentTool[] as any,
input_transforms: agentArgsToTransforms(config) as any
} as any
} satisfies FlowModule
]
initFlowState(flowStore.val as Flow, flowStateStore, workspace, path).catch((err) =>
console.error('agent run pane: could not infer tool schemas for', path, err)
)
let agentModule = $derived(flowStore.val.value.modules?.[0])
let schema = $derived(flowStateStore.val[AGENT_ID]?.schema ?? {})
let testJob: Job | undefined = $state(undefined)
let testIsLoading = $state(false)
let scriptProgress = $state(undefined)
</script>
{#if agentModule}
<!-- A column rather than a split: the inputs take the height they need, capped so the result
always has room, and the result gets the rest. -->
<div class="h-full min-h-0 flex flex-col">
<div class="shrink-0 max-h-[45%] overflow-auto border-b">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={undefined}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
<div class="flex-1 min-h-0">
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</div>
</div>
{/if}
@@ -0,0 +1,100 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
import type { SessionChatStatus } from '$lib/components/sessions/sessionRuntime.svelte'
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
import { PencilLine } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Item } from '$lib/utils'
/**
* One conversation in a list of them: the AI session sidebar's row shape, with the resting
* mark a chat needs (a test run or one of the deployed flow's). Shared by the chat panel's
* own sidebar and by any page that lists conversations beside a chat.
*/
interface Props {
title: string
status?: SessionChatStatus
isTest?: boolean
selected?: boolean
/** Why the row cannot be opened, when something stops it. Disables it and says why. */
lockedReason?: string
unread?: number
/** A message is waiting to be sent in this chat. */
queued?: boolean
/** Keeps the row's menu visible while one of its actions is under way. */
busy?: boolean
onSelect: () => void
/** The row's own actions, shown in a menu on hover. */
actions?: () => Item[]
}
let {
title,
status = 'idle',
isTest = false,
selected = false,
lockedReason = undefined,
unread = 0,
queued = false,
busy = false,
onSelect,
actions = undefined
}: Props = $props()
</script>
<Button
unifiedSize="md"
variant="subtle"
onClick={onSelect}
{selected}
disabled={!!lockedReason}
title={lockedReason}
btnClasses="transition-all duration-150 group gap-2"
>
<!-- Says what the chat is doing where there is something to say, and which kind of chat it
is otherwise. -->
<SessionStatusDot
{status}
isFork={false}
restingTitle={isTest ? 'Test chat, run from the flow editor' : 'Chat on the deployed flow'}
>
{#snippet resting()}
<span
class="w-[6px] h-[6px] rounded-full {isTest
? 'border border-gray-400 dark:border-gray-500'
: 'bg-gray-300 dark:bg-gray-600'}"
></span>
{/snippet}
</SessionStatusDot>
<span
class={twMerge('flex-1 text-left truncate', unread > 0 ? 'font-semibold text-primary' : '')}
>
{title}
</span>
{#if queued || unread > 0}
<span class="shrink-0 inline-flex items-center gap-1">
{#if queued}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Message waiting to send" />
{/if}
<UnreadCountBadge count={unread} />
</span>
{/if}
<!-- Hidden while the row is disabled: it sits inside the row's button, and a disabled button
swallows every click in its subtree, so a visible menu here would be an affordance that
does nothing. -->
{#if actions && !lockedReason}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
busy ? 'opacity-100' : ''
)}
onclick={(e) => e.stopPropagation()}
>
<DropdownV2 items={actions} size="xs" />
</div>
{/if}
</Button>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { Loader2, MessageSquare, SlidersHorizontal } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
@@ -39,6 +40,10 @@
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
/** Rendered above the composer — see `FlowChatProps`. */
inputPreface?: Snippet
/** Replaces this component's own empty-transcript prompt — see `FlowChatProps`. */
emptyHintOverride?: Snippet
}
let {
@@ -49,7 +54,9 @@
path,
identity = undefined,
description = undefined,
wideLayout = false
wideLayout = false,
inputPreface = undefined,
emptyHintOverride = undefined
}: Props = $props()
// Derive helperScript for dynamic inputs from schema
@@ -285,7 +292,7 @@
<div
class="flex flex-col h-full flex-1 min-w-0"
class:min-h-96={chatHost.displayMessages.length > 0}
class:min-h-64={chatHost.displayMessages.length === 0}
class:min-h-64={chatHost.displayMessages.length === 0 && !emptyHintOverride}
>
<AIChatDisplay
messages={chatHost.displayMessages}
@@ -298,7 +305,8 @@
hideHeader
hideModeSelector
{wideLayout}
{emptyHint}
emptyHint={emptyHintOverride ?? emptyHint}
{inputPreface}
footerSettings={modalSchema || modelWiring ? footerSettings : undefined}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress || !!modelGap || !!manager.wrongKindReason}
@@ -33,7 +33,11 @@
wideLayout = false,
frame = 'top',
conversationKind = 'deployed',
parallelTurns = false
parallelTurns = false,
conversationId = undefined,
hideSidebar = false,
inputPreface = undefined,
emptyHint = undefined
}: FlowChatProps = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -69,8 +73,16 @@
// conversation, and tracked, the first send of a fresh chat would select the conversation
// it just created and so abort its own turn. Waits for a workspace, which an embedded
// editor resolves from its session rather than having on the first run.
// A caller naming a conversation is followed instead, each time it names another one; `null`
// asks for a fresh one.
$effect(() => {
if (workspace) untrack(() => manager.selectLatestConversation())
if (!workspace) return
const chosen = conversationId
untrack(() => {
if (chosen === null) manager.createConversation({ clearMessages: true })
else if (chosen) manager.selectConversation(chosen)
else manager.selectLatestConversation()
})
})
// Ends whatever is still running when the panel goes away, which is the only thing that
@@ -107,7 +119,9 @@
<!-- The column's max width and side padding come from AIChatDisplay itself. -->
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
<FlowConversationsSidebar {manager} {description} />
{#if !hideSidebar}
<FlowConversationsSidebar {manager} {description} />
{/if}
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
the panel edge the way the session chat does, while the sidebar and the border
dividing it from the chat still reach the bottom. -->
@@ -121,6 +135,8 @@
{identity}
{description}
{wideLayout}
{inputPreface}
emptyHintOverride={emptyHint}
/>
</div>
</div>
@@ -11,11 +11,10 @@
PanelLeftOpen,
Info
} from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
import ConversationRow from './ConversationRow.svelte'
import type { SessionChatStatus } from '$lib/components/sessions/sessionRuntime.svelte'
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
import { PencilLine } from 'lucide-svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { emptyString, type Item } from '$lib/utils'
@@ -312,53 +311,20 @@
/>
</div>
{:else}
<Button
unifiedSize="md"
variant="subtle"
onClick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
<!-- The dot sits in the slot New chat's icon occupies above, so the column lines
up. -->
<ConversationRow
title={getConversationTitle(conversation)}
status={dotStatus(conversation.id)}
isTest={conversation.is_test}
selected={manager.selectedConversationId === conversation.id}
disabled={!!rowLocked(conversation)}
title={rowLocked(conversation)}
btnClasses="transition-all duration-150 group gap-2"
>
<!-- In the slot New chat's icon occupies above, so the column lines up. Says
what the chat is doing where there is something to say, and which kind of
chat it is otherwise. -->
{@render statusDot(conversation)}
{@const unread = manager.unreadCount(conversation.id)}
<span
class={twMerge(
'flex-1 text-left truncate',
unread > 0 ? 'font-semibold text-primary' : ''
)}
>
{getConversationTitle(conversation)}
</span>
{#if manager.conversationStatus(conversation.id) === 'queued' || unread > 0}
<span class="shrink-0 inline-flex items-center gap-1">
{#if manager.conversationStatus(conversation.id) === 'queued'}
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Message waiting to send" />
{/if}
<UnreadCountBadge count={unread} />
</span>
{/if}
<!-- Hidden while the row is disabled: it sits inside the row's button, and a
disabled button swallows every click in its subtree, so a visible menu
here would be an affordance that does nothing. -->
{#if !rowLocked(conversation)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
manager.deletingConversationId === conversation.id ? 'opacity-100' : ''
)}
onclick={(e) => e.stopPropagation()}
>
<DropdownV2 items={() => rowActions(conversation)} size="xs" />
</div>
{/if}
</Button>
lockedReason={rowLocked(conversation)}
unread={manager.unreadCount(conversation.id)}
queued={manager.conversationStatus(conversation.id) === 'queued'}
busy={manager.deletingConversationId === conversation.id}
onSelect={() => manager.selectConversation(conversation.id, conversation.isDraft)}
actions={() => rowActions(conversation)}
/>
{/if}
</div>
{/if}
@@ -1,3 +1,4 @@
import type { Snippet } from 'svelte'
import type { FlowModule } from '$lib/gen'
import type { ConversationKind } from './FlowChatManager.svelte'
@@ -41,6 +42,18 @@ export interface FlowChatProps {
/** Whether a turn may run while another chat's is still going. Off where one run
* owns the surface — the editor's panel shows it on the graph. */
parallelTurns?: boolean
/** Open on this conversation rather than on the latest one, or on a fresh one for `null`.
* Followed as it changes, so a list outside the panel can drive which chat it shows. */
conversationId?: string | null
/** Leave out the panel's own conversation list, for a surface that lists them itself and
* drives the chat through `conversationId`. */
hideSidebar?: boolean
/** Rendered above the composer, as the session chat's own preface is: what a turn will run
* against, before the first message fixes it. */
inputPreface?: Snippet
/** Replaces the empty transcript's prompt. An empty snippet leaves the composer at the top
* of the panel, as a new session has it. */
emptyHint?: Snippet
}
/**
@@ -8,6 +8,7 @@
import Tab from '$lib/components/common/tabs/Tab.svelte'
import {
Plus,
Bot,
Code2,
LayoutDashboard,
ChevronDown,
@@ -120,6 +121,21 @@
}
] as Option[])
: []),
{
key: 'agent',
label: 'AI agent',
icon: Bot,
accent: 'purple',
tagline: 'A reusable agent with tools',
description:
'Pick a model, write the system prompt and give the agent tools: scripts, flows, MCP servers or other agents. Test it from the editor and link it from any flow.',
bullets: [
'Any provider and model',
'Tools from scripts, flows and MCP',
'Shared across flows'
],
onSelect: () => goto(`${base}/agents/add`)
},
...(HOME_SHOW_CREATE_APP
? ([
{
+18 -2
View File
@@ -2,6 +2,7 @@
import AppRow from '../common/table/AppRow.svelte'
import FlowRow from '../common/table/FlowRow.svelte'
import RawAppRow from '../common/table/RawAppRow.svelte'
import AgentRow from '../common/table/AgentRow.svelte'
import ScriptRow from '../common/table/ScriptRow.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { Alert } from '$lib/components/common'
@@ -44,9 +45,10 @@
// A raw app the listing returns is an `app` row carrying `raw_app`, and is
// selectable like any other app. The separate `raw_app` type is the legacy
// listing shape, which renders through RawAppRow — no selection control there,
// so it must not enter the selection either.
// so it must not enter the selection either. An agent is a resource underneath,
// which none of the bulk actions handle.
let bulkItem = $derived(
homeSelection?.available && item.type !== 'raw_app'
homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent'
? toBulkItem(item, $userStore, $workspaceStore)
: undefined
)
@@ -132,6 +134,20 @@
bind:menuOpen
{keyboardSelected}
/>
{:else if item.type == 'agent'}
<AgentRow
bind:deleteConfirmedCallback
marked={item.marked}
on:change={() => dispatch('reload')}
agent={item}
{shareModal}
{moveDrawer}
{deploymentDrawer}
{depth}
bind:menuOpen
{showEditButton}
{keyboardSelected}
/>
{/if}
{#if menuOpen}
+113 -14
View File
@@ -10,8 +10,10 @@
type ListableApp,
type Script,
ScriptService,
ResourceService,
type Flow,
type ListableRawApp,
type ListableResource,
type RunnableItem
} from '$lib/gen'
import { resource } from 'runed'
@@ -20,6 +22,7 @@
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
Bot,
CheckSquare,
ChevronsDownUp,
ChevronsUpDown,
@@ -63,7 +66,7 @@
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
interface Props {
subtab?: 'flow' | 'script' | 'app'
subtab?: 'flow' | 'script' | 'app' | 'agent'
showEditButtons?: boolean
}
@@ -102,7 +105,8 @@
options: [
{ value: 'script', label: 'Script' },
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
{ value: 'app', label: 'App' }
{ value: 'app', label: 'App' },
{ value: 'agent', label: 'Agent' }
]
},
archived: { type: 'boolean' as const, label: 'Only archived' },
@@ -167,7 +171,15 @@
// depends on these (search, kind, archived, library, user-folder scope), so changing a
// searchbar chip reloads the server stream exactly as toggling the old controls did.
let filter = $derived((filterValues.val._default_ ?? '') as string)
let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
let itemKind = $derived(
(filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all'
)
// Agents are `ai_agent` resources, listed through the resource endpoint: the runnables
// endpoint has no such kind, so it is asked for nothing on the agent view and for every
// kind on the combined one.
let showsRunnables = $derived(itemKind !== 'agent')
let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent')
let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind)
let archived = $derived(!!filterValues.val.archived)
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
@@ -201,7 +213,7 @@
if (el) untrack(() => el.open())
})
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
@@ -219,6 +231,7 @@
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
// Folders that are data pipelines, surfaced as their own "Pipeline" entry
// (the member scripts are folded into it, not listed individually). Two
@@ -304,10 +317,14 @@
let flows: TableFlow[] | undefined = $state()
let apps: TableApp[] | undefined = $state()
let raw_apps: TableRawApp[] | undefined = $state()
// Starts empty rather than undefined: the runnables page decides when the list renders,
// and agents join it when their own request lands.
let agents: TableAgent[] = $state([])
let agentsGen = 0
// Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord).
let fetchOrd = 0
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp | TableAgent)[] = $state([])
let loading = $state(true)
@@ -348,6 +365,57 @@
return base as unknown as TableFlow | TableApp
}
/**
* The workspace's saved agents, as the kind the home page shows them as. Read from the
* resource listing, which pages nothing and orders nothing, so the order is applied here and
* the rows are stamped after every runnable ordinal: a page of runnables keeps the server's
* order and the agents follow it as one block.
*/
async function loadAgents(): Promise<void> {
const ws = $workspaceStore
const gen = ++agentsGen
if (!ws || !$userStore || !showsAgents || archived) {
agents = []
return
}
let rows: ListableResource[]
try {
rows = await ResourceService.listResource({
workspace: ws,
resourceType: 'ai_agent',
includeDraftOnly: true
})
} catch (e: any) {
if (gen !== agentsGen) return
sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true)
agents = []
return
}
if (gen !== agentsGen) return
const scoped = ownerFilter ? rows.filter((r) => r.path.startsWith(ownerFilter + '/')) : rows
const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime()
const sorted = [...scoped].sort((a, b) => {
switch (sortOrder) {
case 'updated_asc':
return byTime(a) - byTime(b)
case 'name_asc':
return cmp(a.path, b.path)
case 'name_desc':
return cmp(b.path, a.path)
default:
return byTime(b) - byTime(a)
}
})
agents = sorted.map((r, i) => ({
...r,
summary: r.description || undefined,
canWrite: canWrite(r.path, (r.extra_perms ?? {}) as any, $userStore) && !$userStore?.operator,
ord: AGENT_ORD_BASE + i
}))
}
/** Above any ordinal a runnables page can reach, so agents sort after them as a block. */
const AGENT_ORD_BASE = 1_000_000
// The merged, server-ordered, keyset-paginated source. `reset` reloads from
// the first page (order/filter change or workspace switch); otherwise it
// appends the next page. All three kinds arrive interleaved and are split into
@@ -364,6 +432,20 @@
// arrays (mixing streams) or clobber the pending reset's generation.
if (!reset && serverCursor === undefined) return
if (scripts === undefined) loading = true
// One request per reset: the resource listing is not paged, so a load-more has
// nothing to append for agents.
if (reset) void loadAgents()
if (!showsRunnables) {
scripts = []
flows = []
apps = []
raw_apps = []
pipelineMemberFolders = new Set()
serverCursor = undefined
hasMoreServer = false
loading = false
return
}
if (reset) {
serverCursor = undefined
hasMoreServer = false
@@ -386,7 +468,7 @@
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: itemKind !== 'all' ? itemKind : undefined,
kinds: runnableKinds,
// Selecting an owner/folder scopes the paged stream to it server-side,
// so a folder's full contents load on demand rather than relying on the
// folder happening to be within the loaded browse window.
@@ -599,7 +681,7 @@
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: itemKind !== 'all' ? itemKind : undefined,
kinds: runnableKinds,
pathStart: prefix,
includeDraftOnly: true,
perPage: OWNER_PAGE_SIZE,
@@ -715,7 +797,7 @@
}
function filterItemsPathsBaseOnUserFilters(
item: TableScript | TableFlow | TableApp | TableRawApp,
item: TableScript | TableFlow | TableApp | TableRawApp | TableAgent,
filterUserFolders: boolean,
filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined
) {
@@ -972,7 +1054,7 @@
try {
const res = await ScriptService.countRunnablesByOwner({
workspace: ws,
kinds: kind !== 'all' ? kind : undefined,
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
includeWithoutMain: withoutMain ? true : undefined,
// Same scope as the listing, so a badge counts the rows behind it.
includeDraftOnly: true
@@ -1160,7 +1242,8 @@
const kind = itemKind
// Any term/scope change restarts search paging.
searchCursor = undefined
if (term === '' || !ws || !$userStore) return
// Agents are all loaded, so the client-side search over them is already complete.
if (term === '' || !ws || !$userStore || kind === 'agent') return
const handle = setTimeout(async () => {
let res: { items: RunnableItem[]; next_cursor?: string }
try {
@@ -1216,7 +1299,7 @@
search: term,
showArchived: showArchived ? true : undefined,
includeWithoutMain: withoutMain ? true : undefined,
kinds: kind !== 'all' ? kind : undefined,
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
pathStart: owner ? owner + '/' : undefined,
includeDraftOnly: true,
perPage: 1000,
@@ -1267,6 +1350,11 @@
...x,
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime()
})),
...agents.map((x) => ({
...x,
type: 'agent' as 'agent',
time: new Date(x.edited_at ?? 0).getTime()
}))
].sort(compareItems)
)
@@ -1327,10 +1415,12 @@
// injected as a node and its rows come from the on-demand `treeOwnerItems` store,
// so the tree never depends on which owners happen to be in the loaded window.
// Otherwise (scoped/search/label) the tree just groups the global `items`.
// Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint,
// which never returns one, while the resource listing already holds them all.
let treeSource = $derived(
treeLazyMode
? treeOwnerItems.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter(
(x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
: items
)
@@ -1663,7 +1753,8 @@
homeSelection.active &&
selectedIndex >= 0 &&
selectedIndex < displayedItems.length &&
displayedItems[selectedIndex].type !== 'raw_app'
displayedItems[selectedIndex].type !== 'raw_app' &&
displayedItems[selectedIndex].type !== 'agent'
) {
e.preventDefault()
homeSelection.toggle(
@@ -1799,6 +1890,14 @@
size="md"
{item}
/>
<ToggleButton
value="agent"
label="Agents"
icon={Bot}
selectedColor="#8b5cf6"
size="md"
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
@@ -1,5 +1,5 @@
import type { ListableApp, Script, Flow, ListableRawApp } from '$lib/gen'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
@@ -14,8 +14,11 @@ type TableScript = TableItem<Script, 'script'>
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
/** A saved agent: an `ai_agent` resource the home page lists as a kind of its own. Its
* description stands in as the summary every other kind carries. */
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp | TableAgent
export interface FolderItem {
folderName: string
@@ -182,6 +185,8 @@ function getModifiedAt(item: ItemType): number {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'raw_app') {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'agent') {
return new Date(item.edited_at ?? 0).getTime() || 0
}
return 0
@@ -37,6 +37,7 @@
AlertCircle,
Database,
Pyramid,
MessagesSquare,
MailIcon,
ChevronDown,
ChevronRight
@@ -236,6 +237,13 @@
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
},
{
label: 'Agent chats',
href: `${base}/agent_chats`,
icon: MessagesSquare,
aiId: 'sidebar-menu-link-agent-chats',
aiDescription: 'Button to navigate to the conversations with the saved agents'
},
{
label: 'Folders',
href: `${base}/folders`,
+2
View File
@@ -1669,6 +1669,8 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
export type Item = {
displayName: string
/** A second, dimmer line under `displayName`, always shown (e.g. an item's path). */
subtitle?: string
action?: (e: MouseEvent) => void
icon?: any
iconColor?: string
@@ -41,7 +41,7 @@
let tab = $state<Tab>('workspace')
let subtab: 'flow' | 'script' | 'app' = $state('script')
let subtab: 'flow' | 'script' | 'app' | 'agent' = $state('script')
const searchParams = useSearchParams(z.object({ search: z.string().nullable() }))
const getFilter = () => searchParams.search ?? ''
@@ -0,0 +1,397 @@
<script lang="ts">
import { resource } from 'runed'
import { Bot, ChevronDown, ExternalLink, Pen, Plus, Trash2 } from 'lucide-svelte'
import { base } from '$lib/base'
import { Button, Skeleton } from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
import ConversationRow from '$lib/components/flows/conversations/ConversationRow.svelte'
import { runFlowPreview } from '$lib/components/flows/utils.svelte'
import { agentArgsToTransforms } from '$lib/components/flows/linkedAgentDrafts'
import {
AGENT_CHAT_BLOCKED_REASON,
agentArgsChatReady,
agentChatFlow,
agentChatReady,
agentChatStreams,
type AgentStepShape
} from '$lib/components/flows/agentChatFlow'
import type { AIAgentConfig } from '$lib/components/flows/agentResourceUtils'
import type { AgentTool } from '$lib/components/flows/agentToolUtils'
import {
FlowConversationsService,
ResourceService,
type FlowConversation,
type ListableResource
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
/**
* Every conversation with a saved agent, listed by agent the way the sidebar lists AI sessions
* by workspace, with the chat itself laid out as a session is: a title row, and the agent it
* talks to shown above the composer until the first message fixes it, then beside the title.
* The chat runs the deployed agent, as the agent's own page does; there is no form here.
*/
let ws = $derived($workspaceStore)
type AgentGroup = { agent: ListableResource; conversations: FlowConversation[] }
// Conversations are stored by flow path, and an agent's chats run under its resource path,
// so grouping is a join on that path. Every agent is kept for the picker; only those someone
// has talked to get a section.
let catalog = resource(
() => ws,
async (
ws
): Promise<
{ agents: ListableResource[]; groups: AgentGroup[]; chatReady: Set<string> } | undefined
> => {
if (!ws) return undefined
const [agents, conversations] = await Promise.all([
ResourceService.listResource({ workspace: ws, resourceType: 'ai_agent' }),
FlowConversationsService.listFlowConversations({ workspace: ws, kind: 'all', perPage: 100 })
])
// The list leaves a deployed row's value out, and whether an agent can chat is in it.
// Read per agent; a draft-only row carries its value and has no deployed one to read.
const values = await Promise.all(
agents.map((a) =>
a.value != null
? Promise.resolve(a.value)
: ResourceService.getResourceValue({ workspace: ws, path: a.path }).catch(
() => undefined
)
)
)
const chatReady = new Set(
agents
.filter((_, i) => agentArgsChatReady(values[i] as Record<string, any> | undefined))
.map((a) => a.path)
)
const byPath = new Map<string, FlowConversation[]>()
for (const c of conversations) {
byPath.set(c.flow_path, [...(byPath.get(c.flow_path) ?? []), c])
}
const sorted = [...agents].sort((a, b) => a.path.localeCompare(b.path))
return {
agents: sorted,
chatReady,
groups: sorted
.filter((agent) => byPath.has(agent.path))
.map((agent) => ({
agent,
conversations: (byPath.get(agent.path) ?? []).sort((a, b) =>
b.updated_at.localeCompare(a.updated_at)
)
}))
}
}
)
let agents = $derived(catalog.current?.agents ?? [])
/** The agents a new chat can talk to: their memory is `auto`, which the chat needs. */
let chatAgents = $derived(agents.filter((a) => catalog.current?.chatReady.has(a.path)))
let groups = $derived(catalog.current?.groups ?? [])
let selectedAgent = $state<string | undefined>(undefined)
let selectedConversation = $state<string | undefined>(undefined)
/** A chat being started. Its agent is picked above the composer, as a session's workspace
* is, and fixed by the first message. */
let newChat = $state(false)
let pickedAgent = $state<string | undefined>(undefined)
let chatStarted = $derived(newChat && selectedConversation !== undefined)
let currentAgent = $derived(newChat ? pickedAgent : (selectedAgent ?? groups[0]?.agent.path))
let currentResource = $derived(agents.find((a) => a.path === currentAgent))
let currentGroup = $derived(groups.find((g) => g.agent.path === currentAgent))
// With nothing picked, the agent's latest chat: what the panel opens on its own, named here
// so the title and the highlighted row agree with it.
let currentConversationId = $derived(
newChat ? selectedConversation : (selectedConversation ?? currentGroup?.conversations[0]?.id)
)
let currentConversation = $derived(
groups.flatMap((g) => g.conversations).find((c) => c.id === currentConversationId)
)
let title = $derived(
newChat && !chatStarted ? 'Untitled chat' : (currentConversation?.title ?? 'Untitled chat')
)
function open(agentPath: string, conversationId?: string) {
newChat = false
selectedAgent = agentPath
selectedConversation = conversationId
}
// Starts on the agent last looked at, as a session starts on the active workspace.
function startNewChat() {
// Read before the switch: once in a new chat, the current agent is the picked one.
const lastLookedAt = currentAgent
newChat = true
pickedAgent = chatAgents.find((a) => a.path === lastLookedAt)?.path ?? chatAgents[0]?.path
selectedConversation = undefined
}
// The deployed config of the agent being talked to, in the shape a flow step carries it.
let agentResource = resource(
() => ({ ws, path: currentAgent }),
async ({ ws, path }) => {
if (!ws || !path) return undefined
return await ResourceService.getResource({ workspace: ws, path })
}
)
let config = $derived((agentResource.current?.value ?? {}) as AIAgentConfig)
let chatStep = $derived<AgentStepShape>({
input_transforms: agentArgsToTransforms(config) as AgentStepShape['input_transforms'],
tools: (Array.isArray(config.tools) ? config.tools : []) as AgentTool[]
})
let chatReady = $derived(agentResource.current !== undefined && agentChatReady(chatStep))
let useStreaming = $derived(agentChatStreams(chatStep))
let chatFlowShape = $derived(agentChatFlow(chatStep))
async function runChatTurn(
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
): Promise<string | undefined> {
const path = currentAgent
if (!path) return undefined
const jobId = await runFlowPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
agentChatFlow(chatStep),
path,
undefined,
conversationId,
undefined,
ws
)
// A first turn creates the conversation this list does not have yet. The chat keeps its
// own state; only the list's highlight follows it, so nothing reloads under the turn.
selectedAgent = path
selectedConversation = conversationId
void catalog.refetch()
return jobId
}
function conversationTitle(conversation: FlowConversation): string {
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
}
let deletingId = $state<string | undefined>(undefined)
async function renameConversation(conversation: FlowConversation) {
const title = window.prompt('Chat name', conversationTitle(conversation))
if (!ws || title == null) return
await FlowConversationsService.updateFlowConversation({
workspace: ws,
conversationId: conversation.id,
requestBody: { title }
})
void catalog.refetch()
}
async function deleteConversation(conversation: FlowConversation) {
if (!ws) return
deletingId = conversation.id
try {
await FlowConversationsService.deleteFlowConversation({
workspace: ws,
conversationId: conversation.id
})
if (selectedConversation === conversation.id) selectedConversation = undefined
await catalog.refetch()
} catch (err) {
sendUserToast(`Could not delete the chat: ${err}`, true)
} finally {
deletingId = undefined
}
}
function rowActions(conversation: FlowConversation): Item[] {
return [
{ displayName: 'Rename', icon: Pen, action: () => renameConversation(conversation) },
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
disabled: deletingId === conversation.id,
action: () => deleteConversation(conversation)
}
]
}
let agentMenu = $derived(
chatAgents.map((a) => ({
displayName: a.description || a.path,
// The summary alone does not tell two agents apart ("AI judge" is common); the path does.
subtitle: a.description ? a.path : undefined,
action: () => (pickedAgent = a.path)
}))
)
</script>
<!-- The chip naming the agent, as the workspace chip names a session's workspace: a picker
before the first message, inert once the chat has one. -->
{#snippet agentChip(interactive: boolean)}
<Button
variant="subtle"
unifiedSize="xs"
title={currentResource?.path}
tabindex={interactive ? undefined : -1}
endIcon={interactive ? { icon: ChevronDown } : undefined}
btnClasses="min-w-0 rounded-md text-2xs justify-start bg-surface-secondary {interactive
? ''
: 'cursor-default active:opacity-100 hover:bg-surface-secondary'}"
wrapperClasses="max-w-[16rem]"
>
<span class="truncate min-w-0 flex-1 text-left">
{currentResource?.description || currentResource?.path || 'Pick an agent'}
</span>
</Button>
{/snippet}
<!-- Nothing where the flow chat would prompt to start: the composer says it, as a session's. -->
{#snippet noHint()}{/snippet}
{#snippet talkingTo()}
<div class="flex flex-row items-center gap-1 py-0.5 px-1 text-2xs text-secondary">
<span class="shrink-0">Talking to</span>
<DropdownV2 fixedHeight={false} placement="bottom-start" enableFlyTransition items={agentMenu}>
{#snippet buttonReplacement()}
{@render agentChip(true)}
{/snippet}
</DropdownV2>
</div>
{/snippet}
<main class="h-screen w-full flex min-h-0">
<!-- Laid out as the sidebar's session picker is: the new-item button on top, then a plain
section title per agent with its chats as text rows, groups spaced apart. -->
<aside class="w-64 shrink-0 border-r flex flex-col min-h-0">
<div class="flex flex-col gap-1 px-2 pt-3 pb-2 shrink-0">
<MenuButton
stopPropagationOnClick={true}
on:click={startNewChat}
isCollapsed={false}
icon={Plus}
label="New chat"
class="!text-xs"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5 px-2 pt-2 pb-3">
{#if catalog.loading && groups.length === 0}
<div class="p-2"><Skeleton layout={[[1], 0.5, [1], 0.5, [1]]} /></div>
{:else if groups.length === 0}
<div class="p-2 text-xs text-tertiary">No conversation yet.</div>
{:else}
{#each groups as group, groupIdx (group.agent.path)}
<!-- The session picker's group header, as a link to the agent: it looks the same at
rest and only answers the pointer. -->
<div
class="flex flex-row items-center gap-1 pl-1 pr-0.5 pt-2 pb-1 min-w-0 {groupIdx > 0
? 'mt-4'
: ''}"
>
<a
href="{base}/agents/get/{group.agent.path}"
title="Open {group.agent.path}"
class="group/title flex items-center gap-1.5 min-w-0 text-secondary text-xs hover:text-primary"
>
<Bot size={14} class="shrink-0" />
<span class="truncate group-hover/title:underline">
{group.agent.description || group.agent.path}
</span>
</a>
</div>
{#each group.conversations as conversation (conversation.id)}
<div class="pl-1">
<ConversationRow
title={conversationTitle(conversation)}
isTest={conversation.is_test}
selected={currentConversationId === conversation.id}
busy={deletingId === conversation.id}
onSelect={() => open(group.agent.path, conversation.id)}
actions={() => rowActions(conversation)}
/>
</div>
{/each}
{/each}
{/if}
</div>
</aside>
<section class="flex-1 min-w-0 flex flex-col min-h-0 pb-2">
{#if currentResource}
<header class="flex flex-row items-center gap-1 pl-4 pr-4 py-2 shrink-0">
<span class="text-sm font-semibold text-primary truncate">{title}</span>
{#if !newChat || chatStarted}
<!-- Fixed by the first message, so it moves up beside the title as a session's
workspace does. -->
<div class="flex items-center gap-1 min-w-0 ml-2 text-2xs text-tertiary">
<span class="shrink-0">Talking to</span>
{@render agentChip(false)}
</div>
{/if}
<div class="grow"></div>
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: ExternalLink }}
title="Open agent"
href="{base}/agents/get/{currentResource.path}"
/>
</header>
<div class="flex-1 min-h-0 w-full flex flex-col {newChat && !chatStarted ? 'pt-8' : ''}">
{#if chatReady}
<!-- Before the first message the chat takes only the height its composer needs, so
it sits at the top as a new session's does, with the agent's description under
it rather than a prompt to start. -->
{#key `${newChat}:${currentResource.path}`}
<div
class={newChat && !chatStarted
? 'shrink-0 flex flex-col'
: 'flex-1 min-h-0 flex flex-col'}
>
<FlowChat
{useStreaming}
onRunFlow={runChatTurn}
conversationKind="test"
frame="none"
wideLayout
path={currentResource.path}
identity={currentResource.path}
description={currentResource.description}
inputSchema={chatFlowShape.schema}
flowModules={chatFlowShape.value.modules}
conversationId={newChat ? null : currentConversationId}
inputPreface={newChat && !chatStarted ? talkingTo : undefined}
emptyHint={noHint}
hideSidebar
/>
</div>
{/key}
{#if newChat && !chatStarted && currentResource.description}
<div class="w-full max-w-3xl mx-auto px-7 pt-4 text-xs text-tertiary">
<GfmMarkdown md={currentResource.description} noPadding prose="sm" />
</div>
{/if}
{:else if agentResource.current !== undefined}
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-6">
{#if newChat && !chatStarted}
{@render talkingTo()}
{/if}
<span class="text-xs text-tertiary">{AGENT_CHAT_BLOCKED_REASON}</span>
</div>
{/if}
</div>
{:else if !catalog.loading && (newChat || agents.length === 0)}
<div class="flex-1 flex items-center justify-center text-xs text-tertiary">
{agents.length === 0
? 'No saved agent in this workspace. Create one from the home page to chat with it.'
: 'No agent can chat yet: set its memory to auto from its editor.'}
</div>
{/if}
</section>
</main>
@@ -0,0 +1,5 @@
<!--
Never rendered — the sibling +page.ts redirects at the load phase, before
this component can mount. Kept as an empty file because SvelteKit only
registers a route when a +page.svelte exists.
-->
@@ -0,0 +1,6 @@
import { makeDraftAddLoad } from '$lib/draftAddRedirect'
import type { PageLoad } from './$types'
export const prerender = false
export const load: PageLoad = makeDraftAddLoad('agents/edit')
@@ -0,0 +1,356 @@
<script lang="ts">
import { page } from '$app/state'
import { resource } from 'runed'
import {
ArrowLeft,
Bot,
Braces,
FormInput,
History,
MessageSquare,
Pencil,
Save,
Settings
} from 'lucide-svelte'
import EditableInput from '$lib/components/common/EditableInput.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Path from '$lib/components/Path.svelte'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import {
AGENT_CHAT_BLOCKED_REASON,
agentArgsChatReady
} from '$lib/components/flows/agentChatFlow'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import { agentWriteCount, markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte'
import { ResourceService } from '$lib/gen'
import { copilotInfo } from '$lib/aiStore'
import { workspaceStore } from '$lib/stores'
/**
* The standalone agent editor: the same host the flow step's dialog and the resources page
* open, on a page of its own. It edits the `ai_agent` resource at the route's path through
* the per-user resource draft and runs it from the host's test pane.
*/
let path = $derived(page.params.path ?? '')
let ws = $derived($workspaceStore)
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
let toolId = $state<string | undefined>(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let saving = $state(false)
// A version restore writes the resource behind the host, which still holds the baseline it
// loaded and any draft over it; deploying from that would write the pre-restore value back.
// Remounting reloads both, as the dialog form of this editor does by closing.
let restores = $state(0)
// Counted per agent so a deploy refetches the version it just minted.
let writes = $derived(agentWriteCount(ws, path))
let versionResource = resource(
() => ({ ws, 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 }
}
)
let version = $derived.by(() => {
const loaded = versionResource.current
return loaded !== undefined &&
loaded.ws === ws &&
loaded.path === path &&
loaded.writes === writes
? loaded.version
: undefined
})
let draft = $derived(host?.draftHandle())
let refused = $derived(draft?.refusal != null)
let readOnly = $derived(draft ? !draft.canWrite : false)
let draftOnly = $derived(draft?.noDeployed ?? false)
/** Chat first; the form stays for a run with explicit inputs. Chat needs `auto` memory,
* read off the draft so the toggle follows the memory field as it is edited. */
let runPane = $state<'chat' | 'form'>('chat')
let chatReady = $derived(agentArgsChatReady(draft?.state?.args))
/** Minted by `/agents/add`, as `/scripts/add` mints a script's: the editor starts empty. */
let isNew = $derived(page.url.searchParams.get('new_draft') === 'true')
// A draft-only agent is shown at the path its first deploy will create, not at its storage path.
let shownPath = $derived(draftOnly ? (draft?.state?.draft_path ?? '') : path)
let pathEditable = $derived(draftOnly && !readOnly && !!draft?.state)
let settingsOpen = $state(false)
// A deploy lands on the deployed agent's page, as a flow's does: what was just written is what
// that page shows and runs.
async function onDeploy() {
saving = true
let deployed = false
try {
deployed = (await host?.deploy()) ?? false
} finally {
saving = false
}
if (deployed) await goto(`${base}/agents/get/${draft?.lastDeployedPath ?? path}`)
}
function onSaved(savedPath: string) {
if (ws) markAgentWritten(ws, savedPath)
}
</script>
<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">
<Button
href="{base}/"
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ArrowLeft }}
title="Back to home"
/>
<Bot size={18} class="text-violet-500 shrink-0" />
<div class="flex items-center gap-2 min-w-0">
<!-- Laid out as the script and flow editors' header is: the path above with its pen, the
summary line editable in place. -->
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight min-w-0">
<div class="flex items-center max-w-full text-2xs text-secondary font-mono min-w-0">
<span class="truncate">{shownPath}</span>
<!-- A deployed agent is renamed with Move, which keeps the flows linking it in view;
only a first deploy picks its path here. -->
{#if pathEditable}
<Popover
placement="bottom-start"
contentClasses="p-4"
usePointerDownOutside
disableFocusTrap
closeOnOtherPopoverOpen
>
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="xs"
iconOnly
startIcon={{ icon: Pencil }}
title="Edit path"
aria-label="Edit path"
btnClasses="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
/>
{/snippet}
{#snippet content()}
<div class="w-[480px]">{@render pathField(true)}</div>
{/snippet}
</Popover>
{/if}
</div>
<div class="max-w-full -mt-[2px]" title={draft?.state?.description}>
<EditableInput
value={draft?.state?.description ?? ''}
placeholder="Add a summary..."
editable={!readOnly && !!draft?.state}
commitOnInput
size="sm"
onSave={(v) => {
if (draft?.state) draft.state.description = v.trim()
}}
textClass="text-xs font-semibold text-emphasis leading-tight"
class="max-w-full"
/>
</div>
</div>
{#if version != undefined && !refused}
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
v{version}
</Badge>
{/if}
<!-- The draft autosave state, as the flow and script editors show it. -->
{#if ws && !refused}
<AutosaveIndicator
workspace={ws}
itemKind="resource"
{path}
{draftOnly}
onResetToDeployed={() => draft?.sync.resetToDeployed(path)}
/>
{/if}
{#if readOnly}
<Badge color="gray" class="shrink-0" title="You do not have write access to this agent">
Read only
</Badge>
{/if}
</div>
<span class="text-xs text-tertiary hidden xl:inline truncate">
Changes here update the saved agent, and every flow that links to it.
</span>
<div class="grow"></div>
{#if !refused}
<ToggleButtonGroup
selected={runPane}
onSelected={(v) => (runPane = v as 'chat' | 'form')}
noWFull
>
{#snippet children({ item })}
<ToggleButton
value="chat"
label="Chat"
icon={MessageSquare}
size="md"
disabled={!chatReady}
tooltip={chatReady ? undefined : AGENT_CHAT_BLOCKED_REASON}
{item}
/>
<ToggleButton value="form" label="Form" icon={FormInput} size="md" {item} />
{/snippet}
</ToggleButtonGroup>
<div class="grow"></div>
<!-- Nothing deployed means no resource to open as JSON and no history to list. -->
{#if !draftOnly}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Braces }}
iconOnly
title="Edit as JSON in the resources page"
href="{base}/resources#/resource/{path}"
/>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: History }}
iconOnly
title="Version history"
on:click={() => versionDrawer?.openDrawer()}
/>
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Settings }}
title="Settings"
on:click={() => (settingsOpen = true)}
>
Settings
</Button>
<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>
<div class="shrink-0">
<LocalDraftBanner
show={draft?.sync.hasDraft ?? false}
reserveSpace={false}
getDeployed={() => draft?.deployed}
getCurrent={() => draft?.state}
onDiscard={() => draft?.sync.resetToDeployed(path)}
title="Deployed <> Unsaved agent changes"
/>
</div>
<div class="flex-1 min-h-0">
{#key `${ws}:${path}:${restores}`}
<AgentEditorHost
bind:this={host}
{path}
workspace={ws}
enableAi={$copilotInfo.enabled}
{toolId}
onSelectTool={(id) => (toolId = id)}
{onSaved}
{runPane}
{isNew}
/>
{/key}
</div>
</div>
<!-- The path field, shared by the header's pen and the settings drawer. A deployed agent's path
is shown, not edited: renaming it is Move, from its page. -->
{#snippet pathField(autofocus: boolean)}
{#if pathEditable}
<Path
{autofocus}
bind:path={
() => draft?.state?.draft_path ?? '',
(v) => {
if (draft?.state) draft.state.draft_path = v
}
}
initialPath={draft?.state?.draft_path ?? ''}
namePlaceholder="agent"
kind="resource"
size="sm"
drawerOffset={4000}
/>
{:else}
<span class="text-xs font-mono text-secondary">{shownPath}</span>
<p class="text-2xs text-tertiary mt-1">
Rename a deployed agent with Move, from its page, so the flows linking it follow.
</p>
{/if}
{/snippet}
<!-- Laid out as the script editor's settings: summary, path, then what the item is for. -->
<Drawer placement="right" bind:open={settingsOpen} size="800px">
<DrawerContent title="Settings" on:close={() => (settingsOpen = false)}>
<Section label="Metadata">
<div class="flex flex-col gap-6">
<Label label="Summary">
<TextInput
bind:value={
() => draft?.state?.description ?? '',
(v) => {
if (draft?.state) draft.state.description = v
}
}
disabled={readOnly || !draft?.state}
inputProps={{ placeholder: 'Short summary to be displayed when listed' }}
/>
</Label>
<Label label="Path">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path"
>
The unique identifier of the agent in the workspace that defines permissions
</Tooltip>
{/snippet}
{@render pathField(false)}
</Label>
</div>
</Section>
</DrawerContent>
</Drawer>
<Drawer bind:this={versionDrawer} size="1200px">
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
<ResourceVersionHistory
{path}
workspace={ws}
canRestore={!readOnly}
onRestore={() => {
versionDrawer?.closeDrawer()
if (ws) markAgentWritten(ws, path)
restores++
}}
/>
</DrawerContent>
</Drawer>
@@ -0,0 +1,416 @@
<script lang="ts">
import { page } from '$app/state'
import { resource } from 'runed'
import { get } from 'svelte/store'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import { Boxes, Bot, FileUp, FolderInput, History, Pen, Shield, Trash } from 'lucide-svelte'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import {
Alert,
Badge,
Button,
Drawer,
DrawerContent,
Tab,
TabContent,
Tabs
} from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import AgentRunPane from '$lib/components/flows/content/AgentRunPane.svelte'
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { FormInput, MessageSquare } from 'lucide-svelte'
import { runFlowPreview } from '$lib/components/flows/utils.svelte'
import { agentArgsToTransforms } from '$lib/components/flows/linkedAgentDrafts'
import {
AGENT_CHAT_BLOCKED_REASON,
agentChatFlow,
agentChatReady,
agentChatStreams,
type AgentStepShape
} from '$lib/components/flows/agentChatFlow'
import { agentWriteCount, markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte'
import {
AGENT_BRAIN_LABELS,
agentEditorRefusal,
summarizeAgentBrain,
type AIAgentConfig
} from '$lib/components/flows/agentResourceUtils'
import { toolDisplayName, type AgentTool } from '$lib/components/flows/agentToolUtils'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { ResourceService, type Resource } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { canWrite } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
/**
* The deployed agent, as the flow and script pages show theirs: what it is, and a form to run
* it. Everything here reads the deployed resource; the editor page is where the draft lives.
*/
let path = $derived(page.params.path ?? '')
let ws = $derived($workspaceStore)
let writes = $derived(agentWriteCount(ws, path))
let agentResource = resource(
() => ({ ws, path, writes }),
async ({ ws, path }) => {
if (!ws || !path) return undefined
const [r, history, deployUi] = await Promise.all([
ResourceService.getResource({ workspace: ws, path }),
ResourceService.getResourceHistory({ workspace: ws, path }).catch(() => undefined),
getDeployUiSettings()
])
return {
resource: r,
version: history?.versions?.[0]?.version,
deployable: isDeployable('resource', path, deployUi)
}
}
)
let loaded = $derived(agentResource.current)
let agent = $derived(loaded?.resource as Resource | undefined)
let refusal = $derived(agent ? agentEditorRefusal(path, agent.resource_type) : undefined)
let config = $derived((agent?.value ?? {}) as AIAgentConfig)
let brain = $derived(summarizeAgentBrain(config))
let tools = $derived(Array.isArray(config.tools) ? (config.tools as AgentTool[]) : [])
let systemPrompt = $derived(
typeof config.system_prompt === 'string' ? config.system_prompt : undefined
)
let can_write = $derived(
agent ? canWrite(agent.path, agent.extra_perms ?? {}, get(userStore) ?? undefined) : false
)
let loadError = $derived(agentResource.error ? String(agentResource.error) : undefined)
let rightTab = $state<'agent' | 'export'>('agent')
/** Chat first, as in the editor; the form stays for a run with explicit inputs. */
let leftPane = $state<'chat' | 'form'>('chat')
// The deployed config in the shape a flow step carries it, which is what the chat runs.
let chatStep = $derived<AgentStepShape>({
input_transforms: agentArgsToTransforms(config) as AgentStepShape['input_transforms'],
tools
})
let chatReady = $derived(agentChatReady(chatStep))
let chatBlockedReason = $derived(chatReady ? undefined : AGENT_CHAT_BLOCKED_REASON)
let useStreaming = $derived(agentChatStreams(chatStep))
let chatFlowShape = $derived(agentChatFlow(chatStep))
// A turn previews the deployed config under the agent's path. There is no deployed run for an
// agent on the server, so these conversations are filed as the preview files them: test ones,
// shared with the editor's list.
async function runChatTurn(
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
): Promise<string | undefined> {
return await runFlowPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
agentChatFlow(chatStep),
path,
undefined,
conversationId,
undefined,
ws
)
}
/** What kind of tool a roster entry is: an MCP server, a web search, a script, a flow... */
function toolKind(tool: AgentTool): string {
const value = tool?.value as Record<string, any> | undefined
return value?.tool_type ?? value?.type ?? 'tool'
}
let shareModal: ShareModal | undefined = $state(undefined)
let moveDrawer: MoveDrawer | undefined = $state(undefined)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let versionDrawer: Drawer | undefined = $state(undefined)
let deleteOpen = $state(false)
async function deleteAgent() {
if (!ws) return
try {
await ResourceService.deleteResource({ workspace: ws, path })
sendUserToast(`Deleted agent ${path}`)
await goto(`${base}/?kind=agent`)
} catch (err) {
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
}
}
let menuItems = $derived.by(() => {
if (!agent || $userStore?.operator) return []
return [
{
displayName: 'Permissions',
icon: Shield,
disabled: !can_write,
action: () => shareModal?.openDrawer?.(path, 'resource')
},
{
displayName: 'Move/Rename',
icon: FolderInput,
disabled: !can_write,
action: () => moveDrawer?.openDrawer(path, agent?.description, 'resource')
},
{
displayName: 'Version history',
icon: History,
action: () => versionDrawer?.openDrawer()
},
{
displayName: 'Open in resources',
icon: Boxes,
href: `${base}/resources#/resource/${path}`
},
...(loaded?.deployable && !agent.ws_specific
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => deploymentDrawer?.openDrawer(path, 'resource')
}
]
: []),
{
displayName: 'Delete',
icon: Trash,
type: 'delete' as const,
disabled: !can_write,
action: () => (deleteOpen = true)
}
]
})
</script>
<ShareModal bind:this={shareModal} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<MoveDrawer
bind:this={moveDrawer}
on:update={async (e) => {
await goto(`${base}/agents/get/${e.detail}?workspace=${ws}`)
}}
/>
<ConfirmationModal
open={deleteOpen}
title="Delete agent"
confirmationText="Delete"
trashbin
on:canceled={() => (deleteOpen = false)}
on:confirmed={() => {
deleteOpen = false
deleteAgent()
}}
>
<span>Every flow linking {path} will fail at its agent step once it is gone.</span>
</ConfirmationModal>
<Drawer bind:this={versionDrawer} size="1200px">
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
<ResourceVersionHistory
{path}
workspace={ws}
canRestore={can_write}
onRestore={() => {
versionDrawer?.closeDrawer()
if (ws) markAgentWritten(ws, path)
}}
/>
</DrawerContent>
</Drawer>
<main class="h-screen w-full flex flex-col">
<div class="border-b">
<div
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12"
>
<div class="grow px-4 inline-flex items-center gap-3 min-w-0">
<Bot size={20} class="text-violet-500 shrink-0" />
<div class="min-w-0 flex flex-col">
<span class="text-sm font-semibold text-emphasis truncate">
{agent?.description || path}
</span>
{#if agent?.description}
<span class="text-2xs text-tertiary truncate">{path}</span>
{/if}
</div>
{#if loaded?.version != undefined}
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
v{loaded.version}
</Badge>
{/if}
{#if agent?.labels?.length}
<div class="hidden md:flex items-center gap-0.5">
{#each agent.labels as label (label)}
<Badge color="blue" small class="px-1">{label}</Badge>
{/each}
</div>
{/if}
<InheritedLabels labels={agent?.inherited_labels} />
</div>
{#if agent && !refusal}
<ToggleButtonGroup
selected={leftPane}
onSelected={(v) => (leftPane = v as 'chat' | 'form')}
noWFull
>
{#snippet children({ item })}
<ToggleButton
value="chat"
label="Chat"
icon={MessageSquare}
size="md"
disabled={!chatReady}
tooltip={chatBlockedReason}
{item}
/>
<ToggleButton value="form" label="Form" icon={FormInput} size="md" {item} />
{/snippet}
</ToggleButtonGroup>
<div class="grow"></div>
{/if}
<div class="flex gap-1 items-center pr-4">
{#if menuItems.length > 0}
<DropdownV2 items={menuItems} placement="bottom-end" size="md" />
{/if}
{#if can_write && !$userStore?.operator}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/agents/edit/{path}"
>
Edit
</Button>
{/if}
</div>
</div>
</div>
{#if loadError}
<div class="p-6 max-w-lg">
<Alert type="error" title="Could not load agent">{loadError}</Alert>
</div>
{:else if refusal}
<div class="p-6 max-w-lg">
<Alert type="error" title="Not an agent">{refusal}</Alert>
</div>
{:else if agent}
<div class="grow min-h-0 w-full">
<Splitpanes>
<Pane size={65} minSize={40}>
<div class="h-full flex flex-col min-h-0">
<!-- In chat the description belongs to the chat, which shows it under the empty
transcript and in the sidebar. -->
{#if agent.description && leftPane === 'form'}
<div class="px-4 pt-4 shrink-0">
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown md={agent.description} noPadding />
</div>
</div>
{/if}
<div class="grow min-h-0 flex flex-col">
{#if leftPane === 'chat' && chatReady}
<FlowChat
{useStreaming}
onRunFlow={runChatTurn}
conversationKind="test"
frame="none"
{path}
identity={path}
description={agent.description}
inputSchema={chatFlowShape.schema}
flowModules={chatFlowShape.value.modules}
/>
{:else if leftPane === 'chat'}
<div class="flex-1 flex items-center justify-center p-6 text-xs text-tertiary">
{chatBlockedReason}
</div>
{:else}
<!-- Keyed on the write count: the pane builds its step once from the config
it is given, so a restore or a deploy from another tab gets a fresh one. -->
{#key `${ws}:${path}:${writes}`}
<AgentRunPane {path} workspace={ws} {config} />
{/key}
{/if}
</div>
</div>
</Pane>
<Pane size={35} minSize={20}>
<div class="flex flex-col h-full">
<Tabs bind:selected={rightTab} wrapperClass="flex-none w-full">
<Tab value="agent" label="Agent" />
<Tab value="export" label="Export" />
{#snippet content()}
<div class="min-h-0 grow overflow-auto">
<TabContent value="agent" class="p-4 flex flex-col gap-6">
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase text-tertiary">Model</h3>
<dl class="flex flex-col gap-1">
{#each brain.filter((p) => p.label !== AGENT_BRAIN_LABELS['system_prompt']) as param (param.label)}
<div class="flex items-baseline gap-2 text-xs">
<dt class="text-tertiary shrink-0 w-32">{param.label}</dt>
<dd class="text-secondary truncate" title={param.value}
>{param.value}</dd
>
</div>
{/each}
</dl>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase text-tertiary">System message</h3>
{#if systemPrompt}
<pre
class="text-xs text-secondary whitespace-pre-wrap rounded-md border border-light bg-surface-tertiary p-3 max-h-80 overflow-auto"
>{systemPrompt}</pre
>
{:else}
<span class="text-xs text-tertiary">None</span>
{/if}
</section>
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase text-tertiary">
Tools ({tools.length})
</h3>
{#if tools.length === 0}
<span class="text-xs text-tertiary">No tools.</span>
{:else}
<ul class="flex flex-col gap-1">
{#each tools as tool (tool.id)}
<li class="flex items-center gap-2 text-xs">
<span class="text-secondary truncate">
{toolDisplayName(tool) ?? tool.id}
</span>
<Badge color="gray" small>{toolKind(tool)}</Badge>
</li>
{/each}
</ul>
{/if}
</section>
</TabContent>
<TabContent value="export" class="p-2">
<HighlightCode
language="json"
code={JSON.stringify(agent.value ?? {}, null, 2)}
/>
</TabContent>
</div>
{/snippet}
</Tabs>
</div>
</Pane>
</Splitpanes>
</div>
{:else}
<div class="p-6 text-xs text-tertiary">Loading agent...</div>
{/if}
</main>