import { ASSETS_PATH, AUDIT_LOGS_PATH, isPageKey, FOLDERS_PATH, GROUPS_PATH, isPreviewListPath, isRunsListPath, pageItemForListPath, pageItemListPath, pageItemPageHref, pageItemUrl, pageKey, parsePageItemRoute, parsePipelineRoute, parseHistoricalScriptEdit, parsePreviewItemRoute, RESOURCES_PATH, RUNS_PATH, SCHEDULES_PATH, barePath, agentPagePath, VARIABLES_PATH, WORKSPACE_SETTINGS_PATH, triggerLabelForPath, TRIGGER_PAGES, type PageItemRef, type PreviewItemMode, type PreviewItemRoute, type TriggerKind } from './previewPaths' import type { UserWorkspace } from '$lib/stores' import { capabilitiesIn, type CapabilitiesUser, type VisibilityKey } from '$lib/roleCapabilities' // 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 { isPageKey, 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. // `pendingRunId` hands this tab a chat tool call parked on a run form: the deployed page // shows that form, and Run there answers the call instead of starting a job of its own. // In the query for the same reason as `version` — the tab stays the item's, and the call // is a property of this tab rather than of the item. The id alone: what the call proposed // is read off the chat, since a tab's url is persisted with the session and those // arguments can hold file bytes and secrets typed into the form. | { type: 'item' item: WorkspaceItem mode?: PreviewItemMode version?: string pendingRunId?: 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): PreviewItemMode { return item.draftOnly ? 'edit' : 'view' } export type PreviewPage = { label: string path: string icon: DrillIcon /** The role visibility flag that shows the page, as the sidebar reads it. */ visibilityKey?: VisibilityKey adminOnly?: boolean } /** Whether `page` loads for `user` rather than showing its "not available" state. An unknown * user is held to the workspace entry's role, and kept off the admin-only pages. */ export function previewPageAllowed( page: PreviewPage, user: CapabilitiesUser | undefined, workspace: UserWorkspace | undefined ): boolean { if (page.adminOnly) return !!user && (!!user.is_admin || !!user.is_super_admin) return !page.visibilityKey || capabilitiesIn(user, workspace).visibility[page.visibilityKey] } // 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, visibilityKey: 'runs' }, { label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, visibilityKey: 'variables' }, { label: 'Resources', path: RESOURCES_PATH, icon: Boxes, visibilityKey: 'resources' }, { label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, visibilityKey: 'schedules' }, { label: 'Assets', path: ASSETS_PATH, icon: Database, visibilityKey: 'assets' }, { label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, visibilityKey: 'folders' }, { label: 'Groups', path: GROUPS_PATH, icon: Users, visibilityKey: 'groups' }, { label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings, adminOnly: true }, { label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, visibilityKey: '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 | undefined function pageRequestParamTable(): Record { 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 = { [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 const agent = barePath(url).match(/^\/agents\/(?:edit|get)\/(.+)$/) if (agent) { let path = agent[1] try { path = decodeURIComponent(path) } catch {} return path.split('/').pop() || path } 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_` 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_` 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 chat tool call a viewer tab was opened to answer, read off the tab's url. A deployed * page tab is still the item's tab — only the call rides in its query — so this is how a tab * says which call it is carrying, and re-pointing it anywhere drops the answer. */ export function pendingRunIdOf(url: string): string | undefined { const slot = resolvePreviewTab(url) if (slot.kind !== 'viewer') return undefined return new URL(url, 'http://x').searchParams.get('pending_run') ?? undefined } /** The run a `/run/` 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/` 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/` 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 | 'agent'; 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' } /** Whether a tab on this slot shows a page, framed or mounted in process, and so has a * location to stamp as the chat's ACTIVE PREVIEW. Editors register as the ACTIVE EDITOR. */ export function slotShowsPage(kind: PreviewSlot['kind']): boolean { return ( kind === 'iframe' || kind === 'pageitem' || kind === 'pagelist' || kind === 'run' || kind === 'viewer' ) } 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 } } // An agent's deployed page; its editor is the resource tab, which hosts it in process. const agent = agentPagePath(url) if (agent !== undefined) return { kind: 'viewer', viewerKind: 'agent', path: agent } 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 } } }