feat: agent nits: multi-select, memory-off notice, agent step label (#11622)

* feat: agent nits: multi-select, memory-off notice, agent step label

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say saved, not renamed, on a new agent's first deploy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: run the agent page form on cmd+enter, select agents by keyboard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the agent step label

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-08 23:24:00 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0a95ea47fa
commit c52ba2fd30
15 changed files with 107 additions and 25 deletions
@@ -69,6 +69,7 @@
retainLinkedToolsScope
} from './flows/linkedAgentToolsStore.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { AGENT_STEP_ID, agentStepLabel } from './flows/conversations/agentEditorChat'
const operatingWorkspace = useOperatingWorkspace()
@@ -1238,6 +1239,8 @@
id: string
type: FlowModuleValue['type']
suspend?: boolean
/** Shown in place of the id. */
label?: string
}
function allModulesForTimeline(
@@ -1250,7 +1253,8 @@
({
id: x.id,
type: x.value.type,
suspend: x.suspend != undefined
suspend: x.suspend != undefined,
label: x.id === AGENT_STEP_ID ? agentStepLabel(job?.script_path) : undefined
}) as FlowModuleForTimeline,
{
skipToolNodes: true
@@ -179,12 +179,12 @@
/>
</div>
{/if}
{#each flowModules as { id: k, type: typ, suspend: isSuspend } (k)}
{#each flowModules as { id: k, type: typ, suspend: isSuspend, label } (k)}
{@const subItems = items?.[k]?.filter((x) => x.created_at && x.started_at)}
<div class="relative px-3 py-1.5">
<div class="flex items-center justify-between mb-0.5">
<div class="text-xs font-medium"
>{k.startsWith('subflow:') ? k.substring(8) : k}
>{label ?? (k.startsWith('subflow:') ? k.substring(8) : k)}
{#if localModuleStates[k]?.selectedForloop && (typ == 'forloopflow' || typ == 'whileloopflow')}
<span class="text-xs font-mono font-medium inline-flex items-center -my-2">
<button onclick={(e) => e.stopPropagation()}>
@@ -16,6 +16,7 @@
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import Row from './Row.svelte'
import { selectMenuItems, type RowSelection } from './rowSelection'
/**
* A home-page row for a saved agent. The agent is an `ai_agent` resource, so the row opens the
@@ -30,6 +31,7 @@
depth?: number
menuOpen: boolean
keyboardSelected?: boolean
rowSelection?: RowSelection
}
let {
@@ -40,7 +42,8 @@
deleteConfirmedCallback = $bindable(),
depth = 0,
menuOpen = $bindable(),
keyboardSelected = false
keyboardSelected = false,
rowSelection = undefined
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -69,6 +72,7 @@
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
canFavorite={false}
{depth}
{rowSelection}
>
{#snippet badges()}
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
@@ -99,8 +103,9 @@
</span>
{/if}
<Dropdown
items={async () =>
agentMenuItems({
items={async () => [
...selectMenuItems(rowSelection),
...agentMenuItems({
deployUiSettings: await getDeployUiSettings(),
path: agent.path,
canWrite: agent.canWrite,
@@ -113,7 +118,8 @@
if (event?.shiftKey) remove(path)
else deleteConfirmedCallback = () => remove(path)
}
})}
})
]}
on:open={() => {
menuOpen = true
}}
@@ -438,6 +438,9 @@ 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')
// A new agent's editor opens on its draft's storage path, so its first save always lands
// elsewhere: that is naming it, not a rename.
const firstSave = noDeployed
deployed = submitted
noDeployed = false
const renamed = submitted.path !== fromPath
@@ -456,7 +459,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
sync.discard(fromPath, submitted)
// A rename moves the row, so the next load must not reuse the old key.
loadedFor = `${ws}:${submitted.path}`
sendUserToast(renamed ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`)
sendUserToast(
renamed && !firstSave ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`
)
return submitted.path
}
@@ -9,7 +9,8 @@
AGENT_CHAT_SCHEMA,
agentChatFlow,
agentChatGap,
agentChatPath
agentChatPath,
AGENT_STEP_ID
} from '../conversations/agentEditorChat'
import { runFlowPreview } from '../utils.svelte'
import { goto } from '$lib/navigation'
@@ -100,7 +101,7 @@
* schema per tool — the linked branch deliberately loads none. Reserved in `forbiddenIds`:
* the root shares a flow-state map with the tools, so a tool of this id would lose its
* schema to the root's. */
const AGENT_ID = '__wm_agent_root'
const AGENT_ID = AGENT_STEP_ID
const draft = useAgentDraft({
path: () => path,
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { agentChatFlow, agentChatGap, agentChatPath } from './agentEditorChat'
import { agentChatFlow, agentChatGap, agentChatPath, agentStepLabel } from './agentEditorChat'
describe('agentChatPath', () => {
it('is a path no flow can take, so a same-path flow never shares its conversations', () => {
@@ -10,6 +10,14 @@ describe('agentChatPath', () => {
})
})
describe('agentStepLabel', () => {
it("names a run's agent step by the agent, a chat turn's included", () => {
expect(agentStepLabel('f/support/agent')).toBe('f/support/agent')
expect(agentStepLabel(agentChatPath('f/support/agent'))).toBe('f/support/agent')
expect(agentStepLabel(undefined)).toBe('AI Agent')
})
})
function agent(inputTransforms: Record<string, any>): FlowModule {
return {
id: '__wm_agent_root',
@@ -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.
@@ -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 @@
</Popover>
<div class="flex items-center space-x-2 relative" bind:clientWidth={idBadgeWidth}>
{#if id && id !== 'preprocessor' && !id.startsWith('failure') && !id.startsWith('subflow:')}
<!-- A saved agent's run is that one step, already named by the run page. -->
{#if id && id !== 'preprocessor' && id !== AGENT_STEP_ID && !id.startsWith('failure') && !id.startsWith('subflow:')}
<Badge
color="transparent"
class="border-none"
+3 -3
View File
@@ -45,10 +45,9 @@
// 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. An agent is a resource underneath,
// which none of the bulk actions handle.
// so it must not enter the selection either.
let bulkItem = $derived(
homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent'
homeSelection?.available && item.type !== 'raw_app'
? toBulkItem(item, $userStore, $workspaceStore)
: undefined
)
@@ -145,6 +144,7 @@
{depth}
bind:menuOpen
{keyboardSelected}
{rowSelection}
/>
{/if}
@@ -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(
@@ -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()
@@ -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<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app'> {
): Extract<UserDraftItemKind, 'script' | 'flow' | 'app' | 'raw_app' | 'resource'> {
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<void> {
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 })
}
@@ -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. */
@@ -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 @@
<span>Every flow that links {path} will fail at its agent step once it is deleted.</span>
</ConfirmationModal>
<svelte:window onkeydown={onKeyDown} />
<main class="h-full w-full flex flex-col">
<!-- `labels`: the pen saves every field it shows in one write, so the labels it shows have to be
the ones the resource carries. Handed none, it would offer an empty list, and the first
@@ -180,6 +199,14 @@
onOpenConfig={() => configModal?.open()}
>
{#snippet viewForm({ schema, run, loading, actions })}
{#if config && !chatAvailable}
<Alert type="info" size="xs" title="Managed memory is off" class="mb-4">
Each run is answered on its own inputs, so this agent has no chat. That suits reusing
it as a step in a flow.{canEdit
? ' Turn on managed memory in its configuration to chat with it.'
: ''}
</Alert>
{/if}
<RunForm
runnable={{ schema, path }}
runAction={run}
@@ -188,6 +215,8 @@
autofocus
{loading}
{actions}
bind:isValid
bind:this={runForm}
/>
{/snippet}
</AgentEditorHost>
@@ -124,6 +124,7 @@
onEditInForkClick
} from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { AGENT_STEP_ID } from '$lib/components/flows/conversations/agentEditorChat'
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
let jobUpdateLastFetch: Date | undefined = $state()
@@ -1144,7 +1145,8 @@
showStepId
>
{#snippet errorAction()}
{#if failedTopLevelStep}
<!-- A saved agent's run is its one step: naming it adds nothing. -->
{#if failedTopLevelStep && failedTopLevelStep !== AGENT_STEP_ID}
<span>at step {failedTopLevelStep}</span>
{#if $enterpriseLicense && canRestart(failedRestart)}
<span>·</span>