mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-15 16:03:02 +00:00
* 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>
152 lines
6.1 KiB
TypeScript
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)
|
|
})
|
|
}
|