mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: rename saved agents from the agent editor and flag broken links (#11147)
* feat: list flows that link a saved agent and flag broken agent links * feat: rename saved agents from the agent editor and repoint the flow * fix: show an unreadable linked agent as not accessible, not missing * fix: address review nits on agent rename and missing-agent state * fix: open content search above modals and keep Escape for it * fix: register content search on the opener's overlay stack * docs: scope the global search z-index comment to the bases it clears * refactor: show linked agents' rename warning as for scripts and flows * fix: keep the failed-lookup rename warning to resources
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3",
|
||||
"query": "SELECT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "00c0ae12b19ba495f307f0ce6b4833947c5b3fe45826fc5468e326d171d95236"
|
||||
"hash": "0c49b098051900b834cb791e37af2e38967680a77316ee9a53e67d70d7df9f63"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3",
|
||||
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru \n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc5eeb7b7bf0b7217ef66eb950ab7e9cf578bba7bd1eec981526be4067bcb314"
|
||||
"hash": "139e153d1ebe584e878d3b2569551892fa3899934e7e0e51ad23bfcd2d6d3d08"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2aa87574b437f0e29991696564c4250441863ab47c345d9685f53c7e224b4887"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT f.path\n FROM workspace_runnable_dependencies wru\n JOIN flow f\n ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id\n WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "46e65196c2a4f07d171a22f1e45c6ac927bd0e6b0f383626e673225065ee90bf"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)\n SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path\n FROM workspace_runnable_dependencies\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a54e2334c365f90577f68ebefc8f3bee9b93ba0387f566ad7f502cbee818296e"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fa8c36eda6d4cb64b4ac5979cc4eea76ab0226a514d25712a733e01add508b71"
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
DELETE FROM workspace_runnable_dependencies WHERE runnable_is_agent;
|
||||
|
||||
DROP INDEX flow_workspace_without_hash_unique_idx;
|
||||
|
||||
CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx
|
||||
ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, workspace_id)
|
||||
WHERE script_hash IS NULL;
|
||||
|
||||
ALTER TABLE workspace_runnable_dependencies DROP COLUMN runnable_is_agent;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- A flow step linked to a saved agent (an `ai_agent` resource) is recorded next to the scripts and
|
||||
-- subflows the flow runs, so renaming the agent can name the flows it would break. An agent row is
|
||||
-- neither a script nor a flow: readers of script usages have to exclude it.
|
||||
ALTER TABLE workspace_runnable_dependencies
|
||||
ADD COLUMN runnable_is_agent BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- A script step and a linked agent can share a path. Without the flag in the key, the second
|
||||
-- insert's ON CONFLICT DO NOTHING would silently drop one of the two rows.
|
||||
DROP INDEX flow_workspace_without_hash_unique_idx;
|
||||
|
||||
CREATE UNIQUE INDEX flow_workspace_without_hash_unique_idx
|
||||
ON workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id)
|
||||
WHERE script_hash IS NULL;
|
||||
|
||||
-- The worker only records a flow when it is next deployed, so seed the ones already linking an
|
||||
-- agent from their current value.
|
||||
INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id)
|
||||
SELECT DISTINCT f.path, agent_ref #>> '{}', false, true, f.workspace_id
|
||||
FROM flow f
|
||||
CROSS JOIN LATERAL jsonb_path_query(f.value, 'lax $.** ? (@.type == "aiagent" && @.agent.type() == "string").agent') AS agent_ref
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -232,7 +232,7 @@ workspace_key: workspace_id(char), kind(workspace_key_kind), key(char)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_groups(text[]), bypass_users(text[]), created_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
|
||||
@@ -77,6 +77,10 @@ pub fn workspaced_service() -> Router {
|
||||
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
|
||||
get(list_paths_from_workspace_runnable),
|
||||
)
|
||||
.route(
|
||||
"/list_paths_linking_agent/{*path}",
|
||||
get(list_paths_linking_agent),
|
||||
)
|
||||
.route("/history_update/v/{version}", post(update_flow_history))
|
||||
.route("/get/v/{version}", get(get_flow_version_by_id))
|
||||
.route("/get/v/{version}/p/{*path}", get(get_flow_version))
|
||||
@@ -508,7 +512,7 @@ async fn list_paths_from_workspace_runnable(
|
||||
FROM workspace_runnable_dependencies wru
|
||||
JOIN flow f
|
||||
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
|
||||
WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
|
||||
WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#,
|
||||
path,
|
||||
matches!(runnable_kind, RunnableKind::Flow),
|
||||
w_id
|
||||
@@ -521,7 +525,7 @@ async fn list_paths_from_workspace_runnable(
|
||||
FROM workspace_runnable_dependencies wru
|
||||
JOIN flow f
|
||||
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
|
||||
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#,
|
||||
WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND NOT wru.runnable_is_agent AND wru.workspace_id = $3"#,
|
||||
path,
|
||||
matches!(runnable_kind, RunnableKind::Flow),
|
||||
w_id
|
||||
@@ -534,6 +538,30 @@ async fn list_paths_from_workspace_runnable(
|
||||
Ok(Json(runnables))
|
||||
}
|
||||
|
||||
/// Flows with a step linked to the `ai_agent` resource at `path`, as of their last deploy.
|
||||
async fn list_paths_linking_agent(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:read:agent/{}", path))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let flows = sqlx::query_scalar!(
|
||||
r#"SELECT DISTINCT f.path
|
||||
FROM workspace_runnable_dependencies wru
|
||||
JOIN flow f
|
||||
ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id
|
||||
WHERE wru.runnable_path = $1 AND wru.runnable_is_agent AND wru.workspace_id = $2"#,
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(flows))
|
||||
}
|
||||
|
||||
async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if new_flow.ws_error_handler_muted.is_some_and(|val| val) {
|
||||
|
||||
@@ -7471,8 +7471,8 @@ async fn clone_workspace_runnable_dependencies(
|
||||
) -> Result<()> {
|
||||
// Clone workspace_runnable_dependencies
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, workspace_id, app_path)
|
||||
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, $1, app_path
|
||||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, workspace_id, app_path)
|
||||
SELECT flow_path, runnable_path, script_hash, runnable_is_flow, runnable_is_agent, $1, app_path
|
||||
FROM workspace_runnable_dependencies
|
||||
WHERE workspace_id = $2",
|
||||
target_workspace_id,
|
||||
|
||||
@@ -12103,6 +12103,25 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/list_paths_linking_agent/{path}:
|
||||
get:
|
||||
summary: list flow paths with a step linked to a saved agent
|
||||
operationId: listFlowPathsLinkingAgent
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: paths of the flows linking the `ai_agent` resource, as of their last deploy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/get/v/{version}:
|
||||
get:
|
||||
summary: get flow version
|
||||
|
||||
@@ -1795,6 +1795,17 @@ async fn lock_modules(
|
||||
agent,
|
||||
tool_inputs,
|
||||
} => {
|
||||
if let Some(agent_path) = agent.as_deref().filter(|_| !skip_flow_update) {
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_runnable_dependencies (flow_path, runnable_path, runnable_is_flow, runnable_is_agent, workspace_id) VALUES ($1, $2, FALSE, TRUE, $3) ON CONFLICT DO NOTHING",
|
||||
job_path,
|
||||
agent_path,
|
||||
job.workspace_id,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Extract FlowModules from tools and track their original indices
|
||||
// MCP tools don't need locking, so we filter them out
|
||||
let mut flow_modules = Vec::new();
|
||||
|
||||
@@ -510,8 +510,8 @@
|
||||
draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user ?? $userStore ?? undefined)])
|
||||
)
|
||||
// The path is passed, so a draft that renames the agent is refused here too: a rename is
|
||||
// the resource editor's to deploy, and this dialog lists the agent under the path the
|
||||
// flow links.
|
||||
// the agent editor's to deploy, and this dialog lists the agent under the path the flow
|
||||
// links.
|
||||
agentRefusal = Object.fromEntries(
|
||||
draftAgents.map((a) => [a.path, agentDraftDeployRefusal(a.state, a.path)])
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { Alert, Button } from './common'
|
||||
import { overlayStack, type OverlayStack } from './common/overlayHost.svelte'
|
||||
import { random_adj } from './random_positive_adjetive'
|
||||
import { ChevronDown, Copy, SearchCode } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
@@ -405,9 +406,11 @@
|
||||
}
|
||||
})
|
||||
|
||||
const openSearchWithPrefilledText: (t?: string) => void = getContext(
|
||||
const openSearchWithPrefilledText: (t?: string, stack?: OverlayStack) => void = getContext(
|
||||
'openSearchWithPrefilledText'
|
||||
)
|
||||
// Handed to the search so it stacks above the modal or drawer this field sits in.
|
||||
const searchStack = overlayStack()
|
||||
|
||||
$effect.pre(() => {
|
||||
;[meta?.name, meta?.owner, meta?.ownerKind]
|
||||
@@ -451,14 +454,18 @@
|
||||
initialPath !== path
|
||||
)
|
||||
let pathUsageInFlowsPromise = $derived(
|
||||
(kind == 'script' || kind == 'flow') &&
|
||||
ws &&
|
||||
initialPath &&
|
||||
FlowService.listFlowPathsFromWorkspaceRunnable({
|
||||
workspace: ws,
|
||||
path: initialPath,
|
||||
runnableKind: kind
|
||||
})
|
||||
ws && initialPath
|
||||
? kind == 'script' || kind == 'flow'
|
||||
? FlowService.listFlowPathsFromWorkspaceRunnable({
|
||||
workspace: ws,
|
||||
path: initialPath,
|
||||
runnableKind: kind
|
||||
})
|
||||
: kind == 'resource'
|
||||
? // Only steps linked to a saved agent are tracked; other `$res:` references are not.
|
||||
FlowService.listFlowPathsLinkingAgent({ workspace: ws, path: initialPath })
|
||||
: undefined
|
||||
: undefined
|
||||
)
|
||||
let pathUsageInAppsPromise = $derived(
|
||||
(kind == 'script' || kind == 'flow') &&
|
||||
@@ -647,24 +654,36 @@
|
||||
</ul>
|
||||
</Alert>
|
||||
{/if}
|
||||
{:else if displayPathChangedWarning && kind == 'resource'}
|
||||
{@render renameMayBreakWarning()}
|
||||
{/if}
|
||||
{:catch}
|
||||
<!-- A resource's references beyond linked agents are never looked up, so a failed lookup
|
||||
still leaves it with the generic warning. -->
|
||||
{#if displayPathChangedWarning && kind == 'resource'}
|
||||
{@render renameMayBreakWarning()}
|
||||
{/if}
|
||||
{/await}
|
||||
{:else if displayPathChangedWarning}
|
||||
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
|
||||
You are renaming an item that may be depended upon by other items. This may break apps, flows
|
||||
or resources. Find if it used elsewhere using the content search. Note that linked variables
|
||||
and resources (having the same path) are automatically moved together.
|
||||
<div class="flex pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
openSearchWithPrefilledText('#')
|
||||
}}
|
||||
startIcon={{ icon: SearchCode }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{@render renameMayBreakWarning()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet renameMayBreakWarning()}
|
||||
<Alert type="warning" class="mt-4" title="Moving may break other items relying on it">
|
||||
You are renaming an item that may be depended upon by other items. This may break apps, flows or
|
||||
resources. Find if it used elsewhere using the content search. Note that linked variables and
|
||||
resources (having the same path) are automatically moved together.
|
||||
<div class="flex pt-2">
|
||||
<Button
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
openSearchWithPrefilledText('#', searchStack)
|
||||
}}
|
||||
startIcon={{ icon: SearchCode }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{/snippet}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
|
||||
import { agentEditorTarget, type AgentEditorTarget } from './agentEditorStore.svelte'
|
||||
import AgentEditorModal from './content/AgentEditorModal.svelte'
|
||||
import { repointLinkedAgent } from './linkedAgentDrafts'
|
||||
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
|
||||
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import WindmillIcon from '../icons/WindmillIcon.svelte'
|
||||
@@ -551,4 +552,5 @@
|
||||
<AgentEditorModal
|
||||
enableAi={!disableAi}
|
||||
owns={(t) => t.host?.flowPath === $pathStore && targetWorkspace(t) === editorWorkspace}
|
||||
onRenamed={(from, to) => repointLinkedAgent(flowStore.val.value, from, to)}
|
||||
/>
|
||||
|
||||
@@ -112,12 +112,11 @@ export function agentDraftDeployRefusal(
|
||||
if (blocked) {
|
||||
return blocked
|
||||
}
|
||||
// Renaming is not the agent editor's to do: moving the resource leaves every step that links to
|
||||
// it naming a path that no longer exists, and reconciling those is a feature of its own. A
|
||||
// renamed path can still reach here, the generic editor writing the same draft row and offering
|
||||
// a path field, so refuse it rather than performing half of a rename.
|
||||
// A rename is the agent editor's to deploy: it repoints the steps of the flow it was opened from.
|
||||
// Deployed from anywhere that names the path it writes to (a flow's deploy dialog, which lists the
|
||||
// agent under the path the flow links), it would move the agent out from under that flow.
|
||||
if (currentPath && state.path !== currentPath) {
|
||||
return `This draft renames the agent to ${state.path}. Deploy it from the resource editor instead.`
|
||||
return `This draft renames the agent to ${state.path}. Deploy it from the agent editor instead.`
|
||||
}
|
||||
// Only a draft naming another type: the load refuses a resource that is not an agent, while a
|
||||
// draft the generic resource editor wrote names no type at all and inherits the loaded one.
|
||||
@@ -132,6 +131,9 @@ export function agentDraftDeployRefusal(
|
||||
* persisted draft row: the form stays editable while a deploy is in flight. Surfaces that deploy
|
||||
* the row itself go through `deployDraft` instead.
|
||||
*
|
||||
* `fromPath` is the path the editor loaded; `state.path` differs from it when the draft renames the
|
||||
* agent, and the update then moves the resource there.
|
||||
*
|
||||
* `notAnAgent` separates the one failure that invalidates the caller's whole view of the path, its
|
||||
* holding something else now, from a write that merely failed.
|
||||
*/
|
||||
@@ -139,6 +141,7 @@ type AgentWriteResult = { ok: true } | { ok: false; error: string; notAnAgent?:
|
||||
|
||||
async function writeAgentResource(
|
||||
workspace: string,
|
||||
fromPath: string,
|
||||
state: AgentResourceState,
|
||||
noDeployed: boolean
|
||||
): Promise<AgentWriteResult> {
|
||||
@@ -161,12 +164,12 @@ async function writeAgentResource(
|
||||
// its own: were the path deleted and recreated as something else meanwhile, this write
|
||||
// would put an agent config inside that resource. Reading it again narrows the window to
|
||||
// the request rather than to however long the editor or the dialog stayed open.
|
||||
const current = await ResourceService.getResource({ workspace, path: state.path })
|
||||
const refused = agentEditorRefusal(state.path, current.resource_type)
|
||||
const current = await ResourceService.getResource({ workspace, path: fromPath })
|
||||
const refused = agentEditorRefusal(fromPath, current.resource_type)
|
||||
if (refused) {
|
||||
return { ok: false, error: refused, notAnAgent: true }
|
||||
}
|
||||
await ResourceService.updateResource({ workspace, path: state.path, requestBody: body })
|
||||
await ResourceService.updateResource({ workspace, path: fromPath, requestBody: body })
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Could not save agent: ${err}` }
|
||||
@@ -193,8 +196,9 @@ export interface AgentDraftHandle {
|
||||
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
|
||||
readonly refusal: string | undefined
|
||||
readonly sync: TriggerDraftSync
|
||||
/** Write the current state to the resource and drop the draft. */
|
||||
deploy: () => Promise<boolean>
|
||||
/** Write the current state to the resource and drop the draft. Resolves to the path written,
|
||||
* which differs from the one loaded when the draft renames the agent, or undefined on failure. */
|
||||
deploy: () => Promise<string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,21 +337,23 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
})
|
||||
})
|
||||
|
||||
async function deploy(): Promise<boolean> {
|
||||
async function deploy(): Promise<string | undefined> {
|
||||
const ws = opts.workspace()
|
||||
const fromPath = opts.path()
|
||||
const s = state
|
||||
if (!ws || !s) return false
|
||||
const refused = agentDraftDeployRefusal(s, opts.path())
|
||||
if (!ws || !fromPath || !s) return undefined
|
||||
// No path to hold the draft to: renaming is this editor's to deploy.
|
||||
const refused = agentDraftDeployRefusal(s, undefined)
|
||||
if (refused) {
|
||||
sendUserToast(refused, true)
|
||||
return false
|
||||
return undefined
|
||||
}
|
||||
// The form stays editable while the request is in flight, so everything below works from a
|
||||
// snapshot taken now. Adopting the live state as `deployed` afterwards would count an edit
|
||||
// made during the request as saved, and the banner would clear on a value the server never
|
||||
// received; against the snapshot it stays a draft, which is what it is.
|
||||
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
|
||||
const written = await writeAgentResource(ws, submitted, noDeployed)
|
||||
const written = await writeAgentResource(ws, fromPath, submitted, noDeployed)
|
||||
if (!written.ok) {
|
||||
// A path that is no longer an agent tears this editor down; anything else is a plain error
|
||||
// the user can retry from the form as it stands.
|
||||
@@ -356,29 +362,31 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
} else {
|
||||
sendUserToast(written.error, true)
|
||||
}
|
||||
return false
|
||||
return undefined
|
||||
}
|
||||
// The counter the step card's write-back used to report, from the surface that now owns the
|
||||
// write: a deploy here reaches every flow linking this agent.
|
||||
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
|
||||
deployed = submitted
|
||||
noDeployed = false
|
||||
const renamed = submitted.path !== fromPath
|
||||
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
|
||||
// what it is given, and the apply-effect copies that back over the form: against an edit made
|
||||
// while the request was in flight that would erase it, draft and all. Such an edit is a real
|
||||
// unsaved change over the version just deployed, so it keeps its draft and its banner.
|
||||
if (!deepEqual($state.snapshot(state), submitted)) {
|
||||
// unsaved change over the version just deployed, so it keeps its draft and its banner. Not
|
||||
// after a rename: the draft is keyed on a path that no longer names the agent.
|
||||
if (!renamed && !deepEqual($state.snapshot(state), submitted)) {
|
||||
sendUserToast(`Saved agent ${submitted.path}. Later edits are still unsaved`)
|
||||
loadedFor = `${ws}:${submitted.path}`
|
||||
return true
|
||||
return submitted.path
|
||||
}
|
||||
// `discard`, not `remove`: it resets the handle's cell to what was just saved, so the
|
||||
// apply-effect cannot bounce the form back to the now-stale draft.
|
||||
sync.discard(opts.path()!, submitted)
|
||||
sync.discard(fromPath, submitted)
|
||||
// A rename moves the row, so the next load must not reuse the old key.
|
||||
loadedFor = `${ws}:${submitted.path}`
|
||||
sendUserToast(`Saved agent ${submitted.path}`)
|
||||
return true
|
||||
sendUserToast(renamed ? `Renamed agent to ${submitted.path}` : `Saved agent ${submitted.path}`)
|
||||
return submitted.path
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
|
||||
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
|
||||
import { useAgentDraft } from '../agentDraft.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
/** The `ai_agent` resource being edited. */
|
||||
@@ -320,13 +323,19 @@
|
||||
if (toolId === id) onSelectTool?.(undefined)
|
||||
}
|
||||
|
||||
/** The path field's own verdict (a taken path, an invalid name), which the server would otherwise
|
||||
* only report after the request. */
|
||||
let pathError = $state('')
|
||||
|
||||
export function deploy(): Promise<boolean> {
|
||||
return draft.deploy().then(async (ok) => {
|
||||
// The path this editor opened, not the draft's live one: `deploy` refuses a renaming draft,
|
||||
// so the write always lands here, while the shared draft can be repointed by another tab
|
||||
// mid-request and would send the reconciliation after a resource nobody wrote.
|
||||
if (ok) await onSaved?.(path)
|
||||
return ok
|
||||
if (pathError) {
|
||||
sendUserToast(`Cannot deploy the agent: ${pathError}`, true)
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return draft.deploy().then(async (written) => {
|
||||
// The path the write landed on, which a rename moves off the one this editor opened.
|
||||
if (written) await onSaved?.(written)
|
||||
return written !== undefined
|
||||
})
|
||||
}
|
||||
export function draftHandle() {
|
||||
@@ -352,6 +361,25 @@
|
||||
<Splitpanes class="h-full">
|
||||
<Pane size={66} minSize={30}>
|
||||
<div class="h-full min-h-0 overflow-auto">
|
||||
<div class="px-4 pt-4">
|
||||
<Label label="Path">
|
||||
<Path
|
||||
bind:path={
|
||||
() => draft.state?.path,
|
||||
(v) => {
|
||||
if (draft.state && v !== undefined) draft.state.path = v
|
||||
}
|
||||
}
|
||||
bind:error={pathError}
|
||||
initialPath={path}
|
||||
namePlaceholder="agent"
|
||||
kind="resource"
|
||||
workspaceOverride={workspace}
|
||||
autofocus={false}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<PropPickerWrapper
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
noPadding
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
type AgentEditorTarget,
|
||||
closeAgentEditor,
|
||||
markAgentWritten,
|
||||
openAgentEditor,
|
||||
showAgentEditorTool,
|
||||
showAgentEditorView
|
||||
} from '../agentEditorStore.svelte'
|
||||
@@ -35,9 +36,12 @@
|
||||
* running its own two-way sync against the one draft row. Required rather than defaulted: a
|
||||
* mount that guesses wrong renders nothing, and silence is a poor way to find that out. */
|
||||
owns: (target: AgentEditorTarget) => boolean
|
||||
/** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface
|
||||
* that opened the editor: a flow's own steps, a page's URL. */
|
||||
onRenamed?: (from: string, to: string) => void
|
||||
}
|
||||
|
||||
let { enableAi = false, owns }: Props = $props()
|
||||
let { enableAi = false, owns, onRenamed = undefined }: Props = $props()
|
||||
|
||||
// Every target names the surface that opened it, and only a flow step or a resource row can:
|
||||
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
|
||||
@@ -178,9 +182,10 @@
|
||||
if (!at.host) return
|
||||
// The host graph resolves a linked agent's tool nodes from the resource, so it has to re-read
|
||||
// what the write just changed. Every step of that flow linking this agent, not only the one
|
||||
// the editor was opened from: they all show tools the write may have moved.
|
||||
// the editor was opened from: they all show tools the write may have moved. Looked up under
|
||||
// the path the editor opened, since a rename is about to move those steps off it.
|
||||
const scope = linkedToolsScope(at.ws, at.host.flowPath)
|
||||
const moduleIds = new Set(linkedModulesForAgent(scope, path))
|
||||
const moduleIds = new Set(linkedModulesForAgent(scope, at.path))
|
||||
moduleIds.add(at.host.moduleId)
|
||||
return Promise.all(
|
||||
// With the draft: a deploy leaves none, but a version restore leaves the draft standing and
|
||||
@@ -202,10 +207,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** What a successful deploy has to reconcile. The path is the one it wrote, which `deploy` holds
|
||||
* to the one the editor opened: this editor does not rename. */
|
||||
/** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename
|
||||
* moves off the one the editor opened. */
|
||||
async function onSaved(savedPath: string) {
|
||||
await reconcile(deployingFor ?? currentWriteTarget(), savedPath)
|
||||
const at = deployingFor ?? currentWriteTarget()
|
||||
// Before the rename is announced: it finds the steps to refresh under the old path.
|
||||
const reconciled = reconcile(at, savedPath)
|
||||
if (at && savedPath !== at.path) {
|
||||
onRenamed?.(at.path, savedPath)
|
||||
// The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it
|
||||
// still shows the one deployed: it can be closed or pointed elsewhere mid-request.
|
||||
if (target?.path === at.path) {
|
||||
openAgentEditor({ path: savedPath, workspace: target.workspace, host: target.host })
|
||||
}
|
||||
}
|
||||
await reconciled
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
} from '../linkedAgentToolsStore.svelte'
|
||||
import { logReusableAgentUsage } from '../agentTelemetry'
|
||||
import { claimLinkedToolsFetch } from '../flowState'
|
||||
import { AgentDraftUnavailable, fetchAgentWithDraft } from '../linkedAgentDrafts'
|
||||
import {
|
||||
AgentDraftUnavailable,
|
||||
fetchAgentWithDraft,
|
||||
isExpectedLinkFailure
|
||||
} from '../linkedAgentDrafts'
|
||||
import type { AgentResourceState } from '../agentDraft.svelte'
|
||||
import { getLocalDraftHint } from '$lib/localDraftHints.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
@@ -103,6 +107,30 @@
|
||||
fromDraft: boolean
|
||||
providerPath?: string
|
||||
providerOk: boolean
|
||||
/** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been
|
||||
* renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder
|
||||
* they cannot read included, which says nothing about whether a run of the flow can read it.
|
||||
* Returned rather than thrown so it is guarded like any result. */
|
||||
unavailable?: 'missing' | 'forbidden'
|
||||
}
|
||||
|
||||
async function fetchLinkedAgent(
|
||||
path: string,
|
||||
ws: string
|
||||
): Promise<{ response: Resource; draft: AgentResourceState | undefined }> {
|
||||
try {
|
||||
return await fetchAgentWithDraft(path, ws)
|
||||
} catch (err) {
|
||||
// Only the DRAFT was unreadable. This card is a display, so fall back to the deployed
|
||||
// agent rather than rendering one with no brain and no tools, which reads as "the agent
|
||||
// is empty" while the Draft badge still says it has unsaved changes. Same fallback the
|
||||
// graph's tool nodes take; the paths that run or deploy the draft still refuse.
|
||||
if (!(err instanceof AgentDraftUnavailable)) throw err
|
||||
return {
|
||||
response: await ResourceService.getResource({ workspace: ws, path }),
|
||||
draft: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A linked agent is rigid and read-only: its brain and tools come from the resource. We
|
||||
@@ -112,29 +140,27 @@
|
||||
let linkedResource = resource(
|
||||
() => ({ ws, path: agent, writes, draftSaves }),
|
||||
async ({ ws, path, writes, draftSaves }): Promise<LinkedInfo> => {
|
||||
const empty = {
|
||||
ws,
|
||||
path,
|
||||
writes,
|
||||
draftSaves,
|
||||
config: {},
|
||||
tools: [],
|
||||
fromDraft: false,
|
||||
providerOk: true
|
||||
}
|
||||
if (!ws || !path) {
|
||||
return {
|
||||
ws,
|
||||
path,
|
||||
writes,
|
||||
draftSaves,
|
||||
config: {},
|
||||
tools: [],
|
||||
fromDraft: false,
|
||||
providerOk: true
|
||||
}
|
||||
return empty
|
||||
}
|
||||
let response: Resource
|
||||
let draft: AgentResourceState | undefined
|
||||
try {
|
||||
;({ response, draft } = await fetchAgentWithDraft(path, ws))
|
||||
;({ response, draft } = await fetchLinkedAgent(path, ws))
|
||||
} catch (err) {
|
||||
// Only the DRAFT was unreadable. This card is a display, so fall back to the deployed
|
||||
// agent rather than rendering one with no brain and no tools, which reads as "the agent
|
||||
// is empty" while the Draft badge still says it has unsaved changes. Same fallback the
|
||||
// graph's tool nodes take; the paths that run or deploy the draft still refuse.
|
||||
if (!(err instanceof AgentDraftUnavailable)) throw err
|
||||
response = await ResourceService.getResource({ workspace: ws, path })
|
||||
if (!isExpectedLinkFailure(err)) throw err
|
||||
const status = (err as { status?: number }).status
|
||||
return { ...empty, unavailable: status === 404 ? 'missing' : 'forbidden' }
|
||||
}
|
||||
const cfg = (draft?.args ?? response.value ?? {}) as AIAgentConfig & {
|
||||
provider?: { resource?: string }
|
||||
@@ -189,6 +215,7 @@
|
||||
let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config))
|
||||
let providerPath = $derived(linkedInfo?.providerPath)
|
||||
let providerOk = $derived(linkedInfo?.providerOk ?? true)
|
||||
let unavailable = $derived(linkedInfo?.unavailable ?? false)
|
||||
// The hint flips on the first keystroke in the agent editor, so the badge does not wait for the
|
||||
// debounced autosave and the refetch behind it; the fetched answer covers a draft written
|
||||
// elsewhere, which no editor here has published an opinion about.
|
||||
@@ -468,6 +495,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A link naming nothing readable has nothing to fork. Dropping it leaves a standalone step with its
|
||||
// flow-local inputs, to configure here or replace with a saved agent; the tool overrides were
|
||||
// keyed by the missing agent's tools, so they go with it.
|
||||
function removeLink() {
|
||||
toolInputs = {}
|
||||
agent = undefined
|
||||
sendUserToast('Removed the link to the missing agent')
|
||||
}
|
||||
|
||||
// Edit the saved agent itself. The step stays linked throughout: the edits live in the agent's
|
||||
// own resource draft, not in this step, so they survive leaving the flow and are the same edits
|
||||
// whichever flow — or the resources page — opened them.
|
||||
@@ -534,7 +570,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !fromAgentEditor}
|
||||
{#if !fromAgentEditor && !unavailable}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
@@ -547,17 +583,19 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Unlink }}
|
||||
iconOnly
|
||||
title="Unlink (fork an editable copy into just this step)"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
unlink()
|
||||
}}
|
||||
/>
|
||||
{#if !unavailable}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Unlink }}
|
||||
iconOnly
|
||||
title="Unlink (fork an editable copy into just this step)"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
unlink()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showDetail && (brainParams.length > 0 || inheritedTools.length > 0)}
|
||||
@@ -581,7 +619,32 @@
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !providerOk}
|
||||
{#if unavailable === 'forbidden'}
|
||||
<div class="mt-1">
|
||||
<Alert type="warning" size="xs" title="Agent not accessible">
|
||||
You don't have access to <span class="font-medium">{agent}</span>, so its configuration
|
||||
can't be shown or edited here.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if unavailable === 'missing'}
|
||||
<div class="mt-1">
|
||||
<Alert type="error" size="xs" title="Agent not found">
|
||||
No saved agent exists at <span class="font-medium">{agent}</span>. It may have been
|
||||
renamed or deleted. Remove the link to configure the step here, or add the agent again
|
||||
from Saved agents.
|
||||
<div class="flex pt-2">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Unlink }}
|
||||
onclick={removeLink}
|
||||
>
|
||||
Remove link
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if !providerOk}
|
||||
<div class="mt-1">
|
||||
<Alert type="error" size="xs" title="Model provider not accessible">
|
||||
This agent's model provider{#if providerPath}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
inlineAgentDraft,
|
||||
inlineAgentDrafts,
|
||||
loadLinkedAgentDrafts,
|
||||
repointLinkedAgent,
|
||||
type LinkedAgentDraft
|
||||
} from './linkedAgentDrafts'
|
||||
import { ResourceService, type FlowModule, type FlowValue } from '$lib/gen'
|
||||
@@ -118,6 +119,38 @@ describe('inlineAgentDrafts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('repointLinkedAgent', () => {
|
||||
// A rename from the agent editor moves every step of the host flow onto the new path, nested ones
|
||||
// included. Miss one and it silently stays linked to a path that no longer exists.
|
||||
it('repoints linked steps at any depth and leaves other agents alone', () => {
|
||||
const value = {
|
||||
modules: [
|
||||
{ id: 'a', value: { type: 'aiagent', agent: 'f/team/support', tools: [] } },
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'branchall',
|
||||
branches: [
|
||||
{
|
||||
modules: [
|
||||
{ id: 'c', value: { type: 'aiagent', agent: 'f/team/support', tools: [] } },
|
||||
{ id: 'd', value: { type: 'aiagent', agent: 'f/team/other', tools: [] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
} as unknown as FlowValue
|
||||
|
||||
expect(repointLinkedAgent(value, 'f/team/support', 'f/team/helpdesk')).toEqual(['a', 'c'])
|
||||
const branch = (value.modules[1].value as any).branches[0].modules
|
||||
expect((value.modules[0].value as any).agent).toBe('f/team/helpdesk')
|
||||
expect(branch[0].value.agent).toBe('f/team/helpdesk')
|
||||
expect(branch[1].value.agent).toBe('f/team/other')
|
||||
})
|
||||
})
|
||||
|
||||
// A link the user cannot resolve is an ordinary state and must not block the flow; anything else is
|
||||
// an outage, and answering "no draft" to one would silently test or deploy against the deployed
|
||||
// agent while the editor shows the draft.
|
||||
|
||||
@@ -35,6 +35,25 @@ export function linkedAgentPaths(value: FlowValue | undefined): string[] {
|
||||
return [...paths]
|
||||
}
|
||||
|
||||
/** Point every step of this flow linked to `from` at `to`, for an agent renamed from inside it.
|
||||
* Returns the ids of the steps it moved. */
|
||||
export function repointLinkedAgent(
|
||||
value: FlowValue | undefined,
|
||||
from: string,
|
||||
to: string
|
||||
): string[] {
|
||||
if (!value?.modules) return []
|
||||
const moved: string[] = []
|
||||
for (const module of dfs(value.modules, (m) => m)) {
|
||||
const v = module?.value as { type?: string; agent?: string } | undefined
|
||||
if (v?.type === 'aiagent' && v.agent === from) {
|
||||
v.agent = to
|
||||
moved.push(module.id)
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
/**
|
||||
* The unsaved draft for an agent, freshest first: the cell an open agent editor is writing, then
|
||||
* what a `get_draft` response carried.
|
||||
@@ -114,7 +133,7 @@ export function agentDraftCanWrite(draft: LinkedAgentDraft, user: UserExt | unde
|
||||
* neither should stop the caller — the flow still tests and deploys, against the deployed agent.
|
||||
* Every other failure is an outage, and answering "no draft" to one would quietly run or deploy
|
||||
* the wrong configuration, which is the whole thing this module exists to prevent. */
|
||||
function isExpectedLinkFailure(err: unknown): boolean {
|
||||
export function isExpectedLinkFailure(err: unknown): boolean {
|
||||
const status = (err as { status?: number } | null | undefined)?.status
|
||||
return status === 401 || status === 403 || status === 404
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
WandSparkles
|
||||
} from 'lucide-svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
import { overlayStack } from '$lib/components/common/overlayHost.svelte'
|
||||
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ContentSearchInner from '../ContentSearchInner.svelte'
|
||||
@@ -367,6 +369,7 @@
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
if ((!isMac() ? event.ctrlKey : event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
if (!open) openedOn = undefined
|
||||
await openModal()
|
||||
}
|
||||
if (open) {
|
||||
@@ -450,6 +453,24 @@
|
||||
mouseMoved = true
|
||||
}
|
||||
|
||||
// On the overlay stack while open: a modal or drawer it was opened from arbitrates Escape on that
|
||||
// stack, and would otherwise close itself on the key meant for the search above it. The opener
|
||||
// names its stack, because a pane hosting an editor (a sessions tab) keeps its own.
|
||||
const globalStack = overlayStack()
|
||||
let openedOn: import('$lib/components/common/overlayHost.svelte').OverlayStack | undefined =
|
||||
$state(undefined)
|
||||
const STACK_ID = 'global-search'
|
||||
$effect(() => {
|
||||
if (!open) return
|
||||
const stack = openedOn ?? globalStack
|
||||
untrack(() => stack.val.push(STACK_ID))
|
||||
return () => {
|
||||
untrack(() => {
|
||||
stack.val = stack.val.filter((id) => id !== STACK_ID)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
@@ -559,7 +580,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSearchWithPrefilledText(text?: string) {
|
||||
export async function openSearchWithPrefilledText(
|
||||
text?: string,
|
||||
stack?: import('$lib/components/common/overlayHost.svelte').OverlayStack
|
||||
) {
|
||||
openedOn = stack
|
||||
await openModal()
|
||||
searchTerm = text ?? searchTerm
|
||||
await handleSearch()
|
||||
@@ -618,9 +643,9 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
`fixed top-0 bottom-0 left-0 right-0 transition-all duration-50 flex items-start justify-center`,
|
||||
' bg-black bg-opacity-40',
|
||||
'z-[1100]'
|
||||
' bg-black bg-opacity-40'
|
||||
)}
|
||||
style="z-index: {zIndexes.globalSearch}"
|
||||
>
|
||||
<div
|
||||
class="{maxModalWidth(tab)} w-full mt-36 bg-surface rounded-lg relative"
|
||||
|
||||
@@ -5,6 +5,10 @@ export const zIndexes = {
|
||||
colorInput: 1002,
|
||||
disposables: 1100, // Modals and Drawers
|
||||
aiChat: 1200,
|
||||
// Above the modal and drawer bases (`disposables`, or `aiChat + 1` while the chat is open) and the
|
||||
// chat panel: it is opened from inside modals (the rename warning's content search) and takes no
|
||||
// z-index from their stack. A disposable raised past it with `minZIndex` still covers it.
|
||||
globalSearch: 1500,
|
||||
svelteSelectOptions: 5000,
|
||||
popover: 5001,
|
||||
contextMenu: 6000,
|
||||
|
||||
@@ -659,8 +659,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openSearchModal(text?: string): void {
|
||||
globalSearchModal?.openSearchWithPrefilledText(text)
|
||||
function openSearchModal(
|
||||
text?: string,
|
||||
stack?: import('$lib/components/common/overlayHost.svelte').OverlayStack
|
||||
): void {
|
||||
globalSearchModal?.openSearchWithPrefilledText(text, stack)
|
||||
}
|
||||
|
||||
setContext('openSearchWithPrefilledText', openSearchModal)
|
||||
|
||||
@@ -1567,6 +1567,18 @@
|
||||
this route's JavaScript and none of what the resources table needs. -->
|
||||
{#if agentEditorTarget()}
|
||||
{#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }}
|
||||
<AgentEditorModal enableAi={$copilotInfo.enabled} owns={(t) => t.host === undefined} />
|
||||
<AgentEditorModal
|
||||
enableAi={$copilotInfo.enabled}
|
||||
owns={(t) => t.host === undefined}
|
||||
onRenamed={(from, to) => {
|
||||
void loadResources()
|
||||
// Only while the dialog still shows the agent: closed mid-request, it already cleared the
|
||||
// anchor, and writing it back would reopen the editor on refresh.
|
||||
if (agentEditorTarget()?.path !== from) return
|
||||
// Claimed first, as a row click does, so the deep-link effect does not reopen it.
|
||||
handledHash = `#/resource/${to}`
|
||||
setPageDrawerAnchor(RESOURCES_PATH, to)
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user