Files
windmill/frontend/src/lib/components/sessions/previewReload.ts
T
GuilhemandClaude Opus 5 b10f83c763 feat(sessions): show an item's deployed page in the preview panel (#11458)
* feat(sessions): show an item's deployed page in the preview panel

The preview panel showed an item's editor and nothing else. It now shows the
deployed page too, as a tab of its own.

**Two sides, two tabs.** The editor and the deployed page are different things
to look at, so `isItemTabFor` keys on the side rather than the item: asking for
a side already open focuses it, asking for the other opens it alongside. Two
editors for one item still cannot coexist — they would race its single
(kind, path) cell — but an editor beside a viewer is safe, since the viewer
reads the deployed version over the API and holds no cell. The tab breadcrumb
keeps re-pointing in place; `Exit & see details` and the detail page's `Edit`
open the other side instead of consuming the one you are on. A tab's label
carries `(edit)` when it is the editor side.

**The detail pages moved out of their routes.** `/scripts/get` and `/flows/get`
are now thin wrappers over `ScriptDetail` and `FlowDetail`, which the panel
renders too. Everything they reach — drawers, triggers, saved inputs — is scoped
to the viewer's workspace through `setOperatingWorkspace`, and every navigation
they attempt is caught by `interceptNav` and turned into a move inside the
panel, so nothing takes the browser out of the session. A plain click is
intercepted; ⌘-click and middle-click still open a real tab, which is why the
pages keep their `href`s.

**The picker opens what exists.** A row opens the deployed page, or the editor
when nothing is deployed there, and carries a Draft / Draft only badge in the
review dock's words. `WorkspaceItem` gained `draftOnly` and `hasDraft` from the
listers, which already returned both; deploys now invalidate the picker cache
through `itemDeployed`, so a just-deployed item stops reading as a draft. Rows
also carry a hover Edit action, reachable from the keyboard with the pick
modifier.

`open_preview` gained a `mode`, narrowed out of the advertised schema for a
session that cannot write drafts and re-checked per path in the handler, where
a refusal reports "couldn't check" apart from "denied". The mode is resolved
before those checks, so a call that omits it is gated as the editor it will
become.

Alongside, the workspace a detail page acts on is now the one it is showing
rather than the one the browser is navigated to: `MoveDrawer`,
`toggleWorkspaceErrorHandler` and the pages' own permission gates read the
operating workspace and its acting user, and `RunForm` mints a password
argument's ephemeral variable there too — in a fork session all of these
previously answered for the parent. `InWorkspaceAppViewer` hands the app's
`ctx` user down as a prop instead of writing the global `userStore`, which from
a session tab was re-pointing every permission check on the page.

Fixes found on the way: `DetailPageLayout` claimed `h-screen` inside a panel
that is not the viewport; Edit on a historical script version dropped the
version from the intercepted click; the Run button stayed spinning after a run
that left the page mounted; and the window-level run shortcut fired from a
collapsed panel and from keys another handler had already claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sessions): historical edits, stale not-found, shared edit rights, tab labels

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): keep deployed-page links and workspace reads in the session

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): scope detail hrefs to the session, re-point the viewer's own tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): keep a previewed raw app's route out of the session URL

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): resolve edit-in-fork against the session workspace

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): read advanced run tags from the session workspace

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): fork from the session workspace, star only navigation items

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): mount only the side a preview tab shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): address CI review round 1 findings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): route links in a deployed page's drawers through the panel

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): tell the chat which deployed page the panel shows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): only a 404 reads as an undeployed raw app

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): scope the unparseable-JSON run gate to the form

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): a flow deleted from the panel stays in its tab

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): the picker's edit shortcut works on the current row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): load-error toasts say what failed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(sessions): keep an oversized invalid JSON editor in its form's run gate

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* revert: keep main's draft_only description in openapi.yaml

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:43:52 +02:00

152 lines
6.1 KiB
TypeScript

import type { SessionPreviewTab } from './sessionState.svelte'
import { whereIs } from './sessionPreviewTabs.svelte'
import {
pageItemListPath,
pageItemUrl,
parsePageItemRoute,
parsePreviewItemRoute,
stripBase,
TRIGGER_PAGES,
type PageItemRef,
type TriggerKind
} from './previewPaths'
// Which list pages a completed chat tool can change, as base-stripped paths
// (e.g. `/schedules`). This allowlist is the single source of truth for "does
// this tool change a list page a preview tab might show". A new mutating tool
// that surfaces on one of these pages must be added here or that tab silently
// goes stale — match by exact tool name, never a name regex, which mis-classifies
// purely-local tools (e.g. `update_user_instructions`) as page mutations.
//
// Item-editor writes (write_script / write_flow / init_app / write_app_*) are
// deliberately absent: every editable item is a live in-process editor that
// self-syncs from the store the chat mutates, so its tab needs no reload — and
// no list page we preview lists open drafts. They fall through to NO_RELOAD.
//
// Page items (variables, resources, schedules, triggers) are the exception among
// in-process tabs: their editors read a draft only when they open, so a write to
// one reloads its tab too. `items` names it when the tool's args do; without a
// path, every tab of that kind reloads.
//
// `deployed` is the other exception. A tab can sit on the VIEW side of an item,
// which renders the DEPLOYED version read over the API — it shares no store with
// the chat, so a tool that changes what is deployed at a path has to name it here
// or that tab keeps showing the previous deployment. Only tools that change the
// deployed item belong in `deployed`; a draft write does not.
export type ToolReloadEffect = { pages: string[]; items: PageItemRef[]; deployed: string[] }
const NO_RELOAD: ToolReloadEffect = { pages: [], items: [], deployed: [] }
export function toolReloadEffect(name: string, args: any): ToolReloadEffect {
switch (name) {
case 'write_schedule':
return withItem(['/schedules'], itemRef('schedule', args))
case 'write_trigger':
// Its path sits in the trigger's own config, not beside `kind`.
return withItem(triggerPages(args?.kind), itemRef('trigger', args?.config, args?.kind))
case 'write_resource':
return withItem(['/resources'], itemRef('resource', args))
case 'write_variable':
return withItem(['/variables'], itemRef('variable', args))
case 'create_folder':
return { pages: ['/folders'], items: [], deployed: [] }
// These two change what is deployed at a path, so a tab on that item's View side
// is showing a version that no longer exists.
case 'delete_workspace_item':
case 'deploy_workspace_item':
return {
...withItem(pagesForItemType(args?.type, args), itemRef(args?.type, args)),
deployed: deployedItems(args)
}
// Generic item tools carry a workspace-item `type`; refresh its list page
// when it lives on one (schedule/resource/variable/trigger). script/flow/app
// have their own live editor tab and no previewed list page → nothing.
case 'discard_local_draft':
case 'rebase_draft':
return withItem(
pagesForItemType(args?.type, args),
itemRef(args?.type, args, args?.trigger_kind)
)
default:
return NO_RELOAD
}
}
// The item path a deploy/delete changed, when it is one of the kinds a preview tab can
// show the deployed side of. Other types (schedule, resource, …) have list pages instead.
function deployedItems(args: any): string[] {
const type = args?.type
if (type !== 'script' && type !== 'flow' && type !== 'app') return []
return typeof args?.path === 'string' && args.path ? [args.path] : []
}
function withItem(pages: string[], item: PageItemRef | undefined): ToolReloadEffect {
return { pages, items: item && pages.length ? [item] : [], deployed: [] }
}
function itemRef(type: unknown, args: any, triggerKind?: unknown): PageItemRef | undefined {
const path = args?.path
if (typeof path !== 'string' || !path) return undefined
if (type === 'variable' || type === 'resource' || type === 'schedule') {
return { kind: type, path }
}
if (type === 'trigger' && (triggerKind as string) in TRIGGER_PAGES) {
return { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path }
}
return undefined
}
function pagesForItemType(type: unknown, args: any): string[] {
switch (type) {
case 'schedule':
return ['/schedules']
case 'resource':
return ['/resources']
case 'variable':
return ['/variables']
case 'trigger':
return triggerPages(args?.trigger_kind)
default:
return []
}
}
function triggerPages(kind: unknown): string[] {
const page = TRIGGER_PAGES[kind as TriggerKind]
return page ? [page.path] : []
}
// The open tabs a reload should refresh: list-page tabs whose observed page path is
// in `pages`, and page item tabs on those pages — only the named ones when a tool
// named its item. Item-editor and pipeline tab routes are never list pages, so they
// never match (see the self-sync invariant above). Pure over a tab snapshot so the
// sessions page can reload by id and this stays unit-testable.
export function tabsToReload(
tabs: SessionPreviewTab[],
pages: ReadonlySet<string>,
items: ReadonlySet<string> = new Set()
): SessionPreviewTab[] {
if (pages.size === 0) return []
return tabs.filter((t) => {
const pageItem = parsePageItemRoute(t.url)
if (!pageItem) return pages.has(stripBase(whereIs(t)))
const listPath = pageItemListPath(pageItem)
if (!pages.has(listPath)) return false
const named = [...items].some((u) => pageItemListPath(parsePageItemRoute(u)!) === listPath)
return !named || items.has(pageItemUrl(pageItem))
})
}
// The open tabs a deploy/delete of `items` should refresh: those sitting on the VIEW side
// of one of those paths. The Edit side is excluded on purpose — it is the live editor that
// self-syncs, and reloading it would throw away whatever is being edited.
export function viewerTabsToReload(
tabs: SessionPreviewTab[],
items: ReadonlySet<string>
): SessionPreviewTab[] {
if (items.size === 0) return []
return tabs.filter((t) => {
const route = parsePreviewItemRoute(whereIs(t))
return route?.mode === 'view' && items.has(route.itemPath)
})
}