Files
windmill/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte
T
hugocasaandClaude Opus 5 f037c73d10 feat(frontend): group the agent form and edit saved agents as drafts (#10880)
* 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>
2026-09-04 16:32:17 +02:00

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]">&nbsp;</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>