Files
windmill/frontend/src/lib/components/sessions/previewRouter.ts
T
AlexRV12andClaude Opus 5 a6abf2c8a7 feat: run and test scripts from the AI chat through an argument form (#11001)
* fix: disable a dynamic input when its schema field is disabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: extract the run form's argument hygiene into job_args

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give Tabs an opt-in sliding selection indicator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: share the chat's scroll-fade measurement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give the chat a run-form contract and incremental job output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: run and test a script from the chat through an argument form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: carry a chat run's card and job across saves and reloads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: render a chat run as a tool call row with its form, logs and result

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: open a pending run form in the sessions preview pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* test: benchmark running a deployed script from the chat

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: offer a test run's dynamic options from the draft it previews

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say that a test run's dynselect helper executes on form display

* fix: send a schema default the model omitted when yolo skips the form

* fix: infer a test run's schema when the stored one declares no properties

* docs: tighten the note on the form's mount-time helper job

* fix: apply a nested schema default the bypass posture counts as answered

* fix: apply a declared default to a null value and an optional nested field

* fix: check required fields inside a supplied optional object before bypassing

* fix: read required args as own properties before bypassing the form

* fix: stop the turn from the run form's action row in the preview panel

* refactor: drop the run-form prediction and share its secret minting

* refactor: prefill a proposed secret instead of emptying the field

* docs: correct the comments the run-form prediction left behind

* fix: keep a proposed secret out of the chat's stored messages

* docs: say what a literal secret argument now does

* test: restore the copilotInfo export the aiStore mock omits

* docs: cut the run form's helper-script note to its constraints

* refactor: settle a run form from one entry and fetch a job's logs once

* fix: separate colliding secret paths, gate plan mode, keep polled logs

* fix: mint before the form opens, skip empty fields, show what ran

* revert: mint a run form's secrets at submit, not before it opens

* fix: settle a cancelled run card on the form's arguments, not the proposal

* fix: settle a stopped run form like a cancelled one, and keep an empty secret empty

* fix: snapshot a run's arguments before minting its secrets

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:31:58 +00:00

519 lines
22 KiB
TypeScript

import {
ASSETS_PATH,
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
pageKey,
pageHref,
parsePreviewItemRoute,
RESOURCES_PATH,
RUNS_PATH,
SCHEDULES_PATH,
stripBase,
VARIABLES_PATH,
WORKSPACE_SETTINGS_PATH,
triggerLabelForPath,
TRIGGER_PAGES,
type PreviewItemRoute,
type TriggerKind
} from './previewPaths'
// Re-exported so the preview code that already reads locations through this module keeps
// one import, while a caller needing only a path can reach for the leaf instead.
export {
pageKey,
pageHref,
parsePreviewItemRoute,
stripBase,
TRIGGER_PAGES,
type PreviewItemRoute,
type TriggerKind
}
import {
Home,
Play,
DollarSign,
Boxes,
Calendar,
Database,
FolderOpen,
GitCompareArrows,
Users,
Settings,
ScrollText
} from 'lucide-svelte'
import type { DrillIcon } from '$lib/components/drillPicker'
import { buildRunsFilterSearchbarSchema } from '$lib/components/runs/runsFilter'
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
import { buildResourcesFilterSchema } from '$lib/components/resources/resourcesFilter'
import { buildAssetsFilterSchema } from '$lib/components/assets/assetsFilter'
import { COMPARE_ITEMS_PARAM } from './modifiedItemsMask'
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import type { SessionTargetKind } from './sessionRuntime.svelte'
/**
* Which version of an artifact an opener wants on screen: a number pins that one, `'latest'`
* drops any pin, and omitting it leaves the reader where they are. `undefined` cannot double
* as `'latest'` — every artifact tool re-opens the document it just wrote, so treating that
* as a request to move would yank a reader out of the version they chose on each edit.
*/
export type ArtifactVersionTarget = number | 'latest'
/** What the preview breadcrumb picker can route to: a static workspace page
* or a workspace item (script/flow/app). The sessions page turns either into
* an iframe URL. */
export type PreviewTarget =
| { type: 'page'; href: string; label: string }
| { type: 'item'; item: WorkspaceItem }
| { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget }
| { type: 'runform'; toolCallId: string; label: string }
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
// Core workspace-level destinations the preview can route to. Intentionally
// curated — the main pages, not every trigger sub-page (those live behind
// EE/feature gating in SidebarContent and aren't worth duplicating here).
export const PREVIEW_PAGES: PreviewPage[] = [
{ label: 'Home', path: '/', icon: Home },
{ label: 'Runs', path: RUNS_PATH, icon: Play },
{ label: 'Variables', path: VARIABLES_PATH, icon: DollarSign },
{ label: 'Resources', path: RESOURCES_PATH, icon: Boxes },
{ label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar },
{ label: 'Assets', path: ASSETS_PATH, icon: Database },
{ label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen },
{ label: 'Groups', path: GROUPS_PATH, icon: Users },
{ label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings },
{ label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText }
]
// The Compare & Deploy review page. Kept out of PREVIEW_PAGES (it's not a picker
// destination — it's reached through the chat's open_page tool or a session's
// Review button) but known here so preview tabs label it and reuse it on
// param changes like the curated pages.
export const COMPARE_PAGE: PreviewPage = {
label: 'Compare & Deploy',
path: '/forks/compare',
icon: GitCompareArrows
}
// Workspace list pages that deep-link one row through the hash. Resources route
// theirs through an extra `/resource/` segment. Nothing else may be read that way:
// a legacy drag-and-drop app hands its hash to the app itself as `context.hash`,
// so treating that as a row would describe app state as a workspace item.
const DRAWER_ANCHOR_PAGES = [SCHEDULES_PATH, VARIABLES_PATH, RESOURCES_PATH] as const
/** The workspace item whose drawer a preview location has open, or undefined when
* the page doesn't deep-link rows (or none is anchored). Takes the location with
* its suffix — a raw href, or an observed location. */
export function drawerAnchorFor(location: string): string | undefined {
const hashAt = location.indexOf('#')
if (hashAt < 0) return undefined
const route = stripBase(location)
const known =
(DRAWER_ANCHOR_PAGES as readonly string[]).includes(route) ||
Object.values(TRIGGER_PAGES).some((p) => p.path === route)
if (!known) return undefined
return location.slice(hashAt + 1).replace(/^\/resource\//, '') || undefined
}
// Query params the preview host injects into an iframe URL (`nomenubar` hides the nav,
// `workspace` scopes the page). Never part of what a location means.
const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const
/** Drop the params the preview host injects, so a location observed in the frame can be
* compared with the one that was commanded (which never carries them). */
export function canonicalizeObservedLoc(loc: string): string {
// An artifact or a run form is a scheme, not a path — `new URL` would happily parse it
// and hand back a pathname with the scheme gone.
if (parseArtifactRoute(loc) || parseRunFormRoute(loc)) return loc
try {
const u = new URL(loc, 'http://_')
for (const p of INJECTED_PARAMS) u.searchParams.delete(p)
return u.pathname + u.search + u.hash
} catch {
return loc
}
}
// The query params belonging to the request; every other one the page wrote into its own
// URL (`filter_path_of`, `page`/`perPage`) and must not read as a view. A page's own
// filter schema is the list, with every option on so the set is its whole vocabulary and
// not one viewer's subset. Built on first use — every trigger drawer imports this module.
let requestParams: Record<string, readonly string[]> | undefined
function pageRequestParamTable(): Record<string, readonly string[]> {
return (requestParams ??= {
[RUNS_PATH]: Object.keys(
buildRunsFilterSearchbarSchema({
paths: [],
usernames: [],
folders: [],
jobTriggerKinds: [],
isSuperAdminOrDevops: true,
isAdminsWorkspace: true
})
),
[SCHEDULES_PATH]: Object.keys(
buildSchedulesFilterSchema({ paths: [], scriptPaths: [], showUserFoldersFilter: true })
),
[VARIABLES_PATH]: Object.keys(
buildVariablesFilterSchema({ paths: [], owners: [], showUserFoldersFilter: true })
),
[RESOURCES_PATH]: Object.keys(
buildResourcesFilterSchema({
paths: [],
resourceTypes: [],
owners: [],
showUserFoldersFilter: true
})
),
[ASSETS_PATH]: Object.keys(buildAssetsFilterSchema({ paths: [], assetKinds: [] })),
[AUDIT_LOGS_PATH]: ['username', 'operation', 'resource'],
[WORKSPACE_SETTINGS_PATH]: ['tab'],
[COMPARE_PAGE.path]: ['workspace_id', 'mode', COMPARE_ITEMS_PARAM]
})
}
/** The query params a request can set on `path` — empty for a page that takes none. */
export function pageRequestParams(path: string): readonly string[] {
return pageRequestParamTable()[stripBase(path)] ?? []
}
/** What a preview location means, read against the page it points at. */
export type PreviewLocation = {
/** What two locations must share to be the same tab. */
identity: string
/** The view within that page: only the params a request could have set. */
view: string
/** The row the page deep-links, `''` where the hash is the page's own state. */
anchor: string
}
/** Decompose a preview location into what it means. Everything comparing two locations
* goes through this: a query or a hash means something different per class of page, and
* answering that at the call site is how a tab ends up duplicated, reloaded, or reported
* as showing a row it is not. The page classes live here and only here. */
export function describeLocation(loc: string): PreviewLocation {
const artifact = parseArtifactRoute(loc)
if (artifact) return { identity: `artifact:${artifact.id}`, view: '', anchor: '' }
const runForm = parseRunFormRoute(loc)
// Identity is the call, never the label: that carries the script's summary, so folding it
// in would open a second tab for the same form whenever the summary differed.
if (runForm) return { identity: `runform:${runForm.toolCallId}`, view: '', anchor: '' }
const canonical = canonicalizeObservedLoc(loc)
const path = stripBase(canonical)
const bare = canonical.split('#')[0]
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
return {
identity: path,
view: requestedParams(query, pageRequestParamTable()[path]),
anchor: drawerAnchorFor(canonical) ?? ''
}
}
// By content, never by the raw string: a page hands its params back in its own order and
// re-encodes what it was given (`path=f/a` arrives as `path=f%2Fa`), and neither is a
// change of view. Re-encoded on the way out so a value holding a delimiter stays one
// pair — decoded, `?arg=x%26result%3Dy` and `?arg=x&result=y` read alike.
function requestedParams(query: string, allowed: readonly string[] | undefined): string {
if (!allowed?.length) return ''
const parts: string[] = []
new URLSearchParams(query).forEach((v, k) => {
if (allowed.includes(k)) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
})
return parts.sort().join('&')
}
// Params a page restores from the user's stored preference whenever the URL it loads with
// says nothing about them. Requestable like any other filter, so they stay in the view —
// but a frame carrying one the request never mentioned is the page's own doing, and
// loading over it lands on a page that seeds it straight back, scroll position gone.
const PAGE_SEEDED_PARAMS: Record<string, readonly string[]> = {
[RUNS_PATH]: ['job_trigger_kind', 'show_future_jobs']
}
/** Whether the frame at `observed` is already showing what `commanded` asks for: same
* page, same row, and every filter the request names carrying the value it named. */
export function showsView(observed: string, commanded: string): boolean {
const x = describeLocation(observed)
const y = describeLocation(commanded)
if (x.identity !== y.identity || x.anchor !== y.anchor) return false
if (x.view === y.view) return true
const seeded = PAGE_SEEDED_PARAMS[x.identity]
if (!seeded?.length) return false
// Both views come out of `requestedParams` already sorted and encoded, so they compare
// as strings once the seeded params are dropped. One direction only: the page may add
// what the request left out, never drop what it asked for — dropping in both would let
// a tab answer a request it does not satisfy.
const pairs = (view: string) => (view ? view.split('&') : [])
const keyOf = (pair: string) => decodeURIComponent(pair.split('=')[0])
const asked = new Set(pairs(y.view).map(keyOf))
return (
pairs(x.view)
.filter((pair) => !seeded.includes(keyOf(pair)) || asked.has(keyOf(pair)))
.join('&') === y.view
)
}
// Filters whose value addresses a workspace object — a path, owner, kind, state, time —
// and so may be repeated to the model. Any other keeps its name and loses its value: the
// rest search *over* content (the free-text box, a job's result, a resource's value), and
// withholding by default means a filter added later leaks nothing until it is listed.
const ADDRESSING_PARAMS = new Set([
'path',
'path_start',
'schedule_path',
'script_path',
'asset_path',
'usage_path',
'owner',
'user',
'username',
'folder',
'worker',
'tag',
'label',
'resource_type',
'asset_kinds',
'job_kinds',
'job_trigger_kind',
'operation',
'resource',
'concurrency_key',
'status',
'min_ts',
'max_ts',
'timeframe',
'all_workspaces',
'show_skipped',
'show_future_jobs',
'resolved',
'user_folders_only',
'columns',
'tab',
'workspace_id',
'mode',
COMPARE_ITEMS_PARAM
])
const CONTEXT_FIELD_MAX = 300
/** Collapse a value to one field of prompt text or a tool result. Both are line-oriented
* formats with no escaping, so a value carrying a newline writes a line of its own — and
* these values are decoded out of URLs, which are attacker-shaped input the moment a link
* can be shared. Length is capped too: a path this long tells the model nothing. */
export function promptSafe(text: string): string {
// C0 and DEL, plus the Unicode terminators a renderer also breaks a line on — these
// values are percent-decoded, so `%E2%80%A8` arrives as a real U+2028.
// eslint-disable-next-line no-control-regex
return text
.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029]+/g, ' ')
.trim()
.slice(0, CONTEXT_FIELD_MAX)
}
/** How a preview location may be described to the model: reassembled from the parts this
* module recognizes, never passed through whole. A tab can host a legacy app whose hash is
* app state, and a filter can search contents rather than address them — so only the
* addressing ones keep their value, the chat having no redaction boundary of its own. */
export function previewLocationContext(loc: string): {
label: string
location: string
open?: string
} {
const { identity, anchor } = describeLocation(loc)
const bare = canonicalizeObservedLoc(loc).split('#')[0]
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
const declared = pageRequestParams(identity)
const filters: string[] = []
new URLSearchParams(query).forEach((v, k) => {
// Re-encoded for the same reason the view is: decoded, a value holding `&` or `=`
// reads as another filter entirely.
if (declared.includes(k)) {
const key = encodeURIComponent(k)
filters.push(ADDRESSING_PARAMS.has(k) ? `${key}=${encodeURIComponent(v)}` : key)
}
})
return {
// Labels come from route shape (page name, trigger kind, run id, item leaf), so
// they carry no query or hash of their own — but a run id and an item leaf are
// decoded out of the path, so they still reach here as free text.
label: promptSafe(previewLocationLabel(loc)),
location: promptSafe(identity + (filters.length ? `?${filters.sort().join('&')}` : '')),
open: anchor ? promptSafe(anchor) : undefined
}
}
// Match a base-stripped preview pathname to a known page, for breadcrumb
// labelling + picker highlight. Exact match; '/' only matches home.
export function matchPreviewPage(path: string): PreviewPage | undefined {
const clean = stripBase(path)
return PREVIEW_PAGES.find((p) => p.path === clean)
}
/** Match a preview href to a page whose tab should be re-pointed in place when
* only its query params change (the open_page filter-change behavior): the
* curated pages plus the compare page. Trigger pages are deliberately not
* matched — they take the generic path in `SessionPreviewTabs.open`, which
* dedupes on the location ignoring the hash and re-points the tab it finds. */
export function matchReusablePage(href: string): PreviewPage | undefined {
if (stripBase(href) === COMPARE_PAGE.path) return COMPARE_PAGE
return matchPreviewPage(href)
}
/** Human label for a preview tab's location — the workspace page name, trigger
* page, run detail, or item path. Shared by the sessions tab strip and the
* close_page matcher so both name a tab the same way. */
export function previewLocationLabel(url: string): string {
const artifact = parseArtifactRoute(url)
if (artifact) return artifact.name || 'Artifact'
const runForm = parseRunFormRoute(url)
if (runForm) return runForm.label || 'Run form'
const page = matchReusablePage(url)
if (page) return page.label
const trigger = triggerLabelForPath(url)
if (trigger) return trigger
const run = stripBase(url).match(/^\/run\/([^/?#]+)/)
if (run) return `Run ${decodeURIComponent(run[1]).slice(0, 8)}`
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) return pipelineFolder
const parsed = parsePreviewItemRoute(url)
if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath
return stripBase(url)
}
/** The friendly display leaf for a preview tab, or `undefined` to fall back to
* `previewLocationLabel`. A never-deployed script / flow / raw app is parked at a
* throwaway `…/draft_<uuid>` storage path while its editor shows a friendly name
* (auto-generated or typed); pass that `friendlyPath` — the live cell's
* `draft_path`/`path` — to label the tab by its leaf instead of the uuid. Returns
* `undefined` for a deployed item (real storage path) or when the friendly path
* is itself a placeholder. Display-only: the tab's URL keeps the storage path. */
export function draftFriendlyLeaf(
storagePath: string,
friendlyPath: string | undefined
): string | undefined {
if (!storagePath.split('/').pop()?.startsWith('draft_')) return undefined
const leaf = friendlyPath?.split('/').pop()
return leaf && !leaf.startsWith('draft_') ? leaf : undefined
}
/** The display name for an item, from what a lister or a live editor cell knows
* about it: its summary when set, else the typed/auto name of an item parked at a
* `…/draft_<uuid>` storage path. `undefined` when neither applies, leaving the
* caller on `previewLocationLabel`. Shared by the live editor's tab stamp and the
* sessions page's pre-mount lookup so one tab can't be named two ways depending
* on which of them got there first. */
export function itemDisplayName(
storagePath: string,
friendlyPath: string | undefined,
summary: string | undefined
): string | undefined {
return summary?.trim() || draftFriendlyLeaf(storagePath, friendlyPath)
}
// The place inside a previewed flow editor its tab URL asks for (`?selected=`,
// the same param the full-page flow editor reads). Live editors are mounted in
// process rather than in an iframe, so the host has to read this off the tab URL
// and seed the editor with it.
export function parsePreviewSelectedId(url: string): string | undefined {
try {
return new URL(url, 'http://_').searchParams.get('selected') || undefined
} catch {
return undefined
}
}
// A `/pipeline/<folder>` route is the data-pipeline graph editor for that folder
// (the folder is a single path segment, not a workspace item path). The bare
// `/pipeline` list page is not an editor. Returns the folder name, or null.
export function parsePipelineRoute(fullPath: string): string | null {
const m = stripBase(fullPath).match(/^\/pipeline\/([^/?#]+)/)
return m ? normalizePipelineFolder(decodeURIComponent(m[1])) : null
}
// The id (before the query) is the artifact's stable routing identity; the name rides in the
// hash so the tab strip labels it without a store lookup, and the version in the query so the
// tab persists it.
export function parseArtifactRoute(
url: string
): { id: string; name: string; version?: number } | null {
const m = url.match(/^artifact:([^?#]+)(?:\?v=(\d+))?(?:#(.*))?$/)
if (!m) return null
// Mirror artifactUrl: a version it would not stamp does not read back as a pin.
const version = Number(m[2])
return {
id: decodeURIComponent(m[1]),
name: m[3] ? decodeURIComponent(m[3]) : '',
version: version > 0 ? version : undefined
}
}
// A chat run form, addressed by the tool call it belongs to. A scheme rather than a path
// for the same reason as artifacts: this tab mounts a component, so there is no page for a
// frame to load, and the label rides in the hash so the strip names it without a lookup.
export function parseRunFormRoute(url: string): { toolCallId: string; label: string } | null {
const m = url.match(/^runform:([^?#]+)(?:#(.*))?$/)
if (!m) return null
return {
toolCallId: decodeURIComponent(m[1]),
label: m[2] ? decodeURIComponent(m[2]) : ''
}
}
export function runFormUrl(toolCallId: string, label: string): string {
return `runform:${encodeURIComponent(toolCallId)}#${encodeURIComponent(label)}`
}
export function artifactUrl(id: string, name: string, version?: number): string {
// Only stamp a version parseArtifactRoute can read back: this url is persisted with the
// tab, so one that round-trips to null would come back as an unopenable tab every reload.
// Safe integers specifically — from 1e21 up a number interpolates as `1e+21`, which the
// digits-only parser rejects outright.
const pinned =
version !== undefined && Number.isSafeInteger(version) && version > 0 ? `?v=${version}` : ''
return `artifact:${encodeURIComponent(id)}${pinned}#${encodeURIComponent(name)}`
}
/** Drill-picker leaf key for an artifact, shared by the picker tree and the
* active-tab highlight so a pick and a highlight agree on identity. */
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.
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'runform'; toolCallId: string }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string): PreviewSlot {
const artifact = parseArtifactRoute(url)
if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version }
const runForm = parseRunFormRoute(url)
if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId }
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
}
const route = parsePreviewItemRoute(url)
if (!route) return { kind: 'iframe' }
const editorKind: SessionTargetKind | undefined =
route.kind === 'script'
? 'script'
: route.kind === 'flow'
? 'flow'
: route.kind === 'app' && route.raw_app
? 'raw_app'
: undefined
if (!editorKind) return { kind: 'iframe' }
return { kind: 'editor', editorKind, path: route.itemPath }
}