mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 08:03:02 +00:00
feat: add a view side to ai session preview tabs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoC4MCs3BhVV1DyevrVc7N
This commit is contained in:
co-authored by
Claude Opus 5
parent
670404ffe2
commit
d656455103
@@ -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<string, Runnable> }
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Kind>('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 @@
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
|
||||
{#each attachedSummary as { label, count } (label)}
|
||||
<span class="text-xs"
|
||||
><span class="font-mono font-semibold">{count}</span> {label}{count === 1
|
||||
? ''
|
||||
: 's'}</span
|
||||
><span class="font-mono font-semibold">{count}</span>
|
||||
{label}{count === 1 ? '' : 's'}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<td class="text-xs">
|
||||
<a
|
||||
class="pr-3"
|
||||
href="{base}/scripts/get/{scriptHash}?workspace={$workspaceStore}"
|
||||
href="{base}/scripts/get/{scriptHash}?workspace={ws}"
|
||||
target="_blank"
|
||||
>
|
||||
{scriptHash}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<a
|
||||
class="pr-3"
|
||||
href="{base}/run/{jobId}?workspace={$workspaceStore}"
|
||||
target="_blank">{jobId.substring(24)}</a
|
||||
<a class="pr-3" href="{base}/run/{jobId}?workspace={ws}" target="_blank"
|
||||
>{jobId.substring(24)}</a
|
||||
>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
|
||||
@@ -121,6 +121,11 @@
|
||||
args?: Record<string, any>
|
||||
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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void> {
|
||||
groups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
|
||||
groups = await GroupService.listGroupNames({ workspace: ws! })
|
||||
}
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
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 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-emphasis"
|
||||
>Extra members ({acls?.length ?? 0})</span
|
||||
>
|
||||
<span class="text-sm font-semibold text-emphasis">Extra members ({acls?.length ?? 0})</span>
|
||||
{#if linkedVarPaths.length > 0}
|
||||
<div class="flex flex-col gap-1.5 p-3 border rounded bg-surface-secondary text-xs">
|
||||
<Toggle
|
||||
|
||||
@@ -23,12 +23,21 @@
|
||||
let {
|
||||
workspace,
|
||||
path,
|
||||
editHref
|
||||
editHref,
|
||||
onEdit,
|
||||
onLoadState
|
||||
}: {
|
||||
workspace: string
|
||||
path: string
|
||||
/** Where the Edit button points (low-code vs raw editor). */
|
||||
editHref: string
|
||||
/** Handle Edit in place instead of following `editHref`. An AI session shows
|
||||
* this viewer inside a preview tab, where a plain link would navigate the whole
|
||||
* page out of the session rather than flipping the tab to its editor. */
|
||||
onEdit?: () => 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}
|
||||
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
|
||||
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
startIcon={{ icon: Pen }}
|
||||
variant="subtle"
|
||||
href={onEdit ? undefined : editHref}
|
||||
on:click={() => onEdit?.()}>Edit</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
WorkspaceService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
type TriggersCount,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
defaultIfEmptyString,
|
||||
emptyString,
|
||||
urlParamsToObject,
|
||||
extractTagFromSharableHash,
|
||||
interpolateTag,
|
||||
isDynamicTag,
|
||||
isTagTemplate
|
||||
} from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
|
||||
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
|
||||
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
|
||||
import {
|
||||
FolderOpen,
|
||||
Archive,
|
||||
Trash,
|
||||
ChevronUpSquare,
|
||||
Shield,
|
||||
Loader2,
|
||||
GitFork,
|
||||
Play,
|
||||
History,
|
||||
Pen,
|
||||
Eye,
|
||||
HistoryIcon,
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import Star from '$lib/components/Star.svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import { setContext } from 'svelte'
|
||||
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
import FlowAssetsHandler, {
|
||||
initFlowGraphAssetsCtx
|
||||
} from '$lib/components/flows/FlowAssetsHandler.svelte'
|
||||
import { setDetailWorkspace } from '$lib/components/details/detailWorkspace'
|
||||
import { interceptNav } from '$lib/components/details/interceptNav'
|
||||
import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CiTestResults from '$lib/components/CiTestResults.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkLabel,
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { agentStreamingEnabled } from '$lib/components/flows/agentFormFields'
|
||||
|
||||
let {
|
||||
path: routePath,
|
||||
workspace,
|
||||
searchParams,
|
||||
locationHash = '',
|
||||
onNavigate = goto,
|
||||
active = true,
|
||||
syncArgsToUrl = true,
|
||||
onLoadState
|
||||
}: {
|
||||
/** The `[...path]` route segment: the flow's path. */
|
||||
path: string
|
||||
workspace: string | undefined
|
||||
/** Query of the URL this detail was opened at (`version`). */
|
||||
searchParams?: URLSearchParams
|
||||
/** `#`-fragment of that URL, carrying prefilled run args from `Run again`. */
|
||||
locationHash?: string
|
||||
onNavigate?: (url: string) => void | Promise<void>
|
||||
/** Gates the window-level Ctrl+Enter handler, so a hidden instance cannot steal it. */
|
||||
active?: boolean
|
||||
/** Whether the run form may mirror its args into the page URL's fragment. False
|
||||
* wherever this is embedded in a page that is not the flow's own. */
|
||||
syncArgsToUrl?: boolean
|
||||
/** How the load ended, for a host that renders its own state around this page. */
|
||||
onLoadState?: (state: 'loaded' | 'not_found') => void
|
||||
} = $props()
|
||||
|
||||
// Every workspace-scoped call below and in the drawers this page opens targets
|
||||
// `workspace`, not the nav store: a session views a flow in its own workspace.
|
||||
setDetailWorkspace(() => workspace)
|
||||
setTriggerWorkspace(() => workspace)
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
let can_write = $state(false)
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
|
||||
let scheduledForStr: string | undefined = $state(undefined)
|
||||
let invisible_to_owner: boolean | undefined = $state(undefined)
|
||||
let overrideTag: string | undefined = $state(undefined)
|
||||
let overrideTagNote: string | undefined = $state(undefined)
|
||||
// Tag carried over from 'Run again', pending the dynamic-tag check in loadFlow
|
||||
let carriedTag: string | undefined = undefined
|
||||
let inputSelected: 'saved' | 'history' | undefined = $state(undefined)
|
||||
let jsonView = $state(false)
|
||||
let deploymentInProgress = $state(false)
|
||||
let deploymentJobId: string | undefined = $state(undefined)
|
||||
|
||||
let intervalId: number | undefined = undefined
|
||||
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = $state(
|
||||
new Triggers([
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
{ type: 'cli', path: '', isDraft: false }
|
||||
])
|
||||
)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState
|
||||
})
|
||||
|
||||
setContext(
|
||||
'FlowGraphAssetContext',
|
||||
initFlowGraphAssetsCtx({ getModules: () => flow?.value.modules ?? [] })
|
||||
)
|
||||
|
||||
// `${path}|${version}` so navigating between pinned and latest re-runs loadFlow.
|
||||
let previousLoadKey: string | undefined = $state(undefined)
|
||||
|
||||
async function archiveFlow(): Promise<void> {
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
requestBody: { archived: !flow?.archived }
|
||||
})
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
async function deleteFlow(): Promise<void> {
|
||||
await FlowService.deleteFlowByPath({ workspace: workspace!, path })
|
||||
sendUserToast('Flow deleted')
|
||||
onNavigate('/')
|
||||
}
|
||||
|
||||
async function loadTriggersCount() {
|
||||
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
||||
workspace: workspace!,
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
await triggersState.fetchTriggers(triggersCount, workspace, path, true, undefined, $userStore)
|
||||
}
|
||||
|
||||
let pinnedVersion: number | undefined = $state(undefined)
|
||||
|
||||
async function loadFlow(): Promise<void> {
|
||||
const versionParam = searchParams?.get('version')
|
||||
const versionId = versionParam ? Number(versionParam) : NaN
|
||||
if (Number.isFinite(versionId)) {
|
||||
const versioned = await FlowService.getFlowVersion({
|
||||
workspace: workspace!,
|
||||
version: versionId
|
||||
})
|
||||
if (versioned.path !== path) {
|
||||
sendUserToast(`Flow version ${versionId} belongs to ${versioned.path}, not ${path}.`, true)
|
||||
onNavigate(`/flows/get/${versioned.path}?workspace=${workspace}&version=${versionId}`)
|
||||
return
|
||||
}
|
||||
flow = versioned
|
||||
pinnedVersion = versionId
|
||||
} else {
|
||||
flow = await FlowService.getFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
withStarredInfo: true
|
||||
})
|
||||
pinnedVersion = undefined
|
||||
}
|
||||
// A carried non-template value equal to the resolution of the flow's own dynamic
|
||||
// tag for these args is not an override but the previous run's pinned resolution:
|
||||
// drop it so the backend re-resolves from the (possibly edited) args. A differing
|
||||
// value is a genuine user override and a template re-resolves at push, so both stay.
|
||||
if (
|
||||
carriedTag &&
|
||||
isDynamicTag(flow.tag) &&
|
||||
!isTagTemplate(carriedTag) &&
|
||||
interpolateTag(flow.tag ?? '', workspace!, args) === carriedTag
|
||||
) {
|
||||
if (overrideTag === carriedTag) {
|
||||
overrideTag = undefined
|
||||
overrideTagNote = `tag ${flow.tag} is resolved at run time, so the previous run's tag ${carriedTag} was not applied`
|
||||
}
|
||||
carriedTag = undefined
|
||||
}
|
||||
if (!flow.path.startsWith(`u/${$userStore?.username}`) && flow.path.split('/').length > 2) {
|
||||
invisible_to_owner = flow.visible_to_runner_only
|
||||
}
|
||||
intervalId && clearInterval(intervalId)
|
||||
deploymentInProgress = flow.lock_error_logs == ''
|
||||
if (deploymentInProgress) {
|
||||
intervalId = setInterval(syncer, 500)
|
||||
}
|
||||
can_write = canWrite(flow.path, flow.extra_perms!, $userStore)
|
||||
}
|
||||
|
||||
let isValid = $state(true)
|
||||
let loading = $state(false)
|
||||
|
||||
async function syncer(): Promise<void> {
|
||||
if (flow) {
|
||||
const status = await FlowService.getFlowDeploymentStatus({
|
||||
workspace: workspace!,
|
||||
path: flow.path
|
||||
})
|
||||
if (status.lock_error_logs == undefined || status.lock_error_logs != '') {
|
||||
deploymentInProgress = false
|
||||
deploymentJobId = undefined
|
||||
flow.lock_error_logs = status.lock_error_logs
|
||||
clearInterval(intervalId)
|
||||
} else if (status.job_id) {
|
||||
deploymentJobId = status.job_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlow(
|
||||
scheduledForStr: string | undefined,
|
||||
args: Record<string, any>,
|
||||
invisibleToOwner: boolean | undefined,
|
||||
overrideTag: string | undefined
|
||||
) {
|
||||
loading = true
|
||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||
try {
|
||||
let run = await JobService.runFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
invisibleToOwner,
|
||||
requestBody: args,
|
||||
scheduledFor,
|
||||
tag: overrideTag,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
await onNavigate('/run/' + run + '?workspace=' + workspace)
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlowForChat(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const run = await JobService.runFlowByPath({
|
||||
workspace: workspace!,
|
||||
path,
|
||||
memoryId: conversationId,
|
||||
requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
skipPreprocessor: true
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
let args: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
// Read once on purpose: these args seed the form, so tracking the fragment would
|
||||
// overwrite what the user has typed whenever it changes.
|
||||
let hash = untrack(() => locationHash)
|
||||
if (hash.length > 1) {
|
||||
try {
|
||||
let searchParams = new URLSearchParams(hash.slice(1))
|
||||
carriedTag = extractTagFromSharableHash(searchParams)
|
||||
overrideTag = carriedTag
|
||||
let params = [...Object.entries(urlParamsToObject(searchParams))].map(([k, v]) => [
|
||||
k,
|
||||
JSON.parse(v)
|
||||
])
|
||||
args = Object.fromEntries(params)
|
||||
} catch (e) {
|
||||
console.error('Was not able to transform hash as args', e)
|
||||
}
|
||||
}
|
||||
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let runForm: RunForm | undefined = $state()
|
||||
|
||||
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
|
||||
const buttons: any = []
|
||||
|
||||
if (flow && !$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
buttonProps: {
|
||||
href: `${base}/flows/add?template=${flow.path}`,
|
||||
onClick: interceptNav(onNavigate, `/flows/add?template=${flow.path}`),
|
||||
variant: 'subtle',
|
||||
unifiedSize: 'md',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
flow &&
|
||||
!$userStore?.operator &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed(workspace, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel(workspace, $userWorkspaces),
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path),
|
||||
onClick: (e: Event | undefined) =>
|
||||
onEditInForkClick(e, 'flow', flow.path, { hasHref: true }),
|
||||
unifiedSize: 'md',
|
||||
variant: !showEditButtons ? 'default' : 'subtle',
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: `Runs`,
|
||||
buttonProps: {
|
||||
href: `${base}/runs/${flow.path}`,
|
||||
onClick: interceptNav(onNavigate, `/runs/${flow.path}`),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: Play
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: History
|
||||
}
|
||||
})
|
||||
|
||||
if (!flow || $userStore?.operator || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
if (!$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
// /apps_raw/add hard-reloads (cross-origin isolation), so the
|
||||
// in-memory importStore would be dropped; hand off via sessionStorage.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await onNavigate('/apps_raw/add')
|
||||
},
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: LayoutDashboard
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: 'Edit',
|
||||
buttonProps: {
|
||||
href: `${base}/flows/edit/${path}`,
|
||||
onClick: interceptNav(onNavigate, `/flows/edit/${path}`),
|
||||
variant: 'accent',
|
||||
unifiedSize: 'md',
|
||||
disabled: !can_write || !showEditButtons,
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: workspace! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
function getMenuItems(
|
||||
flow: Flow | undefined,
|
||||
deployUiSettings: WorkspaceDeployUISettings | undefined
|
||||
) {
|
||||
if (!flow || $userStore?.operator) return []
|
||||
|
||||
const menuItems: any = []
|
||||
|
||||
menuItems.push({
|
||||
label: 'Permissions',
|
||||
onclick: () => shareModal?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: Shield,
|
||||
disabled: !can_write
|
||||
})
|
||||
|
||||
if (showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
onclick: () => moveDrawer?.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
|
||||
Icon: FolderOpen
|
||||
})
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: 'Audit logs',
|
||||
Icon: Eye,
|
||||
onclick: () => {
|
||||
onNavigate(`/audit_logs?resource=${flow?.path}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (isDeployable('flow', flow?.path ?? '', deployUiSettings)) {
|
||||
menuItems.push({
|
||||
label: 'Deploy to staging/prod',
|
||||
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: ChevronUpSquare
|
||||
})
|
||||
}
|
||||
|
||||
if (can_write && showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Deployments',
|
||||
onclick: () => flowHistory?.open(),
|
||||
Icon: HistoryIcon
|
||||
})
|
||||
menuItems.push({
|
||||
label: flow.archived ? 'Unarchive' : 'Archive',
|
||||
onclick: () => flow?.path && archiveFlow(),
|
||||
Icon: Archive,
|
||||
color: 'red'
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Delete',
|
||||
onclick: () => flow?.path && deleteFlow(),
|
||||
Icon: Trash,
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
return menuItems
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
intervalId && clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
// Several instances can be mounted at once (a session keeps hidden tabs
|
||||
// mounted), and each one listens on the window: only the visible one runs.
|
||||
if (!active) return
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm?.run()
|
||||
} else {
|
||||
sendUserToast('Please fix errors before running', true)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
||||
let rightPaneSelected = $state('saved_inputs')
|
||||
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
let path = $derived(routePath ?? '')
|
||||
|
||||
let topSectionHeight = $state(0)
|
||||
let paneHeight = $state(0)
|
||||
let flowGraphHeight = $state(0)
|
||||
|
||||
let graphMinHeight = $derived.by(() => {
|
||||
if (!topSectionHeight || !paneHeight) return 400
|
||||
const availableHeight = paneHeight - topSectionHeight - 1 // Account for the separator between the top section and the graph
|
||||
return Math.max(400, availableHeight)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
|
||||
if (cliTrigger) {
|
||||
cliTrigger.extra = {
|
||||
cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
|
||||
}
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
if (workspace && $userStore && routePath) {
|
||||
const versionParam = searchParams?.get('version') ?? ''
|
||||
const loadKey = `${workspace}|${path}|${versionParam}`
|
||||
if (previousLoadKey !== loadKey) {
|
||||
previousLoadKey = loadKey
|
||||
untrack(() => {
|
||||
// A path with no deployed flow rejects here, which is the normal state of a
|
||||
// draft the user has not deployed — reported so a host can say so in place
|
||||
// rather than leaving the page blank on an unhandled rejection.
|
||||
loadFlow().then(
|
||||
() => onLoadState?.('loaded'),
|
||||
(e) => {
|
||||
onLoadState?.('not_found')
|
||||
if (!onLoadState) console.error('Failed to load flow', e)
|
||||
}
|
||||
)
|
||||
loadTriggersCount()
|
||||
loadTriggers()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
let showEditButtons = $state(false)
|
||||
let mainButtons = $derived(getMainButtons(flow, args))
|
||||
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flow?.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
if (lastModule?.value?.type !== 'aiagent') return false
|
||||
return agentStreamingEnabled(lastModule.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<MoveDrawer
|
||||
bind:this={moveDrawer}
|
||||
on:update={async (e) => {
|
||||
await onNavigate('/flows/get/' + e.detail + `?workspace=${workspace}`)
|
||||
loadFlow()
|
||||
}}
|
||||
/>
|
||||
{#if flow}
|
||||
<FlowHistory bind:this={flowHistory} path={flow.path} onHistoryRestore={loadFlow} />
|
||||
{/if}
|
||||
|
||||
<DetailPageLayout
|
||||
bind:selected={rightPaneSelected}
|
||||
isOperator={$userStore?.operator}
|
||||
forceSmallScreen={chatInputEnabled}
|
||||
isChatMode={chatInputEnabled}
|
||||
flow_json={{
|
||||
value: flow?.value,
|
||||
summary: flow?.summary,
|
||||
description: flow?.description,
|
||||
schema: flow?.schema
|
||||
}}
|
||||
>
|
||||
{#snippet header()}
|
||||
<DetailPageHeader
|
||||
on:seeTriggers={() => {
|
||||
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()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
showDraft={false}
|
||||
{path}
|
||||
newItem={false}
|
||||
isFlow
|
||||
selected={rightPaneSelected == 'triggers'}
|
||||
onSelect={async (triggerIndex: number) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = triggerIndex
|
||||
}}
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if workspace && flow}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
kind="flow"
|
||||
/>
|
||||
{#if flow?.value?.priority != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="blue" variant="outlined" size="xs">
|
||||
{`Priority: ${flow?.value?.priority}`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="gray" variant="outlined" size="xs">
|
||||
{`Concurrency limit: ${flow?.value?.concurrent_limit} runs every ${flow?.value?.concurrency_time_window_s}s`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailPageHeader>
|
||||
{/snippet}
|
||||
{#snippet form()}
|
||||
<div class="px-3">
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
|
||||
</div>
|
||||
{#if flow}
|
||||
<div class="flex flex-col h-full bg-surface divide-y" bind:clientHeight={paneHeight}>
|
||||
<div bind:clientHeight={topSectionHeight} class={twMerge(chatInputEnabled ? 'h-full' : '')}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full flex flex-col',
|
||||
chatInputEnabled ? 'p-3 h-full' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
'mx-auto'
|
||||
)}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a
|
||||
class="underline"
|
||||
href="/flows/get/{path}?workspace={workspace}"
|
||||
onclick={interceptNav(onNavigate, `/flows/get/${path}`)}
|
||||
>
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={workspace}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if chatInputEnabled}
|
||||
<!-- Chat Layout with Sidebar -->
|
||||
<FlowChat
|
||||
onRunFlow={runFlowForChat}
|
||||
{deploymentInProgress}
|
||||
path={flow?.path ?? ''}
|
||||
useStreaming={shouldUseStreaming}
|
||||
inputSchema={flow?.schema}
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
<!-- Normal Mode: Form Layout -->
|
||||
<div class="flex flex-col align-left">
|
||||
{#if hasSchema || inputSelected}
|
||||
<div
|
||||
class="flex flex-row justify-between min-h-12"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<InputSelectedBadge
|
||||
onReject={() => {
|
||||
savedInputsV2?.resetSelected()
|
||||
}}
|
||||
{inputSelected}
|
||||
/>
|
||||
|
||||
{#if hasSchema}
|
||||
<Toggle
|
||||
bind:checked={jsonView}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'JSON',
|
||||
rightTooltip: 'Fill args from JSON'
|
||||
}}
|
||||
lightMode
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.schema?.prompt_for_ai !== undefined}
|
||||
<AIFormAssistant
|
||||
instructions={flow.schema?.prompt_for_ai as string}
|
||||
onEditInstructions={() => {
|
||||
onNavigate(`/flows/edit/${flow?.path}`)
|
||||
}}
|
||||
runnableType="flow"
|
||||
path={flow?.path}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RunForm
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
{overrideTagNote}
|
||||
{syncArgsToUrl}
|
||||
viewKeybinding
|
||||
{loading}
|
||||
autofocus
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={flow}
|
||||
runAction={runFlow}
|
||||
bind:args
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex flex-col gap-1 w-full items-end">
|
||||
<span class="text-2xs text-secondary">
|
||||
Edited <TimeAgo date={flow.edited_at ?? ''} noSeconds /> by {flow.edited_by}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if !chatInputEnabled}
|
||||
<div class="grow min-h-0">
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
minHeight={graphMinHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
}}
|
||||
noBorder={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet save_inputs()}
|
||||
<SavedInputsV2
|
||||
bind:this={savedInputsV2}
|
||||
schema={flow?.schema}
|
||||
{jsonView}
|
||||
flowPath={flow?.path}
|
||||
{isValid}
|
||||
args={args ?? {}}
|
||||
bind:inputSelected
|
||||
on:selected_args={(e) => {
|
||||
const nargs = JSON.parse(JSON.stringify(e.detail))
|
||||
args = nargs
|
||||
if (jsonView) {
|
||||
runForm?.syncJsonEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_step()}
|
||||
{#if flow}
|
||||
{#if stepDetail}
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet triggers()}
|
||||
{#if flow}
|
||||
<TriggersEditor
|
||||
{args}
|
||||
runnableVersion={flow.version_id?.toString()}
|
||||
initialPath={flow.path}
|
||||
currentPath={flow.path}
|
||||
noEditor={true}
|
||||
newItem={false}
|
||||
isFlow={true}
|
||||
schema={flow.schema}
|
||||
isDeployed={true}
|
||||
noCapture={true}
|
||||
isEditor={false}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_graph()}
|
||||
{#if flow}
|
||||
<div class="h-full overflow-auto" bind:clientHeight={flowGraphHeight}>
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={false}
|
||||
noBorder
|
||||
minHeight={flowGraphHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageLayout>
|
||||
|
||||
<FlowAssetsHandler
|
||||
modules={flow?.value.modules ?? []}
|
||||
enableDbExplore
|
||||
enablePathScriptAndFlowAssets
|
||||
/>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<void>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The View side of a session preview tab: the deployed item, rendered by the same
|
||||
* components the workspace `/scripts/get`, `/flows/get` and `/apps_raw/get` pages
|
||||
* render. Everything those pages reach — drawers, triggers, saved inputs — is scoped
|
||||
* to the session's workspace through `workspace`, and every navigation they attempt
|
||||
* is caught here and turned into a move inside the preview panel, so nothing takes
|
||||
* the browser out of the session.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import { base } from '$lib/base'
|
||||
import ScriptDetail from '$lib/components/details/ScriptDetail.svelte'
|
||||
import FlowDetail from '$lib/components/details/FlowDetail.svelte'
|
||||
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
import type { SessionTargetKind } from './sessionRuntime.svelte'
|
||||
import { parsePreviewItemRoute, previewLocationLabel, stripBase } from './previewRouter'
|
||||
|
||||
let {
|
||||
runtime,
|
||||
kind,
|
||||
path,
|
||||
version,
|
||||
workspaceId,
|
||||
active = true
|
||||
}: {
|
||||
runtime: SessionRuntime
|
||||
kind: SessionTargetKind
|
||||
path: string
|
||||
/** Deployed version this tab is pinned to, from its URL's `?version=`. */
|
||||
version?: string
|
||||
workspaceId: string
|
||||
/** Whether this side is the one on screen. Hidden sides stay mounted, so this gates
|
||||
* the window-level keyboard handler — several instances listen on the window. */
|
||||
active?: boolean
|
||||
} = $props()
|
||||
|
||||
// This reads the deployed version over the API rather than from the editor cell the
|
||||
// chat mutates, so it is the one preview kind that does not self-sync (the invariant
|
||||
// `previewReload.ts` states for editors). A deploy from the editor topbar beside it
|
||||
// bumps this counter through `syncPreviewWithDeployed`; a deploy from the chat arrives
|
||||
// instead as `refresh()`, via `previewReload`'s item list. Neither covers a deploy made
|
||||
// outside the session (Compare & Deploy, the CLI, another browser tab).
|
||||
const revision = $derived(runtime.deployedRevision(kind, path))
|
||||
let reloadKey = $state(0)
|
||||
// Seeded from the current value on purpose: before the first run this holds "no deploy
|
||||
// seen yet", so a fresh mount must not count as one.
|
||||
let lastRevision = untrack(() => revision)
|
||||
$effect(() => {
|
||||
const rev = revision
|
||||
if (rev === lastRevision) return
|
||||
lastRevision = rev
|
||||
reloadKey++
|
||||
})
|
||||
|
||||
/** Refetch the deployed item. The host calls this for the reload signals that reach a
|
||||
* tab from outside it; nothing about becoming visible triggers it, so a form the reader
|
||||
* filled in survives switching tabs away and back. */
|
||||
export function refresh(): void {
|
||||
reloadKey++
|
||||
}
|
||||
|
||||
const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined)
|
||||
|
||||
// Nothing deployed at this path. Reset per remount key so a refetch after a deploy is
|
||||
// not answered from the previous load's verdict.
|
||||
let loadState: 'loaded' | 'not_found' | undefined = $state(undefined)
|
||||
const notDeployed = $derived(loadState === 'not_found')
|
||||
function setLoadState(state: 'loaded' | 'not_found') {
|
||||
loadState = state
|
||||
// Tell the runtime, so the View|Edit control can withhold the switch from here on
|
||||
// rather than sending the next reader to this same empty panel.
|
||||
runtime.setDeployedExists(kind, path, state === 'loaded')
|
||||
}
|
||||
$effect(() => {
|
||||
reloadKey
|
||||
version
|
||||
untrack(() => (loadState = undefined))
|
||||
})
|
||||
|
||||
function toEditSide() {
|
||||
onNavigate(
|
||||
kind === 'flow'
|
||||
? `/flows/edit/${path}`
|
||||
: kind === 'script'
|
||||
? `/scripts/edit/${path}`
|
||||
: `/apps_raw/edit/${path}`
|
||||
)
|
||||
}
|
||||
|
||||
// Everything the detail pages navigate to, resolved against the preview panel: a run
|
||||
// opens as its own tab, another side or version of an item re-points this tab, and
|
||||
// any other page opens as a page tab. Nothing reaches the top-level router.
|
||||
function onNavigate(url: string): void {
|
||||
const clean = stripBase(url)
|
||||
const query = new URL(url, 'http://x').searchParams
|
||||
const run = clean.match(/^\/run\/([^/?#]+)$/)
|
||||
if (run) {
|
||||
const jobId = decodeURIComponent(run[1])
|
||||
runtime.manager.openRunInPreview?.({
|
||||
jobId,
|
||||
workspace: query.get('workspace') || workspaceId,
|
||||
label: `Run ${jobId.slice(0, 8)}`
|
||||
})
|
||||
return
|
||||
}
|
||||
const route = parsePreviewItemRoute(url)
|
||||
// Legacy drag-and-drop apps have no in-panel host, so they fall through to a page tab.
|
||||
if (route && (route.kind !== 'app' || route.raw_app)) {
|
||||
// A script's version history addresses a version by putting its hash where the
|
||||
// path goes. Every real script path is `u/<user>/…` or `f/<folder>/…`, so a
|
||||
// segment with no slash is a hash — pinned in the query instead, or this tab's
|
||||
// identity would become that hash and it would stop being a tab on this script.
|
||||
const isVersionHash = route.kind === 'script' && !route.itemPath.includes('/')
|
||||
runtime.previewTabs.navigate({
|
||||
type: 'item',
|
||||
item: {
|
||||
path: isVersionHash ? path : route.itemPath,
|
||||
summary: '',
|
||||
kind: route.kind,
|
||||
raw_app: route.raw_app
|
||||
},
|
||||
mode: route.mode,
|
||||
version: isVersionHash ? route.itemPath : (query.get('version') ?? undefined)
|
||||
})
|
||||
return
|
||||
}
|
||||
runtime.previewTabs.open({
|
||||
type: 'page',
|
||||
href: `${base}${clean}${query.size ? `?${query}` : ''}`,
|
||||
label: previewLocationLabel(clean)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Remount to refetch: both detail components load on mount, so a deploy lands here
|
||||
rather than as a refresh path of their own. -->
|
||||
{#key `${workspaceId}/${path}/${version ?? ''}/${reloadKey}`}
|
||||
{#if notDeployed}
|
||||
<!-- This side shows the DEPLOYED item, and there isn't one. Without this the detail
|
||||
page renders nothing and the panel is simply blank, which reads as a failure
|
||||
rather than as the ordinary state of a draft nobody has deployed yet. -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<div class="text-sm text-primary">Not deployed yet</div>
|
||||
<div class="text-xs text-tertiary max-w-sm">
|
||||
Nothing is deployed at <span class="font-mono">{path}</span>, so there is no deployed
|
||||
version to view. Deploy it from the editor to see it here.
|
||||
</div>
|
||||
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Pen }} on:click={toEditSide}>
|
||||
Back to editor
|
||||
</Button>
|
||||
</div>
|
||||
{:else if kind === 'script'}
|
||||
<ScriptDetail
|
||||
hash={path}
|
||||
workspace={workspaceId}
|
||||
{searchParams}
|
||||
{onNavigate}
|
||||
{active}
|
||||
syncArgsToUrl={false}
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else if kind === 'flow'}
|
||||
<FlowDetail
|
||||
{path}
|
||||
workspace={workspaceId}
|
||||
{searchParams}
|
||||
{onNavigate}
|
||||
{active}
|
||||
syncArgsToUrl={false}
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else}
|
||||
<InWorkspaceAppViewer
|
||||
workspace={workspaceId}
|
||||
{path}
|
||||
editHref="{base}/apps_raw/edit/{path}?nodraft=true"
|
||||
onEdit={() => onNavigate(`/apps_raw/edit/${path}`)}
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if slot.kind === 'editor' && mounted && runtime}
|
||||
{#if itemKind && itemPath && mounted && runtime}
|
||||
<div
|
||||
bind:this={overlayHostEl}
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<!-- Dynamic imports: the live editors pull in the heaviest module graphs in
|
||||
the app (FlowBuilder, ScriptBuilder/Monaco, the raw-app editor, the
|
||||
pipeline graph). Loading them only when an editor tab first mounts keeps
|
||||
the /sessions route chunk thin, so entering session mode stays snappy. -->
|
||||
{#if slot.editorKind === 'flow'}
|
||||
{#await import('./FlowEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
initialSelectedId={selectedId}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.editorKind === 'script'}
|
||||
{#await import('./ScriptEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
{fullscreen}
|
||||
/>
|
||||
{/await}
|
||||
{:else if slot.editorKind === 'pipeline'}
|
||||
{#await import('./PipelineEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default {runtime} path={slot.path} {workspaceId} {isActiveSession} {active} />
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./RawAppEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={slot.path}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
{active}
|
||||
/>
|
||||
{/await}
|
||||
{#if editVisited}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {sideVisibility(mode === 'edit')}"
|
||||
aria-hidden={mode !== 'edit'}
|
||||
>
|
||||
<!-- Dynamic imports: the live editors pull in the heaviest module graphs in
|
||||
the app (FlowBuilder, ScriptBuilder/Monaco, the raw-app editor, the
|
||||
pipeline graph). Loading them only when an editor tab first mounts keeps
|
||||
the /sessions route chunk thin, so entering session mode stays snappy. -->
|
||||
{#if itemKind === 'flow'}
|
||||
{#await import('./FlowEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
active={active && mode === 'edit'}
|
||||
initialSelectedId={selectedId}
|
||||
/>
|
||||
{/await}
|
||||
{:else if itemKind === 'script'}
|
||||
{#await import('./ScriptEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
active={active && mode === 'edit'}
|
||||
{fullscreen}
|
||||
/>
|
||||
{/await}
|
||||
{:else if itemKind === 'pipeline'}
|
||||
{#await import('./PipelineEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{isActiveSession}
|
||||
active={active && mode === 'edit'}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
{#await import('./RawAppEditorView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
{runtime}
|
||||
path={itemPath}
|
||||
{workspaceId}
|
||||
{onNavigate}
|
||||
{isActiveSession}
|
||||
active={active && mode === 'edit'}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if viewVisited && itemKind !== 'pipeline'}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col min-h-0 bg-surface {sideVisibility(mode === 'view')}"
|
||||
aria-hidden={mode !== 'view'}
|
||||
>
|
||||
{#await import('./ItemViewerView.svelte')}
|
||||
{@render editorLoading()}
|
||||
{:then Module}
|
||||
<Module.default
|
||||
bind:this={viewer}
|
||||
{runtime}
|
||||
kind={itemKind}
|
||||
path={itemPath}
|
||||
version={pinnedVersion}
|
||||
{workspaceId}
|
||||
active={active && mode === 'view'}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if slot.kind === 'artifact' && mounted}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>
|
||||
): SessionPreviewTab[] {
|
||||
if (items.size === 0) return []
|
||||
return tabs.filter((t) => {
|
||||
const route = parsePreviewItemRoute(whereIs(t))
|
||||
return route?.mode === 'view' && items.has(route.itemPath)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/<folder>` 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/<folder>` 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 }
|
||||
}
|
||||
|
||||
@@ -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 `#<path>` 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.
|
||||
|
||||
@@ -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<Record<string, number>>({})
|
||||
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<Record<string, boolean>>({})
|
||||
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)
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -1,878 +1,19 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FlowService,
|
||||
JobService,
|
||||
WorkspaceService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
type TriggersCount,
|
||||
type WorkspaceDeployUISettings
|
||||
} from '$lib/gen'
|
||||
import {
|
||||
canWrite,
|
||||
defaultIfEmptyString,
|
||||
emptyString,
|
||||
urlParamsToObject,
|
||||
extractTagFromSharableHash,
|
||||
interpolateTag,
|
||||
isDynamicTag,
|
||||
isTagTemplate
|
||||
} from '$lib/utils'
|
||||
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
|
||||
|
||||
import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte'
|
||||
import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$lib/base'
|
||||
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte'
|
||||
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
|
||||
import {
|
||||
FolderOpen,
|
||||
Archive,
|
||||
Trash,
|
||||
ChevronUpSquare,
|
||||
Shield,
|
||||
Loader2,
|
||||
GitFork,
|
||||
Play,
|
||||
History,
|
||||
Pen,
|
||||
Eye,
|
||||
HistoryIcon,
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
|
||||
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
|
||||
import Star from '$lib/components/Star.svelte'
|
||||
|
||||
import { writable } from 'svelte/store'
|
||||
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte'
|
||||
import type { TriggerContext } from '$lib/components/triggers'
|
||||
import { setContext } from 'svelte'
|
||||
import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte'
|
||||
import { Triggers } from '$lib/components/triggers/triggers.svelte'
|
||||
import FlowAssetsHandler, {
|
||||
initFlowGraphAssetsCtx
|
||||
} from '$lib/components/flows/FlowAssetsHandler.svelte'
|
||||
import { page } from '$app/state'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CiTestResults from '$lib/components/CiTestResults.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import {
|
||||
buildForkEditUrl,
|
||||
editInForkAllowed,
|
||||
editInForkLabel,
|
||||
onEditInForkClick
|
||||
} from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { agentStreamingEnabled } from '$lib/components/flows/agentFormFields'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import FlowDetail from '$lib/components/details/FlowDetail.svelte'
|
||||
|
||||
let flow: Flow | undefined = $state()
|
||||
let can_write = $state(false)
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
|
||||
let scheduledForStr: string | undefined = $state(undefined)
|
||||
let invisible_to_owner: boolean | undefined = $state(undefined)
|
||||
let overrideTag: string | undefined = $state(undefined)
|
||||
let overrideTagNote: string | undefined = $state(undefined)
|
||||
// Tag carried over from 'Run again', pending the dynamic-tag check in loadFlow
|
||||
let carriedTag: string | undefined = undefined
|
||||
let inputSelected: 'saved' | 'history' | undefined = $state(undefined)
|
||||
let jsonView = $state(false)
|
||||
let deploymentInProgress = $state(false)
|
||||
let deploymentJobId: string | undefined = $state(undefined)
|
||||
|
||||
let intervalId: number | undefined = undefined
|
||||
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = $state(
|
||||
new Triggers([
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'default_email', path: '', isDraft: false },
|
||||
{ type: 'cli', path: '', isDraft: false }
|
||||
])
|
||||
)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState
|
||||
})
|
||||
|
||||
setContext(
|
||||
'FlowGraphAssetContext',
|
||||
initFlowGraphAssetsCtx({ getModules: () => flow?.value.modules ?? [] })
|
||||
)
|
||||
|
||||
// `${path}|${version}` so navigating between pinned and latest re-runs loadFlow.
|
||||
let previousLoadKey: string | undefined = $state(undefined)
|
||||
|
||||
async function archiveFlow(): Promise<void> {
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
requestBody: { archived: !flow?.archived }
|
||||
})
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
async function deleteFlow(): Promise<void> {
|
||||
await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path })
|
||||
sendUserToast('Flow deleted')
|
||||
goto('/')
|
||||
}
|
||||
|
||||
async function loadTriggersCount() {
|
||||
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
||||
workspace: $workspaceStore!,
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
async function loadTriggers(): Promise<void> {
|
||||
await triggersState.fetchTriggers(
|
||||
triggersCount,
|
||||
$workspaceStore,
|
||||
path,
|
||||
true,
|
||||
undefined,
|
||||
$userStore
|
||||
)
|
||||
}
|
||||
|
||||
let pinnedVersion: number | undefined = $state(undefined)
|
||||
|
||||
async function loadFlow(): Promise<void> {
|
||||
const versionParam = page.url.searchParams.get('version')
|
||||
const versionId = versionParam ? Number(versionParam) : NaN
|
||||
if (Number.isFinite(versionId)) {
|
||||
const versioned = await FlowService.getFlowVersion({
|
||||
workspace: $workspaceStore!,
|
||||
version: versionId
|
||||
})
|
||||
if (versioned.path !== path) {
|
||||
sendUserToast(`Flow version ${versionId} belongs to ${versioned.path}, not ${path}.`, true)
|
||||
goto(`/flows/get/${versioned.path}?workspace=${$workspaceStore}&version=${versionId}`)
|
||||
return
|
||||
}
|
||||
flow = versioned
|
||||
pinnedVersion = versionId
|
||||
} else {
|
||||
flow = await FlowService.getFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
withStarredInfo: true
|
||||
})
|
||||
pinnedVersion = undefined
|
||||
}
|
||||
// A carried non-template value equal to the resolution of the flow's own dynamic
|
||||
// tag for these args is not an override but the previous run's pinned resolution:
|
||||
// drop it so the backend re-resolves from the (possibly edited) args. A differing
|
||||
// value is a genuine user override and a template re-resolves at push, so both stay.
|
||||
if (
|
||||
carriedTag &&
|
||||
isDynamicTag(flow.tag) &&
|
||||
!isTagTemplate(carriedTag) &&
|
||||
interpolateTag(flow.tag ?? '', $workspaceStore!, args) === carriedTag
|
||||
) {
|
||||
if (overrideTag === carriedTag) {
|
||||
overrideTag = undefined
|
||||
overrideTagNote = `tag ${flow.tag} is resolved at run time, so the previous run's tag ${carriedTag} was not applied`
|
||||
}
|
||||
carriedTag = undefined
|
||||
}
|
||||
if (!flow.path.startsWith(`u/${$userStore?.username}`) && flow.path.split('/').length > 2) {
|
||||
invisible_to_owner = flow.visible_to_runner_only
|
||||
}
|
||||
intervalId && clearInterval(intervalId)
|
||||
deploymentInProgress = flow.lock_error_logs == ''
|
||||
if (deploymentInProgress) {
|
||||
intervalId = setInterval(syncer, 500)
|
||||
}
|
||||
can_write = canWrite(flow.path, flow.extra_perms!, $userStore)
|
||||
}
|
||||
|
||||
let isValid = $state(true)
|
||||
let loading = $state(false)
|
||||
|
||||
async function syncer(): Promise<void> {
|
||||
if (flow) {
|
||||
const status = await FlowService.getFlowDeploymentStatus({
|
||||
workspace: $workspaceStore!,
|
||||
path: flow.path
|
||||
})
|
||||
if (status.lock_error_logs == undefined || status.lock_error_logs != '') {
|
||||
deploymentInProgress = false
|
||||
deploymentJobId = undefined
|
||||
flow.lock_error_logs = status.lock_error_logs
|
||||
clearInterval(intervalId)
|
||||
} else if (status.job_id) {
|
||||
deploymentJobId = status.job_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlow(
|
||||
scheduledForStr: string | undefined,
|
||||
args: Record<string, any>,
|
||||
invisibleToOwner: boolean | undefined,
|
||||
overrideTag: string | undefined
|
||||
) {
|
||||
loading = true
|
||||
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
|
||||
try {
|
||||
let run = await JobService.runFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
invisibleToOwner,
|
||||
requestBody: args,
|
||||
scheduledFor,
|
||||
tag: overrideTag,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
await goto('/run/' + run + '?workspace=' + $workspaceStore)
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlowForChat(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const run = await JobService.runFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
memoryId: conversationId,
|
||||
requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
skipPreprocessor: true
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
let args: Record<string, any> | undefined = $state(undefined)
|
||||
|
||||
let hash = window.location.hash
|
||||
if (hash.length > 1) {
|
||||
try {
|
||||
let searchParams = new URLSearchParams(hash.slice(1))
|
||||
carriedTag = extractTagFromSharableHash(searchParams)
|
||||
overrideTag = carriedTag
|
||||
let params = [...Object.entries(urlParamsToObject(searchParams))].map(([k, v]) => [
|
||||
k,
|
||||
JSON.parse(v)
|
||||
])
|
||||
args = Object.fromEntries(params)
|
||||
} catch (e) {
|
||||
console.error('Was not able to transform hash as args', e)
|
||||
}
|
||||
}
|
||||
|
||||
let moveDrawer: MoveDrawer | undefined = $state()
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state()
|
||||
let runForm: RunForm | undefined = $state()
|
||||
|
||||
function getMainButtons(flow: Flow | undefined, args: object | undefined) {
|
||||
const buttons: any = []
|
||||
|
||||
if (flow && !$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Fork',
|
||||
buttonProps: {
|
||||
href: `${base}/flows/add?template=${flow.path}`,
|
||||
variant: 'subtle',
|
||||
unifiedSize: 'md',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: GitFork
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
flow &&
|
||||
!$userStore?.operator &&
|
||||
!isCloudHosted() &&
|
||||
editInForkAllowed($workspaceStore, $userWorkspaces)
|
||||
) {
|
||||
buttons.push({
|
||||
label: editInForkLabel($workspaceStore, $userWorkspaces),
|
||||
buttonProps: {
|
||||
href: buildForkEditUrl('flow', flow.path),
|
||||
onClick: (e: Event | undefined) =>
|
||||
onEditInForkClick(e, 'flow', flow.path, { hasHref: true }),
|
||||
unifiedSize: 'md',
|
||||
variant: !showEditButtons ? 'default' : 'subtle',
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
label: `Runs`,
|
||||
buttonProps: {
|
||||
href: `${base}/runs/${flow.path}`,
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: Play
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: `History`,
|
||||
buttonProps: {
|
||||
onClick: () => flowHistory?.open(),
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
startIcon: History
|
||||
}
|
||||
})
|
||||
|
||||
if (!flow || $userStore?.operator || !can_write) {
|
||||
return buttons
|
||||
}
|
||||
|
||||
if (!$userStore?.operator) {
|
||||
buttons.push({
|
||||
label: 'Build app',
|
||||
buttonProps: {
|
||||
onClick: async () => {
|
||||
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
|
||||
// /apps_raw/add hard-reloads (cross-origin isolation), so the
|
||||
// in-memory importStore would be dropped; hand off via sessionStorage.
|
||||
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
|
||||
await goto('/apps_raw/add')
|
||||
},
|
||||
unifiedSize: 'md',
|
||||
variant: 'subtle',
|
||||
disabled: !showEditButtons,
|
||||
startIcon: LayoutDashboard
|
||||
}
|
||||
})
|
||||
|
||||
buttons.push({
|
||||
label: 'Edit',
|
||||
buttonProps: {
|
||||
href: `${base}/flows/edit/${path}`,
|
||||
variant: 'accent',
|
||||
unifiedSize: 'md',
|
||||
disabled: !can_write || !showEditButtons,
|
||||
startIcon: Pen
|
||||
}
|
||||
})
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
if (!$enterpriseLicense) {
|
||||
deployUiSettings = ALL_DEPLOYABLE
|
||||
return
|
||||
}
|
||||
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
|
||||
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
|
||||
}
|
||||
getDeployUiSettings()
|
||||
|
||||
function getMenuItems(
|
||||
flow: Flow | undefined,
|
||||
deployUiSettings: WorkspaceDeployUISettings | undefined
|
||||
) {
|
||||
if (!flow || $userStore?.operator) return []
|
||||
|
||||
const menuItems: any = []
|
||||
|
||||
menuItems.push({
|
||||
label: 'Permissions',
|
||||
onclick: () => shareModal?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: Shield,
|
||||
disabled: !can_write
|
||||
})
|
||||
|
||||
if (showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Move/Rename',
|
||||
onclick: () => moveDrawer?.openDrawer(flow?.path ?? '', flow?.summary, 'flow'),
|
||||
Icon: FolderOpen
|
||||
})
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
label: 'Audit logs',
|
||||
Icon: Eye,
|
||||
onclick: () => {
|
||||
goto(`/audit_logs?resource=${flow?.path}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (isDeployable('flow', flow?.path ?? '', deployUiSettings)) {
|
||||
menuItems.push({
|
||||
label: 'Deploy to staging/prod',
|
||||
onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'),
|
||||
Icon: ChevronUpSquare
|
||||
})
|
||||
}
|
||||
|
||||
if (can_write && showEditButtons) {
|
||||
menuItems.push({
|
||||
label: 'Deployments',
|
||||
onclick: () => flowHistory?.open(),
|
||||
Icon: HistoryIcon
|
||||
})
|
||||
menuItems.push({
|
||||
label: flow.archived ? 'Unarchive' : 'Archive',
|
||||
onclick: () => flow?.path && archiveFlow(),
|
||||
Icon: Archive,
|
||||
color: 'red'
|
||||
})
|
||||
menuItems.push({
|
||||
label: 'Delete',
|
||||
onclick: () => flow?.path && deleteFlow(),
|
||||
Icon: Trash,
|
||||
color: 'red'
|
||||
})
|
||||
}
|
||||
return menuItems
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
intervalId && clearInterval(intervalId)
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (isValid) {
|
||||
event.preventDefault()
|
||||
runForm?.run()
|
||||
} else {
|
||||
sendUserToast('Please fix errors before running', true)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
let stepDetail: FlowModule | string | undefined = $state(undefined)
|
||||
let rightPaneSelected = $state('saved_inputs')
|
||||
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
|
||||
let flowHistory: FlowHistory | undefined = $state(undefined)
|
||||
let path = $derived(page.params.path ?? '')
|
||||
|
||||
let topSectionHeight = $state(0)
|
||||
let paneHeight = $state(0)
|
||||
let flowGraphHeight = $state(0)
|
||||
|
||||
let graphMinHeight = $derived.by(() => {
|
||||
if (!topSectionHeight || !paneHeight) return 400
|
||||
const availableHeight = paneHeight - topSectionHeight - 1 // Account for the separator between the top section and the graph
|
||||
return Math.max(400, availableHeight)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
|
||||
if (cliTrigger) {
|
||||
cliTrigger.extra = {
|
||||
cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'`
|
||||
}
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore && $userStore && page.params.path) {
|
||||
const versionParam = page.url.searchParams.get('version') ?? ''
|
||||
const loadKey = `${$workspaceStore}|${path}|${versionParam}`
|
||||
if (previousLoadKey !== loadKey) {
|
||||
previousLoadKey = loadKey
|
||||
untrack(() => {
|
||||
loadFlow()
|
||||
loadTriggersCount()
|
||||
loadTriggers()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
let showEditButtons = $state(false)
|
||||
let mainButtons = $derived(getMainButtons(flow, args))
|
||||
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flow?.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
if (lastModule?.value?.type !== 'aiagent') return false
|
||||
return agentStreamingEnabled(lastModule.value)
|
||||
})
|
||||
// Read once, as the fragment is only ever set by the link that opened this page
|
||||
// (`Run again`, which prefills the run form from it) and never updated in place.
|
||||
const locationHash = window.location.hash
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<MoveDrawer
|
||||
bind:this={moveDrawer}
|
||||
on:update={async (e) => {
|
||||
await goto('/flows/get/' + e.detail + `?workspace=${$workspaceStore}`)
|
||||
loadFlow()
|
||||
}}
|
||||
/>
|
||||
{#if flow}
|
||||
<FlowHistory bind:this={flowHistory} path={flow.path} onHistoryRestore={loadFlow} />
|
||||
{/if}
|
||||
|
||||
<DetailPageLayout
|
||||
bind:selected={rightPaneSelected}
|
||||
isOperator={$userStore?.operator}
|
||||
forceSmallScreen={chatInputEnabled}
|
||||
isChatMode={chatInputEnabled}
|
||||
flow_json={{
|
||||
value: flow?.value,
|
||||
summary: flow?.summary,
|
||||
description: flow?.description,
|
||||
schema: flow?.schema
|
||||
}}
|
||||
>
|
||||
{#snippet header()}
|
||||
<DetailPageHeader
|
||||
on:seeTriggers={() => {
|
||||
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()}
|
||||
<TriggersBadge
|
||||
showOnlyWithCount={true}
|
||||
showDraft={false}
|
||||
{path}
|
||||
newItem={false}
|
||||
isFlow
|
||||
selected={rightPaneSelected == 'triggers'}
|
||||
onSelect={async (triggerIndex: number) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
await tick()
|
||||
triggersState.selectedTriggerIndex = triggerIndex
|
||||
}}
|
||||
small={false}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if $workspaceStore && flow}
|
||||
<Star kind="flow" path={flow.path} summary={flow.summary} />
|
||||
{/if}
|
||||
<OnBehalfOfBadge
|
||||
onBehalfOf={flow?.on_behalf_of}
|
||||
onBehalfOfEmail={flow?.on_behalf_of_email}
|
||||
kind="flow"
|
||||
/>
|
||||
{#if flow?.value?.priority != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="blue" variant="outlined" size="xs">
|
||||
{`Priority: ${flow?.value?.priority}`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined}
|
||||
<div class="hidden md:block">
|
||||
<HeaderBadge color="gray" variant="outlined" size="xs">
|
||||
{`Concurrency limit: ${flow?.value?.concurrent_limit} runs every ${flow?.value?.concurrency_time_window_s}s`}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
</DetailPageHeader>
|
||||
{/snippet}
|
||||
{#snippet form()}
|
||||
<div class="px-3">
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
|
||||
</div>
|
||||
{#if flow}
|
||||
<div class="flex flex-col h-full bg-surface divide-y" bind:clientHeight={paneHeight}>
|
||||
<div bind:clientHeight={topSectionHeight} class={twMerge(chatInputEnabled ? 'h-full' : '')}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full flex flex-col',
|
||||
chatInputEnabled ? 'p-3 h-full' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
'mx-auto'
|
||||
)}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if chatInputEnabled}
|
||||
<!-- Chat Layout with Sidebar -->
|
||||
<FlowChat
|
||||
onRunFlow={runFlowForChat}
|
||||
{deploymentInProgress}
|
||||
path={flow?.path ?? ''}
|
||||
useStreaming={shouldUseStreaming}
|
||||
inputSchema={flow?.schema}
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
<!-- Normal Mode: Form Layout -->
|
||||
<div class="flex flex-col align-left">
|
||||
{#if hasSchema || inputSelected}
|
||||
<div
|
||||
class="flex flex-row justify-between min-h-12"
|
||||
transition:slide={{ duration: 150 }}
|
||||
>
|
||||
<InputSelectedBadge
|
||||
onReject={() => {
|
||||
savedInputsV2?.resetSelected()
|
||||
}}
|
||||
{inputSelected}
|
||||
/>
|
||||
|
||||
{#if hasSchema}
|
||||
<Toggle
|
||||
bind:checked={jsonView}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'JSON',
|
||||
rightTooltip: 'Fill args from JSON'
|
||||
}}
|
||||
lightMode
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.schema?.prompt_for_ai !== undefined}
|
||||
<AIFormAssistant
|
||||
instructions={flow.schema?.prompt_for_ai as string}
|
||||
onEditInstructions={() => {
|
||||
goto(`/flows/edit/${flow?.path}`)
|
||||
}}
|
||||
runnableType="flow"
|
||||
path={flow?.path}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RunForm
|
||||
bind:scheduledForStr
|
||||
bind:invisible_to_owner
|
||||
bind:overrideTag
|
||||
{overrideTagNote}
|
||||
viewKeybinding
|
||||
{loading}
|
||||
autofocus
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={flow}
|
||||
runAction={runFlow}
|
||||
bind:args
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex flex-col gap-1 w-full items-end">
|
||||
<span class="text-2xs text-secondary">
|
||||
Edited <TimeAgo date={flow.edited_at ?? ''} noSeconds /> by {flow.edited_by}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if !chatInputEnabled}
|
||||
<div class="grow min-h-0">
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={true}
|
||||
minHeight={graphMinHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
}}
|
||||
noBorder={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet save_inputs()}
|
||||
<SavedInputsV2
|
||||
bind:this={savedInputsV2}
|
||||
schema={flow?.schema}
|
||||
{jsonView}
|
||||
flowPath={flow?.path}
|
||||
{isValid}
|
||||
args={args ?? {}}
|
||||
bind:inputSelected
|
||||
on:selected_args={(e) => {
|
||||
const nargs = JSON.parse(JSON.stringify(e.detail))
|
||||
args = nargs
|
||||
if (jsonView) {
|
||||
runForm?.syncJsonEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_step()}
|
||||
{#if flow}
|
||||
{#if stepDetail}
|
||||
<FlowGraphViewerStep schema={flow.schema} {stepDetail} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet triggers()}
|
||||
{#if flow}
|
||||
<TriggersEditor
|
||||
{args}
|
||||
runnableVersion={flow.version_id?.toString()}
|
||||
initialPath={flow.path}
|
||||
currentPath={flow.path}
|
||||
noEditor={true}
|
||||
newItem={false}
|
||||
isFlow={true}
|
||||
schema={flow.schema}
|
||||
isDeployed={true}
|
||||
noCapture={true}
|
||||
isEditor={false}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet flow_graph()}
|
||||
{#if flow}
|
||||
<div class="h-full overflow-auto" bind:clientHeight={flowGraphHeight}>
|
||||
<FlowGraphViewer
|
||||
triggerNode={true}
|
||||
download
|
||||
{flow}
|
||||
noSide={false}
|
||||
noBorder
|
||||
minHeight={flowGraphHeight}
|
||||
on:select={(e) => {
|
||||
if (e.detail) {
|
||||
stepDetail = e.detail
|
||||
rightPaneSelected = 'flow_step'
|
||||
} else {
|
||||
stepDetail = undefined
|
||||
rightPaneSelected = 'saved_inputs'
|
||||
}
|
||||
}}
|
||||
on:triggerDetail={(e) => {
|
||||
rightPaneSelected = 'triggers'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageLayout>
|
||||
|
||||
<FlowAssetsHandler
|
||||
modules={flow?.value.modules ?? []}
|
||||
enableDbExplore
|
||||
enablePathScriptAndFlowAssets
|
||||
<FlowDetail
|
||||
path={page.params.path ?? ''}
|
||||
workspace={$workspaceStore}
|
||||
searchParams={page.url.searchParams}
|
||||
{locationHash}
|
||||
onNavigate={goto}
|
||||
active={true}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, PreviewTabHost | undefined> = {}
|
||||
|
||||
let reloadHandle: ReturnType<typeof setTimeout> | 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<string>()
|
||||
// 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<string>()
|
||||
|
||||
// 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<string>) {
|
||||
function reloadTabs(pages: Set<string>, items: Set<string>) {
|
||||
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/<folder>` | `u/<user>`) → 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"
|
||||
>
|
||||
<div class="flex-1 min-h-0 flex flex-col {fullscreen ? 'p-0' : 'p-2 pl-0'}">
|
||||
<!-- The action controls float over the tab strip, so the strip has to reserve
|
||||
their width or tabs slide underneath them. Measured rather than guessed:
|
||||
the set changes with the tab (an artifact has no "Open in workspace", a
|
||||
page has no View|Edit toggle), so a fixed padding is either short for
|
||||
the widest case or wasted space for the narrowest. -->
|
||||
<div
|
||||
class="flex flex-col flex-1 min-h-0 overflow-hidden relative bg-surface {fullscreen
|
||||
? ''
|
||||
: 'rounded-md border border-light'}"
|
||||
style="--preview-actions-w: {previewActionsWidth + 8}px"
|
||||
>
|
||||
{#if !fullscreen}
|
||||
<!-- Collapse the preview panel — floats over the top-left corner so
|
||||
@@ -930,7 +1034,37 @@
|
||||
|
||||
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
|
||||
corner to mirror the collapse control. -->
|
||||
<div class="absolute top-1 right-1 z-30 flex items-center gap-0.5">
|
||||
<!-- Spans the tab strip's own height and centres within it, rather than
|
||||
being pinned a fixed distance from the top: the controls here are not
|
||||
all one height (the View|Edit toggle is taller than the icon buttons),
|
||||
so an offset that centres one leaves the rest — and the collapse
|
||||
button opposite — sitting off the strip's axis. -->
|
||||
<div
|
||||
bind:clientWidth={previewActionsWidth}
|
||||
class="absolute top-0 right-1 z-30 flex h-8 items-center gap-0.5"
|
||||
>
|
||||
{#if activeSide}
|
||||
<!-- Both sides of an item are one tab; this re-points it. -->
|
||||
<div class="mr-1">
|
||||
<ToggleButtonGroup
|
||||
selected={activeSide.mode}
|
||||
on:selected={({ detail }) => switchSide(detail as PreviewItemMode)}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton small value="edit" label="Edit" icon={Pen} {item} />
|
||||
<ToggleButton
|
||||
small
|
||||
value="view"
|
||||
label="View"
|
||||
icon={Eye}
|
||||
disabled={!!viewDisabledReason}
|
||||
tooltip={viewDisabledReason}
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !activeTabIsArtifact}
|
||||
<a
|
||||
href={withWorkspaceParam(
|
||||
@@ -972,7 +1106,7 @@
|
||||
onReorder={reorderTabs}
|
||||
class="session-preview-tab-strip h-8 border-b border-light bg-surface-secondary/50 {fullscreen
|
||||
? 'pl-1.5'
|
||||
: 'pl-9'} pr-16"
|
||||
: 'pl-9'} pr-[var(--preview-actions-w,4rem)]"
|
||||
>
|
||||
{#snippet tabAccessory(_tab, isActive)}
|
||||
{#if isActive}
|
||||
|
||||
Reference in New Issue
Block a user