diff --git a/.agents/skills/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md index 46bf970091..6aceedc25c 100644 --- a/.agents/skills/svelte-frontend/SKILL.md +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -61,7 +61,13 @@ else, go back to the barrel and grep. - {/if} + {/each} - {#each logins.filter((login) => !providersType?.includes(login.type)) as login} - - {/each} - {/if} - {#if saml} - {/if} - {#if !autoRedirecting && !disablePasswordLogin && (saml || (logins && logins.length > 0))} -
0 ? 'mt-6' : '')}> - -
+{/snippet} + +{#snippet orDivider()} +
+
+ or +
+
+{/snippet} + +
+ {#if autoRedirecting} +

Signing you in…

+ {/if} + + {#if !passwordFirst} + {@render providerButtons()} + {#if !autoRedirecting && !disablePasswordLogin && (saml || (logins && logins.length > 0))} + {@render orDivider()} + + {#if !showPassword} +
+ +
+ {/if} + {/if} {/if} {#if !autoRedirecting && showPassword && !disablePasswordLogin} @@ -529,51 +729,73 @@ Welcome! Default credentials admin@windmill.dev / changeme have been prefilled.

{/if} -
- {#if isCloudHosted()} +
+ {#if cloudHosted}

To get credentials without the OAuth providers above, send an email at contact@windmill.dev

{/if} -
- -
- { - // Only move on once the field holds something: while the browser's - // credential dropdown is open, Enter belongs to the dropdown - if (e.key === 'Enter' && !e.isComposing && !e.repeat && e.currentTarget.value) { - e.preventDefault() - passwordField?.focus() +
+
+ +
+ { + // Only move on once the field holds something: while the browser's + // credential dropdown is open, Enter belongs to the dropdown + if (e.key === 'Enter' && !e.isComposing && !e.repeat && e.currentTarget.value) { + e.preventDefault() + passwordField?.focus() + } } - } - }} - /> + }} + /> +
+ +
+ +
+ +
+ +
+ {@render errorMessage()}
- diff --git a/frontend/src/lib/components/LoginHeading.svelte b/frontend/src/lib/components/LoginHeading.svelte new file mode 100644 index 0000000000..478a5e3a53 --- /dev/null +++ b/frontend/src/lib/components/LoginHeading.svelte @@ -0,0 +1,28 @@ + + + +
+ {#if hasThirdParty !== undefined} +

+ {hasThirdParty ? `Log in or sign up to ${instanceName}` : `Log in to ${instanceName}`} +

+

+ {hasThirdParty + ? 'Log in or sign up with any of the methods below' + : 'Log in with your email and password'} +

+ {/if} +
diff --git a/frontend/src/lib/components/LoginPageHeader.svelte b/frontend/src/lib/components/LoginPageHeader.svelte index d841f48aa1..7b49c97e34 100644 --- a/frontend/src/lib/components/LoginPageHeader.svelte +++ b/frontend/src/lib/components/LoginPageHeader.svelte @@ -1,11 +1,34 @@ - -
-
+
+ +
+ {#if showBrand} + {#if $whitelabelNameStore} + {capitalize($whitelabelNameStore)} + {:else} + + Windmill + {/if} + {/if} +
+ +
diff --git a/frontend/src/lib/components/ModulePreviewForm.svelte b/frontend/src/lib/components/ModulePreviewForm.svelte index b8867c75e4..88b99a05c5 100644 --- a/frontend/src/lib/components/ModulePreviewForm.svelte +++ b/frontend/src/lib/components/ModulePreviewForm.svelte @@ -13,6 +13,7 @@ import type SimpleEditor from './SimpleEditor.svelte' import { getResourceTypes } from './resourceTypesStore' import { twMerge } from 'tailwind-merge' + import { workspaceStore } from '$lib/stores' interface Props { schema: Schema | { properties?: Record; required?: string[] } @@ -32,9 +33,11 @@ focusArg = undefined }: Props = $props() - const { stepsInputArgs, flowStateStore, flowStore, previewArgs } = + const { stepsInputArgs, flowStateStore, flowStore, previewArgs, opWorkspace } = getContext('FlowEditorContext') + let opWs = $derived(opWorkspace?.() ?? $workspaceStore) + let inputCheck: { [id: string]: boolean } = $state({}) $effect(() => { isValid = allTrue(inputCheck) ?? false @@ -152,6 +155,7 @@ nullable={schema.properties[argName].nullable} title={schema.properties[argName].title} placeholder={schema.properties[argName].placeholder} + workspace={opWs} > {#snippet fieldHeaderActions()} {#if stepsInputArgs?.isArgManuallySet(mod.id, argName)} diff --git a/frontend/src/lib/components/Password.svelte b/frontend/src/lib/components/Password.svelte index 72e7e43024..569150f7b4 100644 --- a/frontend/src/lib/components/Password.svelte +++ b/frontend/src/lib/components/Password.svelte @@ -18,6 +18,10 @@ autocomplete?: HTMLInputAttributes['autocomplete'] /** Off for login-style fields: keeps Enter free to submit. Overrides `minRows`. */ allowMultiline?: boolean + /** Renders the field in its error state; the message itself is the caller's to display. */ + error?: boolean + /** id of the element holding that message, wired up as aria-describedby. */ + describedBy?: string onKeyDown?: (event: KeyboardEvent) => void onBlur?: (event: FocusEvent) => void } @@ -32,11 +36,14 @@ id, autocomplete = 'new-password', allowMultiline = true, + error = false, + describedBy = undefined, onKeyDown, onBlur }: Props = $props() let red = $derived(required && (password == '' || password == undefined)) + let hasError = $derived(red || error) let hideValue = $state(true) let forceMultiline = $state(false) let isMultiline = $derived( @@ -76,7 +83,7 @@ onBlur?.(e), onkeydown: (e) => { onKeyDown?.(e) @@ -99,13 +108,15 @@ onBlur?.(e), onkeydown: (e) => { if (allowMultiline && e.key === 'Enter') { diff --git a/frontend/src/lib/components/PasswordArgInput.svelte b/frontend/src/lib/components/PasswordArgInput.svelte index 60474e53cb..a45ad6579d 100644 --- a/frontend/src/lib/components/PasswordArgInput.svelte +++ b/frontend/src/lib/components/PasswordArgInput.svelte @@ -2,6 +2,7 @@ import { VariableService } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' import { generateRandomString } from '$lib/utils' + import { sendUserToast } from '$lib/toast' import { Button } from './common' import Password from './Password.svelte' import { untrack } from 'svelte' @@ -10,14 +11,28 @@ value?: string | undefined disabled: boolean minRows?: number + /** Workspace the ephemeral secret is minted in; defaults to the nav workspace. + * Session editors pass their acting workspace. */ + workspace?: string | undefined } - let { value = $bindable(undefined), disabled, minRows }: Props = $props() + let { value = $bindable(undefined), disabled, minRows, workspace }: Props = $props() + + let ws = $derived(workspace ?? $workspaceStore) let path = $state('') - let password = $state( - value && typeof value === 'string' && !value.startsWith('$var:') ? value : '' - ) + // Workspace the variable at `path` actually lives in; `ws` can move away from it. + let mintedIn = $state(undefined) + // What the field mints from: an argument already holding a `$var:` ref has nothing to mint. + function plaintextOf(v: unknown): string { + return typeof v === 'string' && v !== '' && !v.startsWith('$var:') ? v : '' + } + let password = $state(plaintextOf(value)) + + // The argument no longer holds what this field would mint from — a parent can replace the whole + // args object without remounting it (previewing a saved input, say). Minting now would describe a + // secret the argument does not point at, and binding it would discard the replacement. + let argReplaced = $derived(path !== '' && value !== '$var:' + path) let isGenerating = false @@ -25,13 +40,15 @@ 'u/' + ($userStore?.username ?? $userStore?.email)?.split('@')[0] + '/secret_arg/' ) async function generateValue() { - if (isGenerating) return + if (isGenerating || argReplaced) return isGenerating = true + const mintWs = ws! + const boundBefore = value try { let npath = userPrefix + generateRandomString(12) let nvalue = '$var:' + npath await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: mintWs, requestBody: { value: password, is_secret: true, @@ -40,26 +57,49 @@ expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString() } }) + // The arg can be replaced the same way while the create is in flight. Nothing ever + // referenced the variable just minted, so delete it; it expires on its own if that fails. + if (value !== boundBefore) { + VariableService.deleteVariable({ workspace: mintWs, path: npath }).catch(() => {}) + return + } path = npath + mintedIn = mintWs console.log('generated', nvalue) value = nvalue debouncedUpdate() } finally { + // Ended without binding: discarded just above, or the create failed after the argument + // moved. The field would otherwise keep showing a secret the argument does not hold, and + // the mint effect tracks `ws` — a workspace move would bind that stale plaintext over the + // replacement. Re-seeding leaves the field describing the argument again. + if (path === '' && value !== boundBefore) { + password = plaintextOf(value) + } isGenerating = false } } async function updateValue() { + // The first keystroke queues an update before anything is minted: letting it run would 404 and + // retry the mint, binding over an argument that was replaced while the first mint was in flight. + if (path === '') return + const updating = path try { await VariableService.updateVariable({ - workspace: $workspaceStore!, + workspace: mintedIn ?? ws!, path: path, requestBody: { value: password } }) } catch (e) { - generateValue() + // A re-mint can bind a fresh variable while this update is in flight; recovering then + // would orphan the one it just bound. + if (path !== updating) return + generateValue().catch((e) => + sendUserToast(`Could not create the secret: ${e?.body ?? e?.message ?? e}`, true) + ) } } @@ -74,11 +114,31 @@ }) $effect(() => { - $workspaceStore && + ws && ($userStore?.username || $userStore?.email) && path == '' && password != '' && - untrack(() => generateValue()) + untrack(() => + // A failed mint leaves the plaintext bound to nothing and the argument empty. Only a + // further keystroke re-runs this, so say so rather than submitting the job without it. + generateValue().catch((e) => + sendUserToast(`Could not create the secret: ${e?.body ?? e?.message ?? e}`, true) + ) + ) + }) + + // The operating workspace can move after minting (a session forking, say), leaving the + // variable behind where the job will not find it: mint a fresh one in the new workspace. + // Bounded to a live instance: a field mounted onto an existing `$var:` holds neither the + // plaintext nor the workspace it was minted in, so it can only be moved by retyping it. + $effect(() => { + const cur = ws + if (!cur || path === '' || password === '' || mintedIn === cur || argReplaced) return + untrack(() => + generateValue().catch((e) => + sendUserToast(`Could not create the secret in ${cur}: ${e?.body ?? e?.message ?? e}`, true) + ) + ) }) diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 639081cc3d..37eef75bf5 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -89,6 +89,9 @@ * already have written there itself — a setup flow correcting its own failed attempt. * Every other existing path is still refused. */ allowedExistingPath?: string + /** Show the "moving may break other items" warning on a rename. Off for items nothing + * can reference by path and whose dependents move with them (eval datasets). */ + warnOnRename?: boolean } let { @@ -107,7 +110,8 @@ size = 'md', drawerOffset = 0, workspaceOverride = undefined, - allowedExistingPath = undefined + allowedExistingPath = undefined, + warnOnRename = true }: Props = $props() let ws = $derived(workspaceOverride ?? $workspaceStore) @@ -430,7 +434,8 @@ // rename. `checkInitialPathExistence` is what callers set when they are creating something, // which is the same question asked the other way round. let displayPathChangedWarning = $derived( - (['flow', 'script', 'resource', 'variable'] as PathKind[]).includes(kind) && + warnOnRename && + (['flow', 'script', 'resource', 'variable'] as PathKind[]).includes(kind) && !checkInitialPathExistence && initialPath && initialPath !== path diff --git a/frontend/src/lib/components/ResourceVersionHistory.svelte b/frontend/src/lib/components/ResourceVersionHistory.svelte index 90e41b74f0..9f73e7dda1 100644 --- a/frontend/src/lib/components/ResourceVersionHistory.svelte +++ b/frontend/src/lib/components/ResourceVersionHistory.svelte @@ -34,7 +34,11 @@ // moves and only reinstated once its own fetch lands, so the pane can never show one version's // JSON under another version's highlight. let selectedId = $state(undefined) - let loaded = $state<{ id: number; value: string; missing: string[] } | undefined>(undefined) + // `id` addresses the version, `version` is what it is called: the id is unique across every + // resource, so it is no indication of how many times this one has been saved. + let loaded = $state< + { id: number; version: number; value: string; missing: string[] } | undefined + >(undefined) // Undefined until the newest version's value arrives, which is what "Diff with current" needs. // Fetched without blocking the list, so an absent baseline disables the diff rather than // holding up the drawer everyone else opened to read. @@ -105,12 +109,21 @@ async function fetchVersion(id: number) { const version = await ResourceService.getResourceVersion({ workspace: effectiveWorkspace, - version: id + id }) - return { id, value: pretty(version.value), missing: version.missing_references ?? [] } + return { + id, + version: version.version, + value: pretty(version.value), + missing: version.missing_references ?? [] + } } - async function selectVersion(id: number | undefined, generation = loadGeneration) { + async function selectVersion( + id: number | undefined, + number: number | undefined, + generation = loadGeneration + ) { selectedId = id // Dropped up front rather than left in place while the new value is in flight: keeping it // would highlight the clicked row while the pane still rendered the previous version, and @@ -129,7 +142,7 @@ } catch (err) { if (selectedId === id && generation === loadGeneration) { selectedId = undefined - sendUserToast(`Could not load version ${id}`, true) + sendUserToast(`Could not load version ${number}`, true) } } } @@ -138,15 +151,15 @@ // loaded.id, never selectedId: restoring what the pane is showing. A selection whose value // has not arrived leaves `loaded` undefined, so this writes nothing rather than restoring a // version the user has not seen. - const id = loaded?.id - if (id === undefined) return + const target = loaded + if (target === undefined) return restoring = true try { await ResourceService.restoreResourceVersion({ workspace: effectiveWorkspace, - version: id + id: target.id }) - sendUserToast(`Restored ${path} to version ${id}`) + sendUserToast(`Restored ${path} to version ${target.version}`) onRestore?.() await loadVersions() } finally { @@ -222,14 +235,14 @@ {#each versions as version, index (version.id)} selectVersion(version.id)} + onclick={() => selectVersion(version.id, version.version)} >
{#if index === 0} {/if} - {index === 0 ? 'Current' : `Version ${version.id}`} + {index === 0 ? 'Current' : `Version ${version.version}`}
{displayDate(version.created_at)}{version.created_by diff --git a/frontend/src/lib/components/RunChart.svelte b/frontend/src/lib/components/RunChart.svelte index e2f2adca38..1b5c7eed5a 100644 --- a/frontend/src/lib/components/RunChart.svelte +++ b/frontend/src/lib/components/RunChart.svelte @@ -17,6 +17,7 @@ import type { CompletedJob } from '$lib/gen' import { getDbClockNow } from '$lib/forLater' import { Scatter } from '$lib/components/chartjs-wrappers/chartJs' + import { timeTicksWithDate } from '$lib/components/chartjs-wrappers/timeTicks' import DarkModeObserver from './DarkModeObserver.svelte' interface Props { @@ -269,7 +270,7 @@ }, min: minMaxTime.minTime.getTime(), max: minMaxTime.maxTime.getTime(), - ticks: { maxRotation: 0, minRotation: 0 } + ticks: timeTicksWithDate(minMaxTime.minTime, minMaxTime.maxTime) }, y: { grid: { diff --git a/frontend/src/lib/components/S3FilePreview.svelte b/frontend/src/lib/components/S3FilePreview.svelte index a9c168d68c..62c95581e7 100644 --- a/frontend/src/lib/components/S3FilePreview.svelte +++ b/frontend/src/lib/components/S3FilePreview.svelte @@ -97,13 +97,17 @@ function isNotFoundError(err: any): boolean { // HelpersService surfaces backend errors as ApiError with a `status` - // field plus a serialized body. We accept either a 404 status or a - // "not found" substring (case-insensitive) to be robust against - // future error wrapping changes. + // field plus a serialized body. A missing object arrives as a 500 that + // merely *says* "not found" (`load_file_metadata` wraps the object-store + // error), so the substring test carries this and cannot be dropped. 400 + // must short-circuit ahead of it: those messages echo back a + // caller-supplied storage name, and one like `archive not found` would + // otherwise read as a missing object and hide the diagnostic. const status = err?.status ?? err?.response?.status if (status === 404) return true + if (status === 400) return false const body = String(err?.body ?? err?.message ?? err ?? '').toLowerCase() - return body.includes('not found') || body.includes('404') + return body.includes('not found') } // Reload whenever the file key, workspace, or external refreshKey diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index 4df7b4dc0b..0bd2f23e61 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -363,127 +363,122 @@ />
{/if} - -
{ - dispatch('click', argName) - }} - > - {#if args && typeof args == 'object' && prop} - - {#if !hidden[argName]} - { - dispatch('change') - }} - on:nestedChange={() => { - dispatch('nestedChange') - }} - on:acceptChange={(e) => dispatch('acceptChange', e.detail)} - on:rejectChange={(e) => dispatch('rejectChange', e.detail)} - on:keydownCmdEnter={() => dispatch('keydownCmdEnter')} - {disablePortal} - {resourceTypes} - {prettifyHeader} - autofocus={i == 0 && autofocus ? true : null} - label={argName} - description={prop?.description} - bind:value={args[argName]} - type={prop?.type} - oneOf={prop?.oneOf} - required={schema?.required?.includes(argName)} - pattern={prop?.pattern} - bind:valid={inputCheck[argName]} - defaultValue={defaultValues?.[argName] ?? - structuredClone($state.snapshot(prop?.default))} - enum_={dynamicEnums?.[argName] ?? prop?.enum} - format={prop?.format} - contentEncoding={prop?.contentEncoding} - customErrorMessage={prop?.customErrorMessage} - bind:properties={ - () => prop?.properties, - (v) => { - if (prop) prop.properties = v - } + + {#if args && typeof args == 'object' && prop && !hidden[argName]} + +
{ + dispatch('click', argName) + }} + > + { + dispatch('change') + }} + on:nestedChange={() => { + dispatch('nestedChange') + }} + on:acceptChange={(e) => dispatch('acceptChange', e.detail)} + on:rejectChange={(e) => dispatch('rejectChange', e.detail)} + on:keydownCmdEnter={() => dispatch('keydownCmdEnter')} + {disablePortal} + {resourceTypes} + {prettifyHeader} + autofocus={i == 0 && autofocus ? true : null} + label={argName} + description={prop?.description} + bind:value={args[argName]} + type={prop?.type} + oneOf={prop?.oneOf} + required={schema?.required?.includes(argName)} + pattern={prop?.pattern} + bind:valid={inputCheck[argName]} + defaultValue={defaultValues?.[argName] ?? + structuredClone($state.snapshot(prop?.default))} + enum_={dynamicEnums?.[argName] ?? prop?.enum} + format={prop?.format} + contentEncoding={prop?.contentEncoding} + customErrorMessage={prop?.customErrorMessage} + bind:properties={ + () => prop?.properties, + (v) => { + if (prop) prop.properties = v } - bind:order={ - () => prop?.order, - (v) => { - if (prop) prop.order = v - } + } + bind:order={ + () => prop?.order, + (v) => { + if (prop) prop.order = v } - nestedRequired={prop?.required} - itemsType={prop?.items} - disabled={disabledArgs.includes(argName) || disabled || prop?.disabled} - {compact} - {variableEditor} - {itemPicker} - bind:pickForField - password={linkedSecrets.includes(argName)} - extra={prop} - {showSchemaExplorer} - simpleTooltip={schemaFieldTooltip[argName]} - {onlyMaskPassword} - nullable={prop?.nullable} - title={prop?.title} - placeholder={prop?.placeholder} - orderEditable={dndConfig != undefined} - otherArgs={{ ...args, [argName]: undefined }} - {helperScript} - {lightHeader} - diffStatus={diff[argName] ?? undefined} - {nestedParent} - {shouldDispatchChanges} - {nestedClasses} - {appPath} - {computeS3ForceViewerPolicies} - {workspace} - {css} - {displayType} - > - {#snippet actions()} - {@render actions_render?.({ item })} - {#if linkedSecretCandidates?.includes(argName)} -
- { - if (e.detail === 'secret') { - if (!linkedSecrets.includes(argName)) { - linkedSecrets = [...linkedSecrets, argName] - } - } else { - linkedSecrets = linkedSecrets.filter((s) => s !== argName) + } + nestedRequired={prop?.required} + itemsType={prop?.items} + disabled={disabledArgs.includes(argName) || disabled || prop?.disabled} + {compact} + {variableEditor} + {itemPicker} + bind:pickForField + password={linkedSecrets.includes(argName)} + extra={prop} + {showSchemaExplorer} + simpleTooltip={schemaFieldTooltip[argName]} + {onlyMaskPassword} + nullable={prop?.nullable} + title={prop?.title} + placeholder={prop?.placeholder} + orderEditable={dndConfig != undefined} + otherArgs={{ ...args, [argName]: undefined }} + {helperScript} + {lightHeader} + diffStatus={diff[argName] ?? undefined} + {nestedParent} + {shouldDispatchChanges} + {nestedClasses} + {appPath} + {computeS3ForceViewerPolicies} + {workspace} + {css} + {displayType} + > + {#snippet actions()} + {@render actions_render?.({ item })} + {#if linkedSecretCandidates?.includes(argName)} +
+ { + if (e.detail === 'secret') { + if (!linkedSecrets.includes(argName)) { + linkedSecrets = [...linkedSecrets, argName] } - }} - > - {#snippet children({ item })} - - - {/snippet} - -
{/if} - {/snippet} - - {/if} - - - {/if} -
+ } else { + linkedSecrets = linkedSecrets.filter((s) => s !== argName) + } + }} + > + {#snippet children({ item })} + + + {/snippet} + +
{/if} + {/snippet} +
+
+ {/if} {/if} {/each} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 5c6cb3e5a8..e069fe50b8 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -1466,13 +1466,22 @@ corresponding action. {/snippet} + { - template = 'script' - script.kind = detail - initContent(script.language, detail, template) - }} + bind:selected={ + () => script.kind ?? 'script', + (kind) => { + // Load-bearing: any write to script.kind echoes back through the + // group, and initContent replaces the editor content outright. + if (kind === (script.kind ?? 'script')) return + template = 'script' + script.kind = kind as Script['kind'] + initContent(script.language, script.kind, template) + } + } > {#snippet children({ item })} {#each scriptKindOptions as { value, title, desc, documentationLink, Icon }} @@ -2010,6 +2019,7 @@ {/if} diff --git a/frontend/src/lib/components/ScriptSchema.svelte b/frontend/src/lib/components/ScriptSchema.svelte index 4cedaf388d..7a5661c538 100644 --- a/frontend/src/lib/components/ScriptSchema.svelte +++ b/frontend/src/lib/components/ScriptSchema.svelte @@ -7,9 +7,17 @@ interface Props { schema: Schema | any customUi?: EditableSchemaFormUi | undefined + workspace?: string | undefined } - let { schema = $bindable(), customUi = undefined }: Props = $props() + let { schema = $bindable(), customUi = undefined, workspace = undefined }: Props = $props() - + diff --git a/frontend/src/lib/components/UserSettings.svelte b/frontend/src/lib/components/UserSettings.svelte index d3956f7f12..d5811e737b 100644 --- a/frontend/src/lib/components/UserSettings.svelte +++ b/frontend/src/lib/components/UserSettings.svelte @@ -9,6 +9,8 @@ import { createEventDispatcher } from 'svelte' import UserInfoSettings from './settings/UserInfoSettings.svelte' import AIUserSettings from './settings/AIUserSettings.svelte' + import AiUsagePanel from './workspaceSettings/AiUsagePanel.svelte' + import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { getDarkModeVariant, setDarkModeVariant, @@ -105,6 +107,17 @@
+ + {#if $copilotWorkspace} + + {/if} {/if}
diff --git a/frontend/src/lib/components/aiEvals/AddScorer.svelte b/frontend/src/lib/components/aiEvals/AddScorer.svelte new file mode 100644 index 0000000000..590a5ed573 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/AddScorer.svelte @@ -0,0 +1,410 @@ + + +
+ {#if mode === 'new'} + {#if kind === 'agent'} + + An agent handed one whole run to grade. It is an ordinary AI agent resource: this creates it + with the prompt below, and editing the column later means editing that agent. + + {:else} + + A script handed the same run, returning a number, a boolean or {'{ score, reason, checks }'}. + The template scores the answer against the case's expected one, reports how the agent got + there as checks beside it, and leaves a case with no expected answer unmeasured. Helpers + below it cover exact and structural matches, which tools were called, arguments against each + tool's schema, repeated calls, step errors, latency and cost. + + {/if} + + + + + + + + {#if kind === 'agent'} + + + + + + {/if} + {:else} + {#if recent.length > 0} + + {#snippet children({ item })} + + + {/snippet} + + {/if} + + {#if usingRecent} +
+ {#each recent as scorer (scorer.path)} + {@const measures = datasetSummary(datasets, scorer.dataset)} + + {/each} +
+ {:else if kind === 'agent'} + + {:else} + + {/if} + {/if} +
diff --git a/frontend/src/lib/components/aiEvals/AgentEvalModal.svelte b/frontend/src/lib/components/aiEvals/AgentEvalModal.svelte new file mode 100644 index 0000000000..ff014f403a --- /dev/null +++ b/frontend/src/lib/components/aiEvals/AgentEvalModal.svelte @@ -0,0 +1,68 @@ + + + + + {#snippet titleBadge()} + Beta + {/snippet} +
+ {#if agentPath} + + {#key `${opWorkspace ?? ''}:${agentPath}`} + + {/key} + {:else} +
+ Evals run against a saved agent + + This agent is written into the flow step rather than saved as its own agent, so there is + nothing for a dataset and its runs to belong to. Save it as a reusable agent from the + step, and its evals start there. + +
+ {/if} +
+
diff --git a/frontend/src/lib/components/aiEvals/EvalCasesGrid.svelte b/frontend/src/lib/components/aiEvals/EvalCasesGrid.svelte new file mode 100644 index 0000000000..f7fec7a89f --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalCasesGrid.svelte @@ -0,0 +1,132 @@ + + + + +
diff --git a/frontend/src/lib/components/aiEvals/EvalDatasetDrawer.svelte b/frontend/src/lib/components/aiEvals/EvalDatasetDrawer.svelte new file mode 100644 index 0000000000..3da84cf1ad --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalDatasetDrawer.svelte @@ -0,0 +1,419 @@ + + + onClosed?.()}> + + (removingCase = undefined)} + on:confirmed={() => { + const target = removingCase + removingCase = undefined + if (target?.id) deleteCase(target.id) + }} + > + + {caseLabel(removingCase ?? { input: {} })} goes from the dataset. The runs that executed it keep + their results: a run that happened is not undone by curating the case away. + + + (removingDataset = false)} + on:confirmed={() => { + removingDataset = false + deleteDataset() + }} + > + + {datasetPath} goes with its cases and every run recorded against it. The jobs those runs produced + are kept. + + + drawer?.closeDrawer()} + > +
+ + {mode === 'edit' + ? 'The cases this agent is measured on. Editing them leaves the runs that already executed them as they were.' + : 'A set of cases to measure this agent on, and the scorers that read them.'} + + {#key formGeneration} + +
+ + +
+ {/key} + + +
+ (scorersWriting = w)} + /> +
+
+
+ Cases + {workingCases.length} +
+ +
+
+ (casesEditing = v)} + /> +
+
+
+ {#snippet actions()} + {#if mode === 'edit'} + + + {:else} + + {/if} + {/snippet} +
+
diff --git a/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte b/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte new file mode 100644 index 0000000000..d4d1714fd0 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalRunDialog.svelte @@ -0,0 +1,279 @@ + + + +
+ + + + + + +
+ {#snippet actions()} + + {/snippet} +
diff --git a/frontend/src/lib/components/aiEvals/EvalRunsList.svelte b/frontend/src/lib/components/aiEvals/EvalRunsList.svelte new file mode 100644 index 0000000000..5d84ef88ce --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalRunsList.svelte @@ -0,0 +1,170 @@ + + + + + + + + + + + + + Run + Dataset + Cases + Scores + When + + + + {#each experiments as experiment (experiment.id)} + onOpen(experiment)}> + +
+
+ {experimentName(experiment)} + + {subjectLabel(experiment, deployedHash, currentVersion)} + +
+ {experiment.created_by} +
+
+ + {@const summary = datasetSummary(datasets, experiment.dataset)} + + + + {experiment.case_count} + + +
+ {#each experiment.scores ?? [] as score (score.scorer_id)} + {@const value = headline(score)} + + + {#if score.kind === 'agent'} + + {:else} + + {/if} + {score.name} + {#if value != undefined} + {value} + {:else if score.failed > 0} + failed + {:else if experiment.running} + + {:else} + + {/if} + + + {/each} + {#if (experiment.scores ?? []).length === 0} + {#if experiment.running} + + + scoring + + {:else} + not scored + {/if} + {/if} +
+
+ + + + + +
+ {/each} + {#if experiments.length === 0 && !loaded} + + + + + + {:else if experiments.length === 0} + + +
+ No runs yet + + A run answers every case of a dataset and scores the answers. Each one is kept, so the + next has something to be compared against. + + +
+ + + {/if} + +
diff --git a/frontend/src/lib/components/aiEvals/EvalScorers.svelte b/frontend/src/lib/components/aiEvals/EvalScorers.svelte new file mode 100644 index 0000000000..68287b0fbc --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalScorers.svelte @@ -0,0 +1,383 @@ + + +
+
+ Scorers + {scorers.length} +
+ openAdd('agent', 'new') }, + { + displayName: 'Existing AI judge', + icon: Bot, + action: () => openAdd('agent', 'existing') + }, + { displayName: 'New code scorer', icon: Code2, action: () => openAdd('script', 'new') }, + { + displayName: 'Existing code scorer', + icon: Code2, + action: () => openAdd('script', 'existing') + } + ]} + placement="bottom-end" + > + {#snippet buttonReplacement()} + + {/snippet} + +
+ +
+ {#if scorers.length === 0} +
+ A scorer reads one run and returns a number. Every run of this dataset is measured by all of + them, which is what makes two runs comparable. +
+ {:else} +
+ {#each scorers as scorer (scorer.id)} +
+ {#if scorer.kind === 'agent'} + + {:else} + + {/if} +
+ + {scorerLabel(scorer)} + + {scorer.path} +
+ {#if scorer.pass_if != undefined} + + ≥ {scorer.pass_if} + + {/if} +
+ {/each} +
+ {/if} +
+
+ + + scorerDrawer?.closeDrawer()} + > + {#if workspace && datasetPath} + {#key scorerFormGeneration} + + scriptEditorDrawer + ?.openDrawer(hash, onChanged) + .catch((e) => sendUserToast(`Failed to open the scorer: ${e}`, true))} + /> + {/key} + {/if} + {#snippet actions()} + {@const state = addScorerForm?.submitState()} + + {/snippet} + + + + + settingsDrawer?.closeDrawer()}> + {#if settingsScorer} +
+ + {#if settingsScorer.kind === 'agent'} + + {:else} + + {/if} + {settingsScorer.path} + + + + + +
+ {/if} + {#snippet actions()} + + {/snippet} + + + + + + + + (removingScorer = undefined)} + on:confirmed={async () => { + const target = removingScorer + removingScorer = undefined + if (!target) return + try { + await saveScorers(scorers.filter((s) => s.id !== target.id)) + } catch (e) { + sendUserToast(`Failed to remove the scorer: ${e}`, true) + } + }} +> + + The column goes from every run of this dataset, the ones already recorded included. Adding it + again starts a new column, which fills from the next run on. + + diff --git a/frontend/src/lib/components/aiEvals/EvalsPane.svelte b/frontend/src/lib/components/aiEvals/EvalsPane.svelte new file mode 100644 index 0000000000..d9e5b16fe4 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/EvalsPane.svelte @@ -0,0 +1,978 @@ + + +
+
+ {#if viewingRun} + + {/if} +
+ {#if viewingRun && experiment?.run_job_id} + + Open the job + + + {/if} + {#if !viewingRun && loaded && datasets.length > 0} + + {#if experiments.length > 0} + + + {/if} + {/if} +
+ +
+ + +
+ {#if loaded && loadError} +
+ Could not load evals + + The datasets or runs could not be read. Check your access to this agent and reload. + +
+ {:else if loaded && datasets.length === 0} +
+ No dataset yet + + A dataset is the set of cases this agent is measured on. Runs are of a dataset, so + it is the first thing to make. + + +
+ {:else if !viewingRun || !loaded} + openRun(e.id)} + onEditDataset={async (path) => { + if (await useDataset(path)) datasetDrawer?.openDrawer('edit') + }} + onNew={() => (runDialogOpen = true)} + /> + {:else} + + + + + {#each scorers as scorer (scorer.id)} + + {/each} + + + + Case + Answer + {#each scorers as scorer, index (scorer.id)} + {@const mean = means.find((m) => m.scorer_id === scorer.id)} + {@const headline = columnHeadline(scorer, mean)} + + +
+ + {#if scorer.kind === 'agent'} + + {:else} + + {/if} + {scorerLabel(scorer)} + + + {#if headline} + + {headline.value} + + {#if headline.delta && headline.direction !== 0} + 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`} + > + {headline.delta} + + {/if} + {/if} + +
+
+ {/each} + + + + {#each displayRows as row (row.case_id)} + {@const status = statusOf(row.status)} + openCase(row)} + > + + {caseLabel(row)} + + + + + {#if row.output != undefined} + {row.output} + {:else if status === STATUS.not_run} + not run + {:else} + {status.label.toLowerCase()} + {/if} + + + {#each scorers as scorer, index (scorer.id)} + {@const cell = row.scores.find((s) => s.scorer_id === scorer.id)} + + {#if cell?.pending} + + + + {:else if cell?.score != undefined} + + {#snippet text()} +
+ {#if cell.reason} + {cell.reason} + {/if} + {#each checksOf(cell) as check (check.name)} + + + {check.passed ? '✓' : '✗'} + + {check.name} + {#if check.detail} + {check.detail} + {/if} + + {/each} +
+ {/snippet} + + {#if cell.passed != undefined} + + {cell.passed ? '✓' : '✗'} + + {/if} + + {formatScore(cell.score)} + + {#if cell.baseline != undefined && cell.score !== cell.baseline} + {@const delta = cell.score - cell.baseline} + 0 ? 'text-green-500' : 'text-red-500'}`} + > + {formatDelta(delta)} + + {/if} + +
+ {:else if cell?.not_applicable} + + {#snippet text()} + {cell.reason} + {/snippet} + + n/a + + + {:else if cell?.error} + + {#snippet text()} + {cell.error} + {/snippet} + failed + + {:else} + + {/if} +
+ {/each} +
+ {/each} + +
+ {/if} +
+
+ {#if selectedRow} + {@const openRow = selectedRow} + +
+
+ + {openRow.input?.user_message ?? caseLabel(openRow)} + +
+ {#if openRow.job_id} + + Open the case job + + + {/if} +
+
+ {#if openRow.expected != undefined && openRow.expected !== ''} + + {/if} + {#if scorers.length > 0 && openRow.scores.length > 0} + + {/if} + {#if experiment && (openRow.job_id || openRow.output != undefined)} +
+
+ + Case result + +
+
+ {#if openRow.output != undefined} +
+ +
+ {:else if openRow.status === 'running'} + + + Running + + {:else} + {statusOf(openRow.status).label} + {/if} +
+
+ {/if} +
+
+
+ {/if} +
+
+
+ + { + if (await useDataset(path)) { + resumeRunDialog = true + datasetDrawer?.openDrawer('edit') + } + }} + onNewDataset={() => { + resumeRunDialog = true + datasetDrawer?.openDrawer('new') + }} +/> + + { + if (!resumeRunDialog) return + resumeRunDialog = false + // On the dataset the drawer was just in: the dialog opens on the pane's own, which + // creating or editing one has already moved to it. + runDialogOpen = true + }} +/> diff --git a/frontend/src/lib/components/aiEvals/evalUtils.test.ts b/frontend/src/lib/components/aiEvals/evalUtils.test.ts new file mode 100644 index 0000000000..d1f65e0d96 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/evalUtils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { EvalExperiment } from '$lib/gen' +import { parseThreshold, subjectLabel } from './evalUtils' + +describe('parseThreshold', () => { + it('keeps 0 as a threshold and reads only empty text as no threshold', () => { + expect(parseThreshold(0)).toEqual({ value: 0, error: false }) + expect(parseThreshold('0')).toEqual({ value: 0, error: false }) + expect(parseThreshold('')).toEqual({ error: false }) + expect(parseThreshold(' ')).toEqual({ error: false }) + expect(parseThreshold(null)).toEqual({ error: false }) + expect(parseThreshold(undefined)).toEqual({ error: false }) + }) + + it('refuses anything outside 0 to 1 or not a number', () => { + expect(parseThreshold('0.5')).toEqual({ value: 0.5, error: false }) + expect(parseThreshold('1')).toEqual({ value: 1, error: false }) + expect(parseThreshold('1.5')).toEqual({ error: true }) + expect(parseThreshold('-0.1')).toEqual({ error: true }) + expect(parseThreshold('abc')).toEqual({ error: true }) + }) +}) + +describe('subjectLabel', () => { + function run(subject: Record): EvalExperiment { + return { subject: { path: 'u/me/agent', ...subject } } as unknown as EvalExperiment + } + + it('names a deployed run and a pinned version by their number', () => { + expect(subjectLabel(run({ kind: 'agent', version: 4 }))).toBe('v4') + expect(subjectLabel(run({ kind: 'agent_version', version: 2 }))).toBe('v2') + }) + + it('says a draft run is edits on top of the version it was an edit of', () => { + expect(subjectLabel(run({ kind: 'agent_draft', version: 4, draft_hash: 'h1' }))).toBe( + 'v4 + edits' + ) + expect(subjectLabel(run({ kind: 'agent_draft', draft_hash: 'h1' }))).toBe('edits') + }) + + it('reads a draft whose configuration is now deployed as the current version', () => { + const draft = run({ kind: 'agent_draft', version: 4, draft_hash: 'h1' }) + expect(subjectLabel(draft, 'h1', 5)).toBe('v5') + expect(subjectLabel(draft, 'other', 5)).toBe('v4 + edits') + }) +}) diff --git a/frontend/src/lib/components/aiEvals/evalUtils.ts b/frontend/src/lib/components/aiEvals/evalUtils.ts new file mode 100644 index 0000000000..fcc5419f48 --- /dev/null +++ b/frontend/src/lib/components/aiEvals/evalUtils.ts @@ -0,0 +1,107 @@ +import type { + EvalCase, + EvalCaseInput, + EvalDataset, + EvalExperiment, + NewEvalCase, + Scorer +} from '$lib/gen' + +/** The case being edited in the drawer, before it is either run or saved to a dataset. */ +export type CaseDraft = NewEvalCase & { id?: string } + +/** A level the evals pane is on, and the way out of it. */ +export type EvalsLocation = { label: string; back: () => void } + +export type ScorerKind = Scorer['kind'] + +export function emptyCase(): CaseDraft { + return { input: { user_message: '' } } +} + +export function fromStoredCase(c: EvalCase): CaseDraft { + const { created_at: _created_at, created_by: _created_by, ...rest } = c + return rest +} + +export function caseLabel(c: { input?: EvalCaseInput }): string { + const message = c.input?.user_message?.trim() + if (message) return message.length > 60 ? message.slice(0, 60) + '…' : message + return 'Untitled case' +} + +export function experimentName(experiment: EvalExperiment): string { + return `Run ${experiment.run_number}` +} + +/** + * What ran: a deployed version, or a version with edits sitting on top of it. + * + * The list and the results endpoint restamp a draft run whose configuration was later deployed, so + * the kind is usually enough; `deployedHash` and `currentVersion` resolve the one still unstamped. + */ +export function subjectLabel( + experiment: EvalExperiment, + deployedHash?: string, + currentVersion?: number +): string { + if (experiment.subject.kind === 'agent_version') { + return experiment.subject.version ? `v${experiment.subject.version}` : 'a past version' + } + const deployed = + experiment.subject.kind === 'agent' || + (experiment.subject.draft_hash != undefined && experiment.subject.draft_hash === deployedHash) + if (deployed) { + const version = + experiment.subject.kind === 'agent' ? experiment.subject.version : currentVersion + return version ? `v${version}` : 'deployed' + } + return experiment.subject.version ? `v${experiment.subject.version} + edits` : 'edits' +} + +/** A scorer keeps its id when renamed, so its name is the column header and nothing else. */ +export function scorerLabel(scorer: Scorer): string { + return scorer.name || scorer.path.split('/').pop() || scorer.path +} + +export function kindLabel(kind: ScorerKind): string { + return kind === 'agent' ? 'Judge agent' : 'Script' +} + +export function formatScore(score: number | undefined): string { + return score == undefined ? '—' : score.toFixed(2) +} + +export function formatDelta(delta: number): string { + if (delta === 0) return '0.00' + return `${delta > 0 ? '+' : '−'}${Math.abs(delta).toFixed(2)}` +} + +/** What a dataset is for, where it says so: the path names it either way. */ +export function datasetSummary(datasets: EvalDataset[], path: unknown): string | undefined { + return datasets.find((d) => d.path === path)?.summary || undefined +} + +/** + * A pass threshold, as a field holds it. Empty is `''` or null, never a number: a number input + * coerces the text, so a valid threshold of 0 would otherwise read as empty and be dropped. The + * server refuses anything outside 0 to 1, caught here so the form blocks instead of the save. + */ +export function parseThreshold(text: string | number | null | undefined): { + value?: number + error: boolean +} { + const trimmed = typeof text === 'string' ? text.trim() : text + if (trimmed === '' || trimmed == undefined) return { error: false } + const value = Number(trimmed) + if (Number.isNaN(value) || value < 0 || value > 1) return { error: true } + return { value, error: false } +} + +export function summaryToName(summary: string): string { + return summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') +} diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/dbQueriesUtils.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/dbQueriesUtils.ts index 776c48f2b8..7ad28248d2 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/dbQueriesUtils.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/dbQueriesUtils.ts @@ -35,11 +35,12 @@ export function renderForeignKey( dbType: DbType tableName: string /** - * Quotes each dot-separated part of the target in the REFERENCES clause, so a + * Table to name in the REFERENCES clause, quoted per dot-separated part so a * schema-qualified target survives identifiers that need quoting. The constraint - * name is built from the unquoted value either way. + * name stays derived from `fk.targetTable`, so qualifying a target here never + * renames a constraint an earlier migration created under the bare name. */ - quoteTarget?: boolean + qualifiedTarget?: string } ): string { const sourceColumns = fk.columns.map((c) => c.sourceColumn).filter(Boolean) @@ -60,13 +61,12 @@ export function renderForeignKey( .join('_') .replaceAll('.', '_')} `.substring(0, 60) - const targetRef = - options.quoteTarget && targetTable - ? targetTable - .split('.') - .map((part) => renderDbQuotedIdentifier(part, options.dbType)) - .join('.') - : targetTable + const targetRef = options.qualifiedTarget + ? options.qualifiedTarget + .split('.') + .map((part) => renderDbQuotedIdentifier(part, options.dbType)) + .join('.') + : targetTable sql += ` FOREIGN KEY (${sourceColumns.join(', ')}) REFERENCES ${targetRef} (${targetColumns.join( ', ' diff --git a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css new file mode 100644 index 0000000000..1ac9f8b31c --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.css @@ -0,0 +1,26 @@ +/* MultilineCellEditor: a popup positioned over the cell, so it has to paint the cell's own frame + rather than inherit it. */ +.ag-theme-alpine .wm-multiline-cell-editor, +.ag-theme-alpine-dark .wm-multiline-cell-editor { + background-color: var(--ag-background-color); +} +.ag-theme-alpine .wm-multiline-cell-editor textarea, +.ag-theme-alpine-dark .wm-multiline-cell-editor textarea { + display: block; + box-sizing: border-box; + /* Horizontal only: the vertical padding is set by the editor, which knows the height of the row + it is replacing. `line-height` here is what it computes against. */ + padding: 0 calc(var(--ag-cell-horizontal-padding) - 1px); + border: 1px solid var(--ag-input-focus-border-color); + border-radius: 3px; + outline: none; + resize: none; + /* Past this it scrolls rather than growing. */ + max-height: 40vh; + overflow-y: auto; + background-color: var(--ag-background-color); + color: var(--ag-foreground-color); + font: inherit; + line-height: 20px; + white-space: pre-wrap; +} diff --git a/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts new file mode 100644 index 0000000000..00975955c9 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/table/multilineCellEditor.ts @@ -0,0 +1,108 @@ +import type { ColDef, ICellEditorComp, ICellEditorParams } from 'ag-grid-community' +// Beside the editor rather than in the AgGrid theme: that file is the vendored theme, and a rule +// added to it is one the next copy of it drops. +import './multilineCellEditor.css' + +/** Kept in step with the `line-height` the stylesheet gives the textarea. */ +const LINE_HEIGHT = 20 + +/** + * A text cell editor that starts the height of the cell and grows as lines are added, for columns + * holding prose rather than a value. Enter commits, Shift+Enter adds a line, Escape cancels. + * + * Rendered as a popup positioned over the cell: an in-cell editor is clipped to the row height, so + * growing is only visible if the editor is allowed to paint outside it. + */ +export class MultilineCellEditor implements ICellEditorComp { + private eGui!: HTMLDivElement + private textarea!: HTMLTextAreaElement + private params!: ICellEditorParams + private wasEmpty = false + + init(params: ICellEditorParams) { + this.params = params + this.eGui = document.createElement('div') + this.eGui.className = 'wm-multiline-cell-editor' + + this.wasEmpty = params.value == undefined + + this.textarea = document.createElement('textarea') + this.textarea.rows = 1 + // A keystroke that opened the edit replaces the value, as it does in every other cell; F2 + // and double-click keep it to be edited. + this.textarea.value = params.eventKey?.length === 1 ? params.eventKey : (params.value ?? '') + this.textarea.style.width = `${params.column.getActualWidth() - 2}px` + // Padded so one line fills the cell it replaces and a second costs a line rather than a row. + // From the row rather than from `--ag-row-height`, which is the theme's figure and not + // necessarily this grid's. + const rowHeight = params.node.rowHeight ?? 28 + const padding = Math.max(0, (rowHeight - LINE_HEIGHT - 2) / 2) + this.textarea.style.paddingTop = `${padding}px` + this.textarea.style.paddingBottom = `${padding}px` + + this.textarea.addEventListener('input', () => this.resize()) + this.textarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // Kept from whatever is around the grid: a grid in a drawer or a dialog is under a + // surface that closes on Escape, and leaving an edit is not asking to leave that. + e.preventDefault() + e.stopPropagation() + this.params.api.stopEditing(true) + return + } + if (e.key !== 'Enter' || e.isComposing) return + // Both branches keep the key from the grid, which ends the edit on Enter whether or not + // Shift is held: Shift+Enter falls through to the textarea's own newline, and plain Enter + // ends the edit here instead. + e.stopPropagation() + if (!e.shiftKey) { + e.preventDefault() + this.params.stopEditing() + } + }) + this.eGui.appendChild(this.textarea) + } + + private resize() { + this.textarea.style.height = 'auto' + this.textarea.style.height = `${this.textarea.scrollHeight}px` + } + + getGui() { + return this.eGui + } + + afterGuiAttached() { + this.resize() + this.textarea.focus() + // At the end rather than selected: a selection is a keystroke away from erasing the cell. + const end = this.textarea.value.length + this.textarea.setSelectionRange(end, end) + } + + getValue() { + // Nothing typed into a cell that held nothing is not an edit: returning '' here would write + // an empty string over a null, which the grid would see as a change and commit. + if (this.wasEmpty && this.textarea.value === '') return this.params.value + return this.textarea.value + } + + isPopup() { + return true + } + + getPopupPosition(): 'over' | 'under' { + return 'over' + } +} + +/** + * What a column of prose needs, ready to spread into a colDef. `suppressKeyboardEvent` as well as + * the editor: the grid ends an edit on Enter from a handler a popup editor's DOM does not sit + * under, so the editor cannot keep Shift+Enter for itself on its own. + */ +export const multilineCellColDef: Pick = { + cellEditor: MultilineCellEditor, + suppressKeyboardEvent: (p) => + p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey +} diff --git a/frontend/src/lib/components/argInputWorkspaceForwarding.test.ts b/frontend/src/lib/components/argInputWorkspaceForwarding.test.ts new file mode 100644 index 0000000000..1fd29b3700 --- /dev/null +++ b/frontend/src/lib/components/argInputWorkspaceForwarding.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const TAGS = [ + 'SchemaFormDnd', + 'SchemaForm', + 'PasswordArgInput', + 'ArgInput', + 'FlowPropertyEditor', + 'PropertyEditor', + 'EditableSchemaForm', + 'EditableSchemaDrawer', + 'ArrayTypeNarrowing', + 'InputTransformSchemaForm', + 'InputTransformForm', + 'ScriptSchema' +] +// `SchemaFormDnd` precedes `SchemaForm` so the longer tag is not matched as the shorter one. +const OPENING = new RegExp(`<(${TAGS.join('|')})(?=[\\s/>]|$)`, 'g') + +function formMounts(source: string): { tag: string; line: number; block: string }[] { + const lines = source.split('\n') + const mounts: { tag: string; line: number; block: string }[] = [] + for (let i = 0; i < lines.length; i++) { + const open = lines[i].trim().match(new RegExp(`^${OPENING.source}`)) + if (!open) continue + // Requiring a lone `>` would run past a mount whose last prop shares the closing line, into + // the next component, and read its `workspace` as this one's — a false pass on exactly the + // regression this guards. + let end = i + while (end < lines.length && !lines[end].trim().endsWith('>')) end++ + // Running off the end means the props were never delimited, so the block would swallow the + // rest of the file and match any `workspace` in it — a false pass, not a failure. + if (end >= lines.length) { + throw new Error(`unterminated <${open[1]}> mount at line ${i + 1}`) + } + mounts.push({ tag: open[1], line: i + 1, block: lines.slice(i, end + 1).join('\n') }) + i = end + } + return mounts +} + +// `workspace={$workspaceStore}` and `workspace={undefined}` are the nav-workspace fallback this +// guards against, so presence of the prop is not enough. +function forwardsWorkspace(block: string): boolean { + const m = block.match(/\{workspace\}|workspace=\{([^{}]*)\}/) + if (!m) return false + const expr = m[1]?.trim() + return expr === undefined || (expr !== 'undefined' && expr !== '$workspaceStore') +} + +function read(relPath: string): string { + return readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), relPath), 'utf-8') +} + +// Every hop between the form a caller mounts and the PasswordArgInput that mints the secret, plus +// the entry points that supply the workspace in the first place. A hop that drops `workspace` falls +// back to the navigation workspace, so the secret lands where the job will not run — while the +// top-level case keeps passing. +describe.each([ + ['ArgInput.svelte', 7], + ['schema/SchemaFormDND.svelte', 1], + ['SchemaForm.svelte', 2], + ['EditableSchemaForm.svelte', 3], + ['schema/FlowPropertyEditor.svelte', 3], + ['schema/EditableSchemaDrawer.svelte', 2], + ['schema/PropertyEditor.svelte', 3], + ['ArrayTypeNarrowing.svelte', 1], + ['InputTransformSchemaForm.svelte', 1], + ['InputTransformForm.svelte', 1], + ['ScriptSchema.svelte', 1], + ['ScriptBuilder.svelte', 1], + ['flows/content/FlowInput.svelte', 2], + ['flows/content/FlowModuleComponent.svelte', 1], + ['flows/content/AgentToolBindings.svelte', 1], + ['ModulePreviewForm.svelte', 1], + ['dbt/DbtEditor.svelte', 1] + // `flows/content/FlowModuleSuspend.svelte` stays out: its two unthreaded mounts render the + // locally built `groups` schema and a preview whose args stay empty, so neither can mint. +])('%s nested forms', (relPath, minMounts) => { + it('forwards workspace to every nested form', () => { + const source = read(relPath) + const mounts = formMounts(source) + expect(mounts.length).toBeGreaterThanOrEqual(minMounts) + // The scan only recognises a mount opening its own line, so one written inline would be + // skipped and silently unguarded. Every opening tag in the file has to be accounted for. + expect(mounts.length).toBe(source.match(OPENING)?.length ?? 0) + expect( + mounts.filter((m) => !forwardsWorkspace(m.block)).map((m) => `${m.tag}:${m.line}`) + ).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts index 809ccb8ce2..26d3e24d84 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineLanguages.ts @@ -4,7 +4,7 @@ import type { ScriptLang } from '$lib/gen' // actually reach for first: duckdb for in-place SQL on parquet/s3 (the // default), bun for ergonomic data wrangling, python for ML/pandas, then // the sql dialects for warehouse-resident transforms. Everything else -// (deno/bash/go) sits below — still creatable, just not the default +// (bash/go/deno) sits below — still creatable, just not the default // suggestion. export const PIPELINE_LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [ { label: 'DuckDB', lang: 'duckdb' }, @@ -15,7 +15,7 @@ export const PIPELINE_LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [ { label: 'Snowflake', lang: 'snowflake' }, { label: 'MySQL', lang: 'mysql' }, { label: 'MS SQL', lang: 'mssql' }, - { label: 'TypeScript (Deno)', lang: 'deno' }, { label: 'Bash', lang: 'bash' }, - { label: 'Go', lang: 'go' } + { label: 'Go', lang: 'go' }, + { label: 'Deno', lang: 'deno' } ] diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 6bf71c65fd..213beda001 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -217,6 +217,7 @@ INSTANCE_GROUPS_SCIM_DELETE: 'instance_groups.scim_delete', INSTANCE_GROUPS_SCIM_UPDATE: 'instance_groups.scim_update', VARIABLES_DECRYPT_SECRET: 'variables.decrypt_secret', + WORKSPACES_READ_ENCRYPTION_KEY: 'workspaces.read_encryption_key', WORKSPACES_EDIT_COMMAND_SCRIPT: 'workspaces.edit_command_script', WORKSPACES_EDIT_DEPLOY_TO: 'workspaces.edit_deploy_to', WORKSPACES_EDIT_AUTO_INVITE_DOMAIN: 'workspaces.edit_auto_invite_domain', diff --git a/frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts b/frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts new file mode 100644 index 0000000000..7952aca9c6 --- /dev/null +++ b/frontend/src/lib/components/chartjs-wrappers/timeTicks.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest' +import type { Tick } from 'chart.js' +import { timeTicksWithDate } from './timeTicks' + +const year = new Date().getFullYear() + +function labels(dates: Date[]): string[] { + const ticks = dates.map((d) => ({ value: d.getTime() }) as Tick) + const { callback } = timeTicksWithDate(dates[0], dates[dates.length - 1]) + return ticks.map((t, i) => callback(t.value, i, ticks)) +} + +describe('timeTicksWithDate', () => { + test('dates the leftmost tick and each day boundary, not the ticks in between', () => { + expect( + labels([ + new Date(year, 7, 21, 20, 0), + new Date(year, 7, 21, 22, 0), + new Date(year, 7, 22, 0, 0), + new Date(year, 7, 22, 2, 0) + ]) + ).toEqual(['Aug 21 8PM', '10PM', 'Aug 22', '2AM']) + }) + + test('sub-hour ticks keep the same AM/PM casing as the hourly ones', () => { + expect(labels([new Date(year, 7, 21, 23, 45), new Date(year, 7, 22, 0, 15)])).toEqual([ + 'Aug 21 11:45 PM', + 'Aug 22 12:15 AM' + ]) + }) + + test('major ticks are enabled only once the axis spans more than one day', () => { + const within = timeTicksWithDate(new Date(year, 7, 21, 6, 0), new Date(year, 7, 21, 23, 0)) + const across = timeTicksWithDate(new Date(year, 7, 21, 23, 0), new Date(year, 7, 22, 1, 0)) + + // Majors keep day boundaries through autoSkip, but drop tick 0 — the only dated tick an + // axis inside a single day has. + expect(within.major.enabled).toBe(false) + expect(across.major.enabled).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/chartjs-wrappers/timeTicks.ts b/frontend/src/lib/components/chartjs-wrappers/timeTicks.ts new file mode 100644 index 0000000000..7057784a65 --- /dev/null +++ b/frontend/src/lib/components/chartjs-wrappers/timeTicks.ts @@ -0,0 +1,47 @@ +import type { Tick } from 'chart.js' +import { format, isSameDay, startOfDay } from 'date-fns' + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +function dateFormat(time: number): string { + return new Date(time).getFullYear() === new Date().getFullYear() ? 'MMM d' : 'MMM d, yyyy' +} + +function clockFormat(spacing: number): string { + if (spacing < SECOND) return 'h:mm:ss.SSS a' + if (spacing < MINUTE) return 'h:mm:ss a' + if (spacing < HOUR) return 'h:mm a' + return 'ha' +} + +/** + * Ticks for a chart.js time axis that stay unambiguous about the day: sub-day ticks are bare + * clock times ("6PM"), except the leftmost one and the first tick of each day, which spell out + * the date. Without them a range that never reaches a day boundary carries no date at all. + * + * `min`/`max` are the bounds the axis is configured with. + */ +export function timeTicksWithDate(min: Date, max: Date) { + return { + maxRotation: 0, + minRotation: 0, + // Major ticks pin autoSkip's grid to the day boundaries, which is what keeps whole days + // worth of ticks aligned — and dated — on a wide axis. They also un-pin it from tick 0, + // whose date is the only one an axis within a single day has, so they stay off there. + major: { enabled: !isSameDay(min, max) }, + callback(value: number | string, index: number, ticks: Tick[]): string { + const time = Number(value) + // chart.js also calls this with a lone tick to size a sample label, hence the fallback. + const spacing = ticks.length > 1 ? Math.abs(ticks[1].value - ticks[0].value) : HOUR + if (spacing >= 27 * DAY) return format(time, 'MMM yyyy') + const date = format(time, dateFormat(time)) + if (spacing >= DAY) return date + const clock = format(time, clockFormat(spacing)) + if (index > 0 && isSameDay(ticks[index - 1].value, time)) return clock + return time === startOfDay(time).getTime() ? date : `${date} ${clock}` + } + } +} diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 6a27357cf2..642d1366f6 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -94,6 +94,13 @@ return open } + /** Whether this is the overlay on top, i.e. the one a key press is for. Overlays that keep + * Escape for themselves (`preventEscape`) have to ask, or they answer keys aimed at whatever + * is stacked above them. Same condition the handler below arbitrates on. */ + export function isTopmost() { + return stack.val.length === 0 || stack.val[stack.val.length - 1] === id + } + function handleClickAway(e) { const last = stack.val[stack.val.length - 1] if (last === id) { diff --git a/frontend/src/lib/components/common/modal/Modal.svelte b/frontend/src/lib/components/common/modal/Modal.svelte index 20821a33f9..9cfa11b84e 100644 --- a/frontend/src/lib/components/common/modal/Modal.svelte +++ b/frontend/src/lib/components/common/modal/Modal.svelte @@ -1,5 +1,16 @@ + + {#snippet sendStopButton()} - {@const isLoading = loading ?? aiChatManager.loading} + + {@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend} {@const emptyDraft = draft.isEmpty} +
(showDetail = !showDetail)} + onkeydown={(e) => { + // Keys aimed at the buttons inside the row bubble through here; leave them theirs. + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + showDetail = !showDetail + } + }} + > - Linked to - +
{agent} e.stopPropagation()}>{agent} - - {#snippet text()} - Read-only: the configuration comes from this saved agent, and only the message and - inputs are set in this flow. Edit changes the agent everywhere it's used. Unlink forks - an editable copy into just this step. - {/snippet} - - -
+ {#if version != undefined} + + v{version} + + {/if} +
+
+ {#if brainParams.length > 0 || inheritedTools.length > 0} + + {#if showDetail} + + {:else} + + {/if} + + {/if} +
- {#if brainParams.length > 0 || inheritedTools.length > 0} -
+ {#if showDetail && (brainParams.length > 0 || inheritedTools.length > 0)} +
{#each brainParams as param (param.label)}
{param.label}
@@ -478,12 +609,7 @@
Tools
{#each inheritedTools as tool (tool.id)} - - {toolLabel(tool)} - + {toolLabel(tool)} {/each}
@@ -502,14 +628,56 @@ {/if} {:else if editingPath}
- - Editing - {editingPath} -
+
+ +
+
+ {editingPath} + {#if version != undefined} + + v{version} + + {/if} + {#if edited} + + unsaved changes + + {/if} +
+
+ saving updates every flow using it + {#snippet text()} + The edits live in this step until you decide: Evals runs them as they are here, Save + changes writes them to the agent, Cancel drops them and re-links the step. + {/snippet} + +
+
+
+
+ + -
-

- Editing the saved agent. Save changes updates it and re-links this step — the update - propagates to every flow that links to it. Cancel keeps your edits here as a standalone step - instead. -

{#if providerSaveError} -

+

{providerSaveError}

{/if} {:else} -
-
- -
- or - -
+ {/if}
@@ -552,7 +711,8 @@

Save this AI agent's configuration and tools as a reusable resource. Other flows can then - link to it, and updates propagate automatically. + link to it, updates propagate automatically, and it gains a dataset of eval cases of its + own.

+ + + + + + + { + confirmCancel = false + const path = editingPath + if (path) relink(path) + }} + onCanceled={() => (confirmCancel = false)} +> + + The step goes back to {editingPath} as it is deployed, and the edits are not kept anywhere. Save + changes writes them to the agent instead. + + diff --git a/frontend/src/lib/components/flows/content/AgentToolBindings.svelte b/frontend/src/lib/components/flows/content/AgentToolBindings.svelte index 74be8316ea..e1c54f6901 100644 --- a/frontend/src/lib/components/flows/content/AgentToolBindings.svelte +++ b/frontend/src/lib/components/flows/content/AgentToolBindings.svelte @@ -151,6 +151,7 @@ schema={schemas[tool.id]} {pickableProperties} {extraLib} + {workspace} isAgentTool bind:args={ () => localArgs[tool.id], diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 21bbdaa1ec..d780dede0a 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -712,6 +712,7 @@ hiddenArgs={['user_message']} isFlowInput showSensitiveToggle + workspace={opWs} editTab={chatInputsEditTab ? 'inputEditor' : undefined} showDynOpt bind:dynCode @@ -768,6 +769,7 @@ bind:schema={flowStore.val.schema} isFlowInput showSensitiveToggle + workspace={opWs} on:delete={(e) => { addPropertyV2?.handleDeleteArgument([e.detail]) }} diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index e275df3976..f86d870a1a 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -44,6 +44,11 @@ ? 'approval' : 'script' ) + // The preprocessor slot shows no kind toggle, so `kind` stays 'script' there. Everything that + // tags a script (inline template, pre-made list) must key off this, never off `kind`. + let scriptKind: 'script' | 'failure' | 'approval' | 'trigger' | 'preprocessor' = $derived( + preprocessorModule ? 'preprocessor' : kind + ) let pick_existing: 'workspace' | 'hub' = $state('hub') let filter = $state('') @@ -56,7 +61,7 @@ ) function displayLang(lang: SupportedLanguage | 'docker', kind: string) { - if (preprocessorModule) { + if (kind === 'preprocessor') { return canHavePreprocessor(lang as SupportedLanguage) } @@ -218,21 +223,23 @@

Inline new {kind == 'script' ? 'action' : kind}{scriptKind == 'script' ? 'action' : scriptKind} script - Embed {kind == 'script' ? 'action' : kind} script directly inside a flow instead - of saving the script into your workspace for reuse. You can always save an inline script to - your workspace later. + Embed {scriptKind == 'script' ? 'action' : scriptKind} script directly inside + a flow instead of saving the script into your workspace for reuse. You can always save an inline + script to your workspace later.
@@ -250,7 +257,7 @@ {/if}
{#each langs.filter((lang) => customUi?.languages == undefined || customUi?.languages?.includes(lang?.[1])) as [label, lang] (lang)} - {#if displayLang(lang, kind)} + {#if displayLang(lang, scriptKind)} { dispatch('new', { language: lang == 'docker' ? 'bash' : lang, - kind, + kind: scriptKind, subkind: lang == 'docker' ? 'docker' : preprocessorModule ? 'preprocessor' : 'flow', summary }) @@ -289,10 +296,13 @@

Use pre-made {kind == 'script' ? 'action' : kind}{scriptKind == 'script' ? 'action' : scriptKind} script

- {#if pick_existing == 'hub'} + {#if preprocessorModule} + + + {:else if pick_existing == 'hub'} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index d1fdf8f412..28082864aa 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -109,7 +109,7 @@ preprocessorModule?: boolean parentModule?: FlowModule | undefined previousModule: FlowModule | undefined - scriptKind?: 'script' | 'trigger' | 'approval' + scriptKind?: 'script' | 'trigger' | 'approval' | 'preprocessor' scriptTemplate?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' noEditor: boolean enableAi: boolean @@ -171,6 +171,11 @@ shellcheck: false }) + // `scriptKind` only records how a step was created this session, so it is back to 'script' on + // any remount. Being a preprocessor is a property of the slot, and the editor bar's reset code + // and script library depend on it, so derive it rather than reading the stale state. + let editorScriptKind = $derived(preprocessorModule ? 'preprocessor' : scriptKind) + let selected = $state(untrack(() => preprocessorModule) ? 'test' : 'inputs') let canShowChatTab = $derived( !preprocessorModule && @@ -864,7 +869,7 @@ {websocketAlive} iconOnly={width < EDITOR_BAR_WIDTH_THRESHOLD} compactHelpers={width < EDITOR_BAR_HELPERS_INLINE_THRESHOLD} - kind={scriptKind} + kind={editorScriptKind} template={scriptTemplate} args={Object.entries(flowModule.value.input_transforms).reduce((acc, [key, obj]) => { acc[key] = obj.type === 'static' ? obj.value : undefined @@ -1203,6 +1208,7 @@ : undefined} helperScript={retrieveDynCodeAndLang(flowModule.value)} chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} + workspace={opWs} /> {#if agentLinked} + + { + unsavedModalOpen = false + }} + on:confirmed={() => { + closeAnyway = true + unsavedModalOpen = false + scriptEditorDrawer?.closeDrawer() + }} + > +
+ Are you sure you want to discard the changes you have made? + +
+
{ +export async function createAiAgent( + id: string, + agentPath?: string +): Promise<[FlowModule, FlowModuleState]> { const storedConfig = loadStoredConfig() const providerValue = storedConfig ?? { kind: 'openai', resource: '', model: '' } + // A step linked to a saved agent reads its brain and tools from the resource, so it carries only + // the flow-local inputs: seeding `provider`/`output_type` would leave transforms it never reads. const aiAgentFlowModules: FlowModule = { id, value: { type: 'aiagent', + ...(agentPath ? { agent: agentPath } : {}), tools: [], input_transforms: { - provider: { type: 'static', value: providerValue }, - output_type: { type: 'static', value: 'text' }, + ...(agentPath + ? {} + : { + provider: { type: 'static', value: providerValue }, + output_type: { type: 'static', value: 'text' } + }), user_message: { type: 'static', value: undefined } } } diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index c98c10b1a2..ef2dd38c39 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -165,7 +165,8 @@ kind: InsertKind, wsScript?: { path: string; summary: string; hash: string | undefined }, wsFlow?: { path: string; summary: string }, - inlineScript?: InlineScript + inlineScript?: InlineScript, + agentPath?: string ): Promise { let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow') let state = emptyFlowModuleState() @@ -190,7 +191,7 @@ } else if (kind == 'branchall') { ;[module, state] = await createBranchAll(module.id) } else if (kind == 'aiagent') { - ;[module, state] = await createAiAgent(module.id) + ;[module, state] = await createAiAgent(module.id, agentPath) } else if (inlineScript) { const { language, kind, subkind, summary } = inlineScript ;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary) @@ -751,7 +752,8 @@ detail.kind as InsertKind, detail.script, detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined, - detail.inlineScript + detail.inlineScript, + detail.agentPath ) const index = detail.index ?? 0 const extraModules: FlowModule[] = [module] diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 0b3e77a848..3316463bbb 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -10,6 +10,11 @@ import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte' import TopLevelNode from '../pickers/TopLevelNode.svelte' import RefreshButton from '$lib/components/common/button/RefreshButton.svelte' + import Button from '$lib/components/common/button/Button.svelte' + import { ResourceService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' + import type { FlowEditorContext } from '../types' + import { BotIcon, Loader2, Plus } from 'lucide-svelte' const dispatch = createEventDispatcher() interface Props { @@ -42,11 +47,44 @@ | 'approval' | 'flow' | 'failure' - | 'aisandbox' = $state(untrack(() => kind)) + | 'aisandbox' + | 'aiagent' = $state(untrack(() => kind)) let preFilter: 'all' | 'workspace' | 'hub' = $state('all') let loading = $state(false) let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure')) + // Optional: this picker also renders outside the flow editor's context (the triggers wrapper). + const flowEditorContext = getContext('FlowEditorContext') + let ws = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore) + + let savedAgents = $state<{ path: string; description?: string }[]>([]) + let savedAgentsLoading = $state(false) + let savedAgentsWs: string | undefined = undefined + async function loadSavedAgents() { + if (!ws || savedAgentsWs === ws) { + return + } + savedAgentsLoading = true + try { + const rs = await ResourceService.listResource({ + workspace: ws, + resourceType: 'ai_agent', + perPage: 1000 + }) + savedAgents = rs.map((r) => ({ path: r.path, description: r.description })) + savedAgentsWs = ws + } catch { + savedAgents = [] + } finally { + savedAgentsLoading = false + } + } + let filteredAgents = $derived( + funcDesc + ? savedAgents.filter((a) => a.path.toLowerCase().includes(funcDesc.toLowerCase())) + : savedAgents + ) + let height = $state(0) let owners = $state([]) // Only the content-sized host (TriggersWrapper) grows past this. The fixed-height hosts top out @@ -81,6 +119,10 @@ {loading} onClick={() => { refreshCount.val += 1 + if (selectedKind === 'aiagent') { + savedAgentsWs = undefined + loadSavedAgents() + } }} />
@@ -184,9 +226,10 @@ {#if customUi?.aiAgent != false} { - dispatch('close') - dispatch('new', { kind: 'aiagent' }) + selectedKind = 'aiagent' + loadSavedAgents() }} /> {/if} @@ -203,7 +246,52 @@

{/if} - {#if selectedKind === 'aisandbox'} + {#if selectedKind === 'aiagent'} +
+ + {#if savedAgentsLoading} +
+ Loading saved agents +
+ {:else if filteredAgents.length > 0} +
Saved agents
+ {#each filteredAgents as agent (agent.path)} + + {/each} + {:else} +
+ {savedAgents.length > 0 + ? 'No saved agent matches this search' + : 'No saved agent in this workspace yet. Configure a blank one, then Save as reusable agent to reuse it.'} +
+ {/if} +
+ {:else if selectedKind === 'aisandbox'}
('FlowEditorContext') let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore) interface Props { - kind?: 'script' | 'trigger' | 'approval' | 'failure' + kind?: 'script' | 'trigger' | 'approval' | 'failure' | 'preprocessor' isTemplate?: boolean | undefined displayLock?: boolean filter?: string @@ -122,38 +122,48 @@ />
{/if} - {#if filter.length > 0 && filteredItems.length == 0} - - {/if} -
    - {#each filteredItems as { path, hash, summary, description, marked }} -
  • -
- {#if lockHash}{truncateHash(hash ?? '')}{/if} - - - {/each} - + {#if lockHash}{truncateHash(hash ?? '')}{/if} + + + {/each} + + {/if} {:else}
diff --git a/frontend/src/lib/components/flows/pickers/WorkspaceScriptPickerQuick.svelte b/frontend/src/lib/components/flows/pickers/WorkspaceScriptPickerQuick.svelte index 07df5bd5c5..9e3a20b4a7 100644 --- a/frontend/src/lib/components/flows/pickers/WorkspaceScriptPickerQuick.svelte +++ b/frontend/src/lib/components/flows/pickers/WorkspaceScriptPickerQuick.svelte @@ -165,6 +165,11 @@ {#if filteredItems.length == 0}
{kind == 'flow' ? 'No flows found.' : 'No scripts found.'} + {#if kind == 'preprocessor'} +
+ Only workspace scripts whose kind is set to Preprocessor are listed here. +
+ {/if}
{/if}
    diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 0855ac33e6..f83b67267a 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -192,6 +192,8 @@ inlineScript?: InlineScript script?: { path: string; summary: string; hash: string | undefined } flow?: { path: string; summary: string } + /** Saved `ai_agent` resource the inserted agent step links to, for `kind: 'aiagent'`. */ + agentPath?: string kind: InsertKind expandGroup?: { groupId: string; position: 'top' | 'bottom' } }) => Promise diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 450debbef4..3491cd3126 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -58,6 +58,8 @@ export type GraphEventHandlers = { inlineScript?: InlineScript script?: PathScript flow?: { path: string; summary: string } + /** Saved `ai_agent` resource the inserted agent step links to, for `kind: 'aiagent'`. */ + agentPath?: string isPreprocessor?: boolean }) => void deleteBranch: (detail: { id: string; index: number }, label: string) => void diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 89e7c88c94..5678621062 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -192,7 +192,8 @@ branch: data.branch, index: data.index, kind: e.detail.kind, - inlineScript: e.detail.inlineScript + inlineScript: e.detail.inlineScript, + agentPath: e.detail.agentPath }) }} on:pickScript={(e) => { diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 9465f5b49f..3de9c9b839 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -1,31 +1,5 @@ - + - + diff --git a/frontend/src/lib/components/icons/ActivitypubIcon.svelte b/frontend/src/lib/components/icons/ActivitypubIcon.svelte index b8eea782f7..1a98f2ca2f 100644 --- a/frontend/src/lib/components/icons/ActivitypubIcon.svelte +++ b/frontend/src/lib/components/icons/ActivitypubIcon.svelte @@ -13,7 +13,7 @@ y="0px" {width} {height} - viewBox="0 0 168 168" + viewBox="-6.563 -6.399 181.125 181.125" fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/AcumbamailIcon.svelte b/frontend/src/lib/components/icons/AcumbamailIcon.svelte index f36ceafbb5..128f7650de 100644 --- a/frontend/src/lib/components/icons/AcumbamailIcon.svelte +++ b/frontend/src/lib/components/icons/AcumbamailIcon.svelte @@ -8,7 +8,7 @@ - + diff --git a/frontend/src/lib/components/icons/AiAgentIcon.svelte b/frontend/src/lib/components/icons/AiAgentIcon.svelte index f3d042c98d..ecbb9ec326 100644 --- a/frontend/src/lib/components/icons/AiAgentIcon.svelte +++ b/frontend/src/lib/components/icons/AiAgentIcon.svelte @@ -11,7 +11,7 @@ xmlns="http://www.w3.org/2000/svg" {width} {height} - viewBox="0 0 24 24" + viewBox="-0.017 -0.017 24.035 24.035" fill="none" stroke="currentColor" stroke-width="2" diff --git a/frontend/src/lib/components/icons/AirtableIcon.svelte b/frontend/src/lib/components/icons/AirtableIcon.svelte index 70cc6c1698..c7366dbd34 100644 --- a/frontend/src/lib/components/icons/AirtableIcon.svelte +++ b/frontend/src/lib/components/icons/AirtableIcon.svelte @@ -11,7 +11,7 @@ diff --git a/frontend/src/lib/components/icons/AlgoliaIcon.svelte b/frontend/src/lib/components/icons/AlgoliaIcon.svelte index 5b1fdddedd..b8cdd83c97 100644 --- a/frontend/src/lib/components/icons/AlgoliaIcon.svelte +++ b/frontend/src/lib/components/icons/AlgoliaIcon.svelte @@ -12,7 +12,7 @@ class="text-[#003DFF] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 24 24" + viewBox="-1.091 -1.091 26.182 26.182" fill="currentColor" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/AmqpIcon.svelte b/frontend/src/lib/components/icons/AmqpIcon.svelte index c5de0c1699..771af6072c 100644 --- a/frontend/src/lib/components/icons/AmqpIcon.svelte +++ b/frontend/src/lib/components/icons/AmqpIcon.svelte @@ -14,7 +14,7 @@ xmlns="http://www.w3.org/2000/svg" width={`${size}px`} height={`${size}px`} - viewBox="0 0 24 24" + viewBox="1.057 1.057 21.886 21.886" fill={color ?? 'currentColor'} class={clazz} > diff --git a/frontend/src/lib/components/icons/AnsibleIcon.svelte b/frontend/src/lib/components/icons/AnsibleIcon.svelte index 1a09cb853c..e09abbc0a0 100644 --- a/frontend/src/lib/components/icons/AnsibleIcon.svelte +++ b/frontend/src/lib/components/icons/AnsibleIcon.svelte @@ -13,7 +13,7 @@ diff --git a/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte index 8fe884c479..a01fcfd3c7 100644 --- a/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte +++ b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte @@ -11,7 +11,7 @@ xmlns="http://www.w3.org/2000/svg" {width} {height} - viewBox="0 0 24 24" + viewBox="-0.019 -0.066 24.085 24.085" fill="none" stroke="currentColor" stroke-width="2" diff --git a/frontend/src/lib/components/icons/ApifyIcon.svelte b/frontend/src/lib/components/icons/ApifyIcon.svelte index 9c9e6d5978..40942f498a 100644 --- a/frontend/src/lib/components/icons/ApifyIcon.svelte +++ b/frontend/src/lib/components/icons/ApifyIcon.svelte @@ -8,7 +8,13 @@ - + diff --git a/frontend/src/lib/components/icons/AppwriteIcon.svelte b/frontend/src/lib/components/icons/AppwriteIcon.svelte index 34de69ee08..7917a86992 100644 --- a/frontend/src/lib/components/icons/AppwriteIcon.svelte +++ b/frontend/src/lib/components/icons/AppwriteIcon.svelte @@ -11,7 +11,7 @@ diff --git a/frontend/src/lib/components/icons/ArcGisIcon.svelte b/frontend/src/lib/components/icons/ArcGisIcon.svelte index 660d28b804..50b084bd5d 100644 --- a/frontend/src/lib/components/icons/ArcGisIcon.svelte +++ b/frontend/src/lib/components/icons/ArcGisIcon.svelte @@ -8,7 +8,13 @@ - + diff --git a/frontend/src/lib/components/icons/AsanaIcon.svelte b/frontend/src/lib/components/icons/AsanaIcon.svelte index e6043784fd..ac9c289635 100644 --- a/frontend/src/lib/components/icons/AsanaIcon.svelte +++ b/frontend/src/lib/components/icons/AsanaIcon.svelte @@ -11,7 +11,7 @@ Asana diff --git a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte index d7342a2765..6a020d6541 100644 --- a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte +++ b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte @@ -17,7 +17,7 @@ diff --git a/frontend/src/lib/components/icons/AssetResIcon.svelte b/frontend/src/lib/components/icons/AssetResIcon.svelte index 9a6a2faf5d..30d64ef139 100644 --- a/frontend/src/lib/components/icons/AssetResIcon.svelte +++ b/frontend/src/lib/components/icons/AssetResIcon.svelte @@ -13,7 +13,7 @@ {width} {height} class={className} - viewBox="0 0 22 22" + viewBox="-0.664 -0.664 23.156 23.156" fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/AssetS3Icon.svelte b/frontend/src/lib/components/icons/AssetS3Icon.svelte index a578a6f223..bed360a546 100644 --- a/frontend/src/lib/components/icons/AssetS3Icon.svelte +++ b/frontend/src/lib/components/icons/AssetS3Icon.svelte @@ -13,7 +13,7 @@ {width} {height} class={className} - viewBox="0 0 22 22" + viewBox="-1.201 -0.685 23.672 23.672" fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/AttioIcon.svelte b/frontend/src/lib/components/icons/AttioIcon.svelte index 5f0d5b9a3c..2ed815b46e 100644 --- a/frontend/src/lib/components/icons/AttioIcon.svelte +++ b/frontend/src/lib/components/icons/AttioIcon.svelte @@ -13,7 +13,7 @@ class="text-[#1C1D1F] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 18 18" + viewBox="0.553 0.447 17.105 17.105" fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/Auth0Icon.svelte b/frontend/src/lib/components/icons/Auth0Icon.svelte index 97fa53a3d9..1f42d8f5f9 100644 --- a/frontend/src/lib/components/icons/Auth0Icon.svelte +++ b/frontend/src/lib/components/icons/Auth0Icon.svelte @@ -26,7 +26,7 @@ role="img" {width} {height} - viewBox="0 0 19.7865 24" + viewBox="-3.198 -1.091 26.182 26.182" fill={color ?? 'currentColor'} xmlns="http://www.w3.org/2000/svg" class={twMerge('text-[#232220] dark:text-[#FFFFFF]', clazz)} diff --git a/frontend/src/lib/components/icons/AutheliaIcon.svelte b/frontend/src/lib/components/icons/AutheliaIcon.svelte index 4878dcb26f..1e71d1a6bd 100644 --- a/frontend/src/lib/components/icons/AutheliaIcon.svelte +++ b/frontend/src/lib/components/icons/AutheliaIcon.svelte @@ -1,6 +1,11 @@ - + authelia-svg diff --git a/frontend/src/lib/components/icons/AwsIcon.svelte b/frontend/src/lib/components/icons/AwsIcon.svelte index 7f371a6229..36f4913fba 100644 --- a/frontend/src/lib/components/icons/AwsIcon.svelte +++ b/frontend/src/lib/components/icons/AwsIcon.svelte @@ -18,7 +18,7 @@ class={twMerge('text-[#252F3E] dark:text-white', clazz)} width={width ?? size} height={height ?? size} - viewBox="0.02 102.6 511.9 306.4" + viewBox="-23.248 -23.418 558.436 558.436" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/BaremetricsIcon.svelte b/frontend/src/lib/components/icons/BaremetricsIcon.svelte index a05a84c7c9..e6433698f3 100644 --- a/frontend/src/lib/components/icons/BaremetricsIcon.svelte +++ b/frontend/src/lib/components/icons/BaremetricsIcon.svelte @@ -8,7 +8,13 @@ - + diff --git a/frontend/src/lib/components/icons/BarsStaggered.svelte b/frontend/src/lib/components/icons/BarsStaggered.svelte index eedc72de8a..a3aa602600 100644 --- a/frontend/src/lib/components/icons/BarsStaggered.svelte +++ b/frontend/src/lib/components/icons/BarsStaggered.svelte @@ -12,7 +12,7 @@ xmlns="http://www.w3.org/2000/svg" height={`${size}px`} width={`${size}px`} - viewBox="-64 -64 640 640" + viewBox="-23.545 -23.545 559.091 559.091" class={clazz} {style} > diff --git a/frontend/src/lib/components/icons/BaserowIcon.svelte b/frontend/src/lib/components/icons/BaserowIcon.svelte index 5d51c8cfa5..31450e473a 100644 --- a/frontend/src/lib/components/icons/BaserowIcon.svelte +++ b/frontend/src/lib/components/icons/BaserowIcon.svelte @@ -8,7 +8,13 @@ - + diff --git a/frontend/src/lib/components/icons/BcryptIcon.svelte b/frontend/src/lib/components/icons/BcryptIcon.svelte index be79ba1418..f8ad3304ba 100644 --- a/frontend/src/lib/components/icons/BcryptIcon.svelte +++ b/frontend/src/lib/components/icons/BcryptIcon.svelte @@ -7,7 +7,13 @@ let { height = '24px', width = '24px' }: Props = $props() - + - + diff --git a/frontend/src/lib/components/icons/BitlyIcon.svelte b/frontend/src/lib/components/icons/BitlyIcon.svelte index a023981cec..a31da1f900 100644 --- a/frontend/src/lib/components/icons/BitlyIcon.svelte +++ b/frontend/src/lib/components/icons/BitlyIcon.svelte @@ -11,7 +11,7 @@ diff --git a/frontend/src/lib/components/icons/BloggerIcon.svelte b/frontend/src/lib/components/icons/BloggerIcon.svelte index 13ec49a1c0..839f7f167c 100644 --- a/frontend/src/lib/components/icons/BloggerIcon.svelte +++ b/frontend/src/lib/components/icons/BloggerIcon.svelte @@ -8,7 +8,7 @@ let { height = '24px', width = '24px' }: Props = $props() - + diff --git a/frontend/src/lib/components/icons/BotifyIcon.svelte b/frontend/src/lib/components/icons/BotifyIcon.svelte index 375e128ac4..1bd451338d 100644 --- a/frontend/src/lib/components/icons/BotifyIcon.svelte +++ b/frontend/src/lib/components/icons/BotifyIcon.svelte @@ -8,7 +8,7 @@ - + diff --git a/frontend/src/lib/components/icons/BrandLetterIcon.svelte b/frontend/src/lib/components/icons/BrandLetterIcon.svelte index a9e9fa2966..0a7026e3c2 100644 --- a/frontend/src/lib/components/icons/BrandLetterIcon.svelte +++ b/frontend/src/lib/components/icons/BrandLetterIcon.svelte @@ -38,7 +38,7 @@ - + diff --git a/frontend/src/lib/components/icons/BrowserlessIcon.svelte b/frontend/src/lib/components/icons/BrowserlessIcon.svelte index 9dcf785adf..437e124098 100644 --- a/frontend/src/lib/components/icons/BrowserlessIcon.svelte +++ b/frontend/src/lib/components/icons/BrowserlessIcon.svelte @@ -12,7 +12,7 @@ class="text-[#000000] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 1024 1024" + viewBox="-47.545 -46.545 1117.091 1117.091" xmlns="http://www.w3.org/2000/svg" > - + - + diff --git a/frontend/src/lib/components/icons/BunIcon.svelte b/frontend/src/lib/components/icons/BunIcon.svelte index 792ad10b3c..1d73456f91 100644 --- a/frontend/src/lib/components/icons/BunIcon.svelte +++ b/frontend/src/lib/components/icons/BunIcon.svelte @@ -11,7 +11,7 @@ Bun Logo - + - + diff --git a/frontend/src/lib/components/icons/CalendlyIcon.svelte b/frontend/src/lib/components/icons/CalendlyIcon.svelte index d1dfd1f094..70fb159918 100644 --- a/frontend/src/lib/components/icons/CalendlyIcon.svelte +++ b/frontend/src/lib/components/icons/CalendlyIcon.svelte @@ -13,7 +13,7 @@ class="text-[#006BFF] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 24 24" + viewBox="-1.091 -1.091 26.182 26.182" fill="currentColor" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/CampaynIcon.svelte b/frontend/src/lib/components/icons/CampaynIcon.svelte index c343cb186f..8e2a90a0f3 100644 --- a/frontend/src/lib/components/icons/CampaynIcon.svelte +++ b/frontend/src/lib/components/icons/CampaynIcon.svelte @@ -11,7 +11,7 @@ - + - + diff --git a/frontend/src/lib/components/icons/CircleCiIcon.svelte b/frontend/src/lib/components/icons/CircleCiIcon.svelte index 59c97c4e37..cc4ec168fb 100644 --- a/frontend/src/lib/components/icons/CircleCiIcon.svelte +++ b/frontend/src/lib/components/icons/CircleCiIcon.svelte @@ -13,7 +13,7 @@ class="text-[#161616] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 24 24" + viewBox="-1.091 -1.091 26.182 26.182" fill="currentColor" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/CiscoIcon.svelte b/frontend/src/lib/components/icons/CiscoIcon.svelte index aa3ed22d0f..18f408932a 100644 --- a/frontend/src/lib/components/icons/CiscoIcon.svelte +++ b/frontend/src/lib/components/icons/CiscoIcon.svelte @@ -12,7 +12,7 @@ class="text-[#00BCEB] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 24 24" + viewBox="-1.091 -1.091 26.182 26.182" fill="currentColor" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/ClaudeIcon.svelte b/frontend/src/lib/components/icons/ClaudeIcon.svelte index 44ec5723c2..ab0c8491d9 100644 --- a/frontend/src/lib/components/icons/ClaudeIcon.svelte +++ b/frontend/src/lib/components/icons/ClaudeIcon.svelte @@ -9,7 +9,7 @@ - + - + diff --git a/frontend/src/lib/components/icons/ClickupIcon.svelte b/frontend/src/lib/components/icons/ClickupIcon.svelte index e84ddc0633..4d7fcdce27 100644 --- a/frontend/src/lib/components/icons/ClickupIcon.svelte +++ b/frontend/src/lib/components/icons/ClickupIcon.svelte @@ -13,7 +13,7 @@ y="0px" {width} {height} - viewBox="0 0 64 64" + viewBox="-2.364 -2.114 68.727 68.727" fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/CloseIcon.svelte b/frontend/src/lib/components/icons/CloseIcon.svelte index e61b7ada6c..efbb015cc5 100644 --- a/frontend/src/lib/components/icons/CloseIcon.svelte +++ b/frontend/src/lib/components/icons/CloseIcon.svelte @@ -8,7 +8,13 @@ let { height = '24px', width = '24px' }: Props = $props() - + diff --git a/frontend/src/lib/components/icons/CloudinaryIcon.svelte b/frontend/src/lib/components/icons/CloudinaryIcon.svelte index 9dc633dccd..fcb1e1aaaa 100644 --- a/frontend/src/lib/components/icons/CloudinaryIcon.svelte +++ b/frontend/src/lib/components/icons/CloudinaryIcon.svelte @@ -11,7 +11,7 @@ diff --git a/frontend/src/lib/components/icons/CodaIcon.svelte b/frontend/src/lib/components/icons/CodaIcon.svelte index 9a58fb8998..3aee2cfb49 100644 --- a/frontend/src/lib/components/icons/CodaIcon.svelte +++ b/frontend/src/lib/components/icons/CodaIcon.svelte @@ -8,7 +8,13 @@ - + diff --git a/frontend/src/lib/components/icons/CodatIcon.svelte b/frontend/src/lib/components/icons/CodatIcon.svelte index 732a4a878e..46455a7a6c 100644 --- a/frontend/src/lib/components/icons/CodatIcon.svelte +++ b/frontend/src/lib/components/icons/CodatIcon.svelte @@ -12,7 +12,7 @@ - + diff --git a/frontend/src/lib/components/icons/CoinbaseIcon.svelte b/frontend/src/lib/components/icons/CoinbaseIcon.svelte index 5d61e83df7..bdff312a75 100644 --- a/frontend/src/lib/components/icons/CoinbaseIcon.svelte +++ b/frontend/src/lib/components/icons/CoinbaseIcon.svelte @@ -12,7 +12,7 @@ class="text-[#0052FF] dark:text-white" {width} {height} - viewBox="0 0 32 32" + viewBox="-1.455 -1.455 34.909 34.909" fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/ComapeoIcon.svelte b/frontend/src/lib/components/icons/ComapeoIcon.svelte index 062c45e087..f7b3a9a1ea 100644 --- a/frontend/src/lib/components/icons/ComapeoIcon.svelte +++ b/frontend/src/lib/components/icons/ComapeoIcon.svelte @@ -12,7 +12,7 @@ class="text-[#022199] dark:text-[#0066FF]" {width} {height} - viewBox="0 0 320 320" + viewBox="-14.545 -14.545 349.091 349.091" fill="currentColor" xmlns="http://www.w3.org/2000/svg" role="img" diff --git a/frontend/src/lib/components/icons/ConfluenceIcon.svelte b/frontend/src/lib/components/icons/ConfluenceIcon.svelte index a892e912f2..2f1a94b6c6 100644 --- a/frontend/src/lib/components/icons/ConfluenceIcon.svelte +++ b/frontend/src/lib/components/icons/ConfluenceIcon.svelte @@ -8,7 +8,7 @@ let { height = '24px', width = '24px' }: Props = $props() - + - + diff --git a/frontend/src/lib/components/icons/ConvertKitIcon.svelte b/frontend/src/lib/components/icons/ConvertKitIcon.svelte index 0506f62f43..674bb1b967 100644 --- a/frontend/src/lib/components/icons/ConvertKitIcon.svelte +++ b/frontend/src/lib/components/icons/ConvertKitIcon.svelte @@ -12,7 +12,7 @@ class="text-[#1E1E1E] dark:text-[#F2EFE9]" {width} {height} - viewBox="0 0 24 24" + viewBox="-1.091 -1.091 26.182 26.182" xmlns="http://www.w3.org/2000/svg" > diff --git a/frontend/src/lib/components/icons/CoupaIcon.svelte b/frontend/src/lib/components/icons/CoupaIcon.svelte index a8c780bdbc..ac5b288b80 100644 --- a/frontend/src/lib/components/icons/CoupaIcon.svelte +++ b/frontend/src/lib/components/icons/CoupaIcon.svelte @@ -15,7 +15,7 @@ class="text-[#1565C0] dark:text-[#FFFFFF]" {width} {height} - viewBox="0 0 1561 1406" + viewBox="-70.955 -148.455 1702.909 1702.909" fill="currentColor" > - +
    +

    + The app editor runs in a cross-origin isolated context (COOP/COEP headers). This is + required for SharedArrayBuffer, which powers the TypeScript language workers and + lets the editor build and preview your frontend live in the browser. +

    +

    + A side effect is that the browser refuses to load cross-origin resources (images, scripts, + stylesheets, media…) unless the remote server explicitly opts in with CORS or a + Cross-Origin-Resource-Policy header. Resources from servers that don't are + blocked in the editor preview only. When this is the cause, the browser console shows + ERR_BLOCKED_BY_RESPONSE — a plain 404 or DNS error instead means the URL itself is + broken and will fail on the deployed app too. +

    +

    + The deployed app is served without these headers, so the same resources load normally there — + open the deployed app link to verify. If you control the remote server, sending + Cross-Origin-Resource-Policy: cross-origin makes the resource load in the editor too. +

    +
    + diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 0778fda4e4..4da35eb253 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -29,6 +29,7 @@ } from './utils' import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom' import InlineElementPrompt from './InlineElementPrompt.svelte' + import RawAppCoepWarning from './RawAppCoepWarning.svelte' import DarkModeObserver from '../DarkModeObserver.svelte' import { getAppliedDarkModeVariant, type DarkModeVariant } from '$lib/darkModeVariant' import RawAppSidebar from './RawAppSidebar.svelte' @@ -351,6 +352,7 @@ let iframe: HTMLIFrameElement | undefined = $state(undefined) const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html' let previewIframe: HTMLIFrameElement | undefined = $state(undefined) + let coepWarning: RawAppCoepWarning | undefined = $state(undefined) let previewIframeLoaded = $state(false) let lastBuild: { css: string; js: string } | undefined = undefined // Detached preview tab/window rendering the same app-preview bundle as the @@ -1164,6 +1166,9 @@ ) { externalPreviewReady = true feedExternalPreview() + // The detached window is cross-origin isolated like the inline preview, + // so blocked external resources warrant the same COEP warning. + coepWarning?.attachTo(externalPreviewWindow) return } @@ -1504,6 +1509,9 @@ win.addEventListener('load', () => { externalPreviewReady = true feedExternalPreview() + // Attach here too: against an artifact that predates the handshake, this + // is the only place the freshly opened window is ever seen loaded. + coepWarning?.attachTo(win) }) } @@ -2539,6 +2547,7 @@ src={PREVIEW_SHELL_URL} class="w-full flex-1 block" > + {#if buildError} diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index dea4d710b9..e353c6f825 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -261,10 +261,10 @@ export type Job = { }; /** - * Execute a job and wait for it to complete and return the completed job + * Wait for a job to complete and return its result. Rejects if the job failed. * @param id */ -export declare function waitJob(id: string): Promise; +export declare function waitJob(id: string): Promise; /** * Get a job by id and return immediately with the current state of the job diff --git a/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte b/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte index 8ba0bad861..39bd98c1cf 100644 --- a/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte +++ b/frontend/src/lib/components/schema/EditableSchemaDrawer.svelte @@ -57,13 +57,15 @@ /** Render only the editing drawer: the caller shows its own view of the schema and * opens the drawer through `openDrawer()`. */ drawerOnly?: boolean + workspace?: string | undefined } let { schema = $bindable(), jsonView = $bindable(false), hiddenArgs = undefined, - drawerOnly = false + drawerOnly = false, + workspace }: Props = $props() export function openDrawer() { @@ -99,6 +101,7 @@ schemaFormClassName="min-h-full" bind:this={editableSchemaForm} bind:schema + {workspace} isAppInput on:delete={(e) => { ;(addPropertyComponent ?? drawerAddProperty)?.handleDeleteArgument([e.detail]) @@ -211,7 +214,10 @@ {#if schema.properties[item.value]?.type === 'object' && !(schema.properties[item.value].oneOf && schema.properties[item.value].oneOf.length >= 2)}
    {/if} diff --git a/frontend/src/lib/components/schema/FlowPropertyEditor.svelte b/frontend/src/lib/components/schema/FlowPropertyEditor.svelte index 703a74b368..d92c469b6b 100644 --- a/frontend/src/lib/components/schema/FlowPropertyEditor.svelte +++ b/frontend/src/lib/components/schema/FlowPropertyEditor.svelte @@ -51,6 +51,7 @@ onDrawerClose?: () => void hideCatalogPicker?: boolean | undefined hideRawInput?: boolean | undefined + workspace?: string | undefined } let { @@ -77,7 +78,8 @@ displayWebhookWarning = true, onDrawerClose = undefined, hideCatalogPicker = $bindable(undefined), - hideRawInput = $bindable(undefined) + hideRawInput = $bindable(undefined), + workspace = undefined }: Props = $props() let isS3Field = $derived( @@ -272,6 +274,7 @@ {@const idx = oneOf.findIndex((obj) => obj.title === oneOfSelected)}
    { onDrawerClose?.() }} @@ -317,6 +320,7 @@ {#if customObjectSelected === 'editor'} { return { @@ -379,6 +383,7 @@ {disabled} {nullable} {variableEditor} + {workspace} compact noMargin /> diff --git a/frontend/src/lib/components/schema/PropertyEditor.svelte b/frontend/src/lib/components/schema/PropertyEditor.svelte index 3cd0ec5507..129e6996f8 100644 --- a/frontend/src/lib/components/schema/PropertyEditor.svelte +++ b/frontend/src/lib/components/schema/PropertyEditor.svelte @@ -49,6 +49,7 @@ | undefined typeeditor?: import('svelte').Snippet children?: import('svelte').Snippet + workspace?: string | undefined } let { @@ -72,7 +73,8 @@ order = $bindable(), itemsType = $bindable(undefined), typeeditor, - children + children, + workspace }: Props = $props() $effect.pre(() => { @@ -225,6 +227,7 @@ bind:itemsType canEditResourceType={isFlowInput || isAppInput} bind:nonEmpty + {workspace} /> {:else if type == 'string' || ['number', 'integer'].includes(type ?? '')}
    @@ -276,6 +279,7 @@ uiOnly jsonEnabled={false} editTab="inputEditor" + {workspace} />
    {/if} @@ -288,6 +292,7 @@ uiOnly jsonEnabled={false} editTab="inputEditor" + {workspace} />
    {/if} diff --git a/frontend/src/lib/components/schema/SchemaFormDND.svelte b/frontend/src/lib/components/schema/SchemaFormDND.svelte index a2a4e8a12c..21399f775b 100644 --- a/frontend/src/lib/components/schema/SchemaFormDND.svelte +++ b/frontend/src/lib/components/schema/SchemaFormDND.svelte @@ -26,6 +26,7 @@ className?: string dndType?: string lightHeaderFont?: boolean + workspace?: string | undefined } let { @@ -46,7 +47,8 @@ noVariablePicker = false, className = '', dndType = generateRandomString(), - lightHeaderFont + lightHeaderFont, + workspace = undefined }: Props = $props() $effect.pre(() => { @@ -155,6 +157,7 @@ bind:isValid {noVariablePicker} {lightHeaderFont} + {workspace} > {#snippet actions()} {#if !disableDnd} diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index d9fc2dcf70..8dd952e5bf 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -145,17 +145,21 @@ }} > {@render startSnippet?.({ item, close: () => (open = false) })} - - {item.label || '\xa0'} - + +
    + + {item.label || '\xa0'} + + {#if item.subtitle} +
    {item.subtitle}
    + {/if} +
    {#if item.__is_create} {:else} {@render endSnippet?.({ item, close: () => (open = false) })} {/if} - {#if item.subtitle} -
    {item.subtitle}
    - {/if} {/each} diff --git a/frontend/src/lib/components/sessions/SessionChangesBar.svelte b/frontend/src/lib/components/sessions/SessionChangesBar.svelte index 9c5b5603a4..61333a65bc 100644 --- a/frontend/src/lib/components/sessions/SessionChangesBar.svelte +++ b/frontend/src/lib/components/sessions/SessionChangesBar.svelte @@ -11,7 +11,7 @@ import Badge from '$lib/components/common/badge/Badge.svelte' import SessionStatusPopover from './SessionStatusPopover.svelte' import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte' - import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { maybePremium, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { canCreateFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' import { sessionState, type Session } from './sessionState.svelte' @@ -63,9 +63,7 @@ // Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar. On cloud, // forking is a premium-only feature (backend caps it per paid seat). const forksAllowed = $derived( - (!isCloudHosted() || $isPremiumStore) && - canCreateFork($userStore) && - $workspaceStore !== 'admins' + (!isCloudHosted() || $maybePremium) && canCreateFork($userStore) && $workspaceStore !== 'admins' ) const runtime = $derived(getRuntime(session.id)) diff --git a/frontend/src/lib/components/sessions/SessionFilterMenu.svelte b/frontend/src/lib/components/sessions/SessionFilterMenu.svelte index 6a2c726782..d375c5f286 100644 --- a/frontend/src/lib/components/sessions/SessionFilterMenu.svelte +++ b/frontend/src/lib/components/sessions/SessionFilterMenu.svelte @@ -2,9 +2,10 @@ import { untrack } from 'svelte' import { melt } from '@melt-ui/svelte' import type { MenubarMenuBuilders } from '@melt-ui/svelte' - import { ChevronRight, Filter } from 'lucide-svelte' + import { Check, ChevronRight, Filter } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import Toggle from '$lib/components/Toggle.svelte' + import { GROUP_BY_OPTIONS, LAST_ACTIVITY_OPTIONS, type GroupBy } from './sessionFilters' interface Props { // Submenu builders from the enclosing melt Menu — createSubmenu must be @@ -12,9 +13,17 @@ builders: MenubarMenuBuilders showArchived: boolean archivedCount: number + lastActivityDays: number + groupBy: GroupBy } - let { builders, showArchived = $bindable(), archivedCount }: Props = $props() + let { + builders, + showArchived = $bindable(), + archivedCount, + lastActivityDays = $bindable(), + groupBy = $bindable() + }: Props = $props() const { elements: { subTrigger, subMenu }, @@ -23,7 +32,13 @@ // Count of active (non-default) filters, surfaced as a subtle badge on the // trigger so an applied filter is visible without opening the submenu. - let activeCount = $derived(showArchived ? 1 : 0) + // Grouping is left out: it reorders the list rather than hiding anything. + let activeCount = $derived((showArchived ? 1 : 0) + (lastActivityDays > 0 ? 1 : 0)) + + const optionRow = twMerge( + 'px-3 py-1.5 w-full text-left text-xs font-normal text-secondary', + 'flex flex-row items-center gap-2 rounded-sm hover:bg-surface-hover hover:text-primary' + ) + {/each} +
    +
    Group by
    + {#each GROUP_BY_OPTIONS as option (option.value)} + + {/each}
{/if} diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index b93d350dfe..67b53aeff4 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -1,18 +1,22 @@ + +{#snippet bar(q: Quota)} +
+
+
+{/snippet} + +{#snippet ring(q: Quota)} + +{/snippet} + +{#if isCloudHosted() && tightest} +
+ + {#snippet text()} + {tightest.label} this month: {fmt(tightest.used)}/{fmt(tightest.cap)}. + {EXECUTIONS_HINT} + {/snippet} + + +
+ + +
+ {#each quotas as quota (quota.key)} +
+
+ {quota.label} + {fmt(quota.used)}/{fmt(quota.cap)} +
+ {@render bar(quota)} +
+ {/each} +

+ {EXECUTIONS_HINT} Counters reset at the start of every calendar month. +

+ {#if $isPremiumStore} +

+ Your {seats} seat{seats === 1 ? '' : 's'} include {fmt( + (seats ?? 0) * SEAT_EXECUTION_QUOTA + )} executions per month. Every extra {fmt(SEAT_EXECUTION_QUOTA)} executions beyond that add + one billed seat for the month. +

+ {:else} +

+ Either quota reaching {fmt(FREE_EXECUTION_QUOTA)} stops jobs from running for the rest of the + month. Team and Enterprise plans lift both limits. + {#if !$userStore?.is_admin} + Ask a workspace admin to change the plan. + {/if} +

+ {/if} +
+ {#snippet actions()} + {#if $userStore?.is_admin} + + {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/sidebar/UserMenu.svelte b/frontend/src/lib/components/sidebar/UserMenu.svelte index 1ec649cf12..43440249ae 100644 --- a/frontend/src/lib/components/sidebar/UserMenu.svelte +++ b/frontend/src/lib/components/sidebar/UserMenu.svelte @@ -14,8 +14,9 @@ import { Crown, ServerCog, LogOut, Moon, Settings, Sun, User } from 'lucide-svelte' import DarkModeObserver from '../DarkModeObserver.svelte' import MenuButton from './MenuButton.svelte' - import { Menu, MenuItem } from '$lib/components/meltComponents' + import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents' import { type MenubarBuilders } from '@melt-ui/svelte' + import { EXECUTIONS_HINT } from './executionsHint' let darkMode: boolean = $state(false) @@ -103,27 +104,41 @@
- {#if isCloudHosted()} + + {#if isCloudHosted() && $isPremiumStore !== undefined}
- {#if !$isPremiumStore} - {$usageStore}/1000 user execs + {#if $isPremiumStore === false} + + {$usageStore ?? '—'}/1000 user execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + +
-
{#if $workspaceStore != 'demo'} - {$workspaceUsageStore}/1000 free workspace execs + + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + +
diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 286e044e6b..1555884eff 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -2,6 +2,7 @@ import { workspaceMenuHref } from './workspaceMenuHref' import { isPremiumStore, + maybePremium, superadmin, userStore, userWorkspaces, @@ -16,7 +17,8 @@ import { SvelteSet } from 'svelte/reactivity' import { Badge, CopyButton, NameIdTooltip } from '$lib/components/common' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' - import { Menu, MenuItem } from '$lib/components/meltComponents' + import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents' + import { EXECUTIONS_HINT } from './executionsHint' import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte' import { fixupUrlAfterWorkspaceSwitch } from './workspaceSwitchUrl' import { goto } from '$lib/navigation' @@ -114,9 +116,7 @@ // modal carries its own base-workspace picker). Hidden on non-premium cloud, // in the admins workspace, or when forking is disabled. const canForkHere = $derived( - (!isCloudHosted() || $isPremiumStore) && - $workspaceStore !== 'admins' && - canCreateFork($userStore) + (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore) ) const familyWorkspaces = $derived.by(() => { if (strictWorkspaceSelect) return hierarchy @@ -422,14 +422,21 @@
{/if}
- {#if isCloudHosted() && !$isPremiumStore && !strictWorkspaceSelect} + {#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
{#if $workspaceStore != 'demo'} - {$workspaceUsageStore}/1000 free workspace execs + + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + +
-
{/if} diff --git a/frontend/src/lib/components/sidebar/executionsHint.ts b/frontend/src/lib/components/sidebar/executionsHint.ts new file mode 100644 index 0000000000..6530a6a6cd --- /dev/null +++ b/frontend/src/lib/components/sidebar/executionsHint.ts @@ -0,0 +1,7 @@ +export const FREE_EXECUTION_QUOTA = 1000 + +/** Executions each paid seat includes per month (mirrors the billing page). */ +export const SEAT_EXECUTION_QUOTA = 10000 + +export const EXECUTIONS_HINT = + 'An execution is one second of compute, not one job run: a job counts as 1 execution, plus 1 more for each additional second it runs.' as const diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte index f7bc924d8f..591de38b3b 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte @@ -15,10 +15,11 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { base } from '$lib/base' import Toggle from '$lib/components/Toggle.svelte' - import { workspaceStore } from '$lib/stores' + import { userStore, workspaceStore } from '$lib/stores' import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' import { Button, Url } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' import { RefreshCw } from 'lucide-svelte' import Alert from '$lib/components/common/alert/Alert.svelte' import TestingBadge from '../testingBadge.svelte' @@ -41,43 +42,72 @@ } async function loadAllPubSubTopicsFromProject() { - if (!emptyStringTrimmed(gcp_resource_path)) { - try { - loadingTopic = true - topic_items = await GcpTriggerService.listGoogleTopics({ - workspace: wsId!, - path: gcp_resource_path - }) - } catch (error) { - sendUserToast(error.body, true) - } - loadingTopic = false + // Listing is admin-only under application default credentials, and a non-admin viewing an + // inherited trigger cannot change the topic anyway, so asking would only raise a 403 toast. + if (!hasCredentials || blockedByAdminGate) { + return } + try { + loadingTopic = true + topic_items = usesDefaultCredentials + ? await GcpTriggerService.listGoogleTopicsWithDefaultCredentials({ + workspace: wsId!, + projectId: project_id + }) + : await GcpTriggerService.listGoogleTopics({ + workspace: wsId!, + path: gcp_resource_path!, + projectId: project_id + }) + } catch (error) { + sendUserToast(error.body, true) + } + loadingTopic = false } async function loadAllSubscriptionFromGooglePubSubTopic() { - if (!emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)) { - try { - loadingSubscription = true - subscription_items = await GcpTriggerService.listAllTgoogleTopicSubscriptions({ - workspace: wsId!, - path: gcp_resource_path, - requestBody: { - topic_id - } - }) - } catch (error) { - sendUserToast(error.body, true) - } - loadingSubscription = false + if (!hasCredentials || blockedByAdminGate || emptyStringTrimmed(topic_id)) { + return } + try { + loadingSubscription = true + const requestBody = { topic_id, project_id } + subscription_items = usesDefaultCredentials + ? await GcpTriggerService.listAllTgoogleTopicSubscriptionsWithDefaultCredentials({ + workspace: wsId!, + requestBody + }) + : await GcpTriggerService.listAllTgoogleTopicSubscriptions({ + workspace: wsId!, + path: gcp_resource_path!, + requestBody + }) + } catch (error) { + sendUserToast(error.body, true) + } + loadingSubscription = false + } + + /** Subscriptions come back fully qualified so cross-project ones survive the round trip. The + * project is what tells two same-named subscriptions apart, which is exactly the case a + * cross-project topic creates, so it stays in the label rather than being trimmed away. */ + function subscriptionLabel(name: string): string { + const parts = name.split('/') + const id = parts.pop() ?? name + const project = parts.length >= 2 ? parts[1] : undefined + return project ? `${id} (${project})` : id } interface Props { can_write?: boolean headless?: boolean isValid?: boolean - gcp_resource_path?: string + gcp_resource_path?: string | undefined + /** Authenticate as the server itself instead of with a `gcloud` resource. */ + use_default_credentials?: boolean + /** Whether the config was *loaded* in that mode, as opposed to switched into it here. */ + loaded_uses_default_credentials?: boolean + project_id?: string subscription_id?: string topic_id?: string delivery_type?: DeliveryType | undefined @@ -96,7 +126,10 @@ can_write = false, headless = false, isValid = $bindable(false), - gcp_resource_path = $bindable(''), + gcp_resource_path = $bindable(), + use_default_credentials = $bindable(), + loaded_uses_default_credentials = false, + project_id = $bindable(), subscription_id = $bindable(''), topic_id = $bindable(''), delivery_type = $bindable('pull'), @@ -111,13 +144,50 @@ create_update_subscription_id = $bindable('') }: Props = $props() - if (gcp_resource_path) { + /** Only workspace admins may point a trigger at the server's own GCP identity, which no + * resource ACL covers. The backend enforces this too; hiding it keeps a non-admin from + * building a config that cannot be saved. Someone who inherits such a trigger still sees the + * mode it is in. */ + const usesDefaultCredentials = $derived(use_default_credentials ?? false) + // Keyed on the loaded mode, not the live one: reading the live mode would make the toggle a + // one-way door, disabling itself the moment a non-admin switched an inherited ADC trigger away. + const canUseDefaultCredentials = $derived( + $userStore?.is_admin === true || loaded_uses_default_credentials + ) + const hasCredentials = $derived(usesDefaultCredentials || !emptyStringTrimmed(gcp_resource_path)) + /** Saving re-provisions the subscription with the instance's credentials, so the backend runs + * the admin check on every write, not only when the mode is switched. A non-admin who inherits + * such a trigger can open it, so say why saving is unavailable instead of letting them hit a + * bare 403. */ + const blockedByAdminGate = $derived(usesDefaultCredentials && $userStore?.is_admin !== true) + + // One-shot on mount, so read the props rather than the derived: referencing `$derived` state + // here captures its initial value anyway, and Svelte warns about it. + if (gcp_resource_path || use_default_credentials) { loadAllPubSubTopicsFromProject() } + function onCredentialsModeChange(useDefault: boolean) { + use_default_credentials = useDefault + gcp_resource_path = useDefault ? undefined : '' + // The topic and subscription belong to the credentials that listed them. Keeping them + // across a switch leaves the form valid and saveable against names the new credentials may + // not have, or worse may have in a different project. + topic_items = [] + subscription_items = [] + topic_id = '' + subscription_id = '' + cloud_subscription_id = '' + create_update_subscription_id = '' + if (useDefault) { + loadAllPubSubTopicsFromProject() + } + } + $effect(() => { isValid = - !emptyStringTrimmed(gcp_resource_path) && + hasCredentials && + !blockedByAdminGate && !emptyStringTrimmed(topic_id) && !emptyStringTrimmed(subscription_id) }) @@ -152,25 +222,82 @@ {/snippet}
-
- gcp_resource_path, - (v) => { - gcp_resource_path = v - loadAllPubSubTopicsFromProject() +
+ onCredentialsModeChange(e.detail === 'default')} + > + {#snippet children({ item })} + + + {/snippet} + + + {#if !usesDefaultCredentials} + gcp_resource_path, + (v) => { + gcp_resource_path = v + loadAllPubSubTopicsFromProject() + } } - } - /> - {#if !emptyStringTrimmed(gcp_resource_path)} - + /> + {/if} + + +
+ + + project_id ?? '', (v) => (project_id = emptyStringTrimmed(v) ? undefined : v)} + inputProps={{ + placeholder: 'my-gcp-project', + disabled: !can_write, + autocomplete: 'off' + }} + /> +
+
+ + {#if blockedByAdminGate} + + This trigger authenticates as the Windmill server. Saving changes to it needs + workspace admin, because saving re-provisions the subscription with those credentials. + + {/if} + + {#if hasCredentials} + {/if}
- {#if gcp_resource_path} + {#if hasCredentials}
{/if} - {#if !emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)} + {#if hasCredentials && !emptyStringTrimmed(topic_id)}
((subscription_id = t), (cloud_subscription_id = t)) } onClear={() => (subscription_id = '')} - items={safeSelectItems(subscription_items)} + items={subscription_items.map((s) => ({ + value: s, + label: subscriptionLabel(s) + }))} placeholder="Choose a subscription" />