mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
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:
co-authored by
Claude Opus 5.5
parent
d7c910fcec
commit
a80c6ffacb
@@ -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
Reference in New Issue
Block a user