feat: star agents and add runs, audit logs and copy path to their menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB
This commit is contained in:
hugocasa
2026-10-09 19:04:25 +02:00
co-authored by Claude Opus 5.5
parent 2b55a8afbc
commit b7eff99b21
12 changed files with 95 additions and 11 deletions
@@ -0,0 +1 @@
DELETE FROM favorite WHERE favorite_kind = 'agent';
@@ -0,0 +1,8 @@
DO
$do$
BEGIN
ALTER TYPE FAVORITE_KIND ADD VALUE 'agent';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Couldn''t create FAVORITE_KIND::agent: %', SQLERRM;
END
$do$;
+1 -1
View File
@@ -10,7 +10,7 @@ autoscaling_event_type: full_scaleout, scalein, scaleout
aws_auth_resource_type: oidc, credentials
delivery_mode: push, pull
draft_type: script, flow, app
favorite_kind: app, script, flow, raw_app, asset
favorite_kind: app, script, flow, raw_app, asset, agent
gcp_subscription_mode: create_update, existing
http_method: get, post, put, delete, patch
importer_kind: script, flow, app
+11 -2
View File
@@ -9280,6 +9280,12 @@ paths:
required: false
schema:
type: boolean
- name: starred_only
description: Only the `ai_agent` resources the caller starred
in: query
required: false
schema:
type: boolean
responses:
"200":
description: resource list
@@ -24454,7 +24460,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script, raw_app, asset]
enum: [flow, app, script, raw_app, asset, agent]
responses:
"200":
description: star item
@@ -24477,7 +24483,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script, raw_app, asset]
enum: [flow, app, script, raw_app, asset, agent]
responses:
"200":
description: unstar item
@@ -32381,6 +32387,9 @@ components:
On a draft-only row, the path its editor has staged when it
differs from the storage path (e.g. a never-deployed item parked at
`u/{user}/draft_{uuid}`).
starred:
type: boolean
description: For an `ai_agent` resource only, whether the caller starred it.
required:
- path
- resource_type
+1
View File
@@ -32,6 +32,7 @@ pub enum FavoriteKind {
#[allow(non_camel_case_types)]
Raw_App,
Asset,
Agent,
}
#[derive(Deserialize)]
pub struct Favorite {
+22 -1
View File
@@ -225,6 +225,10 @@ pub struct ListedResource {
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub draft_path: Option<String>,
/// Whether the authed user starred this `ai_agent`; unset on every other type.
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(default)]
pub starred: Option<bool>,
}
#[derive(Deserialize)]
@@ -261,6 +265,8 @@ pub struct ListResourceQuery {
/// When true, append per-user draft-only rows; picker callers leave it off
/// to stay deployed-only. See list synthesis in scripts.rs.
pub include_draft_only: Option<bool>,
/// Only the `ai_agent` resources the authed user starred.
pub starred_only: Option<bool>,
}
#[derive(Serialize, FromRow)]
@@ -374,6 +380,12 @@ async fn list_resources(
AND draft.email = ?) as is_draft"
.bind(&authed.email),
)
.field(
&"CASE WHEN resource.resource_type = 'ai_agent' THEN EXISTS(SELECT 1 FROM favorite \
WHERE favorite.workspace_id = resource.workspace_id AND favorite.path = resource.path \
AND favorite.favorite_kind = 'agent' AND favorite.usr = ?) END as starred"
.bind(&authed.username),
)
.left()
.join("variable")
.on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id")
@@ -437,6 +449,14 @@ async fn list_resources(
);
}
if lq.starred_only.unwrap_or(false) {
sqlb.and_where(
"EXISTS(SELECT 1 FROM favorite WHERE favorite.workspace_id = resource.workspace_id \
AND favorite.path = resource.path AND favorite.favorite_kind = 'agent' AND favorite.usr = ?)"
.bind(&authed.username),
);
}
if let Some(label) = &lq.label {
for l in label.split(',') {
sqlb.and_where(
@@ -472,6 +492,7 @@ async fn list_resources(
&& lq.value.is_none()
&& lq.broad_filter.is_none()
&& lq.label.is_none()
&& !lq.starred_only.unwrap_or(false)
{
let rt_filter: Option<Vec<&str>> = lq
.resource_type
@@ -560,7 +581,7 @@ async fn list_resources(
// Synthesized rows are the authed user's draft.
is_draft: Some(true),
};
rows.push(ListedResource { resource, agent_memory, draft_path });
rows.push(ListedResource { resource, agent_memory, draft_path, starred: None });
}
}
@@ -70,7 +70,7 @@
path={agent.draft_path ?? agent.path}
summary={agent.description}
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
canFavorite={false}
canFavorite={!agent.draft_only}
{depth}
{rowSelection}
>
@@ -111,6 +111,7 @@
canWrite: agent.canWrite,
draftOnly: Boolean(agent.draft_only),
wsSpecific: agent.ws_specific,
operator: $userStore?.operator,
onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'),
onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'),
onDelete: (event) => {
@@ -260,7 +260,7 @@
</div>
{/if}
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')}
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow' || kind == 'agent')}
<div class="center-center h-full text-xs font-semibold text-secondary w-9">
<Star {kind} {path} {workspaceId} {summary} />
</div>
@@ -1,8 +1,10 @@
import { FileUp, Shield, Trash } from 'lucide-svelte'
import { Copy, Eye, FileUp, List, Shield, Trash } from 'lucide-svelte'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import { copyToClipboard, type Item } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import type { WorkspaceDeployUISettings } from '$lib/gen'
/** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */
@@ -24,6 +26,8 @@ export function agentMenuItems(agent: {
/** Never deployed: no resource yet to hold permissions or to deploy onward. */
draftOnly?: boolean
wsSpecific?: boolean
/** Hides what an operator has no page for, as on a flow's menu. */
operator?: boolean
/** Undefined until loaded, when nothing is offered for deploy. */
deployUiSettings: WorkspaceDeployUISettings | undefined
onPermissions: () => void
@@ -35,6 +39,28 @@ export function agentMenuItems(agent: {
!agent.draftOnly &&
isDeployable('resource', agent.path, agent.deployUiSettings)
return [
// A draft-only agent has no runs, no audit trail, and only a placeholder path yet.
...(agent.draftOnly
? []
: [
{
// Chat turns are filed under `<path>.chat` (agent_runs.rs) and listed by the agent's
// own chat, so this is the form runs.
displayName: 'View runs',
icon: List,
action: () => goto(`${base}/runs/${agent.path}`)
},
...(agent.operator
? []
: [
{
displayName: 'Audit logs',
icon: Eye,
action: () => goto(`${base}/audit_logs?resource=${agent.path}`)
}
]),
{ displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) }
]),
{
displayName: 'Permissions',
icon: Shield,
@@ -900,13 +900,15 @@
// each row carries its server fetch ordinal (`ord`), which already reflects the
// chosen order, the (path, kind) tiebreaks, full-precision timestamps, the database
// collation, and starred-first pinning. Sorting by it can never disagree with the
// server, so a later page never jumps above shown rows on "load more". Depends on
// server, so a later page never jumps above shown rows on "load more". Agents lead the
// runnables (see loadAgents) everywhere but among the starred, which all come first. Depends on
// `sortOrder` only so its identity changes when the order does (re-grouping the tree,
// whose leaves also sort by ord); the actual reordering comes from the reload that
// restamps ord.
let compareItems = $derived.by(() => {
sortOrder
return (a: { ord?: number }, b: { ord?: number }): number => (a.ord ?? 0) - (b.ord ?? 0)
return (a: { ord?: number; starred?: boolean }, b: { ord?: number; starred?: boolean }) =>
Number(!!b.starred) - Number(!!a.starred) || (a.ord ?? 0) - (b.ord ?? 0)
})
const opts: uFuzzy.Options = {
@@ -10,6 +10,7 @@
flow: `/flows/get/${path}`,
app: `/apps/get/${path}`,
raw_app: `/apps_raw/get/${path}`,
agent: `/agents/get/${path}`,
asset: '#'
}[kind]
}
@@ -71,7 +72,7 @@
</script>
<script lang="ts">
import { CodeXml, LayoutDashboard, Table2, Star, Trash2 } from 'lucide-svelte'
import { Bot, CodeXml, LayoutDashboard, Table2, Star, Trash2 } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { Menu, MenuItem } from '$lib/components/meltComponents'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
@@ -141,6 +142,8 @@
<BarsStaggered size={16} class="text-teal-500" />
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
<LayoutDashboard size={16} class="text-orange-500" />
{:else if favorite.kind == 'agent'}
<Bot size={16} class="text-violet-500" />
{:else if favorite.kind == 'asset'}
<Table2 size={16} class="text-primary" />
{/if}
@@ -7,6 +7,7 @@
AssetService,
FlowService,
OpenAPI,
ResourceService,
ScriptService,
SettingService,
UserService,
@@ -621,6 +622,11 @@
workspace: $workspaceStore ?? '',
starredOnly: true
})
const agents = await ResourceService.listResource({
workspace: $workspaceStore ?? '',
resourceType: 'ai_agent',
starredOnly: true
})
const assets = await AssetService.listFavoriteAssets({ workspace: $workspaceStore ?? '' })
favoriteManager.current = [
...scripts.map((s) => ({
@@ -641,6 +647,12 @@
href: getFavoriteHref(f.path, 'app'),
kind: 'app' as const
})),
...agents.map((a) => ({
label: a.description || getFavoriteLabel(a.path, 'agent'),
path: a.path,
href: getFavoriteHref(a.path, 'agent'),
kind: 'agent' as const
})),
...assets.map((a) => ({
label: getFavoriteLabel(a.path, 'asset'),
path: a.path,