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>
This commit is contained in:
Ruben Fiszel
2026-10-13 12:10:23 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3d74f6ea2a
commit 3d67d3ac6b
11 changed files with 196 additions and 62 deletions
+46 -30
View File
@@ -11,14 +11,12 @@
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores'
import { useHostedPage } from '$lib/components/hostedPage'
import {
userStore,
workspaceStore,
userWorkspaces,
superadmin,
devopsRole,
enterpriseLicense
} from '$lib/stores'
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RunChart from '$lib/components/RunChart.svelte'
@@ -81,6 +79,13 @@
let { initialPath }: Props = $props()
// Under a host — an AI session's preview tab — the document URL, the page header band and
// the navigation workspace all belong to the host: the filters live where it keeps them,
// the filter row renders in the page, and requests go to the workspace it names.
const hosted = useHostedPage()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
let paths: string[] = $state([])
let usernames: string[] = $state([])
let folders: string[] = $state([])
@@ -91,7 +96,7 @@
folders,
jobTriggerKinds,
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
isAdminsWorkspace: $operatingWorkspace === 'admins'
})
)
let perPage = useLocalStorageValue('runs_per_page', 1000, 'number')
@@ -115,6 +120,8 @@
// what was seeded at mount is gone. Re-apply it. Editing a filter writes with `replaceState`,
// which never reaches `page.url`, so a filter the user clears stays cleared.
$effect(() => {
// A host remounts the page when it re-points it, and its document URL is not this page's.
if (hosted) return
page.url.href
untrack(() => filters.seed(filterSeeds()))
})
@@ -171,7 +178,9 @@
let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined)
let graph: 'RunChart' | 'ConcurrencyChart' = $state(
typeOfChart(page.url.searchParams.get('graph'))
typeOfChart(
(hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph')
)
)
let innerWidth = $state(window.innerWidth)
let jobsLoader = useJobsLoader(() => ({
@@ -183,7 +192,7 @@
resultError,
perPage: perPage.val,
lookback: graph === 'RunChart' ? 0 : lookback,
currentWorkspace: $workspaceStore ?? ''
currentWorkspace: $operatingWorkspace ?? ''
}))
let batchProgress = $derived(jobsLoader.batchProgress)
let currentBatchSize = $derived(jobsLoader.currentBatchSize)
@@ -211,18 +220,18 @@
}
async function loadUsernames(): Promise<void> {
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
}
async function loadFolders(): Promise<void> {
folders = await FolderService.listFolders({
workspace: $workspaceStore!
workspace: $operatingWorkspace!
}).then((x) => x.map((y) => y.name))
}
async function loadPaths() {
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' })
const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' })
paths = npaths_scripts.concat(npaths_flows).sort()
}
@@ -265,7 +274,7 @@
const resultFilter = filters.val.result && JSON.parse(filters.val.result)
const { minTs, maxTs } = timeframe.computeMinMax()
return {
workspace: $workspaceStore ?? '',
workspace: $operatingWorkspace ?? '',
startedBefore: maxTs ?? undefined,
startedAfter: minTs ?? undefined,
schedulePath: filters.val.schedule_path ?? undefined,
@@ -323,7 +332,7 @@
}
async function cancelJobs(uuidsToCancel: string[], forceCancel: boolean = false) {
const uuids = await JobService.cancelSelection({
workspace: $workspaceStore ?? '',
workspace: $operatingWorkspace ?? '',
requestBody: uuidsToCancel,
forceCancel: forceCancel,
allWorkspaces: filters.val.all_workspaces || undefined
@@ -350,7 +359,7 @@
for (const j of jobs ?? []) if (j.workspace_id) workspaceById.set(j.id, j.workspace_id)
const byWorkspace = new Map<string, string[]>()
for (const id of jobIds) {
const ws = workspaceById.get(id) ?? $workspaceStore ?? ''
const ws = workspaceById.get(id) ?? $operatingWorkspace ?? ''
const bucket = byWorkspace.get(ws)
if (bucket) bucket.push(id)
else byWorkspace.set(ws, [id])
@@ -428,7 +437,7 @@
}
async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) {
if (!$workspaceStore) return
if (!$operatingWorkspace) return
if (askingForConfirmation) {
askingForConfirmation.loading = true
@@ -442,7 +451,7 @@
// workaround because EventSource does not support POST requests
// https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425
const response = await fetch(`${OpenAPI.BASE}/w/${$workspaceStore}/jobs/run/batch_rerun_jobs`, {
const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -555,7 +564,7 @@
}
$effect(() => {
if ($workspaceStore) {
if ($operatingWorkspace) {
untrack(() => {
loadUsernames()
loadFolders()
@@ -603,7 +612,7 @@
openedRunId = undefined
}
let resolutionNote = $state('')
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
const phone = $derived(!hosted && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
</script>
{#snippet runsHint()}
@@ -620,7 +629,7 @@
two or three buttons, so they take a wider gap than the band's own. `flex-1 min-w-0` passes
the band's flexibility down to the search field, the one control here that gives width
back; the rest keep theirs or drop out of the row entirely at a width. -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex items-center gap-3 flex-1 min-w-0 {hosted ? 'flex-wrap justify-end' : ''}">
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
so without it the badge butts against the first filter.
@@ -816,12 +825,12 @@
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
isAdminsWorkspace: $workspaceStore === 'admins'
isAdminsWorkspace: $operatingWorkspace === 'admins'
})}
bind:value={filters.val}
placeholder="Filter runs..."
size="sm"
autofocus
autofocus={!hosted}
/>
<TimeframeSelect
unifiedSize="sm"
@@ -833,11 +842,11 @@
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
<div class={phone ? 'hidden' : 'contents'}>
<div class={phone || hosted ? 'hidden' : 'contents'}>
<OpenInSessionButton
source={{
page: () => RUNS_PATH + window.location.search,
workspaceId: $workspaceStore ?? undefined
workspaceId: $operatingWorkspace ?? undefined
}}
btnProps={{ unifiedSize: 'sm' }}
/>
@@ -900,13 +909,14 @@
</DrawerContent>
</Drawer>
<!-- The document's history is the host's: its Back is not a move on this page. -->
<svelte:window
onpopstate={() => {
reset()
if (!hosted) reset()
}}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.runs}
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
@@ -915,7 +925,13 @@
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
<!-- `actionsFlexible`: the filter row holds a search field that can give width back, so the
bar lets this box shrink on a phone instead of holding its content width. -->
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
{#if hosted}
<div class="flex px-4 pt-3 pb-2">
{@render headerActions()}
</div>
{:else}
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
{/if}
<!-- Graph -->
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
@@ -1106,7 +1122,7 @@
batchRerunOptionsIsOpen = true
}
},
...(!$userStore?.operator
...(!operatingUser.current?.operator
? [
{
// Operators are rejected by the endpoint, so offering it would only 403.
@@ -13,7 +13,7 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import { JobService, type InputTransform, type ListSelectedJobGroupsResponse } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
import type { Schema } from '$lib/common'
import InputTransformForm from '../InputTransformForm.svelte'
import type { FlowPropPickerConfig, PropPickerContext } from '../prop_picker'
@@ -43,6 +43,8 @@
onConfirm: (options: BatchReRunOptions) => void
} = $props()
const operatingWorkspace = useOperatingWorkspace()
let selected: JobGroup | undefined = $state()
let options: BatchReRunOptions = $state({ flow: {}, script: {} })
watch(
@@ -65,7 +67,7 @@
const listSelectedJobsSchemaCache = new Map<string, JobGroup>()
async function fetchJobGroups(): Promise<JobGroup[]> {
if (!$workspaceStore) return []
if (!$operatingWorkspace) return []
const cachedSelectedIds = selectedIds.filter((id) => listSelectedJobsSchemaCache.has(id))
const nonCachedSelectedIds = selectedIds.filter((id) => !listSelectedJobsSchemaCache.has(id))
@@ -75,7 +77,7 @@
)
const newJobGroups = nonCachedSelectedIds.length
? await JobService.listSelectedJobGroups({
workspace: $workspaceStore,
workspace: $operatingWorkspace,
requestBody: nonCachedSelectedIds
})
: []
@@ -7,7 +7,7 @@
import { Skeleton, Tab, Tabs } from '../common'
import HighlightCode from '../HighlightCode.svelte'
import FlowStatusViewer from '../FlowStatusViewer.svelte'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
import WorkflowTimeline from '../WorkflowTimeline.svelte'
import { isFlowPreview, isScriptPreview, type StateStore } from '$lib/utils'
import { setContext, untrack, createEventDispatcher } from 'svelte'
@@ -26,6 +26,7 @@
let { id, workspace, onResolutionChanged }: Props = $props()
const dispatch = createEventDispatcher()
const operatingWorkspace = useOperatingWorkspace()
let job: (Job & { result_stream?: string }) | undefined = $state(undefined)
@@ -185,7 +186,7 @@
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
<JobArgs
id={job?.id}
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
args={job?.args}
/>
</div>
@@ -21,6 +21,7 @@
import { forLater } from '$lib/forLater'
import { twMerge } from 'tailwind-merge'
import Portal from '$lib/components/Portal.svelte'
import { useHostedPage } from '../hostedPage'
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
import DropdownV2 from '../DropdownV2.svelte'
@@ -29,6 +30,7 @@
import './runs-grid.css'
const dispatch = createEventDispatcher()
const hosted = useHostedPage()
interface Props {
job: Job
@@ -60,7 +62,10 @@
</script>
<Portal name="run-row">
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor
onUpdate={() => (hosted ? hosted.openLink('/schedules') : goto('/schedules'))}
bind:this={scheduleEditor}
/>
</Portal>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -13,7 +13,6 @@
Wrench
} from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import './runs-grid.css'
import { useKeyPressed } from '$lib/svelte5Utils.svelte'
import { twMerge } from 'tailwind-merge'
@@ -21,6 +20,9 @@
import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte'
import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils'
import { goto } from '$lib/navigation'
import { useHostedPage } from '../hostedPage'
import { useOperatingUser, useOperatingWorkspace } from '../operatingWorkspace.svelte'
import { withWorkspaceParam } from '../sessions/sessionMode.svelte'
import BarsStaggered from '../icons/BarsStaggered.svelte'
interface Props {
@@ -201,6 +203,16 @@
}
const dispatch = createEventDispatcher()
const hosted = useHostedPage()
const operatingWorkspace = useOperatingWorkspace()
const operatingUser = useOperatingUser()
// Under a host the link opens where it decides, in the workspace the run belongs to: the
// document's own router would leave the host for the navigation workspace.
function open(href: string) {
if (!hosted) goto(href)
else hosted.openLink(withWorkspaceParam(href, selectedWorkspace ?? $operatingWorkspace))
}
let scrollToIndex = $state(0)
@@ -272,7 +284,7 @@
actions.push({
label: 'Show run details',
icon: ExternalLinkIcon,
onClick: () => goto(`/run/${selectedIds[0]}`)
onClick: () => open(`/run/${selectedIds[0]}`)
})
const job = flatJobs?.find(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
@@ -282,14 +294,20 @@
actions.push({
label: 'Go to script page',
icon: Code2Icon,
onClick: () => goto(`/scripts/get/${job.job.script_hash}`)
onClick: () =>
open(
// A host addresses an item by its path, and pins a version in the query.
hosted
? `/scripts/get/${job.job.script_path}?version=${job.job.script_hash}`
: `/scripts/get/${job.job.script_hash}`
)
})
}
if (job.job.job_kind === 'flow') {
actions.push({
label: 'Go to flow page',
icon: BarsStaggered,
onClick: () => goto(`/flows/get/${job.job.script_path}`)
onClick: () => open(`/flows/get/${job.job.script_path}`)
})
}
}
@@ -314,7 +332,7 @@
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
})
// Operators are rejected by the endpoint, so offering the action would only 403.
const canResolve = !$userStore?.operator
const canResolve = !operatingUser.current?.operator
const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0
const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0
if (resolvable)
@@ -401,7 +419,7 @@
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
</div>
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
{:else if $operatingWorkspace !== 'admins' && omittedObscuredJobs}
<div class="flex flex-row">
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
<Popover>
@@ -3,8 +3,14 @@
import { Loader2 } from 'lucide-svelte'
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { setHostedPage, type HostedPage } from '$lib/components/hostedPage'
import { pageItemForListPath, parsePreviewItemRoute } from './previewPaths'
import { previewLocationLabel } from './previewRouter'
import {
RUNS_PATH,
barePath,
isRunsListPath,
pageItemForListPath,
parsePreviewItemRoute
} from './previewPaths'
import { parseRunRoute, previewLocationLabel } from './previewRouter'
import { whereIs } from './sessionPreviewTabs.svelte'
import type { SessionPreviewTab } from './sessionState.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
@@ -44,20 +50,48 @@
? { pathname: loc, search: '' }
: { pathname: loc.slice(0, at), search: loc.slice(at) }
}
// The location this page last wrote itself, and the one it was last moved to from outside.
let ownLoc: string | undefined
let movedTo: string | undefined
let moves = 0
// Bumped when the location moves other than by the page's own write. Derived rather than
// effected: it has to flip in the same flush as the location, before the page's own
// effects read the new query and write their defaults back over the stored preferences.
const moved = $derived.by(() => {
const loc = whereIs(tab)
if (loc !== ownLoc && loc !== movedTo) {
movedTo = loc
moves++
}
return moves
})
const host: HostedPage = {
get search() {
return locationParts().search
},
setSearch(search) {
runtime.previewTabs.observeLocation(tab.id, locationParts().pathname + search)
ownLoc = locationParts().pathname + search
movedTo = undefined
runtime.previewTabs.observeLocation(tab.id, ownLoc)
},
openItem(itemPath) {
const ref = pageItemForListPath(path, itemPath)
if (ref) runtime.previewTabs.open({ type: 'pageitem', ref })
},
openLink(href) {
const run = parseRunRoute(href)
if (run) {
runtime.manager.openRunInPreview?.({
jobId: run.jobId,
workspace: run.workspace ?? workspaceId,
label: previewLocationLabel(`/run/${run.jobId}`)
})
return
}
const item = parsePreviewItemRoute(href)
if (item) {
// A link to one version stays a page tab: opening an item focuses a tab already
// on it as it stands, where a page tab's URL is written whole, pin included.
if (item && !new URL(href, 'http://_').searchParams.has('version')) {
runtime.previewTabs.open({
type: 'item',
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' },
@@ -83,18 +117,32 @@
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
if (!a || a.hasAttribute('download')) return
const url = new URL(a.href, window.location.href)
if (url.origin !== window.location.origin) return
const href = url.pathname + url.search + url.hash
// A run's link asks for a new tab so the list keeps its place: here that tab is a
// preview tab, not a browser one that would leave the session.
if (a.target && a.target !== '_self' && !parseRunRoute(href)) return
e.preventDefault()
if (a.getAttribute('href')?.startsWith('#')) return
host.openLink(url.pathname + url.search + url.hash)
host.openLink(href)
}
el.addEventListener('click', onClick)
return () => el.removeEventListener('click', onClick)
})
const listed = $derived(pageItemForListPath(path, ''))
// `/runs/<item path>` lists one item's runs.
const runsOf = $derived.by(() => {
if (!isRunsListPath(path)) return undefined
const itemPath = barePath(path).slice(RUNS_PATH.length + 1)
try {
return decodeURIComponent(itemPath)
} catch {
return itemPath
}
})
</script>
{#snippet loading()}
@@ -130,6 +178,15 @@
{:then Module}
<Module.default triggerKind={listed.triggerKind} />
{/await}
{:else if runsOf !== undefined}
<!-- A move from outside remounts it, so it seeds the filters the user keeps stored. -->
{#key moved}
{#await import('$lib/components/RunsPage.svelte')}
{@render loading()}
{:then Module}
<Module.default initialPath={runsOf} />
{/await}
{/key}
{/if}
{/key}
</div>
@@ -84,6 +84,17 @@ export function isPageItemListPath(path: string): boolean {
return pageItemForListPath(path, '') !== undefined
}
/** Whether a path is the runs list: every run, or `/runs/<item path>` for one item's. */
export function isRunsListPath(path: string): boolean {
const clean = barePath(path)
return clean === RUNS_PATH || clean.startsWith(`${RUNS_PATH}/`)
}
/** Whether a path is a list page a session preview mounts in process rather than frames. */
export function isPreviewListPath(path: string): boolean {
return isPageItemListPath(path) || isRunsListPath(path)
}
/** The page item a list page's row names, or undefined for a page that lists none. */
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
const clean = barePath(pagePath)
@@ -145,7 +156,13 @@ export const pageKey = (path: string) => `page:${path}`
/** Strip the query and hash from a preview path so it can be matched against
* `PREVIEW_PAGES` / parsed as an item route. */
export function barePath(path: string): string {
return path.split('?')[0].split('#')[0] || '/'
// The router serves `/runs/` as `/runs`, and links are written both ways.
return (
path
.split('?')[0]
.split('#')[0]
.replace(/(.)\/+$/, '$1') || '/'
)
}
/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the
@@ -313,7 +313,7 @@ describe('itemDisplayName', () => {
describe('resolvePreviewTab', () => {
it('routes a static page to the iframe fallback', () => {
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
expect(resolvePreviewTab('/audit_logs')).toEqual({ kind: 'iframe' })
})
it('mounts a run in process, in the workspace its URL names', () => {
@@ -324,8 +324,19 @@ describe('resolvePreviewTab', () => {
})
expect(resolvePreviewTab('/run/01a0')).toEqual({ kind: 'run', jobId: '01a0' })
expect(resolvePreviewTab('/run/01a0?workspace=fork&view_token=t')).toEqual({ kind: 'iframe' })
// The runs of one item are a list page, not a run.
expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'iframe' })
})
it('mounts the runs list in process, for every run or for one item', () => {
expect(resolvePreviewTab('/runs?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' })
expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'pagelist', path: '/runs/f/a/b' })
expect(resolvePreviewTab('/runsheet')).toEqual({ kind: 'iframe' })
})
it('tells two runs lists apart by their filters, trailing slash or not', () => {
expect(resolvePreviewTab('/runs/?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' })
expect(showsView('/runs/?schedule_path=a', '/runs/?schedule_path=b')).toBe(false)
expect(showsView('/runs?schedule_path=a', '/runs/?schedule_path=a')).toBe(true)
expect(showsView('/runs/f/a/b?status=failure', '/runs/f/a/b')).toBe(false)
})
it('mounts the list page of a page item kind in process, whatever its filters', () => {
@@ -3,7 +3,8 @@ import {
AUDIT_LOGS_PATH,
FOLDERS_PATH,
GROUPS_PATH,
isPageItemListPath,
isPreviewListPath,
isRunsListPath,
pageItemForListPath,
pageItemListPath,
pageItemPageHref,
@@ -247,9 +248,14 @@ function pageRequestParamTable(): Record<string, readonly string[]> {
})
}
// 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()[barePath(path)] ?? []
return pageRequestParamTable()[filterPageOf(barePath(path))] ?? []
}
/** What a preview location means, read against the page it points at. */
@@ -286,7 +292,7 @@ export function describeLocation(loc: string): PreviewLocation {
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
return {
identity: path,
view: requestedParams(query, pageRequestParamTable()[path]),
view: requestedParams(query, pageRequestParamTable()[filterPageOf(path)]),
anchor: drawerAnchorFor(canonical) ?? ''
}
}
@@ -319,7 +325,7 @@ export function showsView(observed: string, commanded: string): boolean {
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]
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
@@ -594,7 +600,8 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
// 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; a `/run/<id>` route without a share token mounts that run's page; everything
// 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 }
@@ -614,7 +621,7 @@ export function resolvePreviewTab(url: string): PreviewSlot {
const pageItem = parsePageItemRoute(url)
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const route = barePath(url)
if (isPageItemListPath(route)) return { kind: 'pagelist', path: route }
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.
@@ -110,7 +110,7 @@
import { isGlobalAiEnabled, sessionsAllowedFor } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo } from '$lib/aiStore'
import {
isPageItemListPath,
isPreviewListPath,
parsePreviewItemRoute,
barePath
} from '$lib/components/sessions/previewPaths'
@@ -467,7 +467,7 @@
}
// A list page mounts in process too, where its rows open as tabs of their own.
const listUrl = navigation.to?.url
if (listUrl && isPageItemListPath(barePath(listUrl.pathname))) {
if (listUrl && isPreviewListPath(barePath(listUrl.pathname))) {
navigation.cancel()
const u = new URL(listUrl.href)
u.searchParams.delete('nomenubar')
@@ -80,7 +80,7 @@
tabsToReload,
viewerTabsToReload
} from '$lib/components/sessions/previewReload'
import { isPageItemListPath, barePath } from '$lib/components/sessions/previewPaths'
import { isPreviewListPath, barePath } from '$lib/components/sessions/previewPaths'
import {
leafKeyFor,
loadKind,
@@ -928,7 +928,7 @@
// A preview frame navigating to a list page: re-point the active tab to the list
// mounted in process, whose rows open as tabs of their own.
if (d.type === 'wm.session.openList') {
if (typeof d.href !== 'string' || !isPageItemListPath(barePath(d.href))) return
if (typeof d.href !== 'string' || !isPreviewListPath(barePath(d.href))) return
owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) })
return
}