mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 00:03:52 +00:00
* feat(frontend): group the AI agent step form and edit saved agents in a modal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: edit a saved AI agent through its own resource draft Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: drop the agent fork-for-edit session now that edits live in a draft Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: edit ai_agent resources from the resources page with the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: send a standalone agent's brain from the module when testing a step Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep the agent draft faithful to the resource it deploys to Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: add the sqlx cache entry for the eval subject rename Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: share the module insert between the graph and the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): open evals inside the agent editor, actions in its header Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): add tools from the agent editor and lighten its test pane Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): open an ai_agent deep link in the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): drop the failed result badge on a step that never ran Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): head the agent editor's levels with a back control Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): drop connect and fill inputs from the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): lighten the agent editor's run panel Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): stop a nested agent tool's config reading as AI-filled Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): offer only AI or static on an agent tool's inputs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): give a saved agent's tool editor a static-only surface Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): open an agent tool in a drawer beside the agent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): hide unset agent config in the run form Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): share the input forms' pickers and s3 lookup Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(frontend): drop a dead agent-editor export and fix two stale comments Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): reach an ai_agent's resource-level settings and copilot Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): open an ai_agent's resource view as JSON, not the generic form Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): address review findings on the agent editor's draft and streaming Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): close the agent editor on a version restore, as the resource editor does Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): stop the provider picker auto-writing a kind, and clear review nits Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): drop the fork-for-edit leftovers from the agent card Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): mount the agent editor in the dev flow editor and guard the deep-link race Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): deploy the agent config that was submitted, and refuse one no run could use Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): build the agent editor's rows from the design-system button Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): keep a draft-only agent's draft, and let a blank MCP summary deploy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): guard read-only agents, incomplete MCP tools and duplicate editor mounts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: read-only agent editor, linked-card refresh, atomic eval rename * fix: eval rename needs the privileged pool, per-workspace write access * fix(frontend): drop the agent editor target when its mount goes away * refactor: drop the agent rename work from this PR, unban the bindable defaults * fix(frontend): refuse a renaming deploy and drop the copilot from static-only fields * fix(frontend): mirror the worker's streaming rule and scope agent writes to their target * fix(frontend): read runtime streaming as off and reset the drawer's json view * fix(frontend): read an unsettled output_type as non-streaming too * fix(frontend): let the showing modal claim an agent opened from inside it * fix(frontend): keep in-flight edits, tool replacements and every linked step in sync * fix(frontend): keep attachments in the run form and bind the agent ref to its tools * fix(frontend): preview the agent as authored and re-evaluate step args on run * fix(frontend): scope agent-editor ownership to the flow's workspace * fix(frontend): drop the tool drill-in where there is no graph to select on * fix(frontend): require a provider kind and keep one resource editor open at a time * fix(frontend): keep legacy nulls, static-only text literal, and the handover anchor * test(worker): pin the agent streaming default * fix(frontend): let an AI-fillable input be switched to static * fix(frontend): report agent editor background failures instead of floating them * fix(frontend): keep the version pane's path alive while the editor closes * fix(frontend): clear the anchor-keep flag at the start of each drawer session * fix(frontend): preview the agent without its synthetic path, refresh the baseline on external writes * refactor(frontend): drop the unverifiable baseline refresh, state the synthetic-path rule * fix(frontend): keep the synthetic path out of agent tool test runs too * refactor(frontend): host the agent editor under the agent's own path * fix(frontend): mark an agent editor's host explicitly instead of inferring it from the path * fix(frontend): discard linked-agent responses from before a deploy * fix(frontend): keep a flow mount from claiming an agent editor's nested target * feat(frontend): keep an agent used as a tool inside the agent being edited * fix(frontend): reserve the agent editor's root module id * docs(frontend): record why the agent editor previews under the agent's path * fix(frontend): refuse to open or deploy a resource that is not an agent * docs(frontend): put the scope-migration comment on the function it describes * fix(frontend): refuse an agent path whose resource type is not proven * fix(frontend): recheck the resource type before deploying, and keep expressions off static-only inputs * fix(frontend): lazy-load the agent editor and slide its levels like the evals pane * refactor: drop unreachable non-list tools check from agent deploy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): clear text-only agent fields on image output, reserve the root id Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): keep the agent editor usable for a non-list tools value Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): stop the parked eval run list from taking arrow keys Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): report a non-list tools value on deploy instead of throwing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): keep temperature editable for image output Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): skip non-object tool entries when rendering an agent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): guard tool entry reads instead of copying the tool array Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): key tool rows by position so duplicate ids render Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
860 lines
24 KiB
Svelte
860 lines
24 KiB
Svelte
<script module>
|
|
function useResizeObserver(node, callback) {
|
|
const observer = new ResizeObserver(() => {
|
|
callback()
|
|
})
|
|
|
|
observer.observe(node)
|
|
|
|
return {
|
|
destroy() {
|
|
observer.disconnect()
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import Button from '$lib/components/common/button/Button.svelte'
|
|
import { Pin, History, Pen, Check, X, Loader2, Pencil, ScrollText } from 'lucide-svelte'
|
|
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
|
|
import StepHistory from './StepHistory.svelte'
|
|
import { Popover } from '$lib/components/meltComponents'
|
|
import { getContext, untrack } from 'svelte'
|
|
import { Tooltip } from '$lib/components/meltComponents'
|
|
import type { Job } from '$lib/gen'
|
|
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
|
import OutputBadge from './OutputBadge.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import DisplayResultControlBar from '$lib/components/DisplayResultControlBar.svelte'
|
|
import LogViewer from '$lib/components/LogViewer.svelte'
|
|
import { base } from '$lib/base'
|
|
import { fade } from 'svelte/transition'
|
|
import type { FlowEditorContext, OutputViewerJob } from '../types'
|
|
import { NEVER_TESTED_THIS_FAR } from '../models'
|
|
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
|
|
|
interface Props {
|
|
prefix?: string
|
|
allowCopy?: boolean
|
|
connectingData?: any | undefined
|
|
mock?:
|
|
| {
|
|
enabled?: boolean
|
|
return_value?: unknown
|
|
}
|
|
| undefined
|
|
moduleId?: string
|
|
fullResult?: boolean
|
|
closeOnOutsideClick?: boolean
|
|
getLogs?: boolean
|
|
isLoading?: boolean
|
|
hideHeaderBar?: boolean
|
|
simpleViewer?: any | undefined
|
|
path?: string
|
|
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
|
|
customHeight?: number | undefined
|
|
rightMargin?: boolean
|
|
disableMock?: boolean
|
|
disableHistory?: boolean
|
|
testJob?: Job & { result_stream?: string }
|
|
derivedHistoryOpen?: boolean // derived from historyOpen
|
|
historyOffset?: any
|
|
clazz?: string
|
|
copilot_fix?: import('svelte').Snippet
|
|
onSelect?: (key: string) => void
|
|
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
|
|
onEditInput?: (moduleId: string, key: string) => void
|
|
selectionId?: string
|
|
initial?: boolean
|
|
onResetInitial?: () => void
|
|
customEmptyJobMessage?: string
|
|
/** Offer a result/logs toggle. Only for viewers that show the result alone —
|
|
* where a log pane sits alongside it, the toggle just hides what is already there. */
|
|
logsToggle?: boolean
|
|
}
|
|
|
|
let {
|
|
testJob,
|
|
prefix = '',
|
|
allowCopy = false,
|
|
connectingData = undefined,
|
|
mock = $bindable({ enabled: false }),
|
|
moduleId = '',
|
|
fullResult = false,
|
|
closeOnOutsideClick = false,
|
|
getLogs = false,
|
|
isLoading = $bindable(false),
|
|
hideHeaderBar = false,
|
|
simpleViewer = undefined,
|
|
path = '',
|
|
loopStatus = undefined,
|
|
customHeight = undefined,
|
|
rightMargin = false,
|
|
disableMock = false,
|
|
disableHistory = false,
|
|
historyOffset = { mainAxis: 8, crossAxis: -4.5 },
|
|
clazz,
|
|
copilot_fix,
|
|
onSelect,
|
|
onUpdateMock,
|
|
onEditInput,
|
|
selectionId,
|
|
initial,
|
|
customEmptyJobMessage,
|
|
logsToggle = false
|
|
}: Props = $props()
|
|
|
|
let jsonView = $state(false)
|
|
let clientHeight: number = $state(0)
|
|
let tmpMock: { enabled: boolean; return_value?: unknown } | undefined = $state(undefined)
|
|
let error = $state('')
|
|
let stepHistoryPopover: Popover | undefined = $state(undefined)
|
|
let historyOpen = $state(false)
|
|
let contentEl: HTMLDivElement | undefined = $state(undefined)
|
|
let hasOverflow = $state(false)
|
|
let preview: 'mock' | 'job' | undefined = $state(undefined)
|
|
let selectedJob: OutputViewerJob = $state(undefined)
|
|
let showLogs = $state(false)
|
|
|
|
function checkOverflow() {
|
|
if (contentEl) {
|
|
hasOverflow = contentEl.scrollHeight > contentEl.clientHeight
|
|
}
|
|
}
|
|
|
|
function selectJob(nJob: OutputViewerJob | undefined) {
|
|
if (nJob && (nJob.result_stream || nJob.type == 'CompletedJob')) {
|
|
selectedJob = nJob
|
|
} else if (lastJob && (lastJob.result_stream || lastJob.type == 'CompletedJob')) {
|
|
selectedJob = lastJob
|
|
} else {
|
|
selectedJob = undefined
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!lastJob || !(lastJob.result_stream || lastJob.type == 'CompletedJob')) {
|
|
return
|
|
}
|
|
selectJob(lastJob)
|
|
})
|
|
|
|
function togglePreview(nPrev: 'mock' | 'job' | undefined) {
|
|
if (!nPrev && preview === 'job') {
|
|
// Reset the job
|
|
selectJob(undefined)
|
|
}
|
|
preview = nPrev
|
|
}
|
|
|
|
let dblClickDisabled = $state(false)
|
|
let hoveringResult = $state(false)
|
|
let debouncedCanEditWithDblClick = $state(false)
|
|
let debounceTimeout: ReturnType<typeof setTimeout> | null = null
|
|
let canEditWithDblClick = $state(false)
|
|
let displayResultJob: DisplayResult | undefined = $state(undefined)
|
|
let displayResultMock: DisplayResult | undefined = $state(undefined)
|
|
let toolbarLocationJob: 'self' | 'external' | undefined = $state(undefined)
|
|
let toolbarLocationMock: 'self' | 'external' | undefined = $state(undefined)
|
|
|
|
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
|
|
const flowStateStore = flowEditorContext?.flowStateStore
|
|
|
|
function updateCanEditWithDblClick(newValue: boolean) {
|
|
canEditWithDblClick = newValue
|
|
if (debounceTimeout) {
|
|
clearTimeout(debounceTimeout)
|
|
}
|
|
debounceTimeout = setTimeout(() => {
|
|
debouncedCanEditWithDblClick = newValue
|
|
}, 200)
|
|
}
|
|
|
|
function togglePin() {
|
|
// The adoption read counts pins still enabled on a deployed flow; this counts
|
|
// the act, including the pins undone before deploying. Keyed per direction,
|
|
// so unpinning shows how often a pin was temporary.
|
|
logFeatureUsage('flow_step', 'pinned', {
|
|
key: mock?.enabled && !preview ? 'off' : 'on'
|
|
})
|
|
if (mock?.enabled && !preview) {
|
|
// Unpin
|
|
onUpdateMock?.({
|
|
...mock,
|
|
enabled: false
|
|
})
|
|
} else if (preview === 'mock') {
|
|
// Restore the pin
|
|
onUpdateMock?.({
|
|
...mock,
|
|
enabled: true
|
|
})
|
|
} else if (selectedJob && 'result' in selectedJob) {
|
|
// Pin the job
|
|
let mockValue: any = structuredClone($state.snapshot(selectedJob.result))
|
|
if (selectedJob.result === NEVER_TESTED_THIS_FAR) {
|
|
mockValue = { example: 'value' }
|
|
}
|
|
const newMock = {
|
|
enabled: true,
|
|
return_value: mockValue
|
|
}
|
|
onUpdateMock?.(newMock)
|
|
} else {
|
|
// Fallback to mock
|
|
onUpdateMock?.({
|
|
enabled: true,
|
|
return_value: { example: 'value' }
|
|
})
|
|
}
|
|
if (preview) {
|
|
// Reset the preview
|
|
if (historyOpen) {
|
|
stepHistoryPopover?.close()
|
|
}
|
|
togglePreview(undefined)
|
|
}
|
|
}
|
|
|
|
function updateLastJob() {
|
|
// Prefer testJob when actively running/streaming (individual step test in progress)
|
|
// or when there's no moduleId (result node — no flowStateStore entry exists)
|
|
if (testJob && (testJob.result_stream || testJob.type === 'QueuedJob' || !moduleId)) {
|
|
return testJob
|
|
}
|
|
// A module with no state yet has no result either. Building the job below from a missing
|
|
// entry mints a completed-and-failed job with no id, which shows as a red badge on a step
|
|
// that never ran; and `selectJob` only ever moves off a job it has selected, so it stays
|
|
// once the state arrives.
|
|
const moduleState = moduleId ? flowStateStore?.val[moduleId] : undefined
|
|
if (!moduleState || moduleState.previewResult === NEVER_TESTED_THIS_FAR) {
|
|
return
|
|
}
|
|
const { previewJobId, previewResult, previewSuccess, previewLogs } = moduleState
|
|
// Use flowStateStore as source of truth — it's updated by both individual step tests
|
|
// (ModuleTest.jobDone) and flow tests (FlowStatusViewerInner.onJobsLoadedInner)
|
|
return {
|
|
id: previewJobId ?? '',
|
|
result: previewResult,
|
|
type: 'CompletedJob' as const,
|
|
success: previewSuccess ?? undefined,
|
|
logs: previewLogs
|
|
} as Job & { result_stream?: string } & { preview?: boolean }
|
|
}
|
|
|
|
const lastJob = $derived.by(updateLastJob)
|
|
|
|
export function setJobPreview() {
|
|
if (mock?.enabled) {
|
|
preview = 'job'
|
|
}
|
|
}
|
|
|
|
const mockUpdateStatus = $derived(
|
|
preview === 'mock' && !mock?.enabled
|
|
? 'restore'
|
|
: preview === 'job' && mock?.enabled && selectedJob?.['type'] === 'CompletedJob'
|
|
? 'override'
|
|
: undefined
|
|
)
|
|
|
|
$effect(() => {
|
|
if (displayResultJob && typeof displayResultJob.getToolbarLocation === 'function') {
|
|
toolbarLocationJob = displayResultJob.getToolbarLocation()
|
|
}
|
|
})
|
|
$effect(() => {
|
|
if (displayResultMock && typeof displayResultMock.getToolbarLocation === 'function') {
|
|
toolbarLocationMock = displayResultMock.getToolbarLocation()
|
|
}
|
|
})
|
|
$effect(() => {
|
|
const newValue =
|
|
!!mock?.enabled &&
|
|
!connectingData &&
|
|
!dblClickDisabled &&
|
|
hoveringResult &&
|
|
!jsonView &&
|
|
!preview
|
|
if (newValue != canEditWithDblClick) {
|
|
canEditWithDblClick = newValue
|
|
untrack(() => updateCanEditWithDblClick(newValue))
|
|
}
|
|
})
|
|
|
|
let popoverHeight = $derived(customHeight ?? (clientHeight > 0 ? clientHeight : 0))
|
|
|
|
// If a test job is running, we override the preview to show the test job
|
|
const executingTestJob = $derived(testJob && testJob.type === 'QueuedJob')
|
|
|
|
const isLoadingAndNotMock = $derived(
|
|
isLoading && lastJob?.result_stream === undefined && (!mock?.enabled || executingTestJob)
|
|
)
|
|
|
|
const copilot_fix_render = $derived(copilot_fix)
|
|
|
|
export function getPreview() {
|
|
return preview
|
|
}
|
|
|
|
async function selectJobFromHistory({
|
|
detail
|
|
}: {
|
|
detail: 'mock' | (Job & { getFullJob: () => Promise<Job> })
|
|
}) {
|
|
if (!detail) {
|
|
togglePreview(undefined)
|
|
return
|
|
}
|
|
if (detail === 'mock') {
|
|
if (mock?.enabled) {
|
|
togglePreview(undefined)
|
|
return
|
|
}
|
|
togglePreview('mock')
|
|
return
|
|
}
|
|
if (detail.id === lastJob?.id && !mock?.enabled) {
|
|
togglePreview(undefined)
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
|
|
try {
|
|
const fullJob = await detail.getFullJob()
|
|
if (fullJob) {
|
|
selectJob(fullJob)
|
|
togglePreview('job')
|
|
}
|
|
} finally {
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
export function getHistoryOpen() {
|
|
return historyOpen
|
|
}
|
|
|
|
export function getSelectedJob() {
|
|
return selectedJob
|
|
}
|
|
|
|
const noHistory = $derived(
|
|
loopStatus ? (loopStatus.type === 'self' ? 'isLoop' : 'isInsideLoop') : undefined
|
|
)
|
|
</script>
|
|
|
|
<div
|
|
class={twMerge('w-full h-full flex flex-col', clazz)}
|
|
bind:clientHeight
|
|
style={canEditWithDblClick ? 'cursor: text;' : ''}
|
|
>
|
|
<!-- Top toolbar -->
|
|
<div
|
|
class={twMerge(
|
|
'text-xs px-1',
|
|
'border-none',
|
|
hideHeaderBar || connectingData ? 'hidden' : 'block',
|
|
hasOverflow ? 'shadow-sm' : ''
|
|
)}
|
|
>
|
|
<div
|
|
class={twMerge(
|
|
'flex flex-row items-center gap-2 justify-between min-h-[33.5px]',
|
|
rightMargin ? `mr-[30px]` : ''
|
|
)}
|
|
>
|
|
<div class="flex flex-row items-center gap-0.5">
|
|
<!-- History picker -->
|
|
{#if !disableHistory}
|
|
{@render historyPicker()}
|
|
{/if}
|
|
<!-- Badge-->
|
|
{#if executingTestJob}
|
|
<OutputBadge
|
|
job={testJob}
|
|
class={twMerge(
|
|
'min-w-16 min-h-[23px] text-secondary',
|
|
preview ? 'bg-surface shadow-sm' : ''
|
|
)}
|
|
/>
|
|
{:else if isLoadingAndNotMock && !mock?.enabled}
|
|
<div
|
|
class="flex flex-row w-fit items-center justify-between gap-2 rounded-md bg-surface-secondary p-1 px-2 min-w-16 min-h-[23px]"
|
|
>
|
|
<Loader2 size={12} class="animate-spin text-secondary shrink-0" />
|
|
<span class="text-xs text-secondary w-[56px]"> </span>
|
|
</div>
|
|
{:else if !isLoadingAndNotMock || mock?.enabled}
|
|
<div
|
|
class={twMerge(
|
|
'w-grow min-w-0 flex gap-1 items-center h-[27px] rounded-md group',
|
|
preview || selectedJob?.id !== lastJob?.id ? 'p-[2px] bg-surface-secondary' : ''
|
|
)}
|
|
>
|
|
{#if loopStatus?.type === 'self'}
|
|
<div
|
|
class={twMerge(
|
|
'min-w-16 rounded-md py-1 px-2 justify-center',
|
|
mockUpdateStatus ? 'bg-surface h-[23px]' : 'bg-surface-secondary h-[27px]'
|
|
)}
|
|
>
|
|
<span
|
|
class="text-xs text-secondary truncate"
|
|
title={loopStatus.flow === 'forloopflow'
|
|
? 'For loop result'
|
|
: 'While loop result'}
|
|
>
|
|
{loopStatus.flow === 'forloopflow' ? 'For loop result' : 'While loop result'}
|
|
</span>
|
|
</div>
|
|
{:else if preview === 'mock' || (mock?.enabled && !preview)}
|
|
<div
|
|
class={twMerge(
|
|
'min-w-16 text-secondary flex flex-row w-fit items-center gap-2 rounded-md bg-surface-secondary p-1 px-2',
|
|
mockUpdateStatus ? 'bg-surface h-[23px]' : 'bg-surface-secondary h-[27px]'
|
|
)}
|
|
>
|
|
<Pin size={12} />
|
|
<span class="text-xs text-secondary truncate"
|
|
>{mock?.enabled ? 'Pin' : 'Last pin'}</span
|
|
>
|
|
</div>
|
|
{:else if preview === 'job' || (selectedJob && !preview)}
|
|
<OutputBadge
|
|
job={selectedJob}
|
|
class={twMerge(
|
|
'min-w-16 text-secondary',
|
|
preview || selectedJob?.id !== lastJob?.id ? 'bg-surface shadow-sm h-[23px]' : ''
|
|
)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if preview}
|
|
<button
|
|
class="px-1 shrink-0 text-secondary text-xs text-thin"
|
|
onclick={() => {
|
|
if (historyOpen) {
|
|
// closing history popover exits preview
|
|
stepHistoryPopover?.close()
|
|
}
|
|
togglePreview(undefined)
|
|
}}>Exit preview</button
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Pin button -->
|
|
{#if !disableMock}
|
|
<Tooltip disablePopup={mock?.enabled}>
|
|
<Button
|
|
color="light"
|
|
size="xs2"
|
|
variant="contained"
|
|
btnClasses={twMerge(
|
|
'bg-transparent h-[27px]',
|
|
mock?.enabled || mockUpdateStatus === 'override'
|
|
? 'text-white bg-blue-500 hover:text-primary hover:bg-blue-700 hover:text-gray-100'
|
|
: '',
|
|
jsonView ? 'pointer-events-none' : ''
|
|
)}
|
|
startIcon={{ icon: Pin }}
|
|
iconOnly={mockUpdateStatus !== 'override'}
|
|
on:click={() => togglePin()}
|
|
>
|
|
{#if mockUpdateStatus === 'override'}
|
|
Override pin
|
|
{/if}
|
|
</Button>
|
|
{#snippet text()}
|
|
Pin data
|
|
{/snippet}
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
{#if jsonView}
|
|
<!-- Save button -->
|
|
<Button
|
|
size="xs2"
|
|
color="green"
|
|
variant="contained"
|
|
startIcon={{ icon: Check }}
|
|
btnClasses="h-[27px]"
|
|
on:click={() => {
|
|
if (!tmpMock) {
|
|
return
|
|
}
|
|
jsonView = false
|
|
mock = tmpMock
|
|
onUpdateMock?.({
|
|
enabled: tmpMock?.enabled ?? false,
|
|
return_value: tmpMock?.return_value
|
|
})
|
|
tmpMock = undefined
|
|
}}
|
|
disabled={!!error || !tmpMock}
|
|
/>
|
|
<!-- Cancel button -->
|
|
<Button
|
|
size="xs2"
|
|
color="red"
|
|
variant="contained"
|
|
startIcon={{ icon: X }}
|
|
btnClasses="h-[27px]"
|
|
on:click={() => {
|
|
jsonView = false
|
|
tmpMock = undefined
|
|
}}
|
|
/>
|
|
{:else if mock?.enabled && !preview && !executingTestJob}
|
|
<!-- Edit pin button -->
|
|
<Tooltip disablePopup={mock?.enabled}>
|
|
<Button
|
|
size="xs2"
|
|
color="light"
|
|
variant="contained"
|
|
startIcon={{ icon: Pen }}
|
|
on:click={() => {
|
|
stepHistoryPopover?.close()
|
|
jsonView = true
|
|
tmpMock = undefined
|
|
}}
|
|
disabled={!mock?.enabled || !!connectingData}
|
|
btnClasses={twMerge(
|
|
'transition-all duration-100 h-[27px]',
|
|
debouncedCanEditWithDblClick
|
|
? 'bg-blue-500/10 text-blue-800 dark:text-blue-200'
|
|
: ''
|
|
)}
|
|
/>
|
|
{#snippet text()}
|
|
{'Pin the output to allow editing'}
|
|
{/snippet}
|
|
</Tooltip>
|
|
{/if}
|
|
|
|
<!-- Logs button -->
|
|
{#if logsToggle && selectedJob?.type === 'CompletedJob' && selectedJob?.['logs']}
|
|
<Tooltip>
|
|
<Button
|
|
size="xs2"
|
|
color="light"
|
|
variant="contained"
|
|
btnClasses={twMerge(
|
|
'h-[27px]',
|
|
showLogs ? 'bg-blue-500/10 text-blue-800 dark:text-blue-200' : 'bg-transparent'
|
|
)}
|
|
startIcon={{ icon: ScrollText }}
|
|
on:click={() => {
|
|
showLogs = !showLogs
|
|
}}
|
|
/>
|
|
{#snippet text()}
|
|
{showLogs ? 'Show result' : 'Show logs'}
|
|
{/snippet}
|
|
</Tooltip>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="px-2">
|
|
{#if selectedJob && displayResultJob && toolbarLocationJob === 'external'}
|
|
<DisplayResultControlBar
|
|
workspaceId={selectedJob.workspace_id}
|
|
jobId={selectedJob.id}
|
|
{base}
|
|
result={selectedJob.result}
|
|
disableTooltips={false}
|
|
on:open-drawer={() => {
|
|
if (displayResultJob && typeof displayResultJob.openDrawer === 'function') {
|
|
displayResultJob.openDrawer()
|
|
}
|
|
}}
|
|
/>
|
|
{:else if mock?.enabled && displayResultMock && toolbarLocationMock === 'external'}
|
|
<DisplayResultControlBar
|
|
customUi={{ disableDownload: true }}
|
|
{base}
|
|
result={mock.return_value}
|
|
disableTooltips={false}
|
|
on:open-drawer={() => {
|
|
if (displayResultMock && typeof displayResultMock.openDrawer === 'function') {
|
|
displayResultMock.openDrawer()
|
|
}
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
<div
|
|
class={twMerge(
|
|
'grow min-h-0 rounded-md w-full pl-2 py-1 pb-2 overflow-auto',
|
|
'transition-all duration-100 outline outline-2 outline-blue-500/0 outline-offset-[-2px]',
|
|
debouncedCanEditWithDblClick ? 'outline outline-2 outline-blue-500/30' : ''
|
|
)}
|
|
style="scrollbar-gutter: stable;"
|
|
>
|
|
<div
|
|
class={twMerge('h-full w-full rounded-md')}
|
|
bind:this={contentEl}
|
|
onscroll={checkOverflow}
|
|
use:useResizeObserver={checkOverflow}
|
|
onmouseover={(event) => {
|
|
if (
|
|
!event.target ||
|
|
!(event.target instanceof HTMLElement) ||
|
|
event.target.closest('[data-interactive]') ||
|
|
event.target.closest('button') ||
|
|
event.target.closest('input') ||
|
|
event.target.closest('textarea') ||
|
|
event.target.closest('select') ||
|
|
event.target.closest('option') ||
|
|
event.target.closest('label') ||
|
|
event.target.closest('a') ||
|
|
event.target.closest('svg')
|
|
) {
|
|
dblClickDisabled = true
|
|
} else {
|
|
dblClickDisabled = false
|
|
}
|
|
}}
|
|
ondblclick={() => {
|
|
if (canEditWithDblClick) {
|
|
stepHistoryPopover?.close()
|
|
jsonView = true
|
|
tmpMock = undefined
|
|
}
|
|
}}
|
|
onmouseenter={() => {
|
|
hoveringResult = true
|
|
}}
|
|
onmouseleave={() => {
|
|
hoveringResult = false
|
|
}}
|
|
>
|
|
{#if logsToggle && showLogs && selectedJob?.type === 'CompletedJob' && selectedJob?.['logs']}
|
|
<LogViewer
|
|
small
|
|
jobId={selectedJob.id}
|
|
duration={selectedJob['duration_ms']}
|
|
mem={selectedJob['mem_peak']}
|
|
content={selectedJob['logs']}
|
|
isLoading={false}
|
|
tag={selectedJob['tag']}
|
|
onLogsResolved={(logs) => {
|
|
if (
|
|
moduleId &&
|
|
flowStateStore?.val[moduleId] &&
|
|
flowStateStore.val[moduleId].previewJobId === selectedJob?.id
|
|
) {
|
|
flowStateStore.val[moduleId] = {
|
|
...flowStateStore.val[moduleId],
|
|
previewLogs: logs
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{:else if isLoadingAndNotMock}
|
|
<div class="flex flex-col items-center justify-center">
|
|
<Loader2 class="animate-spin" />
|
|
</div>
|
|
{:else if connectingData !== undefined || simpleViewer}
|
|
<!-- Display data for input connections -->
|
|
<ObjectViewer
|
|
json={moduleId
|
|
? {
|
|
[moduleId]: connectingData ?? simpleViewer
|
|
}
|
|
: (connectingData ?? simpleViewer)}
|
|
topBrackets={false}
|
|
pureViewer={false}
|
|
{prefix}
|
|
on:select={(e) => {
|
|
onSelect?.(e.detail)
|
|
}}
|
|
{allowCopy}
|
|
{editKey}
|
|
/>
|
|
{:else if jsonView}
|
|
<!-- Json editor for editing mock data -->
|
|
{#await import('$lib/components/JsonEditor.svelte')}
|
|
<Loader2 class="animate-spin" />
|
|
{:then Module}
|
|
<Module.default
|
|
bind:error
|
|
small
|
|
on:changeValue={({ detail }) => {
|
|
if (mock?.enabled) {
|
|
const newMock = {
|
|
enabled: true,
|
|
return_value: structuredClone($state.snapshot(detail))
|
|
}
|
|
tmpMock = newMock
|
|
}
|
|
}}
|
|
code={JSON.stringify(
|
|
mock?.enabled && mock.return_value ? mock.return_value : '',
|
|
null,
|
|
2
|
|
)}
|
|
class="h-full"
|
|
/>
|
|
{/await}
|
|
{:else if (mock?.enabled || preview == 'mock') && preview != 'job' && !executingTestJob}
|
|
<!-- Display data for mock data -->
|
|
{#if fullResult}
|
|
<div class="break-words relative h-full">
|
|
<DisplayResult
|
|
bind:this={displayResultMock}
|
|
workspaceId={undefined}
|
|
jobId={undefined}
|
|
result_stream={undefined}
|
|
result={mock?.return_value}
|
|
externalToolbarAvailable
|
|
fixTableSizingToParent
|
|
on:toolbar-location-changed={({ detail }) => {
|
|
toolbarLocationMock = detail
|
|
}}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<ObjectViewer
|
|
json={moduleId
|
|
? {
|
|
[moduleId]: mock?.return_value
|
|
}
|
|
: mock?.return_value}
|
|
topBrackets={false}
|
|
pureViewer={false}
|
|
/>
|
|
{/if}
|
|
{:else if selectedJob != undefined && (selectedJob.result_stream || selectedJob.type == 'CompletedJob')}
|
|
<!-- Display data for job data -->
|
|
{#if fullResult}
|
|
<div class="break-words relative h-full">
|
|
{#key selectedJob}
|
|
<DisplayResult
|
|
bind:this={displayResultJob}
|
|
workspaceId={selectedJob?.workspace_id}
|
|
jobId={selectedJob?.id}
|
|
result={selectedJob?.result}
|
|
result_stream={selectedJob?.result_stream}
|
|
externalToolbarAvailable
|
|
fixTableSizingToParent
|
|
on:toolbar-location-changed={({ detail }) => {
|
|
toolbarLocationJob = detail
|
|
}}
|
|
>
|
|
{#snippet copilot_fix()}
|
|
{@render copilot_fix_render?.()}
|
|
{/snippet}
|
|
</DisplayResult>
|
|
{/key}
|
|
</div>
|
|
{:else}
|
|
<ObjectViewer
|
|
json={moduleId
|
|
? {
|
|
[moduleId]: selectedJob.result
|
|
}
|
|
: selectedJob.result}
|
|
topBrackets={false}
|
|
pureViewer={false}
|
|
/>
|
|
{/if}
|
|
{:else if !lastJob}
|
|
<div class="flex flex-col items-center justify-center h-full">
|
|
<p class="text-xs text-secondary">
|
|
{customEmptyJobMessage ?? 'Test this step to see results'}{#if !disableMock}
|
|
{' or'}
|
|
<button
|
|
class="text-blue-500 hover:text-blue-700 underline"
|
|
onclick={() => {
|
|
const newMock = {
|
|
enabled: true,
|
|
return_value: mock?.return_value ?? { example: 'value' }
|
|
}
|
|
onUpdateMock?.(newMock)
|
|
}}>pin data<Pin size={16} class="inline" /></button
|
|
>{:else}.{/if}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{#if initial && !mock?.enabled}
|
|
<span
|
|
in:fade
|
|
class="-mb-1 -mt-0.5 w-full text-right pr-4 dark:text-gray-500 text-gray-400 font-normal text-2xs py-0"
|
|
>Run loaded from history</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
|
|
{#snippet editKey(key: string)}
|
|
<button
|
|
onclick={() => onEditInput?.(selectionId ?? '', key)}
|
|
class="h-4 w-fit items-center text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary px-1 rounded-[0.275rem] align-baseline"
|
|
>
|
|
<Pencil size={12} class="-my-1 inline-flex items-center" />
|
|
</button>
|
|
{/snippet}
|
|
|
|
{#snippet historyPicker()}
|
|
<Popover
|
|
bind:this={stepHistoryPopover}
|
|
floatingConfig={{
|
|
strategy: 'fixed',
|
|
placement: 'left-start',
|
|
offset: historyOffset,
|
|
gutter: 0 // hack to make offset effective, see https://github.com/melt-ui/melt-ui/issues/528
|
|
}}
|
|
contentClasses="w-[225px] overflow-hidden"
|
|
{closeOnOutsideClick}
|
|
usePointerDownOutside={closeOnOutsideClick}
|
|
disablePopup={!!connectingData || jsonView}
|
|
bind:isOpen={historyOpen}
|
|
>
|
|
{#snippet trigger()}
|
|
<Button
|
|
color="light"
|
|
size="xs2"
|
|
variant="contained"
|
|
btnClasses="bg-surface h-[27px]"
|
|
startIcon={{ icon: History }}
|
|
nonCaptureEvent
|
|
/>
|
|
{/snippet}
|
|
{#snippet content()}
|
|
<div class="rounded-[inherit]" style={`height: ${popoverHeight}px`}>
|
|
<StepHistory
|
|
{moduleId}
|
|
{getLogs}
|
|
on:select={selectJobFromHistory}
|
|
mockValue={mock?.return_value}
|
|
mockEnabled={mock?.enabled}
|
|
{path}
|
|
{noHistory}
|
|
/>
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
{/snippet}
|
|
|
|
<style>
|
|
.dbl-click-editable {
|
|
cursor: text;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.dbl-click-editable:hover {
|
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5) inset;
|
|
}
|
|
</style>
|