Files
windmill/frontend/src/lib/components/sessions/previewRouter.ts
T
Ruben FiszelandClaude Opus 5.5 3d67d3ac6b refactor: mount the runs list in process in ai session previews (#11724)
* refactor: mount the runs list in process in ai session previews

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

* fix: tell runs list tabs apart by filters and reseed on re-point

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

* fix: remount a hosted runs list only when moved from outside

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:10:23 +00:00

673 lines
28 KiB
TypeScript

import {
ASSETS_PATH,
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
isPreviewListPath,
isRunsListPath,
pageItemForListPath,
pageItemListPath,
pageItemPageHref,
pageItemUrl,
pageKey,
parsePageItemRoute,
parsePipelineRoute,
parseHistoricalScriptEdit,
parsePreviewItemRoute,
RESOURCES_PATH,
RUNS_PATH,
SCHEDULES_PATH,
barePath,
VARIABLES_PATH,
WORKSPACE_SETTINGS_PATH,
triggerLabelForPath,
TRIGGER_PAGES,
type PageItemRef,
type PreviewItemMode,
type PreviewItemRoute,
type TriggerKind
} from './previewPaths'
import type { UserExt, UserWorkspace } from '$lib/stores'
import { sidebarPageAllowed, type OperatorPageKey } from '../sidebar/operatorRoutes'
// 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 {
pageItemListPath,
pageItemUrl,
pageKey,
parseHistoricalScriptEdit,
parsePageItemRoute,
parsePipelineRoute,
parsePreviewItemRoute,
barePath,
TRIGGER_PAGES,
type PageItemRef,
type PreviewItemMode,
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 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 }
// `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 }
| { type: 'runform'; toolCallId: string; label: string }
| { type: 'pageitem'; ref: PageItemRef }
/**
* Which side of an item to open when the opener is showing the reader something
* that already exists, rather than something it just wrote: the deployed page,
* unless nothing is deployed there and the editor is all the item has.
*
* The openers that follow a write (`open_preview`, the write-result preview card)
* pass `'edit'` themselves — the item they mean may well be deployed, and its
* deployed page is precisely not what the reader asked to see.
*/
export function previewModeFor(item: Pick<WorkspaceItem, 'draftOnly'>): PreviewItemMode {
return item.draftOnly ? 'edit' : 'view'
}
export type PreviewPage = {
label: string
path: string
icon: DrillIcon
/** The `operator_settings` key that admits an operator, as the sidebar reads it. */
operatorKey?: OperatorPageKey
adminOnly?: boolean
}
/** Whether `page` loads for `user` rather than showing its "not available" state. An unknown
* user is let through to every page but the admin-only ones. */
export function previewPageAllowed(
page: PreviewPage,
user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined,
workspace: UserWorkspace | undefined
): boolean {
if (page.adminOnly) return !!user && (user.is_admin || user.is_super_admin)
return !page.operatorKey || sidebarPageAllowed(user?.operator, workspace, page.operatorKey)
}
// 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, operatorKey: 'runs' },
{ label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, operatorKey: 'variables' },
{ label: 'Resources', path: RESOURCES_PATH, icon: Boxes, operatorKey: 'resources' },
{ label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, operatorKey: 'schedules' },
{ label: 'Assets', path: ASSETS_PATH, icon: Database, operatorKey: 'assets' },
{ label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, operatorKey: 'folders' },
{ label: 'Groups', path: GROUPS_PATH, icon: Users, operatorKey: 'groups' },
{ label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings, adminOnly: true },
{ label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, operatorKey: 'audit_logs' }
]
// 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 = barePath(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
}
/** The item a list-page location deep-links, as a tab of its own: a session edits these
* in process, so the list page's drawer is never where one belongs. */
export function pageItemForLocation(location: string): PageItemRef | undefined {
const anchor = drawerAnchorFor(location)
if (!anchor) return undefined
let path: string
try {
path = decodeURIComponent(anchor)
} catch {
return undefined
}
return pageItemForListPath(location, path)
}
/** A location with a deep-linked row replaced by that row's own tab; any other unchanged. */
export function pageItemLocation(location: string): string {
const ref = pageItemForLocation(location)
return ref ? pageItemUrl(ref) : location
}
// 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) || parsePageItemRoute(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 page whose filters a path takes: one item's runs filter as every run does.
function filterPageOf(path: string): string {
return isRunsListPath(path) ? RUNS_PATH : path
}
/** 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()[filterPageOf(barePath(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 pageItem = parsePageItemRoute(loc)
if (pageItem) return { identity: pageItemUrl(pageItem), view: '', anchor: '' }
// Keyed by its hash: as a bare path it would claim the live editor's tab on the same script.
const historical = parseHistoricalScriptEdit(loc)
if (historical) {
return { identity: `/scripts/edit/${historical.path}@${historical.hash}`, view: '', anchor: '' }
}
const canonical = canonicalizeObservedLoc(loc)
const path = barePath(canonical)
const bare = canonical.split('#')[0]
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
return {
identity: path,
view: requestedParams(query, pageRequestParamTable()[filterPageOf(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[filterPageOf(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
} {
// Told as its list page with the item open, the shape the model already reads for a row
// whose drawer is open — which is all a page item tab is to it.
const pageItem = parsePageItemRoute(loc)
if (pageItem) {
return {
...previewLocationContext(pageItemListPath(pageItem)),
open: promptSafe(pageItem.path)
}
}
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 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 = barePath(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 (barePath(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 pageItem = parsePageItemRoute(url)
if (pageItem) return pageItem.path.split('/').pop() || pageItem.path
const page = matchReusablePage(url)
if (page) return page.label
const trigger = triggerLabelForPath(url)
if (trigger) return trigger
const run = parseRunRoute(url)
// Job ids are time-ordered, so runs started moments apart share their head.
if (run) return `Run ${run.jobId.slice(-8)}`
// The runs of one item, as a detail page's Runs button links them.
const itemRuns = barePath(url).match(/^\/runs\/(.+)$/)
if (itemRuns) return `Runs · ${decodeURIComponent(itemRuns[1]).split('/').pop()}`
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) return pipelineFolder
const historical = parseHistoricalScriptEdit(url)
if (historical) return `${historical.path.split('/').pop()} @ ${historical.hash.slice(0, 8)}`
const parsed = parsePreviewItemRoute(url)
if (parsed) return parsed.itemPath.split('/').pop() ?? parsed.itemPath
return barePath(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
}
}
// 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]) : ''
}
}
/** The run a `/run/<id>` location shows, in the workspace its query names (the opener's,
* when it names none). */
export function parseRunRoute(
url: string
): { jobId: string; workspace?: string; viewToken: boolean } | null {
const m = barePath(url).match(/^\/run\/([^/?#]+)$/)
if (!m) return null
try {
const query = new URL(url, 'http://_').searchParams
return {
jobId: decodeURIComponent(m[1]),
workspace: query.get('workspace') || undefined,
viewToken: query.has('view_token')
}
} catch {
return null
}
}
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, 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 of
// that folder (`path` is the folder); the list page of a page item kind mounts that
// list, and so does the runs list; a `/run/<id>` route without a share token mounts that
// run's page; 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: 'runform'; toolCallId: string }
| { kind: 'pageitem'; ref: PageItemRef }
| { kind: 'pagelist'; path: string }
| { kind: 'run'; jobId: string; workspace?: 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 pageItem = parsePageItemRoute(url)
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const route = barePath(url)
if (isPreviewListPath(route)) return { kind: 'pagelist', path: route }
const run = parseRunRoute(url)
// A share link's token authorizes every request of the window that installs it, so a run
// opened through one keeps a frame of its own rather than lending the token to the session.
if (run && !run.viewToken) {
return { kind: 'run', jobId: run.jobId, ...(run.workspace && { workspace: run.workspace }) }
}
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
}
const item = parsePreviewItemRoute(url)
if (!item || parseHistoricalScriptEdit(url)) return { kind: 'iframe' }
const itemKind: SessionTargetKind | undefined =
item.kind === 'script'
? 'script'
: item.kind === 'flow'
? 'flow'
: item.kind === 'app' && item.raw_app
? 'raw_app'
: undefined
if (!itemKind) return { kind: 'iframe' }
if (item.mode === 'view') return { kind: 'viewer', viewerKind: itemKind, path: item.itemPath }
return { kind: 'editor', editorKind: itemKind, path: item.itemPath }
}
/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when
* there is none. Every tab kind answers here, so a new one cannot fall through to its url being
* navigated as a path — an artifact or page item url is a scheme, not a route. */
export function workspacePageHref(location: string): string | undefined {
const slot = resolvePreviewTab(location)
switch (slot.kind) {
case 'artifact':
case 'runform':
return undefined
case 'pageitem':
return pageItemPageHref(slot.ref)
case 'editor':
case 'viewer':
case 'pagelist':
case 'run':
case 'iframe':
return location
default: {
const unhandled: never = slot
return unhandled
}
}
}