From 252a086e97989457e341d17a38e55a2b7dd5c9ce Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 24 Sep 2026 09:26:17 +0200 Subject: [PATCH] feat: list agents on the home page and create them from the new menu Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/windmill-store/src/resources.rs | 10 +- .../components/common/table/AgentRow.svelte | 142 +++++++++++++++++ .../lib/components/common/table/Row.svelte | 1 + .../components/common/table/RowIcon.svelte | 4 + .../lib/components/flows/agentDraft.svelte.ts | 20 ++- .../flows/agentEditorStore.svelte.ts | 3 + .../flows/content/AgentEditorHost.svelte | 12 +- .../flows/content/AgentEditorModal.svelte | 1 + .../components/home/CreateActionsMenu.svelte | 17 ++ frontend/src/lib/components/home/Item.svelte | 19 ++- .../src/lib/components/home/ItemsList.svelte | 147 ++++++++++++++++-- .../src/lib/components/home/treeViewUtils.ts | 11 +- .../lib/components/sessions/previewPaths.ts | 2 + .../src/routes/(root)/(logged)/+page.svelte | 2 +- .../(root)/(logged)/resources/+page.svelte | 16 +- 15 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/components/common/table/AgentRow.svelte diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 5bb15b456e..443cbdfa14 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -467,12 +467,10 @@ async fn list_resources( let v: serde_json::Value = serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); // ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? } - let path = v - .get("path") - .and_then(|s| s.as_str()) - .unwrap_or("") - .to_string(); - if path.is_empty() || !allowed(&path) { + // Listed at the draft's key rather than its `path`: an editor keys the draft on the path + // it opened, which is the only one a draft read can find, while `path` moves on rename. + let path = row.path; + if !allowed(&path) { continue; } let description = v diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte new file mode 100644 index 0000000000..d9a5d26f31 --- /dev/null +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -0,0 +1,142 @@ + + + + {#snippet badges()} + + dispatch('change')} + /> + + {/snippet} + + {#snippet actions()} + { + const { path } = agent + return [ + { + displayName: 'Permissions', + icon: Shield, + disabled: !agent.canWrite, + action: () => { + shareModal.openDrawer?.(path, 'resource') + } + }, + { + displayName: 'Move/Rename', + icon: FolderInput, + disabled: !agent.canWrite || 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: !agent.canWrite, + action: (event) => { + if (event?.shiftKey) { + deleteAgent(path) + } else { + deleteConfirmedCallback = () => { + deleteAgent(path) + } + } + } + } + ] + }} + on:open={() => { + menuOpen = true + }} + /> + {/snippet} + diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 4fb90151c1..76cd30640a 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -39,6 +39,7 @@ | 'flow' | 'app' | 'raw_app' + | 'agent' | 'resource' | 'variable' | 'resource_type' diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 9f0395b924..a7c99345a3 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -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 @@ {:else if effectiveKind === 'script'} + {:else if effectiveKind === 'agent'} + {:else if effectiveKind === 'variable'} {:else if effectiveKind === 'resource'} diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 5ee65cd98a..0499fbfd9b 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -181,6 +181,8 @@ export interface AgentDraftOptions { /** The `ai_agent` resource being edited. */ path: () => string | undefined workspace: () => string | undefined + /** A missing row is a new agent to start empty, not a load failure. */ + isNew?: () => boolean } export interface AgentDraftHandle { @@ -327,10 +329,26 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { await sync.maybeRestore() }, (err) => { + if (loadedFor !== key) return + // Nothing is written until the first edit: the sync saves only on user input, and + // the first deploy creates the resource at whatever path the form then holds. + if (opts.isNew?.() && (err as { status?: number })?.status === 404) { + noDeployed = true + deployed = undefined + canWriteResource = true + state = { + path, + description: '', + args: {}, + resource_type: 'ai_agent', + wsSpecific: false + } + 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}`) } ) diff --git a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts index 025a426d28..0488796c76 100644 --- a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts +++ b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts @@ -18,6 +18,9 @@ export interface AgentEditorTarget { /** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a * real flow sets it: the agent editor offers no way to open a second editor from inside itself. */ host?: { flowPath: string; moduleId: string } + /** The path was minted for an agent that does not exist yet: a missing row is an empty agent to + * start from rather than a load failure. */ + isNew?: boolean } let target = $state(undefined) diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 4f69d3a1a1..2e28d7ba8c 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -61,6 +61,8 @@ 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 + /** The path was minted for a new agent, so a missing row starts an empty one. */ + isNew?: boolean } let { @@ -69,7 +71,8 @@ enableAi = false, toolId = undefined, onSelectTool = undefined, - onSaved = undefined + onSaved = undefined, + isNew = false }: Props = $props() /** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a @@ -78,7 +81,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, @@ -447,6 +454,7 @@ } bind:error={pathError} initialPath={path} + checkInitialPathExistence={draft.noDeployed} namePlaceholder="agent" kind="resource" workspaceOverride={workspace} diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index ea45761bf9..e01bdc777e 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -386,6 +386,7 @@ toolId={target.toolId} onSelectTool={(id) => showAgentEditorTool(id)} {onSaved} + isNew={target.isNew} /> {/snippet} diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 7c5bb4986e..8d398f7b9f 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -8,6 +8,7 @@ import Tab from '$lib/components/common/tabs/Tab.svelte' import { Plus, + Bot, Code2, LayoutDashboard, ChevronDown, @@ -29,6 +30,7 @@ import YAML from 'yaml' import type { Snippet } from 'svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' + import { NEW_AGENT_HASH, RESOURCES_PATH } from '$lib/components/sessions/previewPaths' interface Props { /** Replaces the default `New` button, e.g. with an inline text link. */ @@ -120,6 +122,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}${RESOURCES_PATH}${NEW_AGENT_HASH}`) + }, ...(HOME_SHOW_CREATE_APP ? ([ { diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index f9e30eaf65..78dbf83a15 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -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,19 @@ bind:menuOpen {keyboardSelected} /> +{:else if item.type == 'agent'} + dispatch('reload')} + agent={item} + {shareModal} + {moveDrawer} + {deploymentDrawer} + {depth} + bind:menuOpen + {keyboardSelected} + /> {/if} {#if menuOpen} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index f86ec9d5bf..de65860e5e 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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, ChevronsDownUp, ChevronsUpDown, Code2, @@ -62,7 +65,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 } @@ -101,7 +104,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' }, @@ -166,7 +170,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) @@ -200,7 +212,7 @@ if (el) untrack(() => el.open()) }) - type TableItem = T & { + type TableItem = T & { canWrite: boolean marked?: string type?: U @@ -218,6 +230,7 @@ type TableFlow = TableItem type TableApp = TableItem type TableRawApp = TableItem + type TableAgent = TableItem // Folders that are data pipelines, surfaced as their own "Pipeline" entry // (the member scripts are folded into it, not listed individually). Two @@ -303,10 +316,15 @@ 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 agentOwnerCounts: Record = $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) @@ -347,6 +365,66 @@ 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 { + const ws = $workspaceStore + const gen = ++agentsGen + if (!ws || !$userStore || !showsAgents || archived) { + agents = [] + agentOwnerCounts = {} + 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 = [] + agentOwnerCounts = {} + return + } + if (gen !== agentsGen) return + // Counted before the owner scope: the chips and tree nodes list every owner. + const counts: Record = {} + for (const r of rows) { + const owner = r.path.split('/').slice(0, 2).join('/') + counts[owner] = (counts[owner] ?? 0) + 1 + } + agentOwnerCounts = counts + 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 @@ -363,6 +441,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 @@ -385,7 +477,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. @@ -598,7 +690,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, @@ -714,7 +806,7 @@ } function filterItemsPathsBaseOnUserFilters( - item: TableScript | TableFlow | TableApp | TableRawApp, + item: TableScript | TableFlow | TableApp | TableRawApp | TableAgent, filterUserFolders: boolean, filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined ) { @@ -968,6 +1060,7 @@ [() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain], async ([ws, showArchived, kind, withoutMain]) => { if (!ws || showArchived) return undefined + if (kind === 'agent') return {} try { const res = await ScriptService.countRunnablesByOwner({ workspace: ws, @@ -983,7 +1076,16 @@ } } ) - let ownerCounts = $derived(ownerCountsRes.current) + // The runnables endpoint never counts an agent, so the loaded agents are added per owner. + let ownerCounts = $derived.by(() => { + const runnables = ownerCountsRes.current + if (runnables == undefined) return undefined + const counts = { ...runnables } + for (const [owner, n] of Object.entries(agentOwnerCounts)) { + counts[owner] = (counts[owner] ?? 0) + n + } + return counts + }) // The counts decide which owners the tree renders, so drawing it before they land // would show every workspace folder and then prune it away. Hold the skeleton // until the first response instead — it is fetched in parallel with the listing, @@ -1159,7 +1261,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 { @@ -1215,7 +1318,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, @@ -1266,6 +1369,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) ) @@ -1326,10 +1434,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 ) @@ -1662,7 +1772,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( @@ -1798,6 +1909,14 @@ size="md" {item} /> + {/snippet} diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index 2196d19409..bb134bb7eb 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -1,5 +1,5 @@ -import type { ListableApp, Script, Flow, ListableRawApp } from '$lib/gen' -type TableItem = T & { +import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen' +type TableItem = T & { canWrite: boolean marked?: string type?: U @@ -14,8 +14,11 @@ type TableScript = TableItem type TableFlow = TableItem type TableApp = TableItem type TableRawApp = TableItem +/** 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 -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 diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 13d5abca33..f79f839c74 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -11,6 +11,8 @@ export const RUNS_PATH = '/runs' export const SCHEDULES_PATH = '/schedules' export const VARIABLES_PATH = '/variables' export const RESOURCES_PATH = '/resources' +/** On the resources page, opens the agent editor on a new, not yet saved agent. */ +export const NEW_AGENT_HASH = '#/new_agent' export const ASSETS_PATH = '/assets' export const AUDIT_LOGS_PATH = '/audit_logs' export const WORKSPACE_SETTINGS_PATH = '/workspace_settings' diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index c1f4f4818d..d6c29c53e3 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -41,7 +41,7 @@ let tab = $state('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 ?? '' diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index bf62818170..bad906399d 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -88,7 +88,9 @@ handOffPageDrawer, setPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession' - import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths' + import { NEW_AGENT_HASH, RESOURCES_PATH } from '$lib/components/sessions/previewPaths' + import { getUsernameForNamespace } from '$lib/userNamespace' + import { random_adj } from '$lib/components/random_positive_adjetive' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' import ExploreAssetButton, { assetCanBeExplored @@ -671,6 +673,18 @@ let handledHash = '' $effect(() => { const hash = page.url.hash + if (hash === NEW_AGENT_HASH) { + if (hash === handledHash) return + handledHash = hash + untrack(() => { + resourceEditor?.close?.({ keepAnchor: true }) + openAgentEditor({ + path: `u/${getUsernameForNamespace()}/${random_adj()}_agent`, + isNew: true + }) + }) + return + } if (!hash.startsWith('#/resource/')) { // Navigating away from a drawer target must clear the tracker, or // re-targeting the same item later would be skipped as already handled.