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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-25 10:15:38 +02:00
co-authored by Claude Opus 5.5
parent 161e19da44
commit 71d12a0cf1
7 changed files with 358 additions and 160 deletions
+5
View File
@@ -31710,6 +31710,11 @@ components:
(over a deployed row or a synthesized draft-only row).
Frontend appends a `*` to the displayed name.
type: boolean
agent_memory:
description: |
For an `ai_agent` resource only, its `memory` setting: the one part
of the value a listing returns, since it decides whether the agent
keeps a conversation.
required:
- path
- resource_type
+26 -5
View File
@@ -206,6 +206,20 @@ pub struct ListableResource {
pub is_draft: Option<bool>,
}
/// A row of the resource listing: the resource as a single read returns it, plus what only the
/// listing carries.
#[derive(FromRow, Serialize)]
pub struct ListedResource {
#[sqlx(flatten)]
#[serde(flatten)]
pub resource: ListableResource,
/// An `ai_agent`'s `memory` setting, the one part of its value a listing shows: whether it
/// keeps a conversation decides how it is offered. `value` stays unlisted for every type.
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub agent_memory: Option<serde_json::Value>,
}
#[derive(Deserialize)]
pub struct CreateResource {
pub path: String,
@@ -322,7 +336,7 @@ async fn list_resources(
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<ListableResource>> {
) -> JsonResult<Vec<ListedResource>> {
let (per_page, offset) = paginate(pagination);
let mut sqlb = SqlBuilder::select_from("resource")
@@ -344,6 +358,7 @@ async fn list_resources(
"resource.labels",
"folder_labels(resource.workspace_id, resource.path) as inherited_labels",
"ws_specific.path IS NOT NULL as ws_specific",
"CASE WHEN resource.resource_type = 'ai_agent' THEN resource.value->'memory' END as agent_memory",
])
// Scalar EXISTS flags the authed user's per-user draft without fanning rows out.
.field(
@@ -428,11 +443,11 @@ async fn list_resources(
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let allowed = build_scope_path_predicate(&authed, "resources", "read");
let mut rows = sqlx::query_as::<_, ListableResource>(&sql)
let mut rows = sqlx::query_as::<_, ListedResource>(&sql)
.fetch_all(&mut *tx)
.await?
.into_iter()
.filter(|r| allowed(&r.path))
.filter(|r| allowed(&r.resource.path))
.collect::<Vec<_>>();
tx.commit().await?;
@@ -503,8 +518,13 @@ async fn list_resources(
})
});
let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool());
let agent_memory = if resource_type == "ai_agent" {
value.as_ref().and_then(|a| a.get("memory")).cloned()
} else {
None
};
rows.push(ListableResource {
let resource = ListableResource {
workspace_id: w_id.clone(),
path,
value,
@@ -526,7 +546,8 @@ async fn list_resources(
draft_only: Some(true),
// Synthesized rows are the authed user's draft.
is_draft: Some(true),
});
};
rows.push(ListedResource { resource, agent_memory });
}
}
@@ -14,6 +14,8 @@
import { createEventDispatcher } from 'svelte'
import { FileUp, Pen, Shield, Trash } from 'lucide-svelte'
import Button from '../button/Button.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import Row from './Row.svelte'
/**
@@ -59,8 +61,13 @@
let rowHref = $derived(agent.draft_only ? editHref : `${base}/agents/get/${agent.path}`)
</script>
{#snippet chatBadge()}
<ChatFlowBadge title="Managed memory on: this agent opens as a conversation" />
{/snippet}
<Row
href={rowHref}
titleBadge={keepsManagedMemory(agent.agent_memory) ? chatBadge : undefined}
kind="agent"
{keyboardSelected}
{marked}
@@ -0,0 +1,89 @@
<script lang="ts">
import { Badge, Section } from '$lib/components/common'
import {
AGENT_BRAIN_LABELS,
summarizeAgentBrain,
type AIAgentConfig
} from '../agentResourceUtils'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
/** A saved agent's configuration, read-only and at a glance: what a detail page shows beside
* running it, where the editor's form would be all fields and no summary. */
interface Props {
config: AIAgentConfig
}
let { config }: Props = $props()
const SYSTEM_PROMPT_LABEL = AGENT_BRAIN_LABELS['system_prompt']
let settings = $derived(
summarizeAgentBrain(config).filter((r) => r.label !== SYSTEM_PROMPT_LABEL)
)
let systemPrompt = $derived(
typeof config.system_prompt === 'string' && config.system_prompt !== ''
? config.system_prompt
: undefined
)
let tools = $derived(Array.isArray(config.tools) ? (config.tools as AgentTool[]) : [])
function toolKind(tool: AgentTool): string {
const value = tool?.value as Record<string, any> | undefined
if (value?.tool_type === 'mcp') return 'MCP'
if (value?.tool_type === 'websearch') return 'Web search'
switch (value?.type) {
case 'script':
return 'Script'
case 'flow':
return 'Flow'
case 'rawscript':
return 'Inline script'
case 'aiagent':
return 'Agent'
default:
return 'Tool'
}
}
</script>
<div class="flex flex-col gap-6 p-4">
<Section label="Model" small>
<dl class="flex flex-col gap-2">
{#each settings as row (row.label)}
<div class="flex flex-col gap-0.5">
<dt class="text-2xs text-secondary">{row.label}</dt>
<dd class="text-xs text-primary break-words">{row.value}</dd>
</div>
{:else}
<span class="text-xs text-secondary">Not configured</span>
{/each}
</dl>
</Section>
<Section label="System message" small>
{#if systemPrompt}
<p
class="text-xs text-primary whitespace-pre-wrap break-words rounded-md bg-surface-secondary p-2 max-h-80 overflow-auto"
>
{systemPrompt}
</p>
{:else}
<span class="text-xs text-secondary">None</span>
{/if}
</Section>
<Section label="Tools ({tools.length})" small>
{#if tools.length === 0}
<span class="text-xs text-secondary">No tools</span>
{:else}
<ul class="flex flex-col gap-2">
{#each tools as tool, i (tool?.id ?? i)}
<li class="flex items-center justify-between gap-2 min-w-0">
<span class="text-xs text-primary truncate">{toolDisplayName(tool) ?? tool?.id}</span>
<Badge color="gray" small>{toolKind(tool)}</Badge>
</li>
{/each}
</ul>
{/if}
</Section>
</div>
@@ -11,6 +11,8 @@
agentChatPath
} from '../conversations/agentEditorChat'
import { runFlowPreview } from '../utils.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { goto } from '$lib/navigation'
import { deepEqual } from 'fast-equals'
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
import { emptySchema, type StateStore } from '$lib/utils'
@@ -355,6 +357,26 @@
)
}
let runLoading = $state(false)
let scheduledForStr = $state<string | undefined>(undefined)
let invisible_to_owner = $state<boolean | undefined>(undefined)
let overrideTag = $state<string | undefined>(undefined)
/** One run from the form, as the same one-step flow a chat turn runs but outside any
* conversation, then onto its run page. */
async function runOnce(_scheduledFor: string | undefined, args: Record<string, any>) {
if (!agentModule) return
runLoading = true
try {
const flow = agentChatFlow($state.snapshot(agentModule) as FlowModule)
flow.value.chat_input_enabled = false
const id = await runFlowPreview(args, flow, path, undefined, undefined, undefined, workspace)
await goto(`/run/${id}?workspace=${workspace}`)
} finally {
runLoading = false
}
}
// What the composer reads for its model button and paperclip.
let chatModules = $derived(agentModule ? agentChatFlow(agentModule).value.modules : undefined)
let chatGap = $derived(agentChatGap(agentValue?.input_transforms))
@@ -446,18 +468,17 @@
<!-- Named and positioned so the tool picker's popover can portal here: the `#flow-editor` it
otherwise targets is behind this dialog, and does not exist at all on the resources page. -->
<div id="agent-editor" class="relative h-full min-h-0">
<!-- 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">
<!-- Viewing leads with running the agent; editing leads with what is being edited. -->
{#if view}
<Pane size={62} minSize={30}>{@render runPane()}</Pane>
<Pane size={38} minSize={20}>{@render configPane()}</Pane>
{:else}
{#if view}
<!-- A view only runs the agent: the page beside it summarizes the configuration. -->
{@render runPane()}
{:else}
<!-- 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={55} minSize={30}>{@render configPane()}</Pane>
<Pane size={45} minSize={20}>{@render runPane()}</Pane>
{/if}
</Splitpanes>
</Splitpanes>
{/if}
</div>
{#snippet configPane()}
@@ -521,36 +542,54 @@
<div class="h-full min-h-0 flex flex-col">
<!-- Laid out as the script editor's preview column is: what a run takes above what it
produced, both alongside what is being edited. -->
<div class="flex-1 min-h-0 {testMode === 'chat' ? 'hidden' : ''}">
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
{#if view}
<!-- A run of the deployed agent is a run like any other item's, so it opens on its
own page rather than as a test result beside the form. -->
<div class="flex-1 min-h-0 overflow-auto p-4 {testMode === 'chat' ? 'hidden' : ''}">
<RunForm
runnable={{ schema: AGENT_CHAT_SCHEMA, path }}
runAction={runOnce}
schedulable={false}
detailed={false}
autofocus
loading={runLoading}
bind:scheduledForStr
bind:invisible_to_owner
bind:overrideTag
/>
</div>
{:else}
<div class="flex-1 min-h-0 {testMode === 'chat' ? 'hidden' : ''}">
<Splitpanes horizontal class="h-full">
<Pane size={40} minSize={15}>
<div class="h-full overflow-auto">
<ModulePreview
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
/>
</div>
</Pane>
<Pane size={60} minSize={20}>
<ModulePreviewResultViewer
lang="deno"
editor={undefined}
diffEditor={undefined}
mod={agentModule as FlowModule}
schema={flowLocalAgentSchema(schema)}
pickableProperties={stepPropPicker?.pickableProperties}
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
bind:testJob
bind:testIsLoading
bind:scriptProgress
{testJob}
{testIsLoading}
{scriptProgress}
disableMock
disableHistory
/>
</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>
</Pane>
</Splitpanes>
</div>
{/if}
{#if chatMounted}
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
{#if chatGap?.memory}
@@ -1,6 +1,5 @@
<script lang="ts">
import {
ArrowLeft,
ChevronRight,
FlaskConical,
FormInput,
@@ -286,14 +285,6 @@
{:else}
<div class="h-full min-h-0 flex flex-col">
<div class="flex items-center gap-2 px-4 py-2 border-b shrink-0">
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ArrowLeft }}
title="Back"
onClick={close}
/>
<div class="min-w-0 flex flex-col">
<div class="flex items-center gap-1 min-w-0">
{#each trail as segment, i (i)}
@@ -450,23 +441,24 @@
{/snippet}
<!-- Mounted under the evals level too: it holds the draft the header's banner and Deploy act on,
and the config a draft run is offered on. -->
and the config a draft run is offered on. The target is read optionally: a deploy that
navigates away clears it while these are still rendered. -->
{#snippet agentPage()}
<AgentEditorHost
bind:this={host}
path={target.path}
path={target?.path ?? ''}
workspace={ws}
{enableAi}
toolId={target.toolId}
toolId={target?.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
{onSaved}
isNew={target.isNew}
isNew={target?.isNew}
/>
{/snippet}
{#snippet evalsPage()}
<EvalsPane
agentPath={target.path}
agentPath={target?.path ?? ''}
opWorkspace={ws}
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
bind:location={evalsLocation}
@@ -1,17 +1,30 @@
<script lang="ts">
import { page } from '$app/state'
import { Bot, FileUp, FormInput, MessageSquare, Pen, Shield, Trash } from 'lucide-svelte'
import { MediaQuery } from 'svelte/reactivity'
import {
FileUp,
FormInput,
MessageSquare,
PanelRightClose,
PanelRightOpen,
Pen,
Shield,
Trash
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
import { Button } from '$lib/components/common'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import AgentConfigSummary from '$lib/components/flows/content/AgentConfigSummary.svelte'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { ResourceService } from '$lib/gen'
@@ -20,19 +33,18 @@
import { isDeployable } from '$lib/utils_deployable'
/**
* The deployed agent, as a flow's or a script's page shows theirs: what it is, and a way to run
* it. A chat when its memory keeps the conversation, the inputs form otherwise.
* The deployed agent, as a flow's or a script's page shows theirs: a way to run it, a chat when
* its memory keeps the conversation and the inputs form otherwise, beside what it is.
*/
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 agent = $derived(host?.draftHandle())
let testPane = $derived(host?.testPaneHandle())
let loaded = $derived(agent?.state != undefined)
let chatAvailable = $derived(keepsManagedMemory(agent?.state?.args?.memory))
let canEdit = $derived(loaded && (agent?.canWrite ?? false) && !$userStore?.operator)
let config = $derived(agent?.state?.args)
let chatAvailable = $derived(keepsManagedMemory(config?.memory))
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && !$userStore?.operator)
// An agent runs as a flow preview, and the server refuses a preview to an operator, and to a
// non-admin under another user's namespace (`require_path_read_access_for_preview`).
let runBlockedReason = $derived(
@@ -45,6 +57,12 @@
: undefined
)
// Tailwind's `lg`, where the detail pages stop collapsing: the configuration sits beside the run
// pane above it, and in a drawer over it below, closed until asked for.
const wide = new MediaQuery('(min-width: 1024px)')
let panelOpen = $state<boolean | undefined>(undefined)
let showPanel = $derived(panelOpen ?? wide.current)
let shareModal: ShareModal | undefined = $state(undefined)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let deleteOpen = $state(false)
@@ -77,102 +95,129 @@
</ConfirmationModal>
<main class="h-screen w-full flex flex-col">
<div class="flex items-center gap-3 px-4 py-2 border-b shrink-0 min-h-12">
<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?.state?.description || path}
</span>
{#if agent?.state?.description}
<span class="text-2xs text-tertiary truncate">{path}</span>
{/if}
<!-- Laid out as `DetailPageHeader` is for flows and scripts, whose trigger and error handler
controls an agent does not have. -->
<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 py-2 md:py-0"
>
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
<div class={twMerge('min-w-0', $userStore?.operator ? 'pl-10' : '')}>
<SummaryPathDisplay summary={config ? agent?.state?.description : undefined} {path} />
</div>
</div>
<div class="flex gap-1 items-center pr-4">
<!-- Only an agent that keeps the conversation can chat: without managed memory every
message would be answered alone, so it is run from its inputs with nothing to switch. -->
{#if chatAvailable && !runBlockedReason && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="md"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="md"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{#if config && !$userStore?.operator}
<DropdownV2
placement="bottom-end"
size="md"
items={async () => [
{
displayName: 'Permissions',
icon: Shield,
disabled: !canEdit,
action: () => shareModal?.openDrawer?.(path, 'resource')
},
...(!agent?.state?.wsSpecific &&
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: () => (deleteOpen = true)
}
]}
/>
{/if}
<Button
variant="subtle"
unifiedSize="md"
iconOnly
startIcon={{ icon: showPanel ? PanelRightClose : PanelRightOpen }}
title={showPanel ? 'Hide configuration' : 'Show configuration'}
onClick={() => (panelOpen = !showPanel)}
/>
{#if canEdit}
<Button
variant="accent"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/agents/edit/{path}"
>
Edit
</Button>
{/if}
</div>
</div>
<div class="grow"></div>
<!-- Only an agent that keeps the conversation can chat: without managed memory every message
would be answered alone, so it is run from its inputs and nothing is offered to switch. -->
{#if loaded && chatAvailable && !runBlockedReason && testPane?.mode}
<ToggleButtonGroup
bind:selected={
() => testPane?.mode,
(mode) => {
if (testPane) testPane.mode = mode
}
}
noWFull
>
{#snippet children({ item })}
<ToggleButton
size="sm"
value="chat"
label="Chat"
icon={MessageSquare}
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
{item}
/>
<ToggleButton
size="sm"
value="form"
label="Form"
icon={FormInput}
tooltip="Run the agent once, on the inputs in a form"
{item}
/>
{/snippet}
</ToggleButtonGroup>
<div class="grow"></div>
{/if}
{#if loaded && !$userStore?.operator}
<DropdownV2
items={async () => [
{
displayName: 'Permissions',
icon: Shield,
disabled: !canEdit,
action: () => shareModal?.openDrawer?.(path, 'resource')
},
...(!agent?.state?.wsSpecific &&
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: () => (deleteOpen = true)
}
]}
/>
{/if}
{#if canEdit}
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Pen }}
href="{base}/agents/edit/{path}"
>
Edit
</Button>
{/if}
</div>
<div class="flex-1 min-h-0">
{#key `${ws}:${path}`}
<AgentEditorHost
bind:this={host}
{path}
workspace={ws}
enableAi={$copilotInfo.enabled}
{toolId}
onSelectTool={(id) => (toolId = id)}
view
{runBlockedReason}
/>
{/key}
<div class="flex-1 min-h-0 flex">
<div class="flex-1 min-w-0">
{#key `${ws}:${path}`}
<AgentEditorHost
bind:this={host}
{path}
workspace={ws}
enableAi={$copilotInfo.enabled}
view
{runBlockedReason}
/>
{/key}
</div>
{#if wide.current && showPanel && config}
<aside class="w-80 shrink-0 border-l overflow-y-auto">
<AgentConfigSummary {config} />
</aside>
{/if}
</div>
</main>
{#if !wide.current}
<Drawer placement="right" size="320px" bind:open={() => showPanel, (open) => (panelOpen = open)}>
<DrawerContent title="Configuration" on:close={() => (panelOpen = false)} noPadding>
{#if config}
<AgentConfigSummary {config} />
{/if}
</DrawerContent>
</Drawer>
{/if}