feat: list agents with runnables, let them be starred, and fill out their menu (#11651)

* 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

* fix: list an agent's runs as the previews they are

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB

* feat: list agents in the merged runnables stream so they sort with everything else

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB

* perf: index agent paths so the merged listing and owner counts skip other resources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB

* feat: find agents in the ctrl+k search

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB

* feat: open an agent's version history from its menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-14 08:59:01 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 998e6877c1
commit fe2571ea53
19 changed files with 339 additions and 198 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$;
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_resource_agent_owner_prefix;
@@ -0,0 +1,7 @@
-- Backs the agent branch of /runnables/list and /runnables/counts, which reach the
-- `ai_agent` rows of `resource` by workspace and byte-ordered path prefix. Partial, so
-- neither walks the workspace's other resources (state, cache, ...).
-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs.
CREATE INDEX IF NOT EXISTS idx_resource_agent_owner_prefix
ON resource (workspace_id, path text_pattern_ops)
WHERE resource_type = 'ai_agent';
+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
+21 -8
View File
@@ -9367,6 +9367,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
@@ -12783,7 +12789,7 @@ paths:
/w/{workspace}/runnables/list:
get:
summary: list runnables (scripts, flows, apps) merged, ordered and keyset-paginated
summary: list runnables (scripts, flows, apps, agents) merged, ordered and keyset-paginated
operationId: listRunnables
tags:
- script
@@ -12800,7 +12806,7 @@ paths:
- $ref: "#/components/parameters/OrderDesc"
- name: kinds
in: query
description: comma-separated subset of script,flow,app (default all)
description: comma-separated subset of script,flow,app,agent (default all)
schema:
type: string
- name: show_archived
@@ -12874,7 +12880,7 @@ paths:
- $ref: "#/components/parameters/WorkspaceId"
- name: kinds
in: query
description: comma-separated subset of script,flow,app (default all)
description: comma-separated subset of script,flow,app,agent (default all)
schema:
type: string
- name: include_without_main
@@ -24541,7 +24547,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
@@ -24564,7 +24570,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
@@ -30236,9 +30242,10 @@ components:
description: |
A row in the merged runnables listing. `type` is the discriminator;
kind-specific fields (hash/language/kind for scripts, execution_mode/
version for apps) are present only for that kind. `edited_at` is the
unified last-updated time (a script's created_at, a flow/app's edit
time).
version for apps, agent_memory/ws_specific for agents) are present only
for that kind. An agent is an `ai_agent` resource whose description is
reported as `summary`. `edited_at` is the unified last-updated time (a
script's created_at, a flow/app/agent's edit time).
properties:
type:
type: string
@@ -30246,6 +30253,7 @@ components:
- script
- flow
- app
- agent
path:
type: string
summary:
@@ -30313,6 +30321,11 @@ components:
version:
type: integer
format: int64
agent_memory:
description: agent-only. The agent's `memory` setting.
ws_specific:
type: boolean
description: agent-only. Whether the agent is workspace-specific, which keeps it out of deploys to other workspaces.
required:
- type
- path
+3
View File
@@ -113,6 +113,9 @@ lazy_static::lazy_static! {
(20260909163047, include_str!(
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
(20261009173546, include_str!(
"../../migrations/20261009173546_agent_owner_prefix_index.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
// The widening fails every query the previous release's still-running
// servers prepared on the column; `variables::widen_variable_value` does it
// once they are gone.
+1
View File
@@ -32,6 +32,7 @@ pub enum FavoriteKind {
#[allow(non_camel_case_types)]
Raw_App,
Asset,
Agent,
}
#[derive(Deserialize)]
pub struct Favorite {
+128 -41
View File
@@ -7,15 +7,17 @@
*/
//! Unified, keyset-paginated listing of a workspace's runnables (scripts,
//! flows, apps) merged into one globally-ordered stream. The homepage uses it
//! so a chosen order (recently updated / oldest / name) is correct and complete
//! across all three kinds at any workspace size, instead of client-sorting a
//! flows, apps, and the `ai_agent` resources listed as agents) merged into one
//! globally-ordered stream. The homepage uses it so a chosen order (recently
//! updated / oldest / name) is correct and complete across every kind at any
//! workspace size, instead of client-sorting a
//! per-kind capped window.
//!
//! Efficiency: each kind is a UNION ALL branch ordered by an index on
//! `(workspace_id, archived, <sort key>)` (created_at / edited_at, or the lowered
//! summary-or-path expression for name orders); Postgres merges the ordered
//! branches and stops at the page limit. Pagination is keyset — a
//! branches and stops at the page limit. Agents, which are few, are instead
//! reached through the partial `idx_resource_agent_owner_prefix` and sorted whole. Pagination is keyset — a
//! `(sort_key, path, kind, tiebreak)` cursor, where `tiebreak` (a script's hash,
//! 0 for flow/app) is a stable final key that keeps the order total even when rows
//! tie on (sort_key, path, kind) — so deep pages don't re-scan. Visibility is
@@ -51,7 +53,7 @@ struct ListRunnablesQuery {
order_by: Option<String>,
/// Descending when true (default true).
order_desc: Option<bool>,
/// Comma-separated subset of `script,flow,app`; omitted means all.
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
kinds: Option<String>,
show_archived: Option<bool>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
@@ -80,7 +82,7 @@ struct ListRunnablesQuery {
#[derive(Serialize, sqlx::FromRow)]
struct RunnableItem {
#[serde(rename = "type")]
kind: String, // 'script' | 'flow' | 'app'
kind: String, // 'script' | 'flow' | 'app' | 'agent'
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
@@ -130,6 +132,11 @@ struct RunnableItem {
id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<i64>,
// agent-only: the `memory` setting, which decides whether the agent opens as a chat.
#[serde(skip_serializing_if = "Option::is_none")]
agent_memory: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
ws_specific: Option<bool>,
// sort keys, echoed into the cursor (not serialized to the client)
#[serde(skip)]
sort_time: chrono::DateTime<chrono::Utc>,
@@ -247,7 +254,7 @@ fn scope_path_predicate(
}
}
/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
/// The UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
/// column set (NULL for columns that don't apply to that kind). `$1`=workspace,
/// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request
/// WHERE fragments are appended by the caller.
@@ -255,6 +262,7 @@ struct Branches {
script: String,
flow: String,
app: String,
agent: String,
}
fn branch_sqls() -> Branches {
@@ -281,6 +289,7 @@ fn branch_sqls() -> Branches {
o.codebase IS NOT NULL as use_codebase, \
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \
FROM script o \
LEFT JOIN favorite ON favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
@@ -298,6 +307,7 @@ fn branch_sqls() -> Branches {
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
(o.value->>'chat_input_enabled')::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM flow o \
LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
@@ -315,6 +325,7 @@ fn branch_sqls() -> Branches {
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \
o.versions[array_upper(o.versions, 1)] as version, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM app o \
LEFT JOIN favorite ON favorite.favorite_kind = 'app' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
@@ -323,25 +334,68 @@ fn branch_sqls() -> Branches {
draft_users = draft_users("d.typ IN ('app', 'raw_app')")
);
Branches { script, flow, app }
// An agent is an `ai_agent` resource; the subquery renames its description to the
// `summary` the shared search and name sort read.
let agent = format!(
"SELECT 'agent' as kind, o.path, o.summary, o.workspace_id, o.extra_perms, \
favorite.path IS NOT NULL as starred, false as archived, \
draft.email IS NOT NULL as is_draft, NULL::bool as draft_only, NULL::text as draft_path, \
{draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \
NULL::bool as ws_error_handler_muted, o.edited_at, \
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.agent_memory, ws_specific.path IS NOT NULL as ws_specific, \
COALESCE(o.edited_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM (SELECT workspace_id, path, description as summary, extra_perms, labels, edited_at, \
value->'memory' as agent_memory \
FROM resource WHERE resource_type = 'ai_agent') o \
LEFT JOIN favorite ON favorite.favorite_kind = 'agent' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
LEFT JOIN draft ON draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'resource' AND draft.email = $3 \
LEFT JOIN ws_specific ON ws_specific.path = o.path AND ws_specific.workspace_id = o.workspace_id AND ws_specific.item_kind = 'resource'",
draft_users = draft_users("d.typ = 'resource'")
);
Branches { script, flow, app, agent }
}
/// The draft rows a kind lists and the table whose rows make a draft not draft-only.
fn draft_kind_source(kind: &str) -> (&'static str, &'static str) {
match kind {
"script" => ("d.typ = 'script'", "script"),
"flow" => ("d.typ = 'flow'", "flow"),
// Typed by the draft itself, as the resource listing filters its draft-only rows.
"agent" => (
"d.typ = 'resource' AND d.value->>'resource_type' = 'ai_agent'",
"resource",
),
_ => ("d.typ IN ('app', 'raw_app')", "app"),
}
}
/// Where a draft records the path its editor has staged. Scripts and resources bind the
/// Path widget to the item's own `path`, so it round-trips through the draft JSON's
/// `path`; flows and apps write a separate `draft_path` only when it differs from the
/// deployed one. See scripts.rs.
fn draft_typed_path(kind: &str) -> &'static str {
if kind == "script" || kind == "agent" {
"path"
} else {
"draft_path"
}
}
/// The draft-only branch for a kind: the caller's drafts at paths carrying no
/// deployed row, projected into the same column set as `branch_sqls` so they
/// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract.
fn draft_branch_sql(kind: &str) -> String {
let (typ_pred, deployed) = match kind {
"script" => ("d.typ = 'script'", "script"),
"flow" => ("d.typ = 'flow'", "flow"),
_ => ("d.typ IN ('app', 'raw_app')", "app"),
};
// Scripts bind the Path widget to `script.path`, so the typed path round-trips
// through the draft JSON's own `path`; flows and apps write a separate
// `draft_path` only when it differs from the deployed one. See scripts.rs.
let typed_path = if kind == "script" {
"path"
let (typ_pred, deployed) = draft_kind_source(kind);
let typed_path = draft_typed_path(kind);
// A resource draft carries the description where the other kinds carry a summary.
let summary_field = if kind == "agent" {
"description"
} else {
"draft_path"
"summary"
};
// `auto_kind` is only what the editor stamped into the draft — a `// pipeline`
// annotation is not re-derived from the content here, unlike the per-kind
@@ -351,7 +405,8 @@ fn draft_branch_sql(kind: &str) -> String {
"script" => {
"d.value->>'language' as language, d.value->>'kind' as script_kind, \
d.value->>'auto_kind' as auto_kind, false as raw_app, \
NULL::bool as chat_input_enabled"
NULL::bool as chat_input_enabled, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
}
// Type-guarded rather than a bare `::bool` cast: draft JSON is stored
// unvalidated, so a malformed value must yield NULL, not abort the list,
@@ -360,11 +415,20 @@ fn draft_branch_sql(kind: &str) -> String {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
false as raw_app, \
CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled"
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
}
"agent" => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
false as raw_app, NULL::bool as chat_input_enabled, \
(d.value->'args'->'memory')::jsonb as agent_memory, \
CASE WHEN json_typeof(d.value->'wsSpecific') = 'boolean' \
THEN (d.value->>'wsSpecific')::bool END as ws_specific"
}
_ => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled"
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
}
};
format!(
@@ -378,11 +442,12 @@ fn draft_branch_sql(kind: &str) -> String {
NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \
o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.agent_memory, o.ws_specific, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \
FROM ( \
SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \
d.email IS NULL as legacy, \
COALESCE(d.value->>'summary', '') as summary, \
COALESCE(d.value->>'{summary_field}', '') as summary, \
NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \
{kind_cols} \
FROM draft d \
@@ -397,6 +462,8 @@ fn draft_branch_sql(kind: &str) -> String {
)
}
const ALL_KINDS: [&str; 4] = ["script", "flow", "app", "agent"];
async fn list_runnables(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -416,16 +483,16 @@ async fn list_runnables(
};
let mut kinds: Vec<&str> = match q.kinds.as_deref() {
None | Some("") => vec!["script", "flow", "app"],
None | Some("") => ALL_KINDS.to_vec(),
Some(csv) => csv
.split(',')
.map(|s| s.trim())
.filter(|s| ["script", "flow", "app"].contains(s))
.filter(|s| ALL_KINDS.contains(s))
.collect(),
};
// Apps carry no `archived` column and are never listed as archived.
// Apps and agents carry no `archived` column and are never listed as archived.
if show_archived {
kinds.retain(|k| *k != "app");
kinds.retain(|k| *k != "app" && *k != "agent");
}
let branches = branch_sqls();
@@ -533,6 +600,11 @@ async fn list_runnables(
} else {
None
};
let agent_scope = if kinds.contains(&"agent") {
scope_path_predicate(&authed, "resources", "o", 3, &mut binds)
} else {
None
};
// Per-kind archived predicate (scripts/flows have the column; apps don't and
// are excluded from the archived view).
@@ -570,6 +642,10 @@ async fn list_runnables(
if let Some(s) = &app_scope {
app_extras.push(s.clone());
}
let mut agent_extras: Vec<String> = vec![];
if let Some(s) = &agent_scope {
agent_extras.push(s.clone());
}
// Draft-only rows are the caller's own work in progress: never archived, so they
// have no place in the archived view, and carrying no labels of their own they are
@@ -589,6 +665,7 @@ async fn list_runnables(
let scope = match kind {
"script" => &script_scope,
"flow" => &flow_scope,
"agent" => &agent_scope,
_ => &app_scope,
};
// The lib filter reads the same projected `auto_kind`, so a draft-only library
@@ -649,6 +726,7 @@ async fn list_runnables(
"script" => (&branches.script, &script_extras),
"flow" => (&branches.flow, &flow_extras),
"app" => (&branches.app, &app_extras),
"agent" => (&branches.agent, &agent_extras),
_ => return None,
};
Some(build_branch(
@@ -708,7 +786,7 @@ async fn list_runnables(
// No draft branch here: a draft-only path has no favorite row (the UI won't let
// you star one), so it would scan the caller's whole draft slice per kind to
// return nothing. The main stream below takes them unfiltered instead.
let starred_branches: Vec<String> = ["script", "flow", "app"]
let starred_branches: Vec<String> = ALL_KINDS
.iter()
.filter_map(|k| branch_for(k, Some(true), None, None))
.collect();
@@ -728,10 +806,10 @@ async fn list_runnables(
// Main paged stream: non-starred rows (starred were pinned on the first page above).
let main_fav = Some(false);
let ns_branches: Vec<String> =
["script", "flow", "app"]
ALL_KINDS
.iter()
.filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)))
.chain(["script", "flow", "app"].iter().filter_map(|k| {
.chain(ALL_KINDS.iter().filter_map(|k| {
draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))
}))
.collect();
@@ -762,7 +840,7 @@ async fn list_runnables(
#[derive(Deserialize)]
struct CountRunnablesQuery {
/// Comma-separated subset of `script,flow,app`; omitted means all.
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
kinds: Option<String>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
include_without_main: Option<bool>,
@@ -789,7 +867,7 @@ struct OwnerCount {
/// `owner/<rest>` is >= `owner || '/'` and, since '/' (0x2F) is immediately
/// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the
/// text_pattern_ops operators, which is what lets `idx_<kind>_owner_prefix`
/// answer the count with an index-only scan — the default opclass sorts by the
/// (`idx_resource_agent_owner_prefix` for agents) answer the count with an index-only scan — the default opclass sorts by the
/// database collation and cannot serve a byte-prefix range.
fn owner_prefix_range(alias: &str) -> String {
format!("{alias}.path ~>=~ (o.owner || '/') AND {alias}.path ~<~ (o.owner || '0')")
@@ -818,13 +896,13 @@ async fn count_runnables_by_owner(
Query(q): Query<CountRunnablesQuery>,
) -> JsonResult<RunnableCountsResponse> {
let kinds: Vec<&str> = match q.kinds.as_deref() {
None | Some("") => vec!["script", "flow", "app"],
None | Some("") => ALL_KINDS.to_vec(),
// Deduplicated: every kind becomes its own count subquery, so a repeated
// entry would both double that kind's count and multiply the scans.
Some(csv) => {
let mut ks: Vec<&str> = vec![];
for k in csv.split(',').map(|s| s.trim()) {
if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) {
if ALL_KINDS.contains(&k) && !ks.contains(&k) {
ks.push(k);
}
}
@@ -866,6 +944,13 @@ async fn count_runnables_by_owner(
w.push(s);
}
}
"agent" => {
w.push(format!("{alias}.resource_type = 'ai_agent'"));
if let Some(s) = scope_path_predicate(&authed, "resources", alias, base, binds)
{
w.push(s);
}
}
_ => {
if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) {
w.push(s);
@@ -877,6 +962,7 @@ async fn count_runnables_by_owner(
let table_of = |kind: &str| match kind {
"script" => "script",
"flow" => "flow",
"agent" => "resource",
_ => "app",
};
@@ -1048,16 +1134,17 @@ async fn add_draft_counts(
let branches: Vec<String> = kinds
.iter()
.map(|kind| {
let (typ_pred, deployed, domain) = match *kind {
"script" => ("d.typ = 'script'", "script", "scripts"),
"flow" => ("d.typ = 'flow'", "flow", "flows"),
_ => ("d.typ IN ('app', 'raw_app')", "app", "apps"),
let (typ_pred, deployed) = draft_kind_source(kind);
let domain = match *kind {
"script" => "scripts",
"flow" => "flows",
"agent" => "resources",
_ => "apps",
};
// The owner a draft counts under is where it says it will live, not the
// `u/<caller>/draft_<uuid>` it is parked at — same path `/list` groups and
// filters on. Scripts round-trip the typed path through the draft JSON's
// own `path`; flows and apps write `draft_path`. See scripts.rs.
let typed_path = if *kind == "script" { "path" } else { "draft_path" };
// filters on.
let typed_path = draft_typed_path(kind);
let effective_path =
format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path");
let mut w = vec![
+11
View File
@@ -261,6 +261,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)]
@@ -437,6 +439,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 +482,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
@@ -13,6 +13,7 @@
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import Row from './Row.svelte'
import { selectMenuItems, type RowSelection } from './rowSelection'
@@ -54,8 +55,16 @@
let editHref = $derived(`/agents/edit/${agent.path}`)
// A draft-only agent has nothing deployed for its page to run, so it opens in the editor.
let rowHref = $derived(agent.draft_only ? editHref : `/agents/get/${agent.path}`)
let history: AgentHistory | undefined = $state(undefined)
</script>
<AgentHistory
bind:this={history}
path={agent.path}
canRestore={agent.canWrite}
onRestored={() => dispatch('change')}
/>
{#snippet chatBadge()}
<ChatFlowBadge title="Managed memory on: this agent opens as a conversation" />
{/snippet}
@@ -69,7 +78,7 @@
path={agent.draft_path ?? agent.path}
summary={agent.description}
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
canFavorite={false}
canFavorite={!agent.draft_only}
{depth}
{rowSelection}
>
@@ -110,8 +119,10 @@
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'),
onHistory: () => history?.open(),
onDelete: (event) => {
const { path } = agent
if (event?.shiftKey) remove(path)
@@ -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>
@@ -0,0 +1,34 @@
<script lang="ts">
import { Drawer, DrawerContent } from '$lib/components/common'
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
import { workspaceStore } from '$lib/stores'
import { markAgentWritten } from './agentEditorStore.svelte'
interface Props {
path: string
canRestore: boolean
onRestored?: () => void
}
let { path, canRestore, onRestored }: Props = $props()
let drawer: Drawer | undefined = $state()
export function open() {
drawer?.openDrawer()
}
</script>
<Drawer bind:this={drawer} size="1200px">
<DrawerContent title="Version history" on:close={() => drawer?.closeDrawer()} noPadding>
<ResourceVersionHistory
{path}
{canRestore}
onRestore={() => {
drawer?.closeDrawer()
// A restore writes the resource as a deploy does, so whatever shows this agent re-reads it.
markAgentWritten($workspaceStore ?? undefined, path)
onRestored?.()
}}
/>
</DrawerContent>
</Drawer>
@@ -1,8 +1,9 @@
import { FileUp, Shield, Trash } from 'lucide-svelte'
import { Copy, Eye, FileUp, History, 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 { goto } from '$lib/navigation'
import type { WorkspaceDeployUISettings } from '$lib/gen'
/** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */
@@ -24,10 +25,13 @@ 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
onDeploy: () => void
onHistory: () => void
onDelete: (event?: MouseEvent) => void
}): Item[] {
const deployable =
@@ -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
? []
: [
{
// An agent run is a flow preview, which the runs page hides by default. Chat turns
// are filed under `<path>.chat` (agent_runs.rs) and listed by the agent's own chat.
displayName: 'View runs',
icon: List,
action: () => goto(`/runs/${agent.path}?job_kinds=previews`)
},
...(agent.operator
? []
: [
{
displayName: 'Audit logs',
icon: Eye,
action: () => goto(`/audit_logs?resource=${agent.path}`)
}
]),
{ displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) }
]),
{
displayName: 'Permissions',
icon: Shield,
@@ -44,6 +70,9 @@ export function agentMenuItems(agent: {
...(deployable
? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }]
: []),
...(agent.draftOnly || agent.operator
? []
: [{ displayName: 'Version history', icon: History, action: agent.onHistory }]),
{
displayName: 'Delete',
icon: Trash,
+22 -138
View File
@@ -10,7 +10,6 @@
type ListableApp,
type Script,
ScriptService,
ResourceService,
type Flow,
type ListableRawApp,
type ListableResource,
@@ -175,12 +174,7 @@
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 runnableKinds = $derived(itemKind === 'all' ? 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)
@@ -318,11 +312,7 @@
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
let agents: TableAgent[] | undefined = $state()
// Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord).
let fetchOrd = 0
@@ -353,7 +343,7 @@
}
}
function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp {
function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent {
const base = {
...it,
canWrite:
@@ -367,84 +357,14 @@
// combinedItems reads a script's time from `created_at`; the endpoint's
// unified `edited_at` holds exactly that for scripts.
if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript
// An agent row reads its resource's description, which the endpoint reports as `summary`.
if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent
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 the runnables' keyset order knows nothing of, so the order is applied
* here and the rows are stamped before every runnable ordinal: the agents lead as one block and
* a page of runnables keeps the server's order. Leading rather than trailing, or the list's
* first window would never reach them past a page of runnables.
*/
async function loadAgents(): Promise<void> {
const ws = $workspaceStore
const gen = ++agentsGen
if (!ws || !$userStore || !showsAgents || archived) {
agents = []
agentOwnerCounts = {}
return
}
const rows: ListableResource[] = []
try {
// Paged even though search and scoping run over the whole set here: the listing caps a
// page, and only the first carries the draft-only rows, so a short page ends it.
const perPage = 1000
for (let page = 1; ; page++) {
const batch = await ResourceService.listResource({
workspace: ws,
resourceType: 'ai_agent',
includeDraftOnly: true,
page,
perPage
})
rows.push(...batch)
if (batch.length < perPage || gen !== agentsGen) break
}
} 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 = effectivePath(r).split('/').slice(0, 2).join('/')
counts[owner] = (counts[owner] ?? 0) + 1
}
agentOwnerCounts = counts
const scoped = ownerFilter
? rows.filter((r) => effectivePath(r).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(effectivePath(a), effectivePath(b))
case 'name_desc':
return cmp(effectivePath(b), effectivePath(a))
default:
return byTime(b) - byTime(a)
}
})
agents = sorted.map((r, i) => ({
...r,
summary: r.description || undefined,
canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any),
ord: AGENT_ORD_BASE + i
}))
}
/** Below any ordinal a runnables page stamps (they count up from 0), so agents lead 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
// appends the next page. Every kind arrives interleaved and are split into
// the existing per-kind arrays so the downstream pipeline is unchanged.
async function loadRunnables(reset: boolean): Promise<void> {
const ws = $workspaceStore
@@ -458,25 +378,6 @@
// arrays (mixing streams) or clobber the pending reset's generation.
if (!reset && serverCursor === undefined) return
if (scripts === undefined) loading = true
// One load per reset: agents are fetched whole, so a load-more has nothing to append.
const agentsLoad = reset ? loadAgents() : undefined
if (!showsRunnables) {
// Supersedes a page still in flight, which would otherwise land in the agent view.
const gen = ++loadGen
serverCursor = undefined
hasMoreServer = false
// The rows on screen stay until the agents replace them, as a reload keeps them, or the
// view reads as empty in between.
await agentsLoad
if (gen !== loadGen) return
scripts = []
flows = []
apps = []
raw_apps = []
pipelineMemberFolders = new Set()
loading = false
return
}
if (reset) {
serverCursor = undefined
hasMoreServer = false
@@ -519,18 +420,13 @@
// A newer request superseded this one (e.g. order changed mid-flight); drop
// this response so a stale page/cursor can't be mixed with the new order.
if (gen !== loadGen) return
// Landed together with the agents fetched beside it, or a workspace holding only agents
// reads as empty until they arrive.
if (agentsLoad) {
await agentsLoad
if (gen !== loadGen) return
}
serverCursor = res.next_cursor ?? undefined
hasMoreServer = !!res.next_cursor
const s: TableScript[] = reset ? [] : [...(scripts ?? [])]
const f: TableFlow[] = reset ? [] : [...(flows ?? [])]
const a: TableApp[] = reset ? [] : [...(apps ?? [])]
const ag: TableAgent[] = reset ? [] : [...(agents ?? [])]
const memberFolders = reset ? new Set<string>() : new Set(pipelineMemberFolders)
if (reset) fetchOrd = 0
for (const it of res.items ?? []) {
@@ -546,11 +442,14 @@
f.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableFlow)
} else if (it.type === 'app') {
a.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableApp)
} else if (it.type === 'agent') {
ag.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableAgent)
}
}
scripts = s
flows = f
apps = a
agents = ag
raw_apps = []
pipelineMemberFolders = memberFolders
loading = false
@@ -564,10 +463,13 @@
// skipping ones already present. Lets a search reach matches outside the loaded
// browse window without re-fetching the whole list.
function mergeRunnables(newItems: RunnableItem[]) {
const have = new Set([...(scripts ?? []), ...(flows ?? []), ...(apps ?? [])].map(itemKey))
const have = new Set(
[...(scripts ?? []), ...(flows ?? []), ...(apps ?? []), ...(agents ?? [])].map(itemKey)
)
const s = [...(scripts ?? [])]
const f = [...(flows ?? [])]
const a = [...(apps ?? [])]
const ag = [...(agents ?? [])]
const memberFolders = new Set(pipelineMemberFolders)
let changed = false
for (const it of newItems) {
@@ -583,12 +485,14 @@
if (it.type === 'script') s.push(mapped as TableScript)
else if (it.type === 'flow') f.push(mapped as TableFlow)
else if (it.type === 'app') a.push(mapped as TableApp)
else if (it.type === 'agent') ag.push(mapped as TableAgent)
changed = true
}
if (changed) {
scripts = s
flows = f
apps = a
agents = ag
pipelineMemberFolders = memberFolders
}
}
@@ -656,13 +560,6 @@
// Track the prefix as open first — even a no-op call (re-expanding a cached node)
// means it's on screen, so later reloads must refresh it.
openOwners.add(owner)
// The agent view has no runnables to page in: its rows are the loaded agents, which
// `treeSource` adds itself. Rows an owner held from another kind go with the switch.
if (!showsRunnables) {
treeOwnerItems = treeOwnerItems.filter((x) => !effectivePath(x).startsWith(`${owner}/`))
ownerLoad[owner] = { hasMore: false, loading: false, loaded: true, gen: treeGen }
return
}
const st = ownerLoad[owner]
// Only a load for the CURRENT generation blocks a new one. A load left in flight
// by a superseded generation (treeGen bumped on a sort/filter reload) has already
@@ -1105,7 +1002,6 @@
[() => $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,
@@ -1121,16 +1017,7 @@
}
}
)
// 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
})
let ownerCounts = $derived(ownerCountsRes.current)
// 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,
@@ -1314,8 +1201,7 @@
const kind = itemKind
// Any term/scope change restarts search paging.
searchCursor = undefined
// Agents are all loaded, so the client-side search over them is already complete.
if (term === '' || !ws || !$userStore || kind === 'agent') return
if (term === '' || !ws || !$userStore) return
const handle = setTimeout(async () => {
let res: { items: RunnableItem[]; next_cursor?: string }
try {
@@ -1371,7 +1257,7 @@
search: term,
showArchived: showArchived ? true : undefined,
includeWithoutMain: withoutMain ? true : undefined,
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
kinds: kind !== 'all' ? kind : undefined,
pathStart: owner ? owner + '/' : undefined,
includeDraftOnly: true,
perPage: 1000,
@@ -1423,7 +1309,7 @@
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime()
})),
...agents.map((x) => ({
...(agents ?? []).map((x) => ({
...x,
type: 'agent' as 'agent',
time: new Date(x.edited_at ?? 0).getTime()
@@ -1487,12 +1373,10 @@
// 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, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter(
(x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
? treeOwnerItems.filter((x) =>
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
: items
)
@@ -3,16 +3,19 @@
import {
AppService,
FlowService,
ResourceService,
ScriptService,
type Flow,
type ListableApp,
type ListableRawApp,
type ListableResource,
type Script,
type SearchJobsIndexResponse
} from '$lib/gen'
import { clickOutside, isMac, scroll_into_view_if_needed_polyfill } from '$lib/utils'
import {
AlertTriangle,
Bot,
BoxesIcon,
CalendarIcon,
Code2Icon,
@@ -432,6 +435,9 @@
case 'raw_app':
path = `/apps_raw/get/${e.path}`
break
case 'agent':
path = `/agents/get/${e.path}`
break
default:
path = '/'
}
@@ -501,7 +507,7 @@
}
}
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
search_id: string
marked?: string
type?: U
@@ -518,7 +524,9 @@
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
type TableAny = TableScript | TableFlow | TableApp | TableRawApp
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
type TableAny = TableScript | TableFlow | TableApp | TableRawApp | TableAgent
let combinedItems: TableAny[] | undefined = $state(undefined)
@@ -532,8 +540,18 @@
withoutDescription: true
})
const apps = await AppService.listApps({ workspace: $workspaceStore! })
const [agents, starredAgents] = await Promise.all(
[false, true].map((starredOnly) =>
ResourceService.listResource({
workspace: $workspaceStore!,
resourceType: 'ai_agent',
starredOnly
})
)
)
const starredAgentPaths = new Set(starredAgents.map((x) => x.path))
let combinedItems: (TableScript | TableFlow | TableApp | TableRawApp)[] | undefined = [
let combinedItems: TableAny[] | undefined = [
...flows.map((x) => ({
...x,
type: 'flow' as 'flow',
@@ -555,7 +573,17 @@
type: 'app' as 'app',
time: new Date(x.edited_at).getTime(),
search_id: x.path
}))
})),
...agents
.filter((x) => !x.draft_only)
.map((x) => ({
...x,
summary: x.description,
type: 'agent' as 'agent',
time: x.edited_at ? new Date(x.edited_at).getTime() : 0,
starred: starredAgentPaths.has(x.path),
search_id: x.path
}))
].sort((a, b) => (a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1))
return combinedItems
@@ -571,6 +599,8 @@
return LayoutDashboardIcon
case 'raw_app':
return LayoutDashboardIcon
case 'agent':
return Bot
}
}
@@ -729,7 +759,9 @@
{#if tab === 'default'}
{#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0}
<div class="p-2">
<div class="py-2 px-1 text-xs font-semibold text-primary"> Flows/Scripts/Apps </div>
<div class="py-2 px-1 text-xs font-semibold text-primary">
Flows/Scripts/Apps/Agents</div
>
{#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el}
<QuickMenuItem
onselect={(shift) => {
@@ -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,
@@ -670,6 +671,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) => ({
@@ -690,6 +696,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,
@@ -17,6 +17,7 @@
import { Alert } from '$lib/components/common'
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -40,6 +41,7 @@
let configModal: AgentConfigModal | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let history: AgentHistory | undefined = $state(undefined)
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
let runForm: RunForm | undefined = $state(undefined)
@@ -77,6 +79,7 @@
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onHistory: () => history?.open(),
onDelete: () => (deleteOpen = true)
}).map((item) => ({
label: item.displayName,
@@ -91,6 +94,7 @@
<ShareModal bind:this={shareModal} />
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} />
<ConfirmationModal
open={deleteOpen}
title="Delete agent"