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:
Guilhem Lemouel
2026-09-16 11:44:52 +02:00
co-authored by Claude Opus 5
parent 670404ffe2
commit d656455103
29 changed files with 3186 additions and 2250 deletions
@@ -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()
+10 -7
View File
@@ -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">
+8 -1
View File
@@ -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: {
+16 -16
View File
@@ -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}