diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index 1f938dcfdf..c8983fcaab 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -44,9 +44,13 @@ type OnBehalfOfDetails } from './OnBehalfOfSelector.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' const dispatch = createEventDispatcher() + const detailWs = getDetailWorkspace() + let ws = $derived(detailWs?.() ?? $workspaceStore) + interface Props { kind: Kind initialPath?: string @@ -173,7 +177,7 @@ sourceOnBehalfOfInfo[key] = await getOnBehalfOf( dep.kind, dep.path, - $workspaceStore!, + ws!, additionalInformation ) } catch { @@ -198,7 +202,7 @@ ): Promise<{ kind: Kind; path: string }[]> { async function rec(kind: Kind, path: string): Promise<{ kind: Kind; path: string }[]> { if (kind == 'schedule') { - const schedule = await ScheduleService.getSchedule({ workspace: $workspaceStore!, path }) + const schedule = await ScheduleService.getSchedule({ workspace: ws!, path }) if (schedule.script_path && schedule.script_path != '') { if (schedule.script_path) { return [{ kind: 'script', path: schedule.script_path }] @@ -209,7 +213,7 @@ return [] } } else if (kind == 'flow') { - const flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path }) + const flow = await FlowService.getFlowByPath({ workspace: ws!, path }) return getAllModules(flow.value.modules, flow.value.failure_module).flatMap((x) => { let result: { kind: Kind; path: string }[] = [] if (x.value.type == 'script' || x.value.type == 'rawscript' || x.value.type == 'flow') { @@ -229,7 +233,7 @@ return result }) } else if (kind == 'app') { - const app = await AppService.getAppByPath({ workspace: $workspaceStore!, path }) + const app = await AppService.getAppByPath({ workspace: ws!, path }) let result: { kind: Kind; path: string }[] = [] if (app.raw_app) { const rawAppValue = app.value as { runnables?: Record } @@ -257,7 +261,7 @@ } return result } else if (kind == 'resource') { - const res = await ResourceService.getResource({ workspace: $workspaceStore!, path }) + const res = await ResourceService.getResource({ workspace: ws!, path }) function recObj(obj: any): { kind: Kind; path: string }[] { if (typeof obj == 'string') { if (obj.startsWith('$var:')) { @@ -282,12 +286,12 @@ ] } else if (kind == 'trigger') { if (additionalInformation?.triggers) { - return getTriggerDependency(additionalInformation.triggers.kind, path, $workspaceStore!) + return getTriggerDependency(additionalInformation.triggers.kind, path, ws!) } throw new Error('Missing trigger information') } else if (kind == 'script') { const imports = await WorkspaceService.getImports({ - workspace: $workspaceStore!, + workspace: ws!, importerPath: path }) return imports.map((importedPath) => ({ kind: 'script' as Kind, path: importedPath })) @@ -334,7 +338,7 @@ const result = await deployItem({ kind, path, - workspaceFrom: $workspaceStore!, + workspaceFrom: ws!, workspaceTo: workspaceToDeployTo!, additionalInformation, onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind), @@ -366,7 +370,7 @@ diffDrawer?.openDrawer() let values = await Promise.all([ getItemValue(kind, path, workspaceToDeployTo!, additionalInformation), - getItemValue(kind, path, $workspaceStore!, additionalInformation) + getItemValue(kind, path, ws!, additionalInformation) ]) diffDrawer?.setDiff({ mode: 'simple', @@ -377,7 +381,7 @@ } $effect(() => { - WorkspaceService.getDeployTo({ workspace: $workspaceStore! }).then((x) => { + WorkspaceService.getDeployTo({ workspace: ws! }).then((x) => { workspaceToDeployTo = x.deploy_to if (x.deploy_to == undefined) { notSet = true diff --git a/frontend/src/lib/components/HistoricInputs.svelte b/frontend/src/lib/components/HistoricInputs.svelte index 85f2603005..d4d11b68ca 100644 --- a/frontend/src/lib/components/HistoricInputs.svelte +++ b/frontend/src/lib/components/HistoricInputs.svelte @@ -8,6 +8,9 @@ import HistoricList from './HistoricList.svelte' import { Loader2 } from 'lucide-svelte' import { workspaceStore } from '$lib/stores' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' + + const detailWs = getDetailWorkspace() interface Props { runnableId?: string | undefined @@ -32,7 +35,7 @@ workspace = undefined }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? detailWs?.() ?? $workspaceStore) let historicList: HistoricList | undefined = $state(undefined) const dispatch = createEventDispatcher() diff --git a/frontend/src/lib/components/MoveDrawer.svelte b/frontend/src/lib/components/MoveDrawer.svelte index 21b155fce3..020783330e 100644 --- a/frontend/src/lib/components/MoveDrawer.svelte +++ b/frontend/src/lib/components/MoveDrawer.svelte @@ -9,9 +9,13 @@ import Label from './Label.svelte' import TextInput from './text_input/TextInput.svelte' import { FlowService, ScriptService, type TriggersCount } from '$lib/gen' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' const dispatch = createEventDispatcher() + const detailWs = getDetailWorkspace() + let ws = $derived(detailWs?.() ?? $workspaceStore) + type Kind = 'script' | 'resource' | 'schedule' | 'variable' | 'flow' | 'app' let kind = $state('flow') @@ -82,7 +86,7 @@ loadOwner() drawer.openDrawer() if (kind === 'flow') { - onBehalfOfEmail = await checkFlowOnBehalfOf($workspaceStore!, initialPath_l) + onBehalfOfEmail = await checkFlowOnBehalfOf(ws!, initialPath_l) } if (kind === 'script' || kind === 'flow') { void loadAttachedTriggers() @@ -91,7 +95,7 @@ async function loadAttachedTriggers() { try { - const workspace = $workspaceStore! + const workspace = ws! attachedTriggers = kind === 'flow' ? await FlowService.getTriggersCountOfFlow({ workspace, path: initialPath }) @@ -103,13 +107,13 @@ } function loadOwner() { - own = isOwner(initialPath, $userStore!, $workspaceStore!) + own = isOwner(initialPath, $userStore!, ws!) } async function updatePath() { if (kind === 'flow' || kind === 'script' || kind === 'app') { await updateItemPathAndSummary({ - workspace: $workspaceStore!, + workspace: ws!, kind, initialPath, newPath: path ?? '', @@ -142,9 +146,8 @@
{#each attachedSummary as { label, count } (label)} {count} {label}{count === 1 - ? '' - : 's'}{count} + {label}{count === 1 ? '' : 's'} {/each}
diff --git a/frontend/src/lib/components/PersistentScriptDrawer.svelte b/frontend/src/lib/components/PersistentScriptDrawer.svelte index 23fa122b7d..d73e35335b 100644 --- a/frontend/src/lib/components/PersistentScriptDrawer.svelte +++ b/frontend/src/lib/components/PersistentScriptDrawer.svelte @@ -8,8 +8,12 @@ import { displayDate, sleep, sendUserToast } from '$lib/utils' import TableCustom from './TableCustom.svelte' import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' let dispatch = createEventDispatcher() + + const detailWs = getDetailWorkspace() + let ws = $derived(detailWs?.() ?? $workspaceStore) let drawer: Drawer | undefined = $state() let script: Script | undefined = $state() @@ -38,7 +42,7 @@ const timeStart = new Date().getTime() queuedJobsLoading = true let qjs = await JobService.listQueue({ - workspace: $workspaceStore ?? '', + workspace: ws ?? '', orderDesc: false, scriptPathExact: script?.path }) @@ -70,7 +74,7 @@ async function scaleToZero() { cancellingInProgress = true await JobService.cancelPersistentQueuedJobs({ - workspace: $workspaceStore ?? '', + workspace: ws ?? '', path: script?.path ?? '', requestBody: { reason: undefined @@ -139,17 +143,15 @@ {scriptHash} - {jobId.substring(24)}{jobId.substring(24)} diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index a371f3eea9..3bf19242cb 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -121,6 +121,11 @@ args?: Record jsonView?: boolean isValid?: boolean + /** Mirror the current args into the page URL's fragment, which is what makes a + * filled-in form shareable and what `Run again` reads back. Turn off wherever this + * form is embedded in a page that is not the runnable's own — an AI session preview + * tab — since there the fragment would land on an unrelated URL. */ + syncArgsToUrl?: boolean } let { @@ -139,7 +144,8 @@ overrideTagNote = undefined, args = $bindable(), jsonView = false, - isValid = $bindable(true) + isValid = $bindable(true), + syncArgsToUrl = true }: Props = $props() let showPsCommonParams = $derived( @@ -161,6 +167,7 @@ let debounced: number | undefined = undefined function onArgsChange(args: any) { + if (!syncArgsToUrl) return try { debounced && clearTimeout(debounced) debounced = setTimeout(() => { diff --git a/frontend/src/lib/components/SaveInputsButton.svelte b/frontend/src/lib/components/SaveInputsButton.svelte index 91b583e0da..a1966dd034 100644 --- a/frontend/src/lib/components/SaveInputsButton.svelte +++ b/frontend/src/lib/components/SaveInputsButton.svelte @@ -7,6 +7,9 @@ import { sendUserToast } from '$lib/utils.js' import { createEventDispatcher } from 'svelte' import Tooltip from '$lib/components/Tooltip.svelte' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' + + const detailWs = getDetailWorkspace() const dispatch = createEventDispatcher() @@ -30,7 +33,7 @@ workspace = undefined }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? detailWs?.() ?? $workspaceStore) let savingInputs = $state(false) diff --git a/frontend/src/lib/components/SavedInputsPicker.svelte b/frontend/src/lib/components/SavedInputsPicker.svelte index a6a49e566e..3a84c04ade 100644 --- a/frontend/src/lib/components/SavedInputsPicker.svelte +++ b/frontend/src/lib/components/SavedInputsPicker.svelte @@ -12,6 +12,9 @@ import InfiniteList from './InfiniteList.svelte' import { twMerge } from 'tailwind-merge' import SavedInputsPickerViewer from './SavedInputsPickerViewer.svelte' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' + + const detailWs = getDetailWorkspace() interface Props { previewArgs?: any @@ -36,7 +39,7 @@ workspace = undefined }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? detailWs?.() ?? $workspaceStore) interface EditableInput extends Input { isEditing?: boolean diff --git a/frontend/src/lib/components/ScriptVersionHistory.svelte b/frontend/src/lib/components/ScriptVersionHistory.svelte index 693ddef47c..a7ecb97fd9 100644 --- a/frontend/src/lib/components/ScriptVersionHistory.svelte +++ b/frontend/src/lib/components/ScriptVersionHistory.svelte @@ -11,9 +11,13 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import VersionListItem from './VersionListItem.svelte' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' const dispatch = createEventDispatcher() + const detailWs = getDetailWorkspace() + let ws = $derived(detailWs?.() ?? $workspaceStore) + let { openDetails = false, scriptPath }: { openDetails?: boolean; scriptPath: string } = $props() let deploymentMsgUpdateMode = $state(false) @@ -27,7 +31,7 @@ async function loadVersions() { loading = true versions = await ScriptService.getScriptHistoryByPath({ - workspace: $workspaceStore!, + workspace: ws!, path: scriptPath }) loading = false @@ -42,7 +46,7 @@ return } await ScriptService.updateScriptHistory({ - workspace: $workspaceStore!, + workspace: ws!, path: scriptPath, hash: scriptHash, requestBody: { diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 471ed74099..7f3e3d2a3e 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -22,11 +22,15 @@ import Toggle from './Toggle.svelte' import { Trash } from 'lucide-svelte' import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud' + import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' const dispatch = createEventDispatcher() + const detailWs = getDetailWorkspace() + let ws = $derived(detailWs?.() ?? $workspaceStore) + let restricted = $derived( - isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin) + isDemoWorkspaceRestricted(ws, $userStore?.is_admin, $userStore?.is_super_admin) ) type Kind = @@ -81,7 +85,7 @@ defaultPermsLabel = `Folder f/${folderName} permissions` try { const folder: Folder = await FolderService.getFolder({ - workspace: $workspaceStore!, + workspace: ws!, name: folderName }) if (path !== currentPath) return @@ -134,7 +138,7 @@ const currentPath = path try { const resource = await ResourceService.getResource({ - workspace: $workspaceStore!, + workspace: ws!, path: currentPath }) if (path !== currentPath) return @@ -164,27 +168,25 @@ } async function loadOwner() { - own = isOwner(path, $userStore!, $workspaceStore!) + own = isOwner(path, $userStore!, ws!) } async function loadAcls() { - acls = Object.entries( - await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind }) - ) + acls = Object.entries(await GranularAclService.getGranularAcls({ workspace: ws!, path, kind })) } async function loadGroups(): Promise { - groups = await GroupService.listGroupNames({ workspace: $workspaceStore! }) + groups = await GroupService.listGroupNames({ workspace: ws! }) } async function loadUsernames(): Promise { - usernames = await UserService.listUsernames({ workspace: $workspaceStore! }) + usernames = await UserService.listUsernames({ workspace: ws! }) } async function deleteAcl(owner: string) { try { await GranularAclService.removeGranularAcls({ - workspace: $workspaceStore!, + workspace: ws!, path, kind, requestBody: { owner } @@ -193,7 +195,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.removeGranularAcls({ - workspace: $workspaceStore!, + workspace: ws!, path: varPath, kind: 'variable', requestBody: { owner } @@ -212,7 +214,7 @@ async function addAcl(owner: string, write: boolean) { await GranularAclService.addGranularAcls({ - workspace: $workspaceStore!, + workspace: ws!, path, kind, requestBody: { owner, write } @@ -221,7 +223,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.addGranularAcls({ - workspace: $workspaceStore!, + workspace: ws!, path: varPath, kind: 'variable', requestBody: { owner, write } @@ -251,9 +253,7 @@ {/if}
- Extra members ({acls?.length ?? 0}) + Extra members ({acls?.length ?? 0}) {#if linkedVarPaths.length > 0}
void + /** How the load ended, for a host that renders its own state around this viewer. + * A 403 is deliberately neither: the app exists, this member just cannot open it. */ + onLoadState?: (state: 'loaded' | 'not_found') => void } = $props() let app: any = $state(undefined) @@ -96,10 +105,14 @@ app = loaded noPermission = false notExists = false + onLoadState?.('loaded') } catch (e: any) { if (e.status == 401) refresh?.() else if (e.status == 403) noPermission = true - else notExists = true + else { + notExists = true + onLoadState?.('not_found') + } } } @@ -142,6 +155,12 @@ {#if canWriteApp && !hideEditBtn}
- +
{/if} diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte new file mode 100644 index 0000000000..b7d12bdfce --- /dev/null +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -0,0 +1,927 @@ + + + + + + { + await onNavigate('/flows/get/' + e.detail + `?workspace=${workspace}`) + loadFlow() + }} +/> +{#if flow} + +{/if} + + + {#snippet header()} + { + rightPaneSelected = 'triggers' + }} + {mainButtons} + menuItems={getMenuItems(flow, deployUiSettings)} + bind:errorHandlerMuted={ + () => flow?.ws_error_handler_muted ?? false, + (v) => { + if (flow !== undefined) flow.ws_error_handler_muted = v + } + } + scriptOrFlowPath={flow?.path ?? ''} + errorHandlerKind="flow" + tag={flow?.tag ?? ''} + labels={flow?.labels} + inheritedLabels={flow?.inherited_labels} + summary={flow?.summary} + path={flow?.path} + onSaved={can_write + ? async (newPath) => { + if (newPath !== flow?.path) { + await onNavigate(`/flows/get/${newPath}?workspace=${workspace}`) + } else { + loadFlow() + } + } + : undefined} + > + {#snippet trigger_badges()} + { + rightPaneSelected = 'triggers' + await tick() + triggersState.selectedTriggerIndex = triggerIndex + }} + small={false} + /> + {/snippet} + {#if workspace && flow} + + {/if} + + {#if flow?.value?.priority != undefined} + + {/if} + {#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined} + + {/if} + + {/snippet} + {#snippet form()} +
+ (showEditButtons = v)} /> +
+ {#if flow} +
+
+
+ {#if flow?.path} + + {/if} + + {#if flow?.archived} + This flow was archived +
+ {/if} + + {#if pinnedVersion !== undefined} + + This is a historical version of the flow, not the latest. + + View latest + + +
+ {/if} + + {#if !emptyString(flow?.description)} +
+ +
+
+ {/if} + + {#if deploymentInProgress} +
+ + + Deployment in progress + {#if deploymentJobId} + view job + {/if} + +
+ {/if} + {#if flow.lock_error_logs && flow.lock_error_logs != ''} + +

+ This flow has not been deployed successfully because of the following errors: +

+ +
+
+ {/if} + + {#if chatInputEnabled} + + + {:else} + {@const hasSchema = + flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0} + +
+ {#if hasSchema || inputSelected} +
+ { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + + {#if hasSchema} + + {/if} +
+ {/if} + + {#if flow.schema?.prompt_for_ai !== undefined} + { + onNavigate(`/flows/edit/${flow?.path}`) + }} + runnableType="flow" + path={flow?.path} + /> + {/if} + + +
+ +
+ + Edited by {flow.edited_by} + +
+ {/if} +
+
+ {#if !chatInputEnabled} +
+ { + if (e.detail) { + stepDetail = e.detail + rightPaneSelected = 'flow_step' + } else { + stepDetail = undefined + rightPaneSelected = 'saved_inputs' + } + }} + on:triggerDetail={(e) => { + rightPaneSelected = 'triggers' + }} + noBorder={true} + /> +
+ {/if} +
+ {/if} + {/snippet} + {#snippet save_inputs()} + { + const nargs = JSON.parse(JSON.stringify(e.detail)) + args = nargs + if (jsonView) { + runForm?.syncJsonEditor() + } + }} + /> + {/snippet} + + {#snippet flow_step()} + {#if flow} + {#if stepDetail} + + {/if} + {/if} + {/snippet} + {#snippet triggers()} + {#if flow} + + {/if} + {/snippet} + + {#snippet flow_graph()} + {#if flow} +
+ { + if (e.detail) { + stepDetail = e.detail + rightPaneSelected = 'flow_step' + } else { + stepDetail = undefined + rightPaneSelected = 'saved_inputs' + } + }} + on:triggerDetail={(e) => { + rightPaneSelected = 'triggers' + }} + /> +
+ {/if} + {/snippet} +
+ + diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte new file mode 100644 index 0000000000..c20e0b3d09 --- /dev/null +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -0,0 +1,1258 @@ + + + { + await onNavigate('/scripts/get/' + e.detail + `?workspace=${workspace}`) + loadScript(routeHash ?? '') + }} +/> + + + + + + + + + (versionsDrawerOpen = false)} noPadding> + {#if script} + { + if (script) { + onNavigate(`/scripts/get/${e.detail.version}?workspace=${workspace}`) + } + versionsDrawerOpen = false + }} + /> + {/if} + + +{#key script?.hash} + + {#snippet header()} + script?.ws_error_handler_muted ?? false, + (v) => { + if (script !== undefined) script.ws_error_handler_muted = v + } + } + errorHandlerKind="script" + scriptOrFlowPath={script?.path ?? ''} + tag={script?.tag ?? ''} + labels={script?.labels} + inheritedLabels={script?.inherited_labels} + on:seeTriggers={() => { + rightPaneSelected = 'triggers' + }} + summary={script?.summary} + path={script?.path} + onSaved={can_write + ? async (newPath) => { + if (newPath !== script?.path) { + await onNavigate(`/scripts/get/${newPath}?workspace=${workspace}`) + } else { + loadScript(newPath) + } + } + : undefined} + > + {#snippet trigger_badges()} + { + if (rightPaneSelected !== 'triggers') { + rightPaneSelected = 'triggers' + } + await tick() + triggersState.selectedTriggerIndex = triggerIndex + }} + /> + {/snippet} + {#if workspace && script} + + {/if} + {#if script?.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} + {#if script?.auto_kind === 'test'} + + {#snippet text()} + CI test script + {/snippet} + CI test + + {/if} + {#if script?.codebase} + bundleThis script is deployed as a bundle and can only be deployed from the CLI for now + {/if} + + {#if script?.priority != undefined} + + {/if} + {#if script?.restart_unless_cancelled ?? false} + + {/if} + {#if script?.concurrent_limit != undefined && script.concurrency_time_window_s != undefined} + + {/if} + + {/snippet} + {#snippet form()} +
+ (showEditButtons = v)} /> +
+ {#if script} + {@const showsDbtGraph = script.language === 'dbt' && !!script.path} +
+
+ {#if script?.path} + + {/if} + +
+ {#if script.lock_error_logs || topHash || script.archived || script.deleted} +
+ {#if script.lock_error_logs} + +

+ This script has not been deployed successfully because of the following + errors: +

+ +
+ {/if} + {#if topHash} +
+ + This hash is not HEAD (latest non-archived version at this path) : + Go to the HEAD of this path + + {/if} + {#if script.archived && !topHash} + This path was archived + {/if} + {#if script.deleted} + +

The content of this script was deleted (by an admin, no less)

+
+ {/if} +
+ {/if} + + {#if !emptyString(script.description)} +
+ +
+
+ {/if} +
+ + {#if deploymentInProgress} +
+ + + Deployment in progress + {#if deploymentJobId} + view job + {/if} + +
+ {/if} + +
+ {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected} + {@const hasSchema = + script.schema && Object.keys(script.schema.properties ?? {}).length > 0} +
+ { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + {#if hasSchema} + + {/if} +
+ {/if} + + + {#if dbtRetryFrom && (args?.['command'] as any)?.label !== 'retry'} +
+ +
+ + The run you came from failed part-way. dbt retry + rebuilds only its failed and skipped nodes, with the arguments it ran with. + + +
+
+
+ {/if} + + {#if script?.schema?.prompt_for_ai !== undefined} + { + onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) + }} + runnableType="script" + path={script?.path} + /> + {/if} + + +
+ +
+ {#if !isHubScript} + + Edited by {script.created_by || + 'unknown'} + + {/if} +
+ {#if !isHubScript} + + {truncateHash(script?.hash ?? '')} + + {/if} + {#if script?.is_template} + Template + {/if} + {#if script && script.kind !== 'script'} + + {script?.kind} + + {/if} + + +
+
+
+ {#if showsDbtGraph} + +
+ +
+ {:else if isWac && script.content} +
+ +
+ {/if} +
+ {/if} + {/snippet} + {#snippet save_inputs()} + {#if args} + { + const nargs = JSON.parse(JSON.stringify(e.detail)) + args = nargs + if (jsonView) { + runForm?.syncJsonEditor() + } + }} + /> + {/if} + {/snippet} + {#snippet triggers()} + {#if script} + + {/if} + {/snippet} + {#snippet scriptRender()} +
+ + + + + + + {#snippet content()} + {#if script} + +
+ +
+ {#if script?.modules} + {#each Object.entries(script.modules) as [modulePath, mod]} +
+ + {#if expandedModuleCode[modulePath]} +
+ +
+ {/if} +
+ {/each} + {/if} +
+ +
+ {#if script?.lock} +
+
+ {:else} +

+ There is no lock file for this script +

+ {/if} + {#if script?.modules} + {@const moduleEntries = Object.entries(script.modules).filter( + ([_, m]) => m.lock + )} + {#each moduleEntries as [modulePath, mod]} +
+ + {#if expandedModuleLocks[modulePath]} +
+
+ {/if} +
+ {/each} + {/if} +
+
+ +
+ + +
+
+ {/if} + {/snippet} +
+
+ + {#if script?.envs && script.envs.length > 0} +

Static Env Variables

+
    + {#each script?.envs as e} +
  • {e}
  • + {/each} +
+ {/if} + {/snippet} +
+{/key} diff --git a/frontend/src/lib/components/details/detailWorkspace.test.ts b/frontend/src/lib/components/details/detailWorkspace.test.ts new file mode 100644 index 0000000000..5c18ecd1c0 --- /dev/null +++ b/frontend/src/lib/components/details/detailWorkspace.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +function read(relPath: string): string { + return readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), relPath), 'utf-8') +} + +function navStoreReads(source: string): string[] { + return source + .split('\n') + .map((text, i) => `${i + 1}:${text.trim()}`) + .filter((l) => l.includes('$workspaceStore')) +} + +// The one line allowed to read the nav store: the resolver's fallback, which is what every +// call site outside a detail page still takes. +const FALLBACK = + /^\d+:let ws = \$derived\((?:workspace \?\? )?detailWs\?\.\(\) \?\? \$workspaceStore\)$/ + +// A detail component operates on its `workspace` prop, which in an AI session is the session's +// workspace and not the one the top nav points at. Any `$workspaceStore` reintroduced here aims +// that call at the nav workspace, which still looks right on the workspace pages — the only +// place the two coincide — and silently reads the wrong workspace everywhere else. +describe.each(['./ScriptDetail.svelte', './FlowDetail.svelte'])('%s', (relPath) => { + const source = read(relPath) + + it('never reads the nav workspace store', () => { + expect(navStoreReads(source)).toEqual([]) + }) + + it('registers the resolver for the drawers it opens', () => { + expect(source).toContain('setDetailWorkspace(() => workspace)') + expect(source).toContain('setTriggerWorkspace(() => workspace)') + }) +}) + +// The shared components a detail page opens. Each is mounted from many other places too, so it +// resolves its workspace rather than taking it as a prop: the resolver when a detail page set +// one, the nav store otherwise. +describe.each([ + '../ShareModal.svelte', + '../MoveDrawer.svelte', + '../PersistentScriptDrawer.svelte', + '../ScriptVersionHistory.svelte', + '../DeployWorkspace.svelte', + '../SavedInputsPicker.svelte', + '../HistoricInputs.svelte', + '../SaveInputsButton.svelte' +])('%s', (relPath) => { + const source = read(relPath) + + it('reads the nav workspace only as the resolver fallback', () => { + expect(navStoreReads(source).filter((l) => !FALLBACK.test(l))).toEqual([]) + }) + + it('consults the resolver', () => { + expect(source).toContain('const detailWs = getDetailWorkspace()') + }) +}) diff --git a/frontend/src/lib/components/details/detailWorkspace.ts b/frontend/src/lib/components/details/detailWorkspace.ts new file mode 100644 index 0000000000..7cbdcfacfd --- /dev/null +++ b/frontend/src/lib/components/details/detailWorkspace.ts @@ -0,0 +1,32 @@ +import { getContext, setContext } from 'svelte' + +const DETAIL_WORKSPACE_KEY = 'detailWorkspace' + +/** + * Context seam letting the script/flow detail pages and the drawers they open + * operate on a workspace other than the globally-active `$workspaceStore`. An AI + * session views an item in its own (possibly forked) workspace WITHOUT switching + * `$workspaceStore`, which stays on whatever the top nav points at, so + * `ScriptDetail`/`FlowDetail` register a resolver here from their `workspace` prop. + * + * Every workspace-scoped backend call inside the detail subtree — ShareModal, + * DeployWorkspace, MoveDrawer, PersistentScriptDrawer, ScriptVersionHistory and the + * saved-input components — reads its workspace via {@link getDetailWorkspace} and + * falls back to `$workspaceStore` when no resolver is set. No resolver is set + * anywhere outside a detail component, so every other call site of those shared + * components is unchanged. Mirrors `triggerWorkspace` and `rawAppWorkspace`. + * + * A getter (not a value) so a live `$derived` is read reactively at each call site. + */ +export function setDetailWorkspace(resolver: () => string | undefined): void { + setContext(DETAIL_WORKSPACE_KEY, resolver) +} + +/** + * The detail-workspace resolver set by an embedding detail component, or `undefined` + * when none is set (fall back to `$workspaceStore`). Read once during component init; + * use in a `$derived`: `const ws = $derived(detailWs?.() ?? $workspaceStore)`. + */ +export function getDetailWorkspace(): (() => string | undefined) | undefined { + return getContext<(() => string | undefined) | undefined>(DETAIL_WORKSPACE_KEY) +} diff --git a/frontend/src/lib/components/details/interceptNav.ts b/frontend/src/lib/components/details/interceptNav.ts new file mode 100644 index 0000000000..9c44b651be --- /dev/null +++ b/frontend/src/lib/components/details/interceptNav.ts @@ -0,0 +1,18 @@ +/** + * Route a link's plain left-click through the detail page's `onNavigate` instead of + * letting the browser follow it. The detail pages keep real `href`s so a reader can + * still middle-click or ⌘-click one into a new tab — those clicks are left alone — + * but a plain click has to reach `onNavigate`, which in an AI session preview moves + * within the panel rather than taking the browser out of the session. + */ +export function interceptNav( + onNavigate: (url: string) => void | Promise, + url: string +): (e?: Event) => void { + return (e?: Event) => { + const m = e as MouseEvent | undefined + if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return + e?.preventDefault() + void onNavigate(url) + } +} diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte new file mode 100644 index 0000000000..f84de14cd0 --- /dev/null +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -0,0 +1,184 @@ + + + +{#key `${workspaceId}/${path}/${version ?? ''}/${reloadKey}`} + {#if notDeployed} + +
+
Not deployed yet
+
+ Nothing is deployed at {path}, so there is no deployed + version to view. Deploy it from the editor to see it here. +
+ +
+ {:else if kind === 'script'} + + {:else if kind === 'flow'} + + {:else} + onNavigate(`/apps_raw/edit/${path}`)} + onLoadState={setLoadState} + /> + {/if} +{/key} diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index bf26605ac2..d361a2f257 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -64,6 +64,55 @@ // any editable item (script/flow/raw app) or a pipeline folder mounts its own // live editor. const slot = $derived(resolvePreviewTab(tab.url)) + // The item this tab is on and the side of it currently addressed. Both sides are + // one tab, so these are read apart from `slot`: the editor keeps rendering from + // `itemKind`/`itemPath` while the URL names the viewer, and vice versa. + const itemKind = $derived( + slot.kind === 'editor' ? slot.editorKind : slot.kind === 'viewer' ? slot.viewerKind : undefined + ) + const itemPath = $derived( + slot.kind === 'editor' || slot.kind === 'viewer' ? slot.path : undefined + ) + const mode = $derived(slot.kind === 'viewer' ? 'view' : 'edit') + // A pinned deployed version, read from the tab URL rather than passed down: the tab + // URL is the single record of where this tab points. + const pinnedVersion = $derived( + slot.kind === 'viewer' + ? (new URL(tab.url, 'http://x').searchParams.get('version') ?? undefined) + : undefined + ) + + // Once a side has been shown it stays mounted and hidden, so flipping back neither + // remounts a whole editor nor refetches the viewer, and neither side loses what was + // typed into it. Moving the tab to a different item resets both — what is mounted + // belongs to the item it was mounted for. + // + // Eviction resets them too. The page's MRU budget counts tabs, not sides, so a tab + // holding both would otherwise come back from an eviction still holding both and cost + // two heavy trees against one slot — the eviction it just went through would have + // reclaimed nothing. + let sidesFor: string | undefined = $state(undefined) + let editVisited = $state(false) + let viewVisited = $state(false) + $effect(() => { + if (!mounted) { + sidesFor = undefined + editVisited = false + viewVisited = false + return + } + const key = itemKind && itemPath ? `${itemKind}:${itemPath}` : undefined + if (key !== sidesFor) { + sidesFor = key + editVisited = false + viewVisited = false + } + if (slot.kind === 'editor') editVisited = true + else if (slot.kind === 'viewer') viewVisited = true + }) + + const sideVisibility = (shown: boolean) => + shown ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none' // Where inside the editor the tab was opened on ("open this flow step in a // session"). Only the in-process editors need it handed over — an iframe tab // loads the URL whole, params included. @@ -81,6 +130,7 @@ ) let frame: HTMLIFrameElement | undefined = $state() + let viewer: { refresh: () => void } | undefined = $state() // Pages whose theme we mirror on live toggles. Regular apps are the only item // route that resolves to an iframe (scripts/flows/raw apps mount live editors) @@ -108,6 +158,12 @@ // each editor view's onDeploy → runtime.syncPreviewWithDeployed. So only the // iframe fallback (a separate page) has to be told to refresh. if (slot.kind === 'editor') return + // The viewer is the exception to that invariant: it reads the deployed item over + // the API, so nothing about a store mutation reaches it and it has to refetch. + if (slot.kind === 'viewer') { + viewer?.refresh() + return + } try { const win = frame?.contentWindow if (!win) return @@ -251,63 +307,96 @@
{/snippet} -{#if slot.kind === 'editor' && mounted && runtime} +{#if itemKind && itemPath && mounted && runtime}
- - {#if slot.editorKind === 'flow'} - {#await import('./FlowEditorView.svelte')} - {@render editorLoading()} - {:then Module} - - {/await} - {:else if slot.editorKind === 'script'} - {#await import('./ScriptEditorView.svelte')} - {@render editorLoading()} - {:then Module} - - {/await} - {:else if slot.editorKind === 'pipeline'} - {#await import('./PipelineEditorView.svelte')} - {@render editorLoading()} - {:then Module} - - {/await} - {:else} - {#await import('./RawAppEditorView.svelte')} - {@render editorLoading()} - {:then Module} - - {/await} + {#if editVisited} +
+ + {#if itemKind === 'flow'} + {#await import('./FlowEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {:else if itemKind === 'script'} + {#await import('./ScriptEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {:else if itemKind === 'pipeline'} + {#await import('./PipelineEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {:else} + {#await import('./RawAppEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
+ {/if} + {#if viewVisited && itemKind !== 'pipeline'} +
+ {#await import('./ItemViewerView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} +
{/if}
{:else if slot.kind === 'artifact' && mounted} diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 8d10dc1a32..063cf7cc04 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -65,18 +65,28 @@ export function stripBase(path: string): string { return p || '/' } -export type PreviewItemRoute = { kind: WorkspaceItemKind; raw_app: boolean; itemPath: string } +/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the + * deployed version. The two are one tab in a session, switched by re-pointing its URL. */ +export type PreviewItemMode = 'edit' | 'view' -// Parse a preview URL/pathname into the workspace item it edits, or null for a +export type PreviewItemRoute = { + kind: WorkspaceItemKind + raw_app: boolean + itemPath: string + mode: PreviewItemMode +} + +// Parse a preview URL/pathname into the workspace item it addresses, or null for a // non-item page (home, runs, …). Shared by the breadcrumb (drill segments) and // `previewRouter`'s tab resolver so both agree on what counts as an item route. export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null { const p = stripBase(fullPath) - const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(?:edit|get)\/(.+)$/) + const m = p.match(/^\/(scripts|flows|apps|apps_raw)\/(edit|get)\/(.+)$/) if (!m) return null - const itemPath = decodeURIComponent(m[2]) - if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath } - if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath } - if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath } - return { kind: 'app', raw_app: false, itemPath } + const itemPath = decodeURIComponent(m[3]) + const mode: PreviewItemMode = m[2] === 'get' ? 'view' : 'edit' + if (m[1] === 'scripts') return { kind: 'script', raw_app: false, itemPath, mode } + if (m[1] === 'flows') return { kind: 'flow', raw_app: false, itemPath, mode } + if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath, mode } + return { kind: 'app', raw_app: false, itemPath, mode } } diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index de11805b9a..c8da6bc097 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { toolReloadEffect, tabsToReload } from './previewReload' +import { toolReloadEffect, tabsToReload, viewerTabsToReload } from './previewReload' import type { SessionPreviewTab } from './sessionState.svelte' describe('toolReloadEffect', () => { @@ -85,3 +85,66 @@ describe('tabsToReload', () => { expect(tabsToReload([navigated], new Set(['/schedules']))).toEqual([navigated]) }) }) + +describe('viewerTabsToReload', () => { + const viewTab: SessionPreviewTab = { + id: 'v', + url: '/scripts/get/f/foo/bar', + loc: '/scripts/get/f/foo/bar' + } + const editTab: SessionPreviewTab = { + id: 'e', + url: '/flows/edit/f/foo/baz', + loc: '/flows/edit/f/foo/baz' + } + const pageTab: SessionPreviewTab = { id: 'p', url: '/schedules', loc: '/schedules' } + + it('returns the view-side tab whose item was deployed', () => { + expect(viewerTabsToReload([viewTab, editTab, pageTab], new Set(['f/foo/bar']))).toEqual([ + viewTab + ]) + }) + + // The editor holds what the user is writing; refetching the deployed version over it + // would discard that, and it self-syncs from the store the chat already mutated. + it('never returns the edit side, even for the item that was deployed', () => { + expect(viewerTabsToReload([editTab], new Set(['f/foo/baz']))).toEqual([]) + }) + + it('leaves a view tab on a different item alone', () => { + expect(viewerTabsToReload([viewTab], new Set(['f/other/item']))).toEqual([]) + }) + + it('is empty when no items were deployed', () => { + expect(viewerTabsToReload([viewTab, editTab], new Set())).toEqual([]) + }) +}) + +describe('toolReloadEffect items', () => { + // A deploy is invisible to a View tab otherwise: it renders the deployed version over + // the API and shares no store with the chat. + it('names the item a deploy or delete changed, for the kinds a tab can view', () => { + for (const type of ['script', 'flow', 'app']) { + expect(toolReloadEffect('deploy_workspace_item', { type, path: 'u/me/x' }).items).toEqual([ + 'u/me/x' + ]) + expect(toolReloadEffect('delete_workspace_item', { type, path: 'u/me/x' }).items).toEqual([ + 'u/me/x' + ]) + } + }) + + it('names no item for a draft-only tool — nothing deployed changed', () => { + expect(toolReloadEffect('write_script', { path: 'u/me/x' }).items).toEqual([]) + expect( + toolReloadEffect('discard_local_draft', { type: 'script', path: 'u/me/x' }).items + ).toEqual([]) + expect(toolReloadEffect('rebase_draft', { type: 'flow', path: 'u/me/x' }).items).toEqual([]) + }) + + it('names no item for types that have a list page instead of a view side', () => { + expect( + toolReloadEffect('deploy_workspace_item', { type: 'schedule', path: 'u/me/s' }).items + ).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 6e9d750a1f..5df0715063 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -1,6 +1,6 @@ import type { SessionPreviewTab } from './sessionState.svelte' import { whereIs } from './sessionPreviewTabs.svelte' -import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' +import { parsePreviewItemRoute, stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' // Which list pages a completed chat tool can change, as base-stripped paths // (e.g. `/schedules`). This allowlist is the single source of truth for "does @@ -15,34 +15,51 @@ import { stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' // no list page we preview lists open drafts. They fall through to NO_RELOAD. // This "live editors self-sync, only list pages reload" invariant is the reason // the callers below and in the sessions page reload nothing for item tabs. -export type ToolReloadEffect = { pages: string[] } -const NO_RELOAD: ToolReloadEffect = { pages: [] } +// +// `items` is the one exception to it. A tab can sit on the VIEW side of an item, +// which renders the DEPLOYED version read over the API — it shares no store with +// the chat, so a tool that changes what is deployed at a path has to name it here +// or that tab keeps showing the previous deployment. Only tools that change the +// deployed item belong in `items`; a draft write does not. +export type ToolReloadEffect = { pages: string[]; items: string[] } +const NO_RELOAD: ToolReloadEffect = { pages: [], items: [] } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { switch (name) { case 'write_schedule': - return { pages: ['/schedules'] } + return { pages: ['/schedules'], items: [] } case 'write_trigger': - return { pages: triggerPages(args?.kind) } + return { pages: triggerPages(args?.kind), items: [] } case 'write_resource': - return { pages: ['/resources'] } + return { pages: ['/resources'], items: [] } case 'write_variable': - return { pages: ['/variables'] } + return { pages: ['/variables'], items: [] } case 'create_folder': - return { pages: ['/folders'] } + return { pages: ['/folders'], items: [] } + // These two change what is deployed at a path, so a tab on that item's View side + // is showing a version that no longer exists. + case 'delete_workspace_item': + case 'deploy_workspace_item': + return { pages: pagesForItemType(args?.type, args), items: deployedItems(args) } // Generic item tools carry a workspace-item `type`; refresh its list page // when it lives on one (schedule/resource/variable/trigger). script/flow/app // have their own live editor tab and no previewed list page → nothing. - case 'delete_workspace_item': case 'discard_local_draft': - case 'deploy_workspace_item': case 'rebase_draft': - return { pages: pagesForItemType(args?.type, args) } + return { pages: pagesForItemType(args?.type, args), items: [] } default: return NO_RELOAD } } +// The item path a deploy/delete changed, when it is one of the kinds a preview tab can +// show the deployed side of. Other types (schedule, resource, …) have list pages instead. +function deployedItems(args: any): string[] { + const type = args?.type + if (type !== 'script' && type !== 'flow' && type !== 'app') return [] + return typeof args?.path === 'string' && args.path ? [args.path] : [] +} + function pagesForItemType(type: unknown, args: any): string[] { switch (type) { case 'schedule': @@ -74,3 +91,17 @@ export function tabsToReload( if (pages.size === 0) return [] return tabs.filter((t) => pages.has(stripBase(whereIs(t)))) } + +// The open tabs a deploy/delete of `items` should refresh: those sitting on the VIEW side +// of one of those paths. The Edit side is excluded on purpose — it is the live editor that +// self-syncs, and reloading it would throw away whatever is being edited. +export function viewerTabsToReload( + tabs: SessionPreviewTab[], + items: ReadonlySet +): SessionPreviewTab[] { + if (items.size === 0) return [] + return tabs.filter((t) => { + const route = parsePreviewItemRoute(whereIs(t)) + return route?.mode === 'view' && items.has(route.itemPath) + }) +} diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index d56c8166dc..44e83d480d 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -193,22 +193,38 @@ describe('parsePreviewItemRoute', () => { expect(parsePreviewItemRoute('/scripts/edit/f/foo/bar')).toEqual({ kind: 'script', raw_app: false, - itemPath: 'f/foo/bar' + itemPath: 'f/foo/bar', + mode: 'edit' }) expect(parsePreviewItemRoute('/flows/get/u/admin/baz')).toEqual({ kind: 'flow', raw_app: false, - itemPath: 'u/admin/baz' + itemPath: 'u/admin/baz', + mode: 'view' }) expect(parsePreviewItemRoute('/apps_raw/edit/f/a/b')).toEqual({ kind: 'app', raw_app: true, - itemPath: 'f/a/b' + itemPath: 'f/a/b', + mode: 'edit' }) expect(parsePreviewItemRoute('/apps/edit/f/a/b')).toEqual({ kind: 'app', raw_app: false, - itemPath: 'f/a/b' + itemPath: 'f/a/b', + mode: 'edit' + }) + }) + + // The two sides are one tab, keyed by the item path: a query — `?version=` pinning an + // old deployed version among them — must not read as part of the path, or the tab + // would count as a different item and stop deduping against itself. + it('reads the side from the segment and ignores the query', () => { + expect(parsePreviewItemRoute('/scripts/get/f/foo/bar?version=42')).toEqual({ + kind: 'script', + raw_app: false, + itemPath: 'f/foo/bar', + mode: 'view' }) }) @@ -287,8 +303,30 @@ describe('resolvePreviewTab', () => { }) }) + // `/get/` used to resolve to the editor, so a tab pointed at a deployed item mounted + // the thing that edits it. The side now comes from the segment, which is what lets one + // tab hold both. + it('routes the /get/ side of each wrappable kind to its viewer', () => { + expect(resolvePreviewTab('/scripts/get/f/foo/bar')).toEqual({ + kind: 'viewer', + viewerKind: 'script', + path: 'f/foo/bar' + }) + expect(resolvePreviewTab('/flows/get/f/foo/bar')).toEqual({ + kind: 'viewer', + viewerKind: 'flow', + path: 'f/foo/bar' + }) + expect(resolvePreviewTab('/apps_raw/get/f/a/b')).toEqual({ + kind: 'viewer', + viewerKind: 'raw_app', + path: 'f/a/b' + }) + }) + it('never routes a regular drag-and-drop app to an editor (no wrapper exists)', () => { expect(resolvePreviewTab('/apps/edit/f/a/b')).toEqual({ kind: 'iframe' }) + expect(resolvePreviewTab('/apps/get/f/a/b')).toEqual({ kind: 'iframe' }) }) it('routes a pipeline folder to the pipeline editor kind', () => { diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 4651e81b54..57a7cb45a2 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -14,6 +14,7 @@ import { WORKSPACE_SETTINGS_PATH, triggerLabelForPath, TRIGGER_PAGES, + type PreviewItemMode, type PreviewItemRoute, type TriggerKind } from './previewPaths' @@ -25,6 +26,7 @@ export { parsePreviewItemRoute, stripBase, TRIGGER_PAGES, + type PreviewItemMode, type PreviewItemRoute, type TriggerKind } @@ -65,7 +67,11 @@ export type ArtifactVersionTarget = number | 'latest' * an iframe URL. */ export type PreviewTarget = | { type: 'page'; href: string; label: string } - | { type: 'item'; item: WorkspaceItem } + // `mode` picks the side of the item to land on; omitted means Edit, so every + // caller that predates the View side keeps opening the editor. `version` pins the + // View side to one deployed version, in the query so the tab's identity stays the + // item's path rather than becoming the version's own hash. + | { type: 'item'; item: WorkspaceItem; mode?: PreviewItemMode; version?: string } | { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget } export type PreviewPage = { label: string; path: string; icon: DrillIcon } @@ -459,17 +465,26 @@ export const artifactKey = (id: string) => `artifact:${id}` export const isArtifactKey = (key: string) => key.startsWith('artifact:') -// How a preview tab should render: as an in-process live editor or an iframe -// fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts -// its per-(kind,path) cell editor; a `/pipeline/` route mounts the -// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path` -// is the folder); everything else (static pages, regular drag-and-drop apps, any -// other route) stays an iframe. +// How a preview tab should render: as an in-process live editor, the deployed +// item's view page, or an iframe fallback. An item of a wrappable kind (script, +// flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer +// on `/get/`; a `/pipeline/` route mounts the data-pipeline graph editor +// (single, shared runtime.pipelineEditorState — `path` is the folder); everything +// else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } + | { kind: 'viewer'; viewerKind: SessionTargetKind; path: string } | { kind: 'artifact'; id: string; version?: number } | { kind: 'iframe' } +/** The item kind a preview slot hosts, for the slots that host one — so callers can + * ask "which item is this tab on" without caring which side of it is showing. */ +export function slotItemKind(slot: PreviewSlot): SessionTargetKind | 'pipeline' | undefined { + if (slot.kind === 'editor') return slot.editorKind + if (slot.kind === 'viewer') return slot.viewerKind + return undefined +} + export function resolvePreviewTab(url: string): PreviewSlot { const artifact = parseArtifactRoute(url) if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version } @@ -479,7 +494,7 @@ export function resolvePreviewTab(url: string): PreviewSlot { } const route = parsePreviewItemRoute(url) if (!route) return { kind: 'iframe' } - const editorKind: SessionTargetKind | undefined = + const itemKind: SessionTargetKind | undefined = route.kind === 'script' ? 'script' : route.kind === 'flow' @@ -487,6 +502,7 @@ export function resolvePreviewTab(url: string): PreviewSlot { : route.kind === 'app' && route.raw_app ? 'raw_app' : undefined - if (!editorKind) return { kind: 'iframe' } - return { kind: 'editor', editorKind, path: route.itemPath } + if (!itemKind) return { kind: 'iframe' } + if (route.mode === 'view') return { kind: 'viewer', viewerKind: itemKind, path: route.itemPath } + return { kind: 'editor', editorKind: itemKind, path: route.itemPath } } diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index f3ca35aab4..dc095cc4f4 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -1,6 +1,6 @@ import { base } from '$lib/base' import { randomUUID } from '$lib/utils/uuid' -import { editPathFor, type WorkspaceItem } from '$lib/components/workspacePicker' +import { editPathFor, viewPathFor, type WorkspaceItem } from '$lib/components/workspacePicker' import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' import { artifactUrl, @@ -49,12 +49,13 @@ export type PreviewTabsAdapter = { onTabOpened?: (url: string) => void } -// True when a tab's URL is the live editor for a specific editable item. Every -// editable route resolves to an editor, so this doubles as the "same item" dedupe -// test in open()/navigate(). -function isEditorTabFor(url: string, target: SessionTarget): boolean { +// True when a tab's URL is on a specific editable item — either side of it. Both +// sides are one tab, so this is the "same item" dedupe test in open()/navigate(): +// re-opening an item the user left on View must focus that tab, not add a second. +function isItemTabFor(url: string, target: SessionTarget): boolean { const slot = resolvePreviewTab(url) - return slot.kind === 'editor' && slot.editorKind === target.kind && slot.path === target.path + if (slot.kind === 'editor') return slot.editorKind === target.kind && slot.path === target.path + return slot.kind === 'viewer' && slot.viewerKind === target.kind && slot.path === target.path } // The version a tab shows when it is re-pointed: the opener's, if it named one, else whatever @@ -80,7 +81,14 @@ function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string { if (target.type === 'artifact') { return artifactUrl(target.id, target.name, keptVersion(target, onto)) } - return `${base}${editPathFor(target.item)}` + // A re-point with no mode keeps the side the tab is already on, so an AI write to + // an item the user left on View does not yank them back to the editor. + const mode = target.mode ?? (onto ? (parsePreviewItemRoute(onto.url)?.mode ?? 'edit') : 'edit') + if (mode !== 'view') return `${base}${editPathFor(target.item)}` + // Not carried over from `onto`: a version pin is something the reader asked for + // once, so every other re-point of this tab lands on the current deployed version. + const pin = target.version ? `?version=${encodeURIComponent(target.version)}` : '' + return `${base}${viewPathFor(target.item)}${pin}` } // Point a tab at a new destination. Clears `friendlyLabel`/`friendlyPath` @@ -390,7 +398,7 @@ export class SessionPreviewTabs { this.#collapsed = false if (editorTarget) { // One editor tab per item: focus the tab already hosting this exact item. - const existing = this.#tabs.find((t) => isEditorTabFor(t.url, editorTarget)) + const existing = this.#tabs.find((t) => isItemTabFor(t.url, editorTarget)) if (existing) { this.#activeId = existing.id this.#flush() @@ -476,7 +484,7 @@ export class SessionPreviewTabs { // Same dedupe as open(): if another tab already hosts this exact item, // focus it instead of re-pointing this one — two tabs for one item would // mount two editors racing the same (kind, path) cell. - const existing = this.#tabs.find((x) => isEditorTabFor(x.url, editorTarget)) + const existing = this.#tabs.find((x) => isItemTabFor(x.url, editorTarget)) if (existing && existing.id !== t.id) { this.#activeId = existing.id this.#flush() @@ -616,7 +624,7 @@ export class SessionPreviewTabs { label: string | undefined, friendlyPath?: string ): void { - const t = this.#tabs.find((x) => isEditorTabFor(x.url, target)) + const t = this.#tabs.find((x) => isItemTabFor(x.url, target)) if (!t) return // Set before the no-change early return: an item with neither a summary nor // a staged path leaves both fields undefined, and the editor still owns it. @@ -723,7 +731,9 @@ export function describePreview( : // Trigger list pages land here (they're outside PREVIEW_PAGES), and // their `#` is the trigger the drawer has open. `${stripBase(where)}${previewLocationDetail(where)}` - const live = resolvePreviewTab(t.url).kind === 'editor' ? ', live editor' : '' + const slotKind = resolvePreviewTab(t.url).kind + const live = + slotKind === 'editor' ? ', live editor' : slotKind === 'viewer' ? ', deployed view' : '' const active = t.id === activeId ? ', active' : '' // One list entry per tab: an artifact's name, a pipeline folder and an item path // all arrive decoded from a URL, so any of them could otherwise write a line here. diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index c49a464962..bf8c49e6cb 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -62,7 +62,8 @@ import { previewLocationContext, previewLocationLabel, promptSafe, - resolvePreviewTab + resolvePreviewTab, + type PreviewSlot } from './previewRouter' import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' import { logFeatureUsage } from '$lib/utils/featureUsage' @@ -166,6 +167,15 @@ export interface RawAppCell { saved: { val: RawAppSavedValue | undefined } } +// `feature_usage` key for a preview tab landing somewhere. The View keys are +// disjoint from the six a tab open already reports, so the existing counters keep +// their meaning and `view_*` answers how much the View side is used at all. +function tabUsageKey(slot: PreviewSlot): string { + if (slot.kind === 'editor') return slot.editorKind + if (slot.kind === 'viewer') return `view_${slot.viewerKind}` + return slot.kind === 'artifact' ? 'artifact' : 'page' +} + export interface SessionRuntime { readonly sessionId: string readonly manager: AIChatManager @@ -188,6 +198,19 @@ export interface SessionRuntime { // exists yet for this (kind, path)), so callers can check load state without // the cell accessors' create-on-miss side effect. loadedEditorPath(kind: SessionTargetKind, path: string): string | undefined + /** Deploy counter for an item, bumped by `syncPreviewWithDeployed`. A mounted + * viewer refetches when this changes — it reads the deployed version from the + * API, so nothing else tells it the editor beside it just published. */ + deployedRevision(kind: SessionTargetKind, path: string): number + /** Whether a deployed version exists at (kind, path); `undefined` until something has + * looked. Read by the View|Edit control to withhold a switch that could only land on + * "not deployed yet". */ + deployedExists(kind: SessionTargetKind, path: string): boolean | undefined + /** Record what a load found, from either side of the item. */ + setDeployedExists(kind: SessionTargetKind, path: string, exists: boolean): void + /** Record that a preview tab landed on `url`. Fires for a newly opened tab and + * for an existing one switched to the other side of its item. */ + logTabUsage(url: string): void loadRawApp( workspace: string, path: string, @@ -452,6 +475,34 @@ function createRuntime(session: Session): SessionRuntime { if (!c) rawAppCells.set(path, (c = makeRawAppCell())) return c } + // Bumped on every deploy of an item, so a mounted viewer — which reads the + // deployed version over the API rather than from the editor cell — knows its copy + // is stale. Keyed by (kind, path) and never pruned: a counter per item the session + // deployed is a few bytes, and dropping one would replay its bump as "unchanged". + const deployedRevisions = $state>({}) + const revisionKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}` + function deployedRevision(kind: SessionTargetKind, path: string): number { + return deployedRevisions[revisionKey(kind, path)] ?? 0 + } + // Whether a deployed version exists at (kind, path), recorded by whichever side found + // out: the editor cell's load carries `no_deployed`, and the viewer's own fetch either + // renders the deployed item or 404s. Kept on the runtime rather than either component + // so the answer outlives them — a tab opened straight onto the View side has no editor + // cell to ask, and the View|Edit control has to know before it can withhold the switch. + const deployedExistsByItem = $state>({}) + function deployedExists(kind: SessionTargetKind, path: string): boolean | undefined { + return deployedExistsByItem[revisionKey(kind, path)] + } + function setDeployedExists(kind: SessionTargetKind, path: string, exists: boolean): void { + deployedExistsByItem[revisionKey(kind, path)] = exists + } + function logTabUsage(url: string): void { + logFeatureUsage('ai_session', 'tab', { + key: tabUsageKey(resolvePreviewTab(url)), + entityId: session.id, + workspace: getEffectiveWorkspaceId(session) + }) + } function loadedEditorPath(kind: SessionTargetKind, path: string): string | undefined { const cell = kind === 'flow' @@ -490,15 +541,7 @@ function createRuntime(session: Session): SessionRuntime { if (snap.previewSize != null) setSessionPreviewSize(session.id, snap.previewSize) }, onTabsChanged: pruneEditorCells, - onTabOpened: (url) => { - const slot = resolvePreviewTab(url) - logFeatureUsage('ai_session', 'tab', { - key: - slot.kind === 'editor' ? slot.editorKind : slot.kind === 'artifact' ? 'artifact' : 'page', - entityId: session.id, - workspace: getEffectiveWorkspaceId(session) - }) - } + onTabOpened: logTabUsage }) // Let the jobs tray open a run in this session's preview panel (as an iframe @@ -545,6 +588,10 @@ function createRuntime(session: Session): SessionRuntime { pipelineEditorState, flowCell, loadedEditorPath, + deployedRevision, + deployedExists, + setDeployedExists, + logTabUsage, async loadFlow(workspace: string, path: string, force = false) { const { slot, store, stateStore, saved } = flowCell(path) @@ -580,8 +627,10 @@ function createRuntime(session: Session): SessionRuntime { try { const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true }) saved.val = result as SavedFlow + setDeployedExists('flow', path, !(result as { no_deployed?: boolean }).no_deployed) } catch { saved.val = undefined + setDeployedExists('flow', path, false) } await initFlow(aiDraft, store, stateStore, workspace) if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId @@ -593,6 +642,7 @@ function createRuntime(session: Session): SessionRuntime { // No local draft yet — seed from `result.draft ?? result`. const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true }) saved.val = result as SavedFlow + setDeployedExists('flow', path, !(result as { no_deployed?: boolean }).no_deployed) const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow // Seed the per-tab last_sync from the server draft's timestamp so the // seeding save below attaches a matching last_sync and the server can @@ -644,8 +694,10 @@ function createRuntime(session: Session): SessionRuntime { try { const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true }) saved.val = result as SavedScript + setDeployedExists('script', path, !(result as { no_deployed?: boolean }).no_deployed) } catch { saved.val = undefined + setDeployedExists('script', path, false) } // Clone before layering the AI draft on top, else we'd mutate // `saved.val` in place and lose the pristine diff baseline. @@ -686,6 +738,7 @@ function createRuntime(session: Session): SessionRuntime { // No local draft yet — seed from `result.draft ?? result`. const result = await ScriptService.getScriptByPath({ workspace, path, getDraft: true }) saved.val = result as SavedScript + setDeployedExists('script', path, !(result as { no_deployed?: boolean }).no_deployed) // Clone before mutating, else `baseline` aliases `result` and // `baseline.parent_hash` corrupts the diff baseline. const baseline = structuredClone( @@ -754,8 +807,10 @@ function createRuntime(session: Session): SessionRuntime { custom_path: result.custom_path, no_deployed: result.no_deployed } + setDeployedExists('raw_app', path, !result.no_deployed) } catch { saved.val = undefined + setDeployedExists('raw_app', path, false) } store.val = applyDraftToRuntimeRawApp( { @@ -790,6 +845,7 @@ function createRuntime(session: Session): SessionRuntime { custom_path: result.custom_path, no_deployed: result.no_deployed } + setDeployedExists('raw_app', path, !result.no_deployed) // Prefer the server draft over the deployed value (mirrors the // flow/script `result.draft ?? result`). A raw-app draft is already // editor-shaped, same keys the extraction below reads. @@ -849,6 +905,9 @@ function createRuntime(session: Session): SessionRuntime { // bracket. Covers all three kinds since they all funnel through this. UserDraft.stopSync(kind, path, { workspace }) UserDraft.discard(kind, path, undefined, { workspace }) + // The single funnel for editor and chat deploys alike, so one bump here + // covers every way this item's deployed version can change from inside. + deployedRevisions[revisionKey(kind, path)] = deployedRevision(kind, path) + 1 if (kind === 'script') void this.loadScript(workspace, path, true) else if (kind === 'flow') void this.loadFlow(workspace, path, true) else void this.loadRawApp(workspace, path, true) diff --git a/frontend/src/lib/components/sessions/sessionSwitch.test.ts b/frontend/src/lib/components/sessions/sessionSwitch.test.ts index 23ca52a693..f03ba418b6 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.test.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.test.ts @@ -259,7 +259,7 @@ describe('takeNewSessionSeed', () => { rememberNavRoute('/flows/edit/u/me/my_flow?workspace=ws') expect(takeNewSessionSeed()).toEqual({ url: '/flows/edit/u/me/my_flow?workspace=ws', - route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow' } + route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow', mode: 'edit' } }) // A dismissed or taken offer is not repeated until the user leaves again. expect(takeNewSessionSeed()).toBeUndefined() @@ -267,7 +267,8 @@ describe('takeNewSessionSeed', () => { expect(takeNewSessionSeed()?.route).toEqual({ kind: 'app', raw_app: true, - itemPath: 'f/team/dashboard' + itemPath: 'f/team/dashboard', + mode: 'edit' }) }) diff --git a/frontend/src/lib/components/workspacePicker.ts b/frontend/src/lib/components/workspacePicker.ts index e88417b5a2..e32e89f5b6 100644 --- a/frontend/src/lib/components/workspacePicker.ts +++ b/frontend/src/lib/components/workspacePicker.ts @@ -48,6 +48,13 @@ export function editPathFor(item: WorkspaceItem): string { return item.raw_app ? `/apps_raw/edit/${item.path}` : `/apps/edit/${item.path}` } +/** The deployed-version page for an item, the `/get/` counterpart of {@link editPathFor}. */ +export function viewPathFor(item: WorkspaceItem): string { + if (item.kind === 'flow') return `/flows/get/${item.path}` + if (item.kind === 'script') return `/scripts/get/${item.path}` + return item.raw_app ? `/apps_raw/get/${item.path}` : `/apps/get/${item.path}` +} + type WorkspaceCache = { flow?: WorkspaceItem[] script?: WorkspaceItem[] diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 441ed52373..77682500e9 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -1,878 +1,19 @@ - - - - { - await goto('/flows/get/' + e.detail + `?workspace=${$workspaceStore}`) - loadFlow() - }} -/> -{#if flow} - -{/if} - - - {#snippet header()} - { - rightPaneSelected = 'triggers' - }} - {mainButtons} - menuItems={getMenuItems(flow, deployUiSettings)} - bind:errorHandlerMuted={ - () => flow?.ws_error_handler_muted ?? false, - (v) => { - if (flow !== undefined) flow.ws_error_handler_muted = v - } - } - scriptOrFlowPath={flow?.path ?? ''} - errorHandlerKind="flow" - tag={flow?.tag ?? ''} - labels={flow?.labels} - inheritedLabels={flow?.inherited_labels} - summary={flow?.summary} - path={flow?.path} - onSaved={can_write - ? async (newPath) => { - if (newPath !== flow?.path) { - await goto(`/flows/get/${newPath}?workspace=${$workspaceStore}`) - } else { - loadFlow() - } - } - : undefined} - > - {#snippet trigger_badges()} - { - rightPaneSelected = 'triggers' - await tick() - triggersState.selectedTriggerIndex = triggerIndex - }} - small={false} - /> - {/snippet} - {#if $workspaceStore && flow} - - {/if} - - {#if flow?.value?.priority != undefined} - - {/if} - {#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined} - - {/if} - - {/snippet} - {#snippet form()} -
- (showEditButtons = v)} /> -
- {#if flow} -
-
-
- {#if flow?.path} - - {/if} - - {#if flow?.archived} - This flow was archived -
- {/if} - - {#if pinnedVersion !== undefined} - - This is a historical version of the flow, not the latest. - - View latest - - -
- {/if} - - {#if !emptyString(flow?.description)} -
- -
-
- {/if} - - {#if deploymentInProgress} -
- - - Deployment in progress - {#if deploymentJobId} - view job - {/if} - -
- {/if} - {#if flow.lock_error_logs && flow.lock_error_logs != ''} - -

- This flow has not been deployed successfully because of the following errors: -

- -
-
- {/if} - - {#if chatInputEnabled} - - - {:else} - {@const hasSchema = - flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0} - -
- {#if hasSchema || inputSelected} -
- { - savedInputsV2?.resetSelected() - }} - {inputSelected} - /> - - {#if hasSchema} - - {/if} -
- {/if} - - {#if flow.schema?.prompt_for_ai !== undefined} - { - goto(`/flows/edit/${flow?.path}`) - }} - runnableType="flow" - path={flow?.path} - /> - {/if} - - -
- -
- - Edited by {flow.edited_by} - -
- {/if} -
-
- {#if !chatInputEnabled} -
- { - if (e.detail) { - stepDetail = e.detail - rightPaneSelected = 'flow_step' - } else { - stepDetail = undefined - rightPaneSelected = 'saved_inputs' - } - }} - on:triggerDetail={(e) => { - rightPaneSelected = 'triggers' - }} - noBorder={true} - /> -
- {/if} -
- {/if} - {/snippet} - {#snippet save_inputs()} - { - const nargs = JSON.parse(JSON.stringify(e.detail)) - args = nargs - if (jsonView) { - runForm?.syncJsonEditor() - } - }} - /> - {/snippet} - - {#snippet flow_step()} - {#if flow} - {#if stepDetail} - - {/if} - {/if} - {/snippet} - {#snippet triggers()} - {#if flow} - - {/if} - {/snippet} - - {#snippet flow_graph()} - {#if flow} -
- { - if (e.detail) { - stepDetail = e.detail - rightPaneSelected = 'flow_step' - } else { - stepDetail = undefined - rightPaneSelected = 'saved_inputs' - } - }} - on:triggerDetail={(e) => { - rightPaneSelected = 'triggers' - }} - /> -
- {/if} - {/snippet} -
- - diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index f0023ddb49..f26b0ce753 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -1,1210 +1,19 @@ - { - await goto('/scripts/get/' + e.detail + `?workspace=${$workspaceStore}`) - loadScript(page.params.hash ?? '') - }} + - - - - - - - - - (versionsDrawerOpen = false)} noPadding> - {#if script} - { - if (script) { - goto(`/scripts/get/${e.detail.version}?workspace=${$workspaceStore}`) - } - versionsDrawerOpen = false - }} - /> - {/if} - - -{#key script?.hash} - - {#snippet header()} - script?.ws_error_handler_muted ?? false, - (v) => { - if (script !== undefined) script.ws_error_handler_muted = v - } - } - errorHandlerKind="script" - scriptOrFlowPath={script?.path ?? ''} - tag={script?.tag ?? ''} - labels={script?.labels} - inheritedLabels={script?.inherited_labels} - on:seeTriggers={() => { - rightPaneSelected = 'triggers' - }} - summary={script?.summary} - path={script?.path} - onSaved={can_write - ? async (newPath) => { - if (newPath !== script?.path) { - await goto(`/scripts/get/${newPath}?workspace=${$workspaceStore}`) - } else { - loadScript(newPath) - } - } - : undefined} - > - {#snippet trigger_badges()} - { - if (rightPaneSelected !== 'triggers') { - rightPaneSelected = 'triggers' - } - await tick() - triggersState.selectedTriggerIndex = triggerIndex - }} - /> - {/snippet} - {#if $workspaceStore && script} - - {/if} - {#if script?.auto_kind === 'wac'} - - {#snippet text()} - Workflow-as-Code - {/snippet} - wac - - {/if} - {#if script?.auto_kind === 'test'} - - {#snippet text()} - CI test script - {/snippet} - CI test - - {/if} - {#if script?.codebase} - bundleThis script is deployed as a bundle and can only be deployed from the CLI for now - {/if} - - {#if script?.priority != undefined} - - {/if} - {#if script?.restart_unless_cancelled ?? false} - - {/if} - {#if script?.concurrent_limit != undefined && script.concurrency_time_window_s != undefined} - - {/if} - - {/snippet} - {#snippet form()} -
- (showEditButtons = v)} /> -
- {#if script} - {@const showsDbtGraph = script.language === 'dbt' && !!script.path} -
-
- {#if script?.path} - - {/if} - -
- {#if script.lock_error_logs || topHash || script.archived || script.deleted} -
- {#if script.lock_error_logs} - -

- This script has not been deployed successfully because of the following - errors: -

- -
- {/if} - {#if topHash} -
- - This hash is not HEAD (latest non-archived version at this path) : - Go to the HEAD of this path - - {/if} - {#if script.archived && !topHash} - This path was archived - {/if} - {#if script.deleted} - -

The content of this script was deleted (by an admin, no less)

-
- {/if} -
- {/if} - - {#if !emptyString(script.description)} -
- -
-
- {/if} -
- - {#if deploymentInProgress} -
- - - Deployment in progress - {#if deploymentJobId} - view job - {/if} - -
- {/if} - -
- {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected} - {@const hasSchema = - script.schema && Object.keys(script.schema.properties ?? {}).length > 0} -
- { - savedInputsV2?.resetSelected() - }} - {inputSelected} - /> - {#if hasSchema} - - {/if} -
- {/if} - - - {#if dbtRetryFrom && (args?.['command'] as any)?.label !== 'retry'} -
- -
- - The run you came from failed part-way. dbt retry - rebuilds only its failed and skipped nodes, with the arguments it ran with. - - -
-
-
- {/if} - - {#if script?.schema?.prompt_for_ai !== undefined} - { - goto(`/scripts/edit/${script?.path}?metadata_open=true`) - }} - runnableType="script" - path={script?.path} - /> - {/if} - - -
- -
- {#if !isHubScript} - - Edited by {script.created_by || - 'unknown'} - - {/if} -
- {#if !isHubScript} - - {truncateHash(script?.hash ?? '')} - - {/if} - {#if script?.is_template} - Template - {/if} - {#if script && script.kind !== 'script'} - - {script?.kind} - - {/if} - - -
-
-
- {#if showsDbtGraph} - -
- -
- {:else if isWac && script.content} -
- -
- {/if} -
- {/if} - {/snippet} - {#snippet save_inputs()} - {#if args} - { - const nargs = JSON.parse(JSON.stringify(e.detail)) - args = nargs - if (jsonView) { - runForm?.syncJsonEditor() - } - }} - /> - {/if} - {/snippet} - {#snippet triggers()} - {#if script} - - {/if} - {/snippet} - {#snippet scriptRender()} -
- - - - - - - {#snippet content()} - {#if script} - -
- -
- {#if script?.modules} - {#each Object.entries(script.modules) as [modulePath, mod]} -
- - {#if expandedModuleCode[modulePath]} -
- -
- {/if} -
- {/each} - {/if} -
- -
- {#if script?.lock} -
-
- {:else} -

- There is no lock file for this script -

- {/if} - {#if script?.modules} - {@const moduleEntries = Object.entries(script.modules).filter( - ([_, m]) => m.lock - )} - {#each moduleEntries as [modulePath, mod]} -
- - {#if expandedModuleLocks[modulePath]} -
-
- {/if} -
- {/each} - {/if} -
-
- -
- - -
-
- {/if} - {/snippet} -
-
- - {#if script?.envs && script.envs.length > 0} -

Static Env Variables

-
    - {#each script?.envs as e} -
  • {e}
  • - {/each} -
- {/if} - {/snippet} -
-{/key} diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index da8fc31c65..b2ec1c466a 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -11,7 +11,9 @@ PanelRightOpen, ChevronDown, MonitorPlay, - Loader2 + Loader2, + Pen, + Eye } from 'lucide-svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' import { Button } from '$lib/components/common' @@ -63,9 +65,17 @@ parseArtifactRoute, parsePreviewItemRoute, previewLocationLabel, + resolvePreviewTab, + type PreviewItemMode, type PreviewTarget } from '$lib/components/sessions/previewRouter' - import { toolReloadEffect, tabsToReload } from '$lib/components/sessions/previewReload' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import { + toolReloadEffect, + tabsToReload, + viewerTabsToReload + } from '$lib/components/sessions/previewReload' import { leafKeyFor, loadKind, @@ -389,9 +399,20 @@ (owner?.tabs ?? []).map((t) => ({ id: t.id, label: tabLabelFor(t, previewWorkspace ?? ''), - title: tabTitleFor(t, previewWorkspace ?? '') + title: tabTitleFor(t, previewWorkspace ?? ''), + icon: sideIconFor(t.url) })) ) + + // Which side of its item a tab is on, as the same pen/eye the View|Edit control uses. + // Only a tab with two sides gets one: a page, artifact or pipeline has nothing to + // distinguish, and marking it would read as a state it can't be in. + function sideIconFor(url: string): typeof Pen | undefined { + const slot = resolvePreviewTab(url) + if (slot.kind === 'viewer') return Eye + if (slot.kind === 'editor' && slot.editorKind !== 'pipeline') return Pen + return undefined + } let newTabOpen = $state(false) // Separate open flag for the empty-state launcher: it can be mounted at the // same time as the tab-strip "+" popover, so sharing one flag would open both @@ -544,27 +565,35 @@ } } - // Reload mounted preview tabs affected by a mutating chat tool. Item and pipeline - // tabs are live editors that self-sync from the store the chat mutates, so nothing - // reloads them. Only list-page tabs (schedules, resources, …) are iframes, and each - // reloads only when a tool actually changed *its* page (toolReloadEffect) — so a - // schedule write leaves the Resources tab alone, and a purely local tool (saving - // user instructions) reloads nothing. + // Reload mounted preview tabs affected by a mutating chat tool. Editor and pipeline + // tabs self-sync from the store the chat mutates, so nothing reloads them. Two kinds + // do need telling, and each only when a tool actually changed what IT shows + // (toolReloadEffect): list-page tabs (schedules, resources, …), which are iframes, and + // tabs on an item's View side, which render the deployed version over the API. So a + // schedule write leaves the Resources tab alone, and a purely local tool (saving user + // instructions) reloads nothing. const tabHosts: Record = {} let reloadHandle: ReturnType | undefined // Base-stripped list-page paths (e.g. `/schedules`) a chat round touched since // the last flush — see toolReloadEffect for how tools map to pages. let pendingPages = new Set() + // Item paths whose DEPLOYED version a chat round changed. Only the View side of a tab + // reads that version, so these refresh those tabs and leave the editors alone. + let pendingItems = new Set() // Reload the mounted list-page tabs a chat round changed, across all warm // sessions (a hidden preview would otherwise show pre-mutation content on // return). tabsToReload picks only the tabs whose page is in `pages`. - function reloadTabs(pages: Set) { + function reloadTabs(pages: Set, items: Set) { for (const s of warmSessions) { const owner = getRuntime(s.id)?.previewTabs if (!owner) continue - for (const tab of tabsToReload(owner.tabs, pages)) { + const affected = [ + ...tabsToReload(owner.tabs, pages), + ...viewerTabsToReload(owner.tabs, items) + ] + for (const tab of affected) { const key = tabKey(s.id, tab.id) if (mountedTabKeys.has(key)) tabHosts[key]?.reload() } @@ -572,21 +601,25 @@ } function flushReload() { const pages = pendingPages + const items = pendingItems pendingPages = new Set() - reloadTabs(pages) + pendingItems = new Set() + reloadTabs(pages, items) } $effect(() => { // Debounced so a burst of writes (the AI editing several files) reloads once. setToolCompletionListener((name, args) => { - const { pages } = toolReloadEffect(name, args) - if (pages.length === 0) return + const { pages, items } = toolReloadEffect(name, args) + if (pages.length === 0 && items.length === 0) return for (const p of pages) pendingPages.add(p) + for (const i of items) pendingItems.add(i) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) }) return () => { clearTimeout(reloadHandle) pendingPages = new Set() + pendingItems = new Set() setToolCompletionListener(undefined) } }) @@ -611,6 +644,68 @@ // to the plain path. const parsedRoute = $derived(parsePreviewItemRoute(displayPath)) + // The View|Edit control acts on the active tab by re-pointing its URL. Only an item + // with two sides gets one: pages, artifacts, pipelines and legacy drag-and-drop apps + // have a single side. Read from the tab's `url` rather than the observed `loc` — an + // in-realm side reports no location of its own, so `loc` can lag a flip. + const activeSide = $derived.by(() => { + const url = owner?.activeTab?.url + const slot = url ? resolvePreviewTab(url) : undefined + if (!slot) return undefined + if (slot.kind === 'viewer') { + return { kind: slot.viewerKind, path: slot.path, mode: 'view' as PreviewItemMode } + } + if (slot.kind === 'editor' && slot.editorKind !== 'pipeline') { + return { kind: slot.editorKind, path: slot.path, mode: 'edit' as PreviewItemMode } + } + return undefined + }) + + // Nothing to view until the item has been deployed once. This only ever hides the + // switch when the editor cell has actually been told so by the backend; when it knows + // nothing the switch stays open and the viewer says what it found, which is the case + // that has to work — a tab can be opened, or restored, straight onto the View side + // with no editor ever mounted. + const viewDisabledReason = $derived.by(() => { + const side = activeSide + const rt = activeRuntime + if (!side || !rt) return undefined + // Through the cell accessor, not `loadedEditorPath`: that one peeks into a plain + // Map, so this would read "no cell yet" once and never re-run when the load lands. + // The accessor creates the cell on a miss, which costs nothing here — a tab is open + // on this item, so its Edit side owns a cell either way. + const cell = + side.kind === 'flow' + ? rt.flowCell(side.path) + : side.kind === 'script' + ? rt.scriptCell(side.path) + : rt.rawAppCell(side.path) + // Two ways to know, because either side can be the one that looked: the editor + // cell's `no_deployed`, and what the viewer's own fetch found. An absent baseline is + // NOT a third: the cell is created empty and `SessionEditorTarget` clears + // `loadedPath` on every unmount, so "this cell knows nothing" is a state a deployed + // item reaches too — reading that as "not deployed" offered View on drafts and + // withheld it from deployed items, by whether the Edit side happened to be loaded. + const undeployed = + cell.saved.val?.no_deployed === true || rt.deployedExists(side.kind, side.path) === false + return undeployed ? 'Not deployed yet' : undefined + }) + + function switchSide(mode: PreviewItemMode) { + const side = activeSide + if (!side || side.mode === mode) return + const item: WorkspaceItem = { + path: side.path, + summary: '', + kind: side.kind === 'raw_app' ? 'app' : side.kind, + raw_app: side.kind === 'raw_app' + } + owner?.navigate({ type: 'item', item, mode }) + // Counted per switch onto the View side, under keys disjoint from the tab-open + // ones, so `entity_count` answers how many sessions ever used it. + if (mode === 'view') activeRuntime?.logTabUsage(owner?.activeTab?.url ?? '') + } + // Split the item path into breadcrumb dirs + leaf, mirroring EditorHeader: // scope (`f/` | `u/`) → subfolders → item name. Prefers the // tab's friendly path (a draft-only item's typed name): the picker tree @@ -664,6 +759,9 @@ : undefined ) let activeTabPickerOpen = $state(false) + /** Measured width of the floating action controls, reserved as the tab strip's right + * padding so tabs stop before them instead of scrolling underneath. */ + let previewActionsWidth = $state(0) // Breadcrumb picks steer the *active* tab; the "+" picker opens new ones. An // editable item also becomes the session's live editor (owner.navigate). @@ -909,10 +1007,16 @@ class="flex flex-col min-h-0" >
+
{#if !fullscreen} -
+ +
+ {#if activeSide} + +
+ switchSide(detail as PreviewItemMode)} + > + {#snippet children({ item })} + + + {/snippet} + +
+ {/if} {#if !activeTabIsArtifact} {#snippet tabAccessory(_tab, isActive)} {#if isActive}