refactor: mount the run page in process in ai session previews (#11721)

* refactor: mount the run page in process in ai session previews

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

* fix: let run again fire repeatedly and re-pin an open script tab

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

* fix: keep a run opened through a share link in its own frame

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:14:49 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d7c910fcec
commit a80c6ffacb
10 changed files with 1673 additions and 1381 deletions
@@ -3,7 +3,7 @@
import { type MetricDataPoint, MetricsService } from '$lib/gen'
import { displayTime } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { useOperatingWorkspace } from './operatingWorkspace.svelte'
import {
CategoryScale,
Chart as ChartJS,
@@ -26,6 +26,8 @@
let { jobId, jobUpdateLastFetch }: Props = $props();
const operatingWorkspace = useOperatingWorkspace()
let jobMetricsLastFetch: Date | undefined = undefined
let jobMemoryStats: MetricDataPoint[] | undefined = $state(undefined)
@@ -38,7 +40,7 @@
async function loadMetricsData() {
try {
let jobStatsPromise = MetricsService.getJobMetrics({
workspace: $workspaceStore!,
workspace: $operatingWorkspace!,
id: jobId,
requestBody: {
from_timestamp: jobMetricsLastFetch?.toISOString(),
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,10 @@
import { resource } from 'runed'
import { canSkipRetryChainQuery } from './scriptRetryChain'
let { job }: { job: Job } = $props()
let {
job,
onNavigate = goto
}: { job: Job; onNavigate?: (url: string) => void | Promise<void> } = $props()
type AttemptColor = 'green' | 'red' | 'blue' | 'gray'
type Attempt = { id: string; color: AttemptColor; current: boolean }
@@ -141,7 +144,7 @@
size="xs"
color="gray"
variant="border"
onclick={() => goto(`/run/${self?.handledJob}?workspace=${ws}`)}
onclick={() => onNavigate(`/run/${self?.handledJob}?workspace=${ws}`)}
>
Handled run
</Button>
@@ -163,7 +166,7 @@
size="xs"
color={attempt.color}
variant={attempt.current ? 'contained' : 'border'}
onclick={() => goto(`/run/${attempt.id}?workspace=${ws}`)}
onclick={() => onNavigate(`/run/${attempt.id}?workspace=${ws}`)}
>
{i === 0 ? 'Original' : `Attempt ${i}`}
</Button>
@@ -180,7 +183,7 @@
size="xs"
color={handler.color}
variant="border"
onclick={() => goto(`/run/${handler.id}?workspace=${ws}`)}
onclick={() => onNavigate(`/run/${handler.id}?workspace=${ws}`)}
>
{HANDLER_LABEL[handler.kind]}
</Button>
@@ -28,6 +28,8 @@
kind,
path,
version,
locationHash = '',
dbtRetryFrom,
workspaceId,
tabId,
container,
@@ -42,6 +44,10 @@
path: string
/** Deployed version this tab is pinned to, from its URL's `?version=`. */
version?: string
/** `#`-fragment of the tab's URL: the args a run's `Run again` prefills the form with. */
locationHash?: string
/** The failed dbt run that `Run again` offers to resume, from `?dbt_retry_from=`. */
dbtRetryFrom?: string
workspaceId: string
/** Whether this side is the one on screen. Hidden sides stay mounted, so this gates
* the window-level keyboard handler — several instances listen on the window. */
@@ -83,10 +89,20 @@
reloadKey++
}
const searchParams = $derived(version ? new URLSearchParams({ version }) : undefined)
const searchParams = $derived(
version || dbtRetryFrom
? new URLSearchParams({
...(version ? { version } : {}),
...(dbtRetryFrom ? { dbt_retry_from: dbtRetryFrom } : {})
})
: undefined
)
// Remounts the detail page below, which loads on mount.
const loadKey = $derived(`${workspaceId}/${kind}/${path}/${version ?? ''}/${reloadKey}`)
// The fragment is part of it: the detail pages seed their form from it once, on mount.
const loadKey = $derived(
`${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${reloadKey}`
)
// Nothing deployed at this path. Stamped with the load it answers, so a refetch or a
// re-pointed tab is never answered from an earlier load's verdict.
let notFoundFor: string | undefined = $state(undefined)
@@ -214,6 +230,7 @@
hash={path}
workspace={workspaceId}
{searchParams}
{locationHash}
{onNavigate}
{active}
embedded
@@ -224,6 +241,7 @@
{path}
workspace={workspaceId}
{searchParams}
{locationHash}
{onNavigate}
{active}
embedded
@@ -97,6 +97,9 @@
? (new URL(tab.url, 'http://x').searchParams.get('version') ?? undefined)
: undefined
)
// What a run's `Run again` hands the deployed page it opens: the run's args in the
// fragment, and for a dbt run the failure it could resume instead.
const viewerUrl = $derived(slot.kind === 'viewer' ? new URL(tab.url, 'http://x') : undefined)
// The path an editor reports is its deployed one, which a staged rename can make
// differ from this tab's — so the view opens on what actually exists.
@@ -147,6 +150,7 @@
// A page item's editor reads its draft only when it loads, so a reload remounts it.
let pageItemReloadNonce = $state(0)
let pageListReloadNonce = $state(0)
let runReloadNonce = $state(0)
export function reload() {
// A live editor shares the runtime store the chat mutates, so generic chat
@@ -168,6 +172,10 @@
pageListReloadNonce++
return
}
if (slot.kind === 'run') {
runReloadNonce++
return
}
try {
const win = frame?.contentWindow
if (!win) return
@@ -393,6 +401,8 @@
kind={itemKind}
path={itemPath}
version={pinnedVersion}
locationHash={viewerUrl?.hash}
dbtRetryFrom={viewerUrl?.searchParams.get('dbt_retry_from') ?? undefined}
{workspaceId}
tabId={tab.id}
container={overlayHostEl}
@@ -444,6 +454,29 @@
{/await}
{/if}
</div>
{:else if slot.kind === 'run' && mounted && runtime}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
{#if overlayHostEl}
{#await import('./RunViewerView.svelte')}
{@render editorLoading()}
{:then Module}
<!-- A run stays in the workspace it ran in, which the tab's URL names: the
session's own can change under an open tab (a staged fork committing). -->
<Module.default
{runtime}
jobId={slot.jobId}
workspaceId={slot.workspace ?? workspaceId}
tabId={tab.id}
container={overlayHostEl}
reloadNonce={runReloadNonce}
/>
{/await}
{/if}
</div>
{:else if slot.kind === 'artifact' && mounted}
<div
bind:this={overlayHostEl}
@@ -0,0 +1,106 @@
<script lang="ts">
/*
* A run in a session preview tab, rendered by the component the workspace `/run/<id>` page
* renders. Everything it reaches is scoped to the run's workspace through `workspace`, and
* every navigation it attempts is caught here and turned into a move inside the preview
* panel, so nothing takes the browser out of the session.
*/
import { setContext, untrack } from 'svelte'
import RunDetail from '$lib/components/runs/RunDetail.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import {
parseHistoricalScriptEdit,
parsePreviewItemRoute,
parseRunRoute,
previewLocationLabel,
barePath
} from './previewRouter'
let {
runtime,
jobId,
workspaceId,
tabId,
container,
reloadNonce = 0
}: {
runtime: SessionRuntime
jobId: string
workspaceId: string
/** The preview tab hosting this run, which a move to another run re-points. */
tabId: string
/** The tab's host element, which also holds the overlays the page portals out. */
container: HTMLElement | undefined
/** Bumped to load the run again from the server. */
reloadNonce?: number
} = $props()
// Mounted in the preview panel, outside the chat's own subtree, as ItemViewerView is: the
// run page's AI entry points would otherwise offer a session from inside one.
setContext(
'aiChatManager',
untrack(() => runtime.manager)
)
// Everything the run page navigates to, resolved against the preview panel. Another run —
// a re-run, a retry, the parent job — is still this tab's subject, so it re-points in
// place; an item or any other page opens beside it. Nothing reaches the top-level router.
function onNavigate(url: string): void {
const parsed = new URL(url, window.location.origin)
const href = `${parsed.pathname}${parsed.search}${parsed.hash}`
if (parseRunRoute(href)) {
runtime.previewTabs.navigate({ type: 'page', href, label: previewLocationLabel(href) }, tabId)
return
}
const route = parsePreviewItemRoute(href)
// Legacy drag-and-drop apps and historical script edits have no in-panel host, so they
// fall through to a page tab. So does `Run again`, whose args ride in the fragment, and
// a link to one version: opening an item focuses a tab already on it as it stands,
// where a page tab's URL is written whole, pin and fragment included.
if (
route &&
(route.kind !== 'app' || route.raw_app) &&
!parseHistoricalScriptEdit(href) &&
!parsed.hash &&
!parsed.searchParams.has('version')
) {
runtime.previewTabs.open({
type: 'item',
item: { path: route.itemPath, summary: '', kind: route.kind, raw_app: route.raw_app },
mode: route.mode
})
return
}
runtime.previewTabs.open({ type: 'page', href, label: previewLocationLabel(barePath(href)) })
}
// Every in-app link under the tab, not only the ones that call `onNavigate`: a plain anchor
// followed by the browser would take it out of the session. Listened for on `body`, as
// ItemViewerView does and for the same reason.
$effect(() => {
const el = container
if (!el) return
function onClick(e: MouseEvent) {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const target = e.target as Element | null
if (!target || !el!.contains(target)) return
const a = target.closest?.('a[href]') as HTMLAnchorElement | null
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
const url = new URL(a.href, window.location.href)
if (url.origin !== window.location.origin) return
e.preventDefault()
if (a.getAttribute('href')?.startsWith('#')) return
onNavigate(`${url.pathname}${url.search}${url.hash}`)
}
document.body.addEventListener('click', onClick)
return () => document.body.removeEventListener('click', onClick)
})
</script>
<div class="flex h-full min-h-0 flex-col overflow-auto">
<!-- Keyed: the run page binds one job's loaders and panels for its lifetime. -->
{#key `${workspaceId}/${jobId}/${reloadNonce}`}
<RunDetail id={jobId} workspace={workspaceId} {onNavigate} embedded />
{/key}
</div>
@@ -316,6 +316,18 @@ describe('resolvePreviewTab', () => {
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
})
it('mounts a run in process, in the workspace its URL names', () => {
expect(resolvePreviewTab('/run/01a0?workspace=fork')).toEqual({
kind: 'run',
jobId: '01a0',
workspace: 'fork'
})
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 list page of a page item kind in process, whatever its filters', () => {
expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({
kind: 'pagelist',
@@ -464,9 +464,9 @@ export function previewLocationLabel(url: string): string {
if (page) return page.label
const trigger = triggerLabelForPath(url)
if (trigger) return trigger
const run = barePath(url).match(/^\/run\/([^/?#]+)/)
const run = parseRunRoute(url)
// Job ids are time-ordered, so runs started moments apart share their head.
if (run) return `Run ${decodeURIComponent(run[1]).slice(-8)}`
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()}`
@@ -550,6 +550,25 @@ export function parseRunFormRoute(url: string): { toolCallId: string; label: str
}
}
/** 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)}`
}
@@ -575,7 +594,7 @@ 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; everything
// 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 }
@@ -584,6 +603,7 @@ export type PreviewSlot =
| { 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 {
@@ -595,6 +615,12 @@ export function resolvePreviewTab(url: string): PreviewSlot {
if (pageItem) return { kind: 'pageitem', ref: pageItem }
const route = barePath(url)
if (isPageItemListPath(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 }
@@ -628,6 +654,7 @@ export function workspacePageHref(location: string): string | undefined {
case 'editor':
case 'viewer':
case 'pagelist':
case 'run':
case 'iframe':
return location
default: {
@@ -471,6 +471,7 @@ function createRuntime(session: Session): SessionRuntime {
slotKind !== 'iframe' &&
slotKind !== 'pageitem' &&
slotKind !== 'pagelist' &&
slotKind !== 'run' &&
slotKind !== 'viewer'
)
return undefined
@@ -593,8 +594,8 @@ function createRuntime(session: Session): SessionRuntime {
onTabOpened: logTabUsage
})
// Let the jobs tray open a run in this session's preview panel (as an iframe
// tab over the run page). The global side-panel chat leaves this unset and
// Let the jobs tray open a run in this session's preview panel (as a tab on
// the run's page). The global side-panel chat leaves this unset and
// falls back to a new browser tab.
manager.openRunInPreview = ({ jobId, workspace, label }) => {
previewTabs.open({
File diff suppressed because it is too large Load Diff