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 @@