feat: list agents on the home page and create them from the new menu

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-24 09:26:17 +02:00
co-authored by Claude Opus 5.5
parent 6cd70a8e08
commit 252a086e97
15 changed files with 377 additions and 30 deletions
+4 -6
View File
@@ -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
@@ -0,0 +1,142 @@
<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 { 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 { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
import { createEventDispatcher } from 'svelte'
import { FileUp, FolderInput, Shield, Trash } from 'lucide-svelte'
import Row from './Row.svelte'
/**
* A home-page row for a saved agent. The agent is an `ai_agent` resource, so the row opens the
* agent editor the resources page hosts, and its menu carries the resource actions.
*/
interface Props {
agent: ListableResource & { canWrite: boolean }
marked: string | undefined
shareModal: ShareModal
moveDrawer: MoveDrawer
deploymentDrawer: DeployWorkspaceDrawer
deleteConfirmedCallback?: (() => void) | undefined
depth?: number
menuOpen?: boolean
keyboardSelected?: boolean
}
let {
agent,
marked,
shareModal,
moveDrawer,
deploymentDrawer,
deleteConfirmedCallback = $bindable(),
depth = 0,
menuOpen = $bindable(false),
keyboardSelected = false
}: Props = $props()
const dispatch = createEventDispatcher()
async function deleteAgent(path: string): Promise<void> {
try {
await ResourceService.deleteResource({ workspace: $workspaceStore!, path })
sendUserToast(`Deleted agent ${path}`)
dispatch('change')
} catch (err) {
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
}
}
</script>
<Row
href="{base}{RESOURCES_PATH}#/resource/{agent.path}"
kind="agent"
{keyboardSelected}
{marked}
path={agent.path}
summary={agent.description}
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
canFavorite={false}
{depth}
>
{#snippet badges()}
<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')}
/>
<InheritedLabels labels={agent.inherited_labels} />
{/snippet}
{#snippet actions()}
<Dropdown
items={async () => {
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}
</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'}
@@ -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}`)
}
)
@@ -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<AgentEditorTarget | undefined>(undefined)
@@ -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<void>
/** 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}
@@ -386,6 +386,7 @@
toolId={target.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
{onSaved}
isNew={target.isNew}
/>
{/snippet}
@@ -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
? ([
{
+17 -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,19 @@
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
{keyboardSelected}
/>
{/if}
{#if menuOpen}
+133 -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,
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, 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
@@ -218,6 +230,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
@@ -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<string, number> = $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<void> {
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<string, number> = {}
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}
/>
<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
@@ -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'
@@ -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 ?? ''
@@ -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.