From 21eb5d28bdb88a4a04d29477f105c3a31f5bb3e7 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 30 Sep 2026 02:32:59 -0700 Subject: [PATCH] feat(sessions): open deployed pages from the preview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview panel showed an item's editor and nothing else. It now shows the deployed page too, as a tab of its own, and that page is where a deployed run happens. **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. **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, 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. **Deployed runs adopt the page.** `run_script` and `run_flow` open the item's own deployed page with the model's arguments in its run form, announced by the strip that says the agent is waiting and declines the call. Run hands those arguments to the parked call rather than starting a job, so the tool still owns the run and the chat card still gets its logs; the tab then becomes that run. Test runs keep the chat's own form — they execute the draft, which the deployed page would not. `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". Co-Authored-By: Claude Opus 5 (1M context) --- backend/windmill-api/openapi.yaml | 6 + .../src/lib/components/DrillPicker.svelte | 144 +++++++++++++----- .../copilot/chat/AIChatManager.svelte.ts | 11 ++ .../copilot/chat/RunScriptCard.svelte | 39 ++++- .../copilot/chat/ToolPreviewCard.svelte | 17 ++- .../components/copilot/chat/global/core.ts | 109 +++++++++++-- .../components/copilot/chat/itemPreview.ts | 24 ++- .../components/copilot/chat/shared.test.ts | 15 +- .../copilot/chat/workspaceItems.svelte.ts | 8 +- .../lib/components/details/FlowDetail.svelte | 35 ++++- .../components/details/ScriptDetail.svelte | 36 ++++- .../lib/components/details/pendingRun.test.ts | 34 +++++ .../src/lib/components/details/pendingRun.ts | 42 +++++ .../schema/InputSelectedBadge.svelte | 29 ++-- .../components/sessions/FlowEditorView.svelte | 9 +- .../components/sessions/ItemViewerView.svelte | 18 ++- .../components/sessions/PageListView.svelte | 6 +- .../sessions/PreviewRouterPicker.svelte | 41 ++++- .../sessions/RawAppEditorView.svelte | 9 +- .../sessions/ScriptEditorView.svelte | 9 +- .../sessions/SessionChangesBar.svelte | 4 +- .../components/sessions/previewRouter.test.ts | 16 ++ .../lib/components/sessions/previewRouter.ts | 13 ++ .../sessions/sessionPreviewTabs.svelte.ts | 70 +++++++-- .../sessions/sessionPreviewTabs.test.ts | 37 +++++ .../sessions/sessionRuntime.svelte.ts | 92 ++++++++++- .../components/workspaceItemsLoader.svelte.ts | 4 +- .../src/lib/components/workspacePicker.ts | 29 +++- .../(root)/(logged)/sessions/+page.svelte | 7 +- 29 files changed, 786 insertions(+), 127 deletions(-) create mode 100644 frontend/src/lib/components/details/pendingRun.test.ts create mode 100644 frontend/src/lib/components/details/pendingRun.ts diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 21ca421af3..864140fc7f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -35898,6 +35898,12 @@ components: True when the authed user has a draft for this app — either no deployed row exists at this path (draft-only) or the user has saved a per-user draft on top of the deployed row. + draft_only: + type: boolean + description: | + Nothing is deployed at this path. Only ever true on the rows + `include_draft_only=true` synthesizes from a draft, so a caller + that did not ask for those never sees it. draft_path: type: string description: | diff --git a/frontend/src/lib/components/DrillPicker.svelte b/frontend/src/lib/components/DrillPicker.svelte index 524a33e249..4e17637b77 100644 --- a/frontend/src/lib/components/DrillPicker.svelte +++ b/frontend/src/lib/components/DrillPicker.svelte @@ -28,9 +28,13 @@ leaves and ignores the current scope. resolveScope, scopeChain, type DrillBranch, + type DrillIcon, type DrillLeaf, type DrillNode } from './drillPicker' + import Badge from '$lib/components/common/badge/Badge.svelte' + import type { BadgeColor } from '$lib/components/common/badge/model' + import Button from '$lib/components/common/button/Button.svelte' interface Props { tree: DrillNode[] @@ -72,6 +76,18 @@ leaves and ignores the current scope. /** Full-text hover tooltip for a leaf row (rows truncate their text). * Return `undefined` to show none for that leaf. */ rowTooltip?: (leaf: DrillLeaf) => string | undefined + /** A second thing a leaf row can do, offered on hover and on ⌘/Ctrl-pick + * (the preview picker's "open the editor instead of the deployed page"). + * Return `undefined` for a leaf that has only one. The button renders as a + * SIBLING of the row — a button inside a button is invalid markup — and + * stays out of the tab order, because focus lives in the search field and + * rows are reached with the arrow keys. */ + leafAction?: ( + leaf: DrillLeaf + ) => { icon: DrillIcon; label: string; title: string; run: () => void } | undefined + /** Status badge on the right of a leaf row. Returns `undefined` for a leaf with + * nothing to report. The hover action, when there is one, covers it. */ + leafBadge?: (leaf: DrillLeaf) => { label: string; color: BadgeColor } | undefined } let { @@ -88,7 +104,9 @@ leaves and ignores the current scope. onScopeChange, onFilterChange, rootLoading = false, - rowTooltip + rowTooltip, + leafAction, + leafBadge }: Props = $props() let searchInput: TextInput | undefined = $state() @@ -277,8 +295,18 @@ leaves and ignores the current scope. if (mouseActive) highlightedKey = key } - function pick(leaf: DrillLeaf) { + /** `alt` picks the row's second action (see `leafAction`) rather than the row + * itself — ⌘/Ctrl-click and ⌘/Ctrl-Enter, the keyboard reach for the button + * that only appears on hover. A leaf with no second action ignores it. */ + function pick(leaf: DrillLeaf, alt = false) { if (leaf.current || leaf.disabled) return + if (alt) { + const action = leafAction?.(leaf) + if (action) { + action.run() + return + } + } onPick(leaf) } @@ -316,23 +344,23 @@ leaves and ignores the current scope. $effect(() => () => clearTimeout(tooltipTimer)) - function activate(key: string | undefined) { + function activate(key: string | undefined, alt = false) { if (!key) return if (isSearching) { const found = (searchedItems ?? []).find((r) => r.leaf.key === key) - if (found) pick(found.leaf) + if (found) pick(found.leaf, alt) return } const entry = entryList.find((e) => e.key === key) if (!entry) return - drill(entry) + drill(entry, alt) } - function drill(entry: Entry) { + function drill(entry: Entry, alt = false) { if (entry.type === 'branch') { scope = [...scope, entry.key] } else { - pick(entry.node) + pick(entry.node, alt) } } @@ -362,7 +390,7 @@ leaves and ignores the current scope. e.preventDefault() e.stopPropagation() mouseActive = false - activate(highlightedKey) + activate(highlightedKey, e.metaKey || e.ctrlKey) } else if ( (e.key === 'ArrowLeft' || e.key === 'Backspace') && filter === '' && @@ -476,42 +504,78 @@ leaves and ignores the current scope. {@const isHl = key === highlightedKey} {@const isCur = !!leaf.current} {@const tip = rowTooltip?.(leaf)} - + + + {#if leafBadge || leafAction} +
+ {#if action} + + + {/if} + {#if badge} +
+ {badge.label} +
+ {/if} +
+ {/if} + {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e51f80601..cbee92a64a 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -557,6 +557,17 @@ export class AIChatManager implements ChatViewHost { * chat card holds. Unset outside a session: a chat-bound form has nowhere else to go, * so the card hides the control rather than offering a tab that cannot run. */ openRunForm?: (a: { toolCallId: string; label: string }) => void + /** A DEPLOYED run instead shows the item's own deployed page, with these arguments in + * the run form that is already there — the reader runs it where they would have run it + * themselves, seeing its triggers and past runs. The page hands the arguments back to + * this call rather than starting a job, so the tool still owns the run. */ + openRunOnDeployedPage?: (a: { + toolCallId: string + kind: 'script' | 'flow' | 'raw_app' + path: string + summary: string + args: Record + }) => void closeRunForm?: (toolCallId: string) => void /** Hands that tab from the form to the run it just started, in place: the tab keeps its * position in the strip and stays active if it was. */ diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index 4aff51d002..6a60031fbe 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -373,25 +373,45 @@ // waiting on one, the run once a job exists. Neither, and there is nothing to open, so // the button is not drawn at all — a form has nowhere to go outside a session, and a // call cancelled before Run never became a run. + // A deployed run is shown on the item's own deployed page rather than a form of our own: + // the reader runs it where they would have run it themselves. A test run has no deployed + // page to show — it runs the draft being written — so it keeps the form. + const asDeployedPage = $derived( + pending && runForm?.kind === 'run' && !!path && !!aiChatManager.openRunOnDeployedPage + ) const previewTarget = $derived( pending - ? aiChatManager.openRunForm - ? ('form' as const) - : undefined + ? asDeployedPage + ? ('deployed' as const) + : aiChatManager.openRunForm + ? ('form' as const) + : undefined : job ? ('run' as const) : undefined ) const previewTitle = $derived( - previewTarget === 'form' - ? `Open this form in the preview panel: ${path}` - : aiChatManager.openRunInPreview - ? `Open this run in the preview panel: ${path || runnableName}` - : `Open this run in a new tab: ${path || runnableName}` + previewTarget === 'deployed' + ? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and its run form: ${path}` + : previewTarget === 'form' + ? `Open this form in the preview panel: ${path}` + : aiChatManager.openRunInPreview + ? `Open this run in the preview panel: ${path || runnableName}` + : `Open this run in a new tab: ${path || runnableName}` ) function openPreview() { const label = runnableName + if (previewTarget === 'deployed') { + aiChatManager.openRunOnDeployedPage?.({ + toolCallId: message.tool_call_id, + kind: runnableKind === 'flow' ? 'flow' : 'script', + path, + summary: label, + args: aiChatManager.runFormDraft(message.tool_call_id, runForm!).args ?? {} + }) + return + } if (previewTarget === 'form') { aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label }) return @@ -424,11 +444,14 @@ on its way in, the run on its way out. Not a toggle — pressing it again focuses the tab it already opened. The row's only control, as on every other tool call. --> {#snippet previewChip()} + {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte index b5f4fa8bae..d808003d14 100644 --- a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte @@ -1,5 +1,5 @@ -
+ +

- Using {inputSelected === 'history' - ? 'historic' - : inputSelected === 'captures' - ? 'captures' - : inputSelected === 'ai' - ? 'AI generated' - : 'saved'} input arguments + {#if inputSelected === 'pending_run'} + The agent wants to run this + {:else} + Using {inputSelected === 'history' + ? 'historic' + : inputSelected === 'captures' + ? 'captures' + : inputSelected === 'ai' + ? 'AI generated' + : 'saved'} input arguments + {/if}

{#if acceptButton} {@render acceptButton()} diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index d74436578f..3f6ff2ebda 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -1,7 +1,10 @@