): FlowModule {
return {
id: '__wm_agent_root',
diff --git a/frontend/src/lib/components/flows/conversations/agentEditorChat.ts b/frontend/src/lib/components/flows/conversations/agentEditorChat.ts
index b754e04bb8..0f1bc86bda 100644
--- a/frontend/src/lib/components/flows/conversations/agentEditorChat.ts
+++ b/frontend/src/lib/components/flows/conversations/agentEditorChat.ts
@@ -13,6 +13,15 @@ export function agentChatPath(agentPath: string): string {
return `${agentPath}.chat`
}
+/** The id of the one step a saved agent's run is made of, as the server names it too: job-read
+ * scoping and the run's agent detection match on it, so it is only ever relabeled for display. */
+export const AGENT_STEP_ID = '__wm_agent_root'
+
+/** What a run shows for that step: the agent it ran, read off the run's path. */
+export function agentStepLabel(runPath: string | undefined): string {
+ return runPath?.replace(/\.chat$/, '') || 'AI Agent'
+}
+
/**
* The inputs of the flow a saved agent is chatted with: the message, and the files the composer's
* paperclip uploads. `user_message` is the name the server requires of a chat-mode run.
diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte
index a0ec4759f5..c87e20139e 100644
--- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte
+++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItemViewer.svelte
@@ -4,6 +4,7 @@
import type { FlowNodeColorClasses } from '$lib/components/graph'
import { twMerge } from 'tailwind-merge'
import { getContext } from 'svelte'
+ import { AGENT_STEP_ID } from '../conversations/agentEditorChat'
// Detached mode (sessions): a step's editor is hidden until asked for, so add the
// hint to the name tooltip. Provided by FlowEditor; absent (=> false) elsewhere.
@@ -69,7 +70,8 @@
- {#if id && id !== 'preprocessor' && !id.startsWith('failure') && !id.startsWith('subflow:')}
+
+ {#if id && id !== 'preprocessor' && id !== AGENT_STEP_ID && !id.startsWith('failure') && !id.startsWith('subflow:')}
{/if}
diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte
index afdcc8b38a..fe2c89304b 100644
--- a/frontend/src/lib/components/home/ItemsList.svelte
+++ b/frontend/src/lib/components/home/ItemsList.svelte
@@ -1821,8 +1821,7 @@
homeSelection.active &&
selectedIndex >= 0 &&
selectedIndex < displayedItems.length &&
- displayedItems[selectedIndex].type !== 'raw_app' &&
- displayedItems[selectedIndex].type !== 'agent'
+ displayedItems[selectedIndex].type !== 'raw_app'
) {
e.preventDefault()
homeSelection.toggle(
diff --git a/frontend/src/lib/components/home/bulkActions.test.ts b/frontend/src/lib/components/home/bulkActions.test.ts
index ccc29d063e..e00d5f5da5 100644
--- a/frontend/src/lib/components/home/bulkActions.test.ts
+++ b/frontend/src/lib/components/home/bulkActions.test.ts
@@ -57,6 +57,15 @@ describe('blockedReason', () => {
expect(blockedReason('unarchive', item({ archived: false }), admin)).toBeDefined()
})
+ // AgentRow's delete is `disabled: !canWrite`; the server moves no resource draft.
+ it('gates an agent as its row does, and keeps a draft-only one in place', () => {
+ const agent = item({ kind: 'agent', owner: false })
+ expect(blockedReason('delete', agent, member)).toBeUndefined()
+ expect(blockedReason('delete', { ...agent, canWrite: false }, member)).toBeDefined()
+ expect(blockedReason('archive', agent, admin)).toBeDefined()
+ expect(blockedReason('move', item({ kind: 'agent', draftOnly: true }), admin)).toBeDefined()
+ })
+
it('lets a draft-only row move or discard, never archive/delete', () => {
const draftOnly = item({ draftOnly: true, isDraft: true })
expect(blockedReason('discard', draftOnly, admin)).toBeUndefined()
diff --git a/frontend/src/lib/components/home/bulkActions.ts b/frontend/src/lib/components/home/bulkActions.ts
index 2345311459..20a65ec596 100644
--- a/frontend/src/lib/components/home/bulkActions.ts
+++ b/frontend/src/lib/components/home/bulkActions.ts
@@ -8,18 +8,19 @@
* addresses the deployed row. A draft-only item is therefore not deletable —
* there is nothing deployed at its path.
*/
-import { AppService, DraftService, FlowService, ScriptService } from '$lib/gen'
+import { AppService, DraftService, FlowService, ResourceService, ScriptService } from '$lib/gen'
import type { UserDraftItemKind } from '$lib/gen'
import { updateItemPathAndSummary } from '$lib/components/moveRenameManager'
import { discardDraft } from '$lib/utils_draft_deploy'
import type { BulkItem } from './homeSelection.svelte'
-/** The draft overlay is the one place a raw app is its own kind. Narrowed to the
- * four kinds `moveDraft` accepts — a `BulkItem` is never anything else, and
- * saying so lets the compiler check that rather than trusting it. */
+/** The draft overlay is the one place a raw app is its own kind, and an agent's
+ * draft is filed as the resource it is. Narrowed so the compiler checks that a
+ * `BulkItem` never maps to anything else. */
function draftKind(
item: BulkItem
-): Extract {
+): Extract {
+ if (item.kind === 'agent') return 'resource'
return item.kind === 'app' && item.rawApp ? 'raw_app' : item.kind
}
@@ -43,6 +44,9 @@ export function blockedReason(
switch (action) {
case 'move':
if (item.archived) return 'archived items cannot be moved'
+ // The server moves only script, flow and app drafts.
+ if (item.kind === 'agent' && item.draftOnly)
+ return 'a draft-only agent is moved by changing its path in the editor'
if (!item.owner) return notOwner
if (!item.canWrite) return 'you do not have write permission on this path'
return undefined
@@ -94,9 +98,11 @@ async function moveItem(ctx: BulkContext, item: BulkItem, target: string): Promi
if (item.draftOnly) {
// Nothing is deployed at this path, so there is no deploy to re-run: the
// item IS its draft row, and moving it rewrites that row.
+ const kind = draftKind(item)
+ if (kind === 'resource') throw new Error('a draft-only agent cannot be moved here')
await DraftService.moveDraft({
workspace: ctx.workspace,
- kind: draftKind(item),
+ kind,
path: item.path,
requestBody: { new_path: newPath }
})
@@ -138,6 +144,8 @@ async function deleteItem(ctx: BulkContext, item: BulkItem): Promise {
await ScriptService.deleteScriptByPath({ workspace: ctx.workspace, path: item.path })
} else if (item.kind === 'flow') {
await FlowService.deleteFlowByPath({ workspace: ctx.workspace, path: item.path })
+ } else if (item.kind === 'agent') {
+ await ResourceService.deleteResource({ workspace: ctx.workspace, path: item.path })
} else {
await AppService.deleteApp({ workspace: ctx.workspace, path: item.path })
}
diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts
index 70acdfa0fd..0b4cfcba7a 100644
--- a/frontend/src/lib/components/home/homeSelection.svelte.ts
+++ b/frontend/src/lib/components/home/homeSelection.svelte.ts
@@ -13,7 +13,7 @@ import type { UserExt } from '$lib/stores'
/** A raw app is not a kind of its own here: the listing returns it as an `app`
* row carrying `raw_app`, which every action addresses through `BulkItem.rawApp`
* (only the draft overlay distinguishes the two). */
-export type BulkKind = 'script' | 'flow' | 'app'
+export type BulkKind = 'script' | 'flow' | 'app' | 'agent'
/** The flattened row facts every bulk action needs, snapshotted at selection
* time so an action still knows what it is acting on after the list reloads. */
diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte
index 30eb5cb32c..3070345ddf 100644
--- a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte
@@ -15,10 +15,12 @@
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
import RunForm from '$lib/components/RunForm.svelte'
+ import { Alert } from '$lib/components/common'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { userStore, workspaceStore } from '$lib/stores'
+ import { sendUserToast } from '$lib/toast'
/**
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
@@ -41,6 +43,21 @@
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
+ let runForm: RunForm | undefined = $state(undefined)
+ let isValid = $state(true)
+
+ // Cmd/Ctrl+Enter runs the form, as on a script's or a flow's page. The form stays mounted, hidden,
+ // while the chat shows, and the chat's composer owns the key then.
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return
+ if (!runForm || testPane?.mode === 'chat') return
+ if (isValid) {
+ event.preventDefault()
+ runForm.run()
+ } else {
+ sendUserToast('Please fix errors before running', true)
+ }
+ }
async function remove() {
if (ws && (await deleteAgent(ws, path))) await goto(`${base}/?kind=agent`)
@@ -88,6 +105,8 @@
Every flow that links {path} will fail at its agent step once it is deleted.
+
+
+ {#if failedTopLevelStep && failedTopLevelStep !== AGENT_STEP_ID}
at step {failedTopLevelStep}
{#if $enterpriseLicense && canRestart(failedRestart)}
·