From 6e948b07d13b85e9251c3a200bf149f167649bc4 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:40:45 +0200 Subject: [PATCH 01/27] feat: ask before minting the frontend sdk token in the raw app editor (#11604) * feat: ask before minting the frontend sdk token in the raw app editor * fix: record editor sdk consent on continue so a late mint cannot override a decline Co-Authored-By: Claude Opus 5.5 * fix: only the newest editor sdk mint may start the preview Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../apps/editor/PublicAppFrame.svelte | 8 +- .../components/raw_apps/RawAppEditor.svelte | 137 ++++++++++++++++-- .../raw_apps/RawAppSdkConsent.svelte | 19 ++- .../lib/components/raw_apps/sdkScopes.test.ts | 13 +- .../src/lib/components/raw_apps/sdkScopes.ts | 44 +++++- frontend/src/lib/components/raw_apps/utils.ts | 2 + .../sessions/sessionRuntime.svelte.ts | 8 + 7 files changed, 197 insertions(+), 34 deletions(-) diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index e994767488..a351117e5c 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -35,7 +35,11 @@ import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types' import { loadAppPreview } from './loadAppPreview' import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte' - import { hasStoredSdkConsent, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes' + import { + hasStoredSdkConsent, + sdkConsentCovers, + storeSdkConsent + } from '$lib/components/raw_apps/sdkScopes' type EmbedToken = { token?: string | null @@ -388,7 +392,7 @@ appPath = resp.app_path ?? appPath workspaceId = resp.workspace_id ?? workspaceId const granted = resp.sdk_scopes ?? [] - if (!granted.every((s) => approved.includes(s))) { + if (!sdkConsentCovers(approved, granted)) { // The app was redeployed with more scopes between the prompt and the // mint. Never inject a token carrying scopes the viewer wasn't shown: // drop it and ask again with the new set. diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 8a0655d70e..2288403438 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -50,7 +50,14 @@ import { createAppSelectedContext, type AppCodeSelectionElement } from '../copilot/chat/context' import { captureScale, MAX_IMAGE_EDGE } from '../copilot/chat/imageUtils' import { rawAppLintStore } from './lintStore' - import { dbSchemas } from '$lib/stores' + import { dbSchemas, userStore } from '$lib/stores' + import RawAppSdkConsent from './RawAppSdkConsent.svelte' + import { + hasStoredSdkConsent, + movePreviewSdkConsent, + sdkConsentCovers, + storeSdkConsent + } from './sdkScopes' import { MousePointerSquareDashed, RefreshCw, @@ -414,6 +421,9 @@ // carry our origin, so they travel wrapped: listeners that trust a same-origin // message must not see the isolated app's as their own. let previewControllerUrl: string | undefined = undefined + // Editor-to-controller only, never relayed to the app: the consent prompt lives in + // the editor, so the pop-out says why it is blank. + const PREVIEW_NOTICE = 'wm:previewNotice' onDestroy(() => { if (previewControllerUrl) URL.revokeObjectURL(previewControllerUrl) }) @@ -423,14 +433,19 @@ [ `App preview - + -
+
{#if module.value.type === 'script'} {#if customUi?.tagEdit != false} Date: Thu, 8 Oct 2026 08:42:46 +0200 Subject: [PATCH 03/27] feat: one page header across routes, and one sidebar for operators and developers (#11310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: one sidebar for operators and developers, with a detach mode Co-Authored-By: Claude Opus 5 (1M context) * feat: snap-detach the operator sidebar, and join the handle to the picker Dragging the sidebar into the left snap zone detaches it for an operator, who has no icon-only rail to collapse into; the width the drag started from is what gets persisted, so re-attaching restores the sidebar's size. The handle's colour pastille slides onto the card's workspace pastille as the card opens, alongside the workspace name, and both buttons that attach or detach the sidebar carry a tooltip. Co-Authored-By: Claude Opus 5 (1M context) * feat: attach and detach from the sidebar header, open the card under its handle The button that swaps the sidebar between docked and detached moves to the header, next to the workspace name, so it stays in one place across the swap; the icon-only rail has no room for it and keeps the footer copy. The handle itself becomes a component of its own, carries the workspace icon its picker uses, and opens the card rather than docking — docking is the header button's job. The card is placed under the handle measured at open time instead of at a fixed offset. Co-Authored-By: Claude Opus 5 (1M context) * feat: one page header across routes, with the workspace picker in it Every route now wears the same bar above its content: the sidebar toggle, the workspace picker, and whatever the route registers — an item's breadcrumb and summary, or a section name — with the route's own buttons at the far end. The bar's row is level with the sidebar's own top row, which the workspace ⇄ sessions switch now owns, so the two read as one band. A route fills the bar by mounting PageHeaderContent, which registers a getter rather than a snapshot: the layout calls it while rendering, so a path or summary the page edits reaches the bar without an effect keeping two copies in step. Nothing registers yet; the routes follow. Co-Authored-By: Claude Opus 5 (1M context) * feat: detail pages fill the page header, which now spans above the sidebar A script or flow detail page no longer draws a bar of its own: its path becomes the header's breadcrumb, its summary keeps the rename-and-labels popover next to it, and its badges, menu and buttons ride along as the header's actions. The band runs the full width with the sidebar hanging below it. A snippet rendered in the header initialises its components under the header, not under the page that wrote it, so anything the page's tree provides is lost — the detail page's buttons read TriggerContext and threw. ContextBridge carries the entries a registration names back around the render. The floating handle is gone with it: the header's own toggle is the one control, hovering it peeks the sidebar in and clicking pins it. The workspace picker in the header is a denser variant of the sidebar's, and the card's outside-click catcher starts at the card rather than the viewport top, so it no longer swallows clicks meant for the toggle. Co-Authored-By: Claude Opus 5 (1M context) * feat: one breadcrumb in the page header, and the banners that used to sit under it The header band now carries a single breadcrumb — workspace, fork, then the item's path — built from one component, so every segment has the same size, weight and icon size, and slashes separate them. The item's kind icon rides with its summary, after a dot, rather than with the path. The fork and drafts banners move into the band as badges plus a subtle Review button: ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu, alongside the toggle that pins the session chat to the page. The flow editor and the app viewer hand their bar to the band too, and a route can drop the band's bottom border when its own content draws one — the border stays in the box with a transparent colour, so contents never shift a pixel across a navigation. Co-Authored-By: Claude Opus 5 (1M context) * feat: the runs page's own header row moves into the page header The title, the queue counters, the kind and status filters, the schedule and future-job switches, the search field and the timeframe picker all ride the band now, and the page starts at the chart — one row of vertical space back. The two toggle groups appear from 1500px: below that the bar cannot hold them next to the search field, and the same filtering is reachable from the search field's own kind and status chips. The search field is what gives width back, and the breadcrumb yields last, so a page's own name stays whole while the workspace and fork names truncate. The queue counters keep their icon form at every width, since the bar has room for two counters and never for the two labelled blocks. Their counts hang outside their icons, so the group carries the margins that keep a badge off its neighbour, and the suspended counter answers a hover at zero rather than sitting there as a dead icon. The band is 44px rather than 40, and a page can hand the breadcrumb a hint — the documentation tooltip that belongs with the page's name rather than with its buttons. Co-Authored-By: Claude Opus 5 (1M context) * feat: no border under the page header, and the peek card closes on the way out The band draws no bottom edge and no page asks it to, so `barBorder` goes with it — a page that needs the separation draws its own line, as the detail pages now do above the run form and the tab strip. With nothing toggling a border, the band is a flat 44px on every route. The detached sidebar's card is a hover affordance, so it leaves when the pointer does: off the handle and off the card both, on the same debounce a hover menu uses, because the two do not touch and the leave of one fires before the enter of the other. The mobile drawer is opened deliberately and still stays until it is dismissed. Co-Authored-By: Claude Opus 5 (1M context) * feat: one band, one workspace part, and the pickers that hang off it The band is a single element whose placement is styled rather than branched: a page registering a side-panel inset used to swap it between two `{#if}` arms, destroying the header and rebuilding it a frame later — a blink of no header with the content sliding up into the gap. The workspace and its fork become one breadcrumb part: the name leads home (inert in a session, where the workspace is the session's scope rather than a place to go) and one chevron opens the picker, which already expands a family to reach its forks. The picker's panel hangs from the part's left edge, opens with the active family expanded, and closes before the switch runs — the switch renames the workspace, which moves the trigger the panel hangs from, and floating-ui was chasing it across the viewport. The item's path is a plain label rather than a row of pickers: same separators and weight, and a click copies it with its own confirmation. The kind icon goes, the page below says what kind it is. Around them: a "prod" badge for a root workspace, the session's acting fork beside the session's name rather than in the breadcrumb, the edit-in-dev affordance as a button in the header, the drafts count as a sentence, and a fade when the workspace changes under a page that stays mounted. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a page's own controls when the workspace navigation is hidden `menuHidden` means "this is an embed, don't show the workspace's navigation" — but Runs, the detail pages and the flow editor now keep their controls in the band and nowhere else, so hiding it took the filters, the timeframe and the page's buttons with it. A session's preview iframe opens exactly that way. The band now renders for an embed too, without its breadcrumb, so the page keeps its controls and the embed still gains no way to navigate the host's workspace. The flow editor's autosave indicator moves into the header's actions with the rest of its bar: saving state, the autosave toggle and other people's drafts were left behind in the branch the full-page editor no longer takes. Co-Authored-By: Claude Opus 5 (1M context) * fix: the header carries what it promised, and nothing it does not The registry's item described pickers the breadcrumb no longer has: nine fields — the editability flags, the change callbacks, the navigation hook — that nothing read, while the flow editor passed eight of them and quietly lost its inline rename. The type now says what the bar does: a path, and either a summary or the page's own control for one. The flow editor renames through that control, as the detail page already did. `barPlacement` went the same way: no page registered 'inline', so the layout's guard never fired. The sessions page registered an inset of 0 whenever its preview pane never mounted — the "no session selected" and opted-out arms — and 0 reads as "a page owns the right edge", floating the band over the page's first line. It registers an inset only once the pane has a width. The band's sidebar handle docks through `setDetached` like the card's own toggle, so docking still suppresses the rail's entry animation and closes the card; a summary without its breadcrumb no longer leads with a stray separator in an embed; a page's hint sits with its name whichever way the page named itself; and `Menu.closeInstantly` and `EditorHeader`'s `inline`/`hideSummary` go, their callers having been replaced. Co-Authored-By: Claude Opus 5 (1M context) * feat: a deployed app gets the viewport, and the header waits in the corner An app is the page, not a page inside Windmill's chrome, so the band leaves the flow and floats above the viewport until the handle in the app's corner calls it down. The handle is faded until the pointer nears it, hovering peeks the band over the app, and a click pins it — one flag for every deployed app, in this browser, because someone who wants the breadcrumb over a dashboard wants it over the next one too. Peeking overlays rather than pushes: the app is an iframe, and taking 44px off its box on every pass of the pointer would re-lay-out whatever it draws. Pinned, the band is in flow like any other route's and the app starts under it. The sidebar is untouched — docked stays docked, detached stays a peek — since the pin is about the header alone. A page says it wants the viewport with one registry field, so the layout keeps asking the registry rather than knowing about apps. Co-Authored-By: Claude Opus 5 (1M context) * feat: one corner control for the chrome an app page hides Hovering the button overlaid on a deployed app brings back whatever that page is hiding: the band always, and the sidebar card too when the sidebar is detached. The card hangs from this button rather than from the header's own handle, which is off-screen with the band. The handle in the band no longer attaches the sidebar — it reveals it and nothing more. A control that hides the thing it sits on cannot also be the way to bring it back, so attaching stays with the toggle in the sidebar's own footer, where detaching happens. The band's two buttons go in the order the things nest: the sidebar's handle, then the header's pin. The overlaid button stands where the pin will be once pinned — same 28px box, and a slot further along when the sidebar's handle takes the first — so pinning moves nothing. It wears the panel pair the sidebar's toggle uses: solid while the band is away, dashed while it is shown. Co-Authored-By: Claude Opus 5 (1M context) * feat: the access point sits in the corner, and the header covers it The button is in the page's top-left corner — 8px in, or just past the rail when the sidebar is docked — rather than shifted along to where the header's pin would land. Hovering brings the header down over it: an access point that the regular view covers once it arrives, so there is nothing to line up and nothing to move. Because the header lands on the button, the pointer leaves it the instant it arrives. The band holds both itself and the sidebar card open while the pointer is anywhere on it, or the card would retract under a header that stayed. Co-Authored-By: Claude Opus 5 (1M context) * feat: on an app page the floating button owns both, and they move together The band drops its sidebar handle there: the floating control in the corner reveals the sidebar along with the band, and two handles for one gesture is one too many. The two are then linked — the pointer resting on either holds both, and leaving either collapses both — so they behave as the single thing the one button revealed. The card meets the header in a line: it hangs from the header's bottom edge rather than from whichever control opened it, which had it starting a couple of pixels high. With no header to sit under — an embed — it still hangs from the control that reached for it. Co-Authored-By: Claude Opus 5 (1M context) * feat: the revealed sidebar is a panel under the header, not a card over the page No rounded corners, no inset from the window: it starts at the header's bottom edge and runs to the left and bottom edges, with one border down its right side and a shadow to lift it off the page. A card floating in from nowhere read as a separate thing; this reads as the sidebar the header sits on top of. Co-Authored-By: Claude Opus 5 (1M context) * fix: only an app page gets the flush panel; elsewhere the card still floats Over an app the sidebar reads as the panel the header sits on — flush left and bottom, square, one border down its right side. On every other page it is what it was: a card inset from the window with rounded corners and a border all round. Co-Authored-By: Claude Opus 5 (1M context) * fix: an app opens with the header already away Opening an app showed the header and then slid it out: the page claimed the viewport on mount, and the band animated from where it had been rendering. The slide belongs to the hover, not to arriving, so it is armed a frame after the claim — the first hide is a cut. The claim moves to the route, which mounts before the viewer waiting on the workspace to resolve. A cold load now paints the band away from the first frame instead of showing a header that leaves again. Co-Authored-By: Claude Opus 5 (1M context) * feat: an app's Edit sits with its name At the far end of the bar it was a journey across the window from the thing it edits — on a page whose header is only there while the pointer holds it, a journey with a time limit. It goes beside the app's path instead. The slot it uses is the one Runs' documentation tooltip already had, renamed from `hint` to `afterName` and documented for both: what belongs to the page's name, whether that is a mark or a control that acts on what the name points at. Co-Authored-By: Claude Opus 5 (1M context) * feat: developers collapse, operators detach One control each, rather than three for everyone. A developer switches the sidebar between icon-only and full width and never detaches it; an operator, who has no icon-only rail to collapse to, hides it instead — by the button in its footer or by the same drag that collapses it for a developer. The way back survives either way: a detached sidebar keeps its "Show sidebar" button for anyone, so a developer who detached before this was so is not left with a sidebar they cannot return. Co-Authored-By: Claude Opus 5 (1M context) * fix: a page is as tall as it is given, not as tall as the screen The band takes 44px off the top of every route, so a page sized to the viewport overflows by exactly that much: h-screen inside a column that already lost the band's height. These pages measure the box they are in instead. CenteredModal gains `inPage` for the five pages that render it inside the layout rather than over it — the same modal, centred in what is left of the page rather than in the window. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band narrows, wraps, and draws its own edge The band publishes its width, so the pages filling it can answer it. Two tiers: under 1000px the breadcrumb keeps the workspace disc and its env badge and drops the name, and under PHONE_BAR (800) the trail stops yielding at 10rem — that floor is what makes the bar wrap, so a page's buttons take the line below rather than squeezing the name to an ellipsis. The band also draws the line between itself and the page, and only while something is scrolled under it. One capture listener on the window sees every scroller, since most pages scroll inside their own box and never move the layout's. An inset shadow, not a border: a border would move every page down a pixel the moment the line appeared. DetailPageLayout and InWorkspaceAppViewer drew that first line themselves; two lines would stack, so theirs go. Co-Authored-By: Claude Opus 5 (1M context) * feat: the script, app, runs and audit pages fill the band Each of these kept a header of its own above the page. They register with the band instead: the breadcrumb names what the page is on, and the page's buttons sit at the far end of the one row. They answer the band's width rather than the window's, since the buttons share that row with the breadcrumb. The script editor folds Diff and Settings into the menu it already shows when compact, the app editor drops the panel buttons, Awareness and Export and goes icon-only on Deploy and the mode switch, the flow editor menus its diff, and runs hides its counters. Runs also declares `actionsFlexible`: its search is the one control here that can give width back, and a box that refused to shrink would make it take its maximum instead. The run detail page loses its ActionRow, and the audit log its big title — the breadcrumb says where you are, and the page keeps its tooltip. Co-Authored-By: Claude Opus 5 (1M context) * feat: one sidebar column, shown as a rail or as a panel The rail and the drawer held two copies of the same column — the same sections, in the same order, in two pieces of markup, so a change to one silently skipped the other. Both now render `sidebarColumn`, and the shells keep only what differs: the rail its width, resize handle and collapse button, the panel its outside-click catcher and its transform. A phone gets that panel rather than a modal. There was a width test deciding between two presentations; the card under the band's handle works at any width, so it goes. What is left of the width test is the choice between a rail and a panel, plus a `(hover: hover)` test — the old `innerWidth >= 768` was standing in for "has a pointer", which is what decides whether the panel closes when the pointer leaves it. With the card everywhere, the backdrop, the `aria-modal` (nothing dims and nothing traps focus) and the drawer's close cross all go: the band's handle opens the panel and a tap outside closes it. The burger row goes with them. Entering panel width now collapses the rail transiently, so coming back to a wide window does not land on a rail the page was resized away from. The band also stops easing its right edge. That transition belongs to the rail, which moves the band's left; its right follows a splitter the page owns, and easing it left the band lying over a session's side panel — a blank strip across that panel's own header for 200ms after every drag. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home composer flies to the menu that restores it Removing the composer puts it away rather than throws it out, and the way back is one item in the band's menu — easy to miss on a page the composer has just vacated. A mark flies from the control that removed it to that menu, and the menu answers with one ring as the mark dissolves, so where it went is witnessed rather than explained. The cross and the menu item now read "Remove session chat", one name for one thing whichever a reader reaches for. Reduced motion skips both. Co-Authored-By: Claude Opus 5 (1M context) * revert: keep operators out of AI sessions That change is its own PR; this one is about the navigation. `prefersSessionHandoff` takes the role again and every entry point passes it, the sessions page refuses an operator arriving by URL, and the layout leaves them on the docked chat. Two clauses are new rather than restored, because the nav rework is what gives an operator a sidebar at all: the Workspace/AI Sessions switch and session mode are both hidden from them, since the page behind them declines. `askAiShown` gains the operator case for the same reason — they keep the docked pane whatever the beta gate says, so the sidebar has to offer a way into it. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep every control the band took over reachable Four controls the page-header conversion could put out of reach, each found by a review pass and reproduced in the browser: - The flow editor's preview buttons. `topbarWidth` is bound only in the branch a full-page editor does not render, so `compactTopbar` was permanently false there; below the 800px phone bar `phoneTopbar` dropped the buttons inline and the graph overlay never took them, leaving FlowPreviewButtons mounted nowhere and step testing, whole-flow testing and "Test flow & record" no-ops through optional calls. It now measures the header the way ScriptBuilder and AppEditorHeader do, and the inline and overlay conditions are exact complements. - The pin on a deployed app. The band lands over the corner handle that called it down, so the pointer that arrives there hits the band. The band carries the pin itself now, on the same pixels, in place of the sidebar handle it hides. - The detached sidebar's own menus. They portal to `body`, so moving into one left the card and closed it 200ms later, sliding it out from under the menu being read. The card holds while the pointer is in a menu and takes the close from the leave of that. - A deployed app's name for a read-only viewer. The breadcrumb was registered behind write access, so without it the trail fell back to the section name "Apps"; and the Edit button rode `afterName`, which the band drops for an embed along with the trail. The name is registered for whoever opened the app, and what sits after it survives `navHidden` as the page's own control. navPageFor gets a table test: it answers both the sidebar links and the breadcrumb label, and its longest-prefix and first-segment rules are what made the "Apps" fallback look like a correct answer. Co-Authored-By: Claude Opus 5 (1M context) * docs: drop what the operator revert and this refactor left behind The operator tour's last step named AI sessions among the pages the sidebar opens, which operators have no way to reach. The nav handle's comment named the AI chat layout's menu button as a second way into the card; that button and its prop are gone, and the corner control on a page that hides the band is what stands in for the handle now. Co-Authored-By: Claude Opus 5 (1M context) * style: prettier the three files this branch left unformatted `prettier --check` fails on all three at the previous head. Whitespace only, apart from one Button tag that fits on a line. Co-Authored-By: Claude Opus 5 (1M context) * fix: give a page without the band's chrome what the band was carrying Second review round, three reachable defects plus one decision: - A public raw app was 150px tall. The bundle's iframe is `h-full`, and dropping its `min-h-screen` left nothing to resolve against on the share, embed and custom-path routes — none of which has a layout above them. Measured: 150px in a 900px viewport. The floor goes back for those routes and stays off inside the workspace, where the layout hands a height down and `min-h-screen` would overflow it by the band. - The band retracted out from under its own open menu on a deployed app. Last round taught the sidebar card to hold while the pointer is in a portaled menu and left its sibling alone, but the band opens menus too — the workspace disc, the fork family, every path level. One helper now, used by both closers. - `bandPeeked` outlived the page it belonged to. Clicking a control in the band navigates away, and the next page may not be one the band floats over, so the hover handlers that would clear it are gone — leaving the band already down when a full-bleed page came back. `beforeNavigate` clears it. - An embed has no band to carry a control, so the app's Edit floats over the canvas there, where it sat before the band existed. Registering it as the band's `afterName` under `navHidden` instead would have cost the app 44px of the iframe for a row holding one button. Reading "is the workspace navigation hidden" now has one definition (`isMenuHidden`), since a page has to answer it too: the flag is sticky inside an iframe, so the raw query param is the wrong thing to read. Also inset the band by the rail only when there is a rail — an embed reserved its width and crowded its own controls into a compact layout early. Co-Authored-By: Claude Opus 5 (1M context) * fix: a path that names nothing yet has no breadcrumb leaf `splitItemPath` documents that it gives up on a path still being typed, but `f/demo/` is three parts and returned a leaf with an empty name — a blank segment in the trail. The editor binds the path live, so the breadcrumb sees every keystroke. Tested here rather than `navPageFor`'s sort: no two `NAV_PAGES` paths nest, so the longest-prefix case that test claimed to pin has only one candidate. Renamed it to what it actually checks. Co-Authored-By: Claude Opus 5 (1M context) * style: prettier the four remaining files this branch left unformatted All four fail `prettier --check` on the branch and pass at the merge base. `raw_apps/utils.ts` also fails but already did at the base, so it is left alone. Co-Authored-By: Claude Opus 5 (1M context) * feat: the raw app editor fills the band like the other three It was the one editor route still rendering its own bar under the band, so it showed two header rows and the band named the app "Apps raw" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. Its top bar now becomes the page header the way the script, flow and low-code app editors' do: the path and summary are the breadcrumb's, the file-sidebar toggle, autosave cloud, presence mark, menu, Diff, Jobs, export, AI and Deploy are the header's actions, and the path is a plain label since this editor renames from its deploy drawer. The actions render under the band, outside the subtree `RawAppEditor` declares the operating workspace in, and `OpenInSessionButton` asks that context who the acting user is — so the workspace travels with them. `operatingWorkspaceContextEntry()` hands it over without exporting the key. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the folders a half-typed path already names The trailing-separator guard gave up on `f/demo/` entirely, which dropped the whole trail to the kind chip and brought `f/demo` back on the first character of the name. The levels the path does name come back now; only the leaf waits. Co-Authored-By: Claude Opus 5 (1M context) * fix: pin holds the first slot, and the nav table says what it shares Pinning brings the sidebar's handle back, and from behind it the unpin button sat one slot over from where the pin had just been clicked — so a second click in the same place opened the sidebar instead. An operator is detached by default, which makes that the default state of the feature rather than an edge case. `NAV_PAGES` claimed one table stops a page being called one thing in the nav and another in the header, which holds for the seven entries the sidebar's main group is built from and not for the four grouped separately — `/routes` was "Triggers" in the header and "HTTP" in the nav. The comment now says which is which, and that entry is named for the page rather than its group. `sidebarPageAllowed` gets a table test: it decides what an operator sees, and its `=== true` is what a future `Option`-style default would silently change. Co-Authored-By: Claude Opus 5 (1M context) * fix: the band names the path Deploy will create, and keeps the summary readable A brand-new app is parked at a `draft_` placeholder, and the band named that rather than the path its Deploy will create — `newEditedPath` refuses the placeholder for exactly this reason, and the editor's own bar read it. Both app editors register it now. Verified: the trail reads `u/admin/gentle_app` while the URL is still the draft. The band's summary gets the `title` the editor's own bar had, so one the bar truncates can still be read on hover. The wrapper that hid AppExportButton on a phone never hid anything: the component draws no trigger — it is the export drawer, which portals to `body` — so the class toggle was inert in both states and the comment read as if something moved. Dropped from both app editors. Two comments corrected: `NAV_PAGES`' `icon` does reach the breadcrumb for all eleven entries (`NavBreadcrumb` renders `routePage.icon`), so only `operatorKey` goes unread for the four the sidebar groups separately; and the raw editor's phone note described folding that does not happen — the file-sidebar toggle stays in the band, Jobs and the export are already in the menu, and AI is what stands down. Co-Authored-By: Claude Opus 5 (1M context) * fix: an item the picker files under no kind keeps its name in the band The merge brought main's agents detail page, which reaches DetailPageHeader with no `errorHandlerKind` — and the band's item required one. `WorkspaceItemKindIcon` falls through to the script glyph for anything it does not know, so defaulting would have labelled an agent a script; the kind is optional now and the trail names the item without claiming it is one of the three. Co-Authored-By: Claude Opus 5 (1M context) * fix: the file sidebar's toggle leaves the band The band is the workspace's header, not the raw editor's layout controls. ⌘B still folds the file sidebar, and the editor's own bar keeps the button where it draws one — a session pane. Co-Authored-By: Claude Opus 5 (1M context) * fix: the scroll edge stops measuring, and the comments describe the code The band's edge listener captures every scroller in the app — Monaco, the audit table, a run's logs — about once a frame while any of them moves, and it took two `getBoundingClientRect()` reads before looking at `scrollTop`. It now answers from `scrollTop` first and only measures when that answer differs from what the band draws, which is the transition rather than the steady state. The rect check still authorizes every change, so a scroller that does not start at the content's top still cannot claim the edge. The two agent routes main just added size themselves to the viewport, which is 44px more than the box the band leaves them. They measure 856 either way — the layout's `min-h-0` column clamps them — so this is the sweep the rest of the branch already had, making the height follow the box rather than depend on a parent's clamp. Six comments described the conversion rather than the code, which AGENTS.md calls out by name. Each now states the constraint a reader could break: why the summary keeps its popover here, why the audit hint is `afterName` and not an action, why a run registers a `section` rather than an item, why the Runs filters are `actionsFlexible`. Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix: the file sidebar's toggle leaves the band" This reverts commit 1da8d16740b9aaef2d604acf06ac6451f2a10c28. A folded file sidebar renders nothing of its own, so the toggle on the bar is the only way back short of knowing ⌘B. Co-Authored-By: Claude Opus 5 (1M context) * feat: the editors name their item in the band, and a pen renames it The band showed each editor's summary as an inline field, which put editing where the page names what it is working on. The summary is text there now, with its `title` for one the band truncates, and the pen that opens the path for editing sits after it — the same popover the editors' own bar carries, at the end of the name it renames. Every editor already has a field for the summary itself: the script's settings, the flow's Settings tab, and both app editors' deploy drawer. The popover moves into `PathEditPopover` rather than being written out four more times. It owns the open state and hands back the path as it was when it opened: the band's trail reads that while the popover is open, so a path growing under the pointer does not drag the pen — and floating-ui the popover — along with it. `EditorHeader` renders the same component, so the session pane and the band cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) * feat: the pen renames the item and its summary The pen opened the path alone, which left the summary with nowhere to be edited from the band. It carries a Summary field above the path now — the item's name first, the address it lives at below — and the band's text follows it as the user types. `TextInput` with a label rather than the bar's inline field: `EditableInput`'s own docs rule itself out inside a popover, and it wraps this same input in its edit mode, so the field, the "Add a summary..." placeholder and the trim on commit are the behaviour that was there, in the shape the deploy drawer's Summary field already uses. The keydown stops at the field because every editor binds keys of its own and a summary is prose. `summaryEditable` carries the hosts' `customUi.topBar.editableSummary` through, and the editors' own bar passes it false: that bar keeps its inline field, and `hidePath` would take the pen with it, leaving a condensed session pane no way to name the item at all. Co-Authored-By: Claude Opus 5 (1M context) * feat: the agent editor fills the band too It was the last editor route drawing its own header row under the band, and the band named it "Agents" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. The agent's path is the breadcrumb now, its version badge reads after that name, the sentence that was the row's second line became the hint beside it, and the Chat/Form switch, Settings, Evals, version history and Deploy are the band's actions. No trail levels in the registration: this layout opens settings in a drawer and evals over the page, so unlike the dialog it never stands on a level — `trail` is its root alone here. Co-Authored-By: Claude Opus 5 (1M context) * feat: the pipeline page fills the band Found by sweeping every route under the band: the pipeline editor drew its own row beneath it, 48px of a second header, while the band named only the section. Its name is the band's now, the folder it is open on sits with that name — the switcher is how you change pipelines, so it belongs to the name rather than to the actions — and the mode switch, deploy-all, activity, refresh and overflow menu are the bar's actions. The arrow back to the list stays: nothing else on the page points there, and the sidebar does not list pipelines. Co-Authored-By: Claude Opus 5 (1M context) * fix: the app canvas controls stay reachable, and an unnamed item says so Codex found the app editor's canvas controls unreachable on an ordinary laptop: the owning route collapsed at 1350px, and a 1440 window with the rail out leaves 1232, so the width, theme and breakpoint toggles were hidden with nothing in the menu to replace them — and the breakpoint is the only way onto the mobile layout. Measured on the band, the toggles are 240px beside 334 of other actions and a ~430 trail, so they fit to about 1050: the threshold is 1100 now, and below it each of the three keeps a menu entry rather than disappearing. An item with no summary read as a bare `·` with an invisible pen after it, since the plain text replaced a field that had a placeholder. It now reads "Add a summary..." in the italic SummaryPathDisplay uses for the same state, and the pen stops hiding when there is nothing to hover. Three more from the review: - `actionsFlexible` says "these actions can give width back", which is true of the Runs filter row and not of the pipeline's row of fixed buttons. - `pathSnapshot ?? (newEditedPath || …)` keeps the `||` chain the five rename sites read and the declaration documents; `??` had made the rest unreachable, since `newEditedPath` is a string that is never undefined. - The band bridged `contexts` onto the actions alone, while the pen that decides whether a path is the caller's to change renders in `summaryContent`. The breadcrumb's `afterName` and the summary are bridged the same way now, so that permission check reads the tree that registered it. Co-Authored-By: Claude Opus 5 (1M context) * fix: the widest action rows scroll, and the canvas toggles show only where they fit Measuring the band instead of estimating it: with the canvas toggles shown the app editor's actions are 826-911px and the trail with the summary another ~500, so the row overflowed the bar at every width up to about 1470 — the 1100 I set from an estimate was wrong, and the 1350 before it was too. The toggles come out at 1500 now, and the menu entries are what keeps all three reachable below that rather than merely out of the way. The two widest rows — this editor's and the pipeline's — scroll inside their own box, which is the escape the rows they replaced had (`overflow-x-auto whitespace-nowrap`). That answers pi's finding and clears the last 23-40px of band overflow at middling widths, which predated the conversion. Measured after: zero horizontal overflow from 852px of bar to 1392. From the CI round's other findings: - The pen is offered when either the summary or the path is editable, not the path alone: a host that allowed one and refused the other could no longer edit the summary from the band. `PathEditPopover` takes `pathEditable` to match, and its label names whichever it is given. - The four editors' comments called the summary read-only, which stopped being true when the pen beside it started editing it. - The pipeline's "centre group" comment described a layout it no longer has. Co-Authored-By: Claude Opus 5 (1M context) * docs: actionsFlexible covers both ways a row gives width back It read as if a search field were the only way to earn it, which stopped being true when the app and pipeline editors' rows started scrolling inside their own box. Both are the same bargain — the box may shrink because its contents can — and the flag is what the bar reads to allow it. Co-Authored-By: Claude Opus 5 (1M context) * fix: the app viewer names the page it owns, and stops claiming the viewport A deployed app opened in an AI session's preview panel renamed the band from the session to the app, and put the app's Edit beside the session's name. The viewer registered wherever it was mounted; it takes `ownsPageHeader` now, the way the detail pages already did, and keeps Edit over its own canvas otherwise. Both viewers also claimed the whole viewport under a band that takes 44px of it, so a deployed app scrolled by exactly the height of its own header (the low-code one measured 900px in an 856px box). The height comes from the host now: the three standalone routes floor themselves at the viewport and everything inside them is `h-full`. A raw app reached through `/app_embed` had no height above it at all and collapsed to nothing; it fills the frame now, though that route still fetches no bundle secret, so what it shows is blank — as it is on main. The agent editor's pen was a second editor of the same path that reported nothing: an invalid path never reached the deploy gate the settings drawer's field reports to. It binds to the same slot now. Two things that surfaced with it: a rename never reached the trail, which read the deployed path; and typing the saved path back raised "path already used", since the popover's `initialPath` moves with the rename — `allowedExistingPath` is what says the item already occupies it. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the band's controls come from the design system, and the row has one copy `NavBreadcrumbTrigger` was `MeltButton` written again — same `use:melt` on a plain button, same spread — so the workspace chevron uses the component that already exists, and the sidebar handle is a `Button`. `DetailPageHeader` carried its action row twice, once for the band and once for the row it draws inside a session's preview panel, and the two had already drifted: different dropdown size, no `unifiedSize` on the buttons, and the deprecated `variant="contained" color="light" size="xs"` on the schedule button. Both placements render the same two snippets now, at the size each needs. `OperatorPageKey` admitted every `operator_settings` field, including the three write rights, which `sidebarPageAllowed`'s `=== true` rule answers wrongly: a write right is granted unless withdrawn, so its absence means "never configured". It is the ten visibility flags now. A test over the registry's merge: later registration wins field by field, actions sort by `actionsOrder` then registration, release gives its fields back. That rule is why a component registering where it is not the page renames someone else's band. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band owns its bottom edge, and one pen renames everywhere The page header decides whether a separator is drawn, so a page cannot draw a second line under it. A registration asks for `onScroll` (the default, drawn only while something passes under the bar), `always` for a page whose content starts right beneath it, or `none` where the page genuinely draws its own first line. Detail pages, the agent editor, workspace settings and the pipeline list take `always`; the raw app editor takes `none`, since its splitpanes already draw that line. The pen that opens the rename popover becomes PathEditPen, shared by the editors and the deployed pages so the two cannot drift, and the deployed pages gain it. SummaryPathDisplay makes the pen the only trigger rather than the whole name block, so the name is selectable text again. Renaming an agent goes through ResourceService with only the fields the popover edits: the backend sets a column per field it is given, so writing the config back would clobber a concurrent edit. The popover also stops clearing a real collision verdict by telling Path which path is its own. Two Button fixes, both reaching call sites beyond this work: - `dropdownItems` given as a function was asked for `.length`, which is its arity and therefore 0, so a split button built that way rendered no caret. Both app editors' Deploy dropdowns were unreachable. - the anchor branch dropped `title`, so a link button had no tooltip. The audit log filters move into the header actions at the design system's sizes and trade absolutely positioned labels for the form layout in brand-guidelines.md: a label above its input, 4px apart, fields 8px apart. The deployed app viewer floors itself at `min-h-full` instead of claiming the viewport, which would overhang the page box by the height of the band, and carries Edit and a public-url link that fold into a menu on a narrow bar. The root layout drops the legacy menu block, which rendered a second hidden sidebar and 54 invisible tab stops, and marks the closed detached sidebar `inert`. Co-Authored-By: Claude Opus 5 (1M context) * feat: service logs and the audit filters move into the band Service logs: the page's name is the breadcrumb's, so the `

` is gone and the search field, the timeframe and the two switches sit in the band beside it, with the documentation tooltip after the name. ServiceLogsInner registers the band itself, the way RunsPage does, since that route is its only caller, and the `title` snippet the page used to hand it goes with the heading. The search is the widest control there, because searching is what the page is for, and it is the one that gives width back under pressure. The two switches fold into a popover below a 1200px bar rather than at a phone's width, so the field keeps its full width down to a much narrower window — folded rather than dropped, because the page no longer carries a toolbar they could fall back to. The timeframe control took no size of its own, so it defaulted to `md` beside a `sm` field, and sat in a `w-48` box 20px narrower than its content, whose overflow is visible and so spilled into its neighbours. No `separator`: the two panes below start under the bar with borders of their own, so at rest there is nothing for the band to divide. Audit logs: the filters lose their labels. Each select says what it filters while unset — "all usernames", "all resources" — and once set the value names the field by itself. From and To become the TimeframeSelect the runs page uses, which absorbs the standalone Refresh button, since that control's left half is the refresh. `auditTimeframes` joins the runs and service-logs presets, with "All time" first. The bounds stay the filters' own `after`/`before` rather than a remembered preset label: the timeline's zoom writes them too, and a label could then contradict the chart. That takes the row from 1326px to 898px, so it stays inline down to a ~1500px viewport instead of ~2500. The fold measures the row against the bar less what the breadcrumb takes, plus a gap the two must never close — the breadcrumb yields width grudgingly and the row not at all, so sized to just meet they overlap rather than compress, and mid-resize the tooltip after the page's name lands on the timeframe button. Co-Authored-By: Claude Opus 5 (1M context) * fix: an embedded page keeps its name in the band A page in a session's preview panel loads in a frame that sets `nomenubar`, so the layout renders the band with `navHidden`. That flag hid the whole breadcrumb — which is what carries the page's name — while the actions beside it had no such guard, leaving a row of controls belonging to nothing. Hiding the trail is still right: it leads to the host's workspace and offers a picker that would take the reader out of the session. So the breadcrumb gains a `nameOnly` mode that drops the workspace part and its picker and keeps the page's own name, and the band asks for it instead of rendering nothing. The summary line loses the same guard, so an item in there shows its path and summary. Only the pages whose own title the band took over are affected. Variables, Resources, Schedules and Triggers still draw their own `PageHeader` and render no band when embedded, so nothing is doubled. The bar's left padding is there to sit a button against — the sidebar handle, or the workspace disc, both of which carry their own margin. Embedded there is neither, and the name started as bare text 8px from the edge, so that case takes the same 16px the bar already keeps on its right. Workspace settings drops the copy-id button from the band: the General tab names the workspace ID with a field of its own, which is where someone looks for it. Co-Authored-By: Claude Opus 5 (1M context) * feat: a session in full screen keeps its tabs in the band Full screen used to drop the session's name from the breadcrumb and leave the tab strip inside the panel. The band now carries both: the session's name where "Sessions" stood, and from the breadcrumb to the right edge one filled surface holding the tabs and the preview's controls, the bar's full height. The switch between the two layouts is the panel expanding to the left. The chat column is resized to nothing rather than unmounted, so it can be seen taking its place -- and so a toggle no longer tears down every mounted chat and rebuilds it on the way back. Panes transition their width only while that switch is in flight, since a splitter drag sets the same inline width and would trail the pointer. The band floats over the content in full screen as it already did beside a panel, so only its right edge moves and the page below never shifts by the bar's height. Leaving full screen, the panel travels back across ground the band has already claimed, so it passes one layer above it for the length of the move: pinned to the width the panel will end at, the bar would otherwise show through as a white patch over the panel's left edge. The breadcrumb also gains the acting fork, the way every other page shows it, which replaces the "Acting on" control the session header carried. Co-Authored-By: Claude Opus 5 (1M context) * fix: the band's inset survives a resize made in full screen The panel was remembered as a width, sampled while it stood still. A window resized during full screen left that width describing a layout that no longer existed, and the way out pinned the band to it for the length of the move. It is now remembered as the share of the row the panel takes, which the new row width turns back into the right inset: leaving full screen at 1200px after entering it at 1600px, the band starts at 628 rather than at 909. The pane transition's duration is read from the property the timer is set from, so the class cannot outlive the movement it describes, and two comments that described earlier drafts are rewritten: the band's name in full screen comes from the session's own header once it has mounted, and the tab strip's wrapper carries a fade, not a measured move. Co-Authored-By: Claude Opus 5 (1M context) * fix: an agent's pen keeps the labels the agent already carries The pen on an agent's page was handed the summary and the path but not the labels, so the popover offered an empty list over a resource that had some. It saves every field it shows in one write, and the resource endpoint sets the column it is given, so adding a label there replaced the agent's own: an agent labelled `production` and `scheduled` came back from one edit labelled only with the label just added. A flow's page and a script's page both pass theirs; this one now does too. Verified against the running instance: an agent created with `["production","scheduled"]`, one label added through the pen, reads back `["production","scheduled","new"]` — and `["new"]` with the prop removed again. Co-Authored-By: Claude Opus 5 (1M context) * fix: the sidebar remembers detaching per role, not per account Whether the sidebar starts detached depends on the role: an operator gets it out of the way, a developer gets it docked. That default held only until the control was pressed once, because the choice went to a single key read before the role. From then on both roles wore whichever was pressed last, and the one that had not asked for it could never get its own default back — switch a workspace where you are an operator for one where you are a developer, and the sidebar stayed detached. Each role now keeps its own choice, and falls back to its own default until it has one. Co-Authored-By: Claude Opus 5 (1M context) * docs: the handle's comment describes what the handle does It claimed a click holds the sidebar card open. Nothing distinguishes a click from a hover there — both call the same opener, and the pointer leaving schedules the close either way — so on a desktop the card follows the pointer and goes when it goes. Measured: hovering slides the rail from -213 to 5, the click moves nothing, and the pointer leaving puts it back at -213. Co-Authored-By: Claude Opus 5 (1M context) * feat: a dev workspace's badge wears its fork's own colour Every surface that names a dev workspace drew the same blue badge, which said nothing about which workspace it belonged to. One component, DevWorkspaceBadge, now draws it: a fork with a colour fills the badge with that hue and writes the label in white, and a fork without one keeps whatever its row had before. The hue cannot carry white at a fixed lightness — HSL lightness is not perceived brightness, so yellow and purple at the same L differ by several stops — so forkAccentStyle solves the badge's lightness by bisection instead, taking the lightest shade of the hue that still holds white at WCAG AA. Measured across the wheel the contrast lands between 4.52 and 4.64. The session picker opens with every family collapsed, since the fork a session runs in is the session's own and not something that picker should offer to change. Co-Authored-By: Claude Opus 5 (1M context) * feat: the name is what opens its rename editor, and the trail truncates from its start The pen beside a name is gone. The name itself is the trigger, with a hover fill behind it where the click opens an editor rather than going anywhere — the path segment that only copies keeps no fill, which is what tells the two apart. A bar with a summary field of its own (EditorHeader's condensed pane) still gets the pen, since it has no name to hang the trigger on. Clicking either half of a name opens the editor under that half with the cursor in the matching field. The band draws the path and the page draws the summary, so one popover cannot anchor under both: a page hands the band a `pathTrigger` snippet and the band renders its path segment inside it, passing the classes its own segment wears. Each editor supplies a second PathEditPopover for it, and `focusField` names the field to focus — `#path` explicitly, since melt otherwise lands on the row's first button. Those snippets render from the band's tree, which outlives the values they read by a flush, so each is gated on the value it reads. Without that an agent page whose draft had not loaded threw inside the band and left the breadcrumb empty. The trail now carries two weights: the segment that ends it is what the page is about and takes `font-medium text-emphasis`, everything before it is a step quieter. Which segment that is follows the route — the workspace on home (the fork alone inside a fork), a list page's own name, an item's summary. And it truncates instead of switching to icons at a breakpoint. The workspace part yields first, the family name before the fork, then the path's folders, and the item's own name last. The bar no longer wraps on a phone: a wrapping row never squeezes anything, it hands the trail a line of its own and leaves it whole. Co-Authored-By: Claude Opus 5 (1M context) * fix: a page keeps its path segment when it cannot offer a rename Four things the local reviews caught in the rename work: A detail page handed the band a `pathTrigger` whether or not it could save one. Without `onSaved` — every script, flow or agent page the reader cannot write — `SummaryPathDisplay` fell through to its read-only branch, which ignores the `label` it was given and draws its own block: the trail lost its path and the summary appeared twice, two lines deep, in a 44px bar. The page now offers the trigger only where a rename can be saved, and the component renders what it was handed rather than its own row. The band gives up its own segment the moment a page offers a `pathTrigger`, so a trigger whose own gate is closed left the trail with no path at all — for the whole of an agent draft's load, not just a flush. Each gated snippet now draws the path itself in that case. `openFocus` named the path field as `#path`, which melt resolves with a document-wide query; `Path` hardcodes that id at every call site, and the popover is portalled to the body, so it lost document order to any other `Path` on the page. It is scoped to the popover's own field now. And `open` was an exported `$bindable(false)` on an optional prop, which the frontend's own rules ban. No caller bound it, so it is local state. Co-Authored-By: Claude Opus 5 (1M context) * fix: the badge solves its lightness against the colour the browser paints Three nits from the review round. The contrast bisection measured the hue at full float precision, but a browser paints 8-bit channels: rounding them moved the result up to 0.025 off the target, which took #993388 to 4.478 against white — under the AA floor the comment claims. Quantizing the channels inside `paintedLuminance` puts the worst case across 2880 hue and saturation combinations at 4.500, and your own fork's badge measures 4.53 as painted. An agent nobody can write still offered the rename behind its path segment: an editor with two read-only fields, in place of the click that copies the path. It is gated on `readOnly` now, the way a detail page is gated on `onSaved`. The script and flow editors take the same gate from the `customUi` flags that already decide whether their summary offers one — unreachable today, since the editors own the band only on routes that pass no `customUi`, but the four editors now answer the question the same way. And the registry's doc named an `onEditName` field that has never existed. Co-Authored-By: Claude Opus 5 (1M context) * fix: an operator is not offered a fork of a workspace A fork does not carry the role. The server writes the forker's membership row in the new workspace from the parent's `is_admin` and lets `operator` fall to its `false` default, so an operator who forks lands in a full-rights copy of the workspace they could only run things in. `canCreateFork` refuses an operator, which takes the entry out of the workspace picker, the session family picker and the session changes bar; `editInForkAllowed` refuses one too, so no editor offers "Edit in fork". The fork page refuses on its own, since a URL reaches it whatever the pickers show. And a workspace the user is an operator in is no longer a base the fork form will accept, so the base picker cannot route around the gate. This hides the affordance; it does not enforce the rule. `create_fork` asks only for membership — `require_admin` covers the dev-workspace case alone — so the API still answers an operator who calls it directly. The server-side fix is to carry `operator` into that membership row, the way the member-copy path already does, and it belongs in a backend change rather than here. Co-Authored-By: Claude Opus 5 (1M context) * style: the fork refusal uses the design system's alert The refusal panel was hand-built from a div and two paragraphs with literal colors, copied from the Runs page's own operator refusal. The frontend's rules say to use the design-system component, and `Alert` already draws exactly this. Co-Authored-By: Claude Opus 5 (1M context) * fix: frontend type errors failing npm_check The dev workspace badge accepts the nullable label the API returns, the session strip transitions take the node Svelte passes them, and an unused import is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: the frontend type-checks again `check frontend build` has been red on every head for a day, through four review rounds that each came back clean — none of the reviewers type-check, and they say so in their own coverage sections. Ten errors: The dev badge's `label` and `color` were typed `string | undefined`, but the workspace rows carry `string | null`, so all seven of its call sites failed. `forkAccentStyle` took the same widening. A Svelte transition is called with the node it animates, and `stripArrive` and `stripLeave` were declared with no parameters at all. And `DevWorkspaceSetting` still imported `Badge` after its badge moved into the shared component. `svelte-check` now reports 0 errors. Note that it only agrees with CI once `src/lib/gen` has been regenerated — the gitignored client goes stale against main and invents errors CI never sees. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home bar carries the CLI and hub buttons while it has the room A menu is where a thing goes when the bar cannot hold it, not where it belongs. Above 1024px of bar, "CLI / MCP" and "Hub" stand as their own buttons; below it they fold back into the ellipsis, which is still where they live on a phone. The threshold reads `pageHeader.barWidth`, not the viewport, so it tracks what the sidebar actually leaves behind. Home's trail is ~250px and its other registered actions ~400, so the two labelled buttons fit from about 850 up; 1024 leaves margin before the trail would start truncating, and it does not truncate at any width between 760 and 1600. The ellipsis hides itself when it would open an empty menu, which happens only once both side trips are in the bar and the composer is already on the page. Co-Authored-By: Claude Opus 5 (1M context) * feat: a fork's band counts its drafts beside its drift, and every count opens what it names A fork that had drifted stopped saying anything about its drafts: the banner printed the draft count only while the fork was in sync, and the drift badges replaced it the moment there was anything to deploy. A fork can be 86 ahead, 39 behind and holding 128 drafts; it now says so. All four counts open the page that holds them — ahead the deploy direction, behind `?dir=update`, conflicts the side they are resolved from before deploying, drafts the drafts half. The button beside them still chooses a direction on the reader's behalf; a badge means the one thing it names. The colours and the icons are the compare page's, so the counts in the bar and the rows behind them say the same thing the same way: ahead green, behind blue, conflicts orange. Drafts share blue with behind and are told apart by a pencil and by naming what they count, rather than by an arrow that would read as a third direction. Both arrows are straight, here and on the compare page. "Review changes" replaces "Review & Deploy Changes", and it renders in every state. What the comparison has to say — still counting, or failed — is a mark beside the button rather than a branch that replaces it: a fork whose count is unfinished is exactly when a reader wants the page that counts properly. The drafts banner outside a fork drops "This workspace has", so the count reads the same wherever it appears. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home band keeps its hero's own cross, and the side trips stay quiet The CLI and hub buttons take the shape the hero gave them before the bar did — `xs`, hint-coloured, the hub keeping its outbound mark — so a side trip does not read as loudly as the thing the page is for. The ellipsis is gone while the hero is on the page: the hero's cross already puts it away, so a menu whose only item repeats that is a button that does nothing new. It comes back when it has something of its own to carry, which is the side trips on a narrow bar and the way back to a hero that has been put away. That entry is now "Pin session hero to homepage", and "Remove session chat" survives only as the cross's own words, where one action keeps one name. Co-Authored-By: Claude Opus 5 (1M context) * feat: a fork's counts and the way to review them are one control Four badges beside a button read as four things that happen to sit together. They are one thing — every segment opens the same page on the part it names — so they now share a box: one outline, square corners inside it, and the rounding on the box alone, which means no segment has to know where in the row it sits. The only rule in the strip is the one before the end cap: everything left of it is a reading, that is an action. With room every count says what it counts; below 1280px of bar they keep their icon and their number, which is the part a reader scans for, and the tooltip names them either way. The end cap keeps its words at every width — the counts have icons to fall back on and it has none. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page asks once, says it is working, and knows its own name The page re-requested the same summaries and on-behalf-of emails forever. Two faults compounding: `fetchOnBehalfOfInfo` skipped a key it had already answered with `onBehalfOfInfo[k] !== undefined`, but stored `undefined` on failure — so "asked, and there was nothing" could not be told from "never asked". An item that is only ahead does not exist in the parent, so that side always fails: a permanently un-cacheable set, re-requested on every pass. And the effect that drives both fetchers depended on what they write. Each reads its cache synchronously before its first `await`, which registers that cache as a dependency; the write that follows re-runs the effect. Write, re-run, write. Both now record what they asked for in a plain Set, marked before the request rather than after the answer, and the effect is untracked so it depends on the comparison alone. The caches are cleared when the compared pair changes, since a summary's key carries no workspace and those Sets would otherwise never ask again. While the comparison was in flight the page printed "No changes between this workspace and X" — an empty list and an unanswered one look alike from the layout, and that is a claim with no answer behind it. The page tracks the request now, and the layout spins instead. The breadcrumb called the page "Forks", which is what `navPageFor` falls back to when a route names itself nowhere: the first segment, capitalised. It registers "Compare" instead — in the page rather than in NAV_PAGES, which is what the sidebar renders from. Co-Authored-By: Claude Opus 5 (1M context) * Revert "feat: a fork's counts and the way to review them are one control" This reverts commit e4504d2843. The counts go back to separate badges and "Review changes" to a button of its own. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page follows the fork picked in the trail Switching a fork from the breadcrumb left the page comparing the pair it opened with. Two reasons, neither reactive: the page seeds `currentWorkspaceId` from `?workspace_id` once at mount and never reads it again, and the switch rewrites `?workspace`, a different param that this page does not read. It follows `$workspaceStore` now — picking a fork in the trail means comparing that fork. The param is rewritten so a reload keeps the pair on screen, and an arbitrary `?target=` is dropped, since it named a destination for the workspace just left. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band is a step taller, and the runs row sizes with it The bar goes from h-11 to h-12, taken from one constant beside the top padding a floating band owes the page under it, so the two cannot drift. The runs page's controls now share the sm height the rest of a header's actions use: FilterSearchbar learns the size, dropping the top margin that only centres text at md and taking the shared leading instead, and sm buttons get px-3 so a label reads as a button. The actions sit in their own row with a wider gap, and the chart below meets the header with no gap of its own. The search field holds one width through typing. As flex-1 it was whatever the row had left over, so committing a chip grew it from 226 to 288 and took those pixels off the breadcrumb. A fork's count badges keep their labels while the bar is at least 1280 wide and fall back to icon and number below that. Co-Authored-By: Claude Opus 5 (1M context) * fix: the trail holds a path's place instead of naming its kind A page registers its kind when it mounts and its path once it has loaded one. In between the trail printed "script" or "flow", which reads as the answer and then jumps when the real path lands. A skeleton of a plausible path's width holds the place, with aria-busy and a label naming what is loading. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page reads the workspace it was linked with The effect that follows the trail's fork also ran on mount, so a `?workspace_id` that differs from the navigation workspace was thrown away. That difference is the contract: a session's Review button passes the fork it committed to and leaves the navigation workspace alone, as does the prod -> dev link in UpdateDevWorkspaceModal. Both opened on the sidebar's workspace instead, comparing the wrong pair or none at all. Only a change in the store now retargets the page, measured against what it held when the page opened. The URL rewriting goes with it. The picker's items are links that carry every other param forward, so their navigation lands after the effect and put back the `workspace_id` it had just rewritten: switching fork and reloading left the trail naming one workspace and the page comparing another. The page reads `?workspace=` ahead of `?workspace_id=` instead — the param the picker writes and the root layout already applies to the store — and `?target=` lapses while the page is on another workspace rather than being deleted from a URL someone else is about to replace. Two 44px literals left over from the h-11 band: the detached sidebar card's measured-top fallback and the rail morph's end state, both now from BAR_HEIGHT_PX beside the class the band wears. The runs search field keeps its 18rem without growing, but as `shrink-0` nothing in that row could give width back, so a short bar had no answer but the trail's truncation and an overlong row would have run past the edge. `min-w-0` restores the yield without restoring the growth: a flex item's floor is its min-content width, which the chips inside the field raise. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page's target picker works, and a switch survives reload `?target=` was gated on the page still being on the workspace the URL named. Opened from "Merge into another workspace" in the workspace settings the URL names no workspace at all, so that comparison was ` === undefined` and the param was ignored for good: picking a target left the page on the "no parent workspace" prompt, which is the only place the migration can be started from. Read the param as it was read before. The follow effect writes `?workspace=` again. A plain click in the picker preventDefaults its own link and switches the store directly, so nothing navigates afterwards and nothing overwrites the write — while `fixupUrlAfterWorkspaceSwitch` rewrites the param only once it is already there, which on a page opened with `workspace_id` alone it never is. Without it, switching fork and reloading left the trail naming one workspace and the page comparing another. Exercised on a fork pair: the settings entry's picker loads the comparison it names, a plain-click switch survives a reload, and a `workspace_id` deep link still outranks the navigation workspace. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the compare page reads its pair instead of following it Net effect count: three removed, none added. The page derives the workspace it compares from the URL and the store rather than keeping it in state and running an effect to follow a switch. The workspace-switch link is what moves it: the picker's items carry every param forward, so a page-side rewrite raced that navigation and lost — the trace showed the fixup's replaceState landing first and the link's pushState putting the old params straight back. The href now swaps `workspace_id` alongside `workspace` and drops `target`, and `fixupUrlAfterWorkspaceSwitch` does the same for the paths that do not navigate, so both writers agree and the order stops mattering. Dropping `target` on a switch also closes the case where switching onto the workspace that was the target left the page comparing it with itself. CompareWorkspaces remounts on either side of the pair changing, not just the target: two forks of one parent shared a mount, since the target is the parent they have in common, and with it the selection, the deploy direction, the summaries, the pinned rows, the deploy permissions and the CI results. The cache-clearing effect that stood in for that is gone. Its summary loops outlive their mount, so they read the pair once at the start and stop writing when the instance is destroyed. The app viewer asked for a public secret on every app view to decide whether to draw a button; it reads the mode off the app it has already loaded and fetches the secret when the button is used. The summary popover seeds its fields on the open transition rather than from an effect that also read the item: a save landing while it was open reseeded the fields over what was being typed. Exercised against a running instance, 18 cases: idle issues nothing, a switch moves page, trail and URL together and survives a reload, a deep `workspace_id` link still outranks the navigation workspace, four fast switches settle with no foreign row, and the migration target picker loads the pair it names. Co-Authored-By: Claude Opus 5 (1M context) * fix: the public url opens the tab the click itself opened The secret is fetched when the button is used, and the tab was opened once it arrived — after an await, where Safari no longer counts the click as the gesture that opened it and blocks the popup without saying so. Open the tab synchronously and point it at the url once the secret is known, closing it and saying why when there is none. Co-Authored-By: Claude Opus 5 (1M context) * fix: the public url button survives a sandboxed app A sandboxed low-code app loads inside the opaque /app_embed iframe, so `loadApp` never runs on this page and `app` stays undefined — taking the button and its compact-menu entry with it, for the owner too. The mode now comes from the lite read that already runs for the Edit button, which is there for exactly this reason: it is the one description of the app this page has whether or not the app itself loads here. Exercised on four apps: anonymous sandboxed low-code, anonymous unsandboxed low-code and an anonymous raw app all show the button and fetch the secret only on click; a publisher app shows neither and asks nothing. At 640px the button folds into the menu, which carries it. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the removed hero leaves a blue flash on the ellipsis The mark that flew from the cross to the menu is gone, with its two element refs, its flight state and timer, and the `data-home-ai-menu` hook that existed only as its destination. What is left is the thing it was there to say: the ellipsis holds the way back, so it flashes blue as it appears. Written as a keyframe animation, not a transition. A transition needs the element painted at its start value before the end value is set, and this mounts in the same flush that collapses the hero, so the frame that would have shown it at rest is the frame it is already told to leave — one `requestAnimationFrame` is not reliably past that paint, and the flash was never drawn at all. `animate-fade-out` already exists for this (AssetsDropdownButton flashes a background the same way); mounting it only while pulsing gets a fresh run per removal without that component's reflow juggling. The flash rides the ellipsis rather than the row: with the CLI and hub buttons beside it the row is three controls wide, and marking all three names none of them. Co-Authored-By: Claude Opus 5 (1M context) * fix: the flash rests invisible, and its comment describes what is there `animate-fade-out` carries no fill mode, so at the end of the run the element returns to its own opacity. At the default 1 that is the flash coming back at full strength for as long as the unmount timer lags the animation — which the hero's collapse, happening right there, is well placed to cause. `opacity-0` at rest makes the revert land on invisible. The comment above it still described the mark that used to fly to the menu, and made its point three times over. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page asks only about the pair it is showing Switching workspace on this page fetched the previous pair's item paths from the new workspace, where none of them exist: 97 of 174 requests 404ing on one switch. The comparison was plain state, and the card is remounted when the pair changes — in the same render that changes it, before the effect that cleared the comparison could run, so the new mount started from the old pair's diffs. It now carries the pair it describes and is read only while that matches, which leaves nothing to clear. `computeFullScan` had the same shape: it read the target again after its await, so a switch mid-scan invalidated another pair's cache, named the wrong workspace in its toast, and left the scan button spinning on a pair that was never scanned. It captures the pair up front, re-compares only while the page is still on it, and reports it as its own. Each diff says which side holds the item, so the summary and on-behalf-of loops stop asking the side that does not: an item that is only ahead cannot answer in the parent, and asking was a guaranteed 404 and a console error per one-sided row. Those two loops also read the same object — a summary and an identity are fields of one payload — so every row was fetched twice per workspace. One read per item per workspace now serves both. A failed read is not kept: held, it would read as "this item has no identity", which is what decides whether a deploy offers to preserve one, and an item deployed without it silently becomes the deployer's. The header names the workspace being compared when that is not the one the app is pointed at, which is how a session's Review button and the prod -> dev link open this page. The trail said the parent while the page compared the fork. Measured on a 90-item pair, before -> after: a cold load 247 -> 170 requests and 167 -> 90 item reads; a workspace switch 174 requests with 97 404s -> 71 with none. Sixteen cases over cold load, idle, switching in both directions, four switches with no time to settle, a cross-family switch, reload, deep link, the migration picker and leaving an arbitrary target: no request in any of them names a pair the page has left. Co-Authored-By: Claude Opus 5 (1M context) * fix: the scan toast reports the pair it scanned Off the scanned pair the re-compare is skipped, so there is nothing to report as compared — but the success branch said "Compared N items" anyway, and the error it checked first belonged to whichever pair the page had moved to. Each outcome now says what happened: the seed alone when the page has moved on, and the comparison's result only where one was asked for. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ruben Fiszel --- .../components/BreadcrumbItemContent.svelte | 16 + .../lib/components/BreadcrumbSegment.svelte | 10 +- .../src/lib/components/CenteredModal.svelte | 8 +- .../lib/components/CompareWorkspaces.svelte | 151 ++- .../src/lib/components/ContextBridge.svelte | 18 + .../lib/components/DevWorkspaceBadge.svelte | 56 ++ .../lib/components/DevWorkspaceSetting.svelte | 10 +- .../src/lib/components/EditorHeader.svelte | 143 +-- .../src/lib/components/FilterSearchbar.svelte | 15 +- .../src/lib/components/FlowBuilder.svelte | 330 +++++-- .../lib/components/ForkWorkspaceBanner.svelte | 390 +++----- .../src/lib/components/NavBreadcrumb.svelte | 425 +++++++++ .../lib/components/NoDirectDeployAlert.svelte | 36 +- .../src/lib/components/PageHeaderBar.svelte | 216 +++++ .../lib/components/PageHeaderContent.svelte | 43 + .../src/lib/components/PathEditPen.svelte | 37 + .../src/lib/components/PathEditPopover.svelte | 227 +++++ frontend/src/lib/components/RunsPage.svelte | 481 +++++----- .../src/lib/components/ScriptBuilder.svelte | 453 ++++++--- .../components/ScriptSettingsBadges.svelte | 8 +- .../lib/components/ServiceLogsInner.svelte | 225 +++-- .../lib/components/SummaryPathDisplay.svelte | 226 +++-- .../components/WorkspaceDeployLayout.svelte | 9 + .../components/WorkspaceDraftsBanner.svelte | 50 +- .../components/WorkspaceItemKindIcon.svelte | 19 + .../components/WorkspaceScopeTrigger.svelte | 16 +- .../components/apps/editor/AppEditor.svelte | 8 +- .../apps/editor/AppEditorHeader.svelte | 453 ++++++--- .../apps/editor/InWorkspaceAppViewer.svelte | 183 +++- .../apps/editor/PreviewToggle.svelte | 9 +- .../components/apps/editor/PublicApp.svelte | 32 +- .../apps/editor/PublicAppFrame.svelte | 2 +- frontend/src/lib/components/apps/types.ts | 2 + .../auditLogs/AuditLogMobileFilters.svelte | 2 +- .../auditLogs/AuditLogsFilters.svelte | 139 ++- .../src/lib/components/breadcrumbPath.test.ts | 27 + frontend/src/lib/components/breadcrumbPath.ts | 30 + .../components/common/button/Button.svelte | 21 +- .../src/lib/components/common/button/model.ts | 4 +- .../common/tabs/DraggableTabs.svelte | 7 +- .../toggleButton-v2/ToggleButtonMore.svelte | 9 +- .../copilot/chat/AiChatLayout.svelte | 34 +- .../components/copilot/chat/global/core.ts | 2 +- .../details/DetailPageHeader.svelte | 244 +++-- .../details/ErrorHandlerToggleButton.svelte | 11 +- .../lib/components/details/FlowDetail.svelte | 1 + .../components/details/ScriptDetail.svelte | 1 + frontend/src/lib/components/flow_builder.ts | 3 + .../flows/content/AgentEditorModal.svelte | 157 ++- .../src/lib/components/home/HomeAIChat.svelte | 182 +++- .../lib/components/meltComponents/Menu.svelte | 6 + .../src/lib/components/moveRenameManager.ts | 23 +- .../components/operatingWorkspace.svelte.ts | 8 + .../components/pageHeaderRegistry.svelte.ts | 185 ++++ .../lib/components/pageHeaderRegistry.test.ts | 55 ++ .../components/raw_apps/RawAppEditor.svelte | 8 + .../raw_apps/RawAppEditorHeader.svelte | 395 +++++--- .../components/raw_apps/RawAppPreview.svelte | 2 +- frontend/src/lib/components/raw_apps/utils.ts | 3 + .../src/lib/components/runs/RunsQueue.svelte | 65 +- .../components/runs/TimeframeSelect.svelte | 22 +- .../src/lib/components/runs/timeframes.ts | 9 + frontend/src/lib/components/script_builder.ts | 2 + .../sessions/OpenInSessionButton.svelte | 2 +- .../components/sessions/PreviewTabHost.svelte | 1 + .../sessions/RawAppEditorView.svelte | 5 + .../sessions/SessionDiffDrawer.svelte | 5 +- .../sessions/SessionModeSwitch.svelte | 4 +- .../components/sessions/SessionPicker.svelte | 13 +- .../components/sessions/SessionWrapper.svelte | 205 ++-- .../sessions/WorkspaceFamilyPicker.svelte | 17 +- .../components/sessions/sessionMode.svelte.ts | 24 + .../lib/components/sidebar/MenuButton.svelte | 32 +- .../components/sidebar/OperatorMenu.svelte | 337 ------- .../components/sidebar/SettingsMenu.svelte | 17 +- .../components/sidebar/SidebarContent.svelte | 147 ++- .../sidebar/SwitchWorkspaceSubmenu.svelte | 68 -- .../components/sidebar/WorkspaceMenu.svelte | 27 +- .../sidebar/WorkspacePickerBody.svelte | 114 ++- .../sidebar/WorkspaceScopeHeader.svelte | 17 +- .../components/sidebar/navDetached.svelte.ts | 28 + .../sidebar/navHandlePlacement.svelte.ts | 50 + .../lib/components/sidebar/navPages.test.ts | 24 + .../src/lib/components/sidebar/navPages.ts | 68 ++ .../components/sidebar/operatorRoutes.test.ts | 26 + .../lib/components/sidebar/operatorRoutes.ts | 84 +- .../sidebar/workspaceMenuHref.test.ts | 37 + .../components/sidebar/workspaceMenuHref.ts | 11 + .../components/sidebar/workspaceSwitchUrl.ts | 46 +- .../components/tutorials/OperatorTour.svelte | 20 +- .../lib/components/tutorials/operatorTour.ts | 2 - .../components/workspace/WorkspaceCard.svelte | 15 +- .../CreateWorkspaceInner.svelte | 8 +- frontend/src/lib/utils/editInFork.ts | 28 +- frontend/src/lib/utils/forkColor.ts | 102 +- .../src/routes/(root)/(logged)/+layout.svelte | 894 +++++++++--------- .../src/routes/(root)/(logged)/+page.svelte | 33 +- .../agents/edit/[...path]/+page.svelte | 2 +- .../agents/get/[...path]/+page.svelte | 20 +- .../(logged)/apps/edit/[...path]/+page.svelte | 3 +- .../(logged)/apps/get/[...path]/+page.svelte | 2 +- .../apps_raw/edit/[...path]/+page.svelte | 4 +- .../apps_raw/get/[...path]/+page.svelte | 2 +- .../(root)/(logged)/audit_logs/+page.svelte | 55 +- .../flows/edit/[...path]/+page.svelte | 1 + .../(logged)/forks/compare/+page.svelte | 110 ++- .../(root)/(logged)/pipeline/+page.svelte | 29 +- .../(logged)/pipeline/[folder]/+page.svelte | 327 +++---- .../remote_deploy/callback/+page.svelte | 2 +- .../(root)/(logged)/run/[...run]/+page.svelte | 542 ++++++----- .../scripts/edit/[...path]/+page.svelte | 1 + .../(root)/(logged)/service_logs/+page.svelte | 17 +- .../(root)/(logged)/sessions/+page.svelte | 502 ++++++---- .../user/(user)/fork_workspace/+page.svelte | 16 +- .../(logged)/user/cli-success/+page.svelte | 2 +- .../(root)/(logged)/user/cli/+page.svelte | 2 +- .../user/remote_deploy_authorize/+page.svelte | 2 +- .../(logged)/workspace_settings/+page.svelte | 37 +- .../workspace_settings/checkout/+page.svelte | 2 +- frontend/src/routes/a/[...path]/+page.svelte | 48 +- .../[workspace]/[...path]/+page.svelte | 45 +- .../[workspace]/[...secret]/+page.svelte | 48 +- 122 files changed, 6546 insertions(+), 3750 deletions(-) create mode 100644 frontend/src/lib/components/BreadcrumbItemContent.svelte create mode 100644 frontend/src/lib/components/ContextBridge.svelte create mode 100644 frontend/src/lib/components/DevWorkspaceBadge.svelte create mode 100644 frontend/src/lib/components/NavBreadcrumb.svelte create mode 100644 frontend/src/lib/components/PageHeaderBar.svelte create mode 100644 frontend/src/lib/components/PageHeaderContent.svelte create mode 100644 frontend/src/lib/components/PathEditPen.svelte create mode 100644 frontend/src/lib/components/PathEditPopover.svelte create mode 100644 frontend/src/lib/components/WorkspaceItemKindIcon.svelte create mode 100644 frontend/src/lib/components/breadcrumbPath.test.ts create mode 100644 frontend/src/lib/components/breadcrumbPath.ts create mode 100644 frontend/src/lib/components/pageHeaderRegistry.svelte.ts create mode 100644 frontend/src/lib/components/pageHeaderRegistry.test.ts delete mode 100644 frontend/src/lib/components/sidebar/OperatorMenu.svelte delete mode 100644 frontend/src/lib/components/sidebar/SwitchWorkspaceSubmenu.svelte create mode 100644 frontend/src/lib/components/sidebar/navDetached.svelte.ts create mode 100644 frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts create mode 100644 frontend/src/lib/components/sidebar/navPages.test.ts create mode 100644 frontend/src/lib/components/sidebar/navPages.ts create mode 100644 frontend/src/lib/components/sidebar/operatorRoutes.test.ts diff --git a/frontend/src/lib/components/BreadcrumbItemContent.svelte b/frontend/src/lib/components/BreadcrumbItemContent.svelte new file mode 100644 index 0000000000..9e945586c7 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbItemContent.svelte @@ -0,0 +1,16 @@ + + + +{@render icon?.()} +{label} diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte index 8a266e4fc4..4d55675f26 100644 --- a/frontend/src/lib/components/BreadcrumbSegment.svelte +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to close siblings. -->
import { AlertTriangle, - ArrowDownRight, + ArrowDown, ArrowRight, - ArrowUpRight, + ArrowUp, Building, CircleCheck, CircleX, @@ -16,7 +16,7 @@ RefreshCw, UserPlus } from 'lucide-svelte' - import { untrack } from 'svelte' + import { onDestroy, untrack } from 'svelte' import type { CiTestResult } from '$lib/gen' import { Alert, Badge } from './common' import { @@ -97,6 +97,8 @@ onRetry?: () => void onSelectTarget?: (target: string) => void comparison: WorkspaceComparison | undefined + /** The comparison is still in flight, so an empty list says nothing yet. */ + comparisonLoading?: boolean /** Set when the comparison request failed (e.g. not an admin of the target). */ comparisonError?: string /** Initial merge direction; lets the page restore the chosen direction when @@ -144,6 +146,7 @@ onRetry, onSelectTarget, comparison, + comparisonLoading = false, comparisonError, initialMergeIntoParent = true, deployCount = 0, @@ -486,6 +489,12 @@ // Summary cache: stores summaries from both workspaces type SummaryCache = Record let summaryCache = $state({}) + // What has already been asked for. Deliberately not `$state`: the fetchers below run inside an + // effect, and a guard that reads reactive state makes that effect depend on what it writes — + // which is a loop. A plain Set is also what tells "asked, and the answer was nothing" from + // "never asked", which a value map cannot when the value for both is `undefined`. + const summariesRequested = new Set() + const onBehalfOfRequested = new Set() // On-behalf-of tracking for flows and scripts // Source workspace on_behalf_of emails (keyed by workspace/kind:path) @@ -499,51 +508,101 @@ return `${diff.kind}:${diff.path}` } + /** The two things a row needs from an item — the name it shows and who it runs as — are fields + * of the same object, so it is read once per item per workspace and awaited by both loops. + * + * `SHARED_READ_KINDS` is the kinds that carry both. A folder has a summary and no identity; a + * schedule or a trigger has an identity read from its own endpoint, which `getOnBehalfOf` + * dispatches. */ + const SHARED_READ_KINDS = ['script', 'flow', 'app', 'raw_app'] + type ItemRead = { summary?: string; onBehalfOf?: string } + const itemReads = new Map>() + + function readItem(kind: string, path: string, workspace: string): Promise { + const key = `${workspace}/${kind}:${path}` + let pending = itemReads.get(key) + if (!pending) { + // A failure is not an answer, so it is not kept: held, it would read as "this item has + // no identity", which is what decides whether the deploy offers to preserve one — and + // an item deployed without it silently becomes the deployer's. Each caller swallows its + // own, as both did before they shared a read. + pending = loadItem(kind, path, workspace).catch((error) => { + itemReads.delete(key) + throw error + }) + itemReads.set(key, pending) + } + return pending + } + + async function loadItem(kind: string, path: string, workspace: string): Promise { + if (kind === 'script') { + const script = await ScriptService.getScriptByPath({ workspace, path }) + return { summary: script.summary, onBehalfOf: script.on_behalf_of_email } + } else if (kind === 'flow') { + const flow = await FlowService.getFlowByPath({ workspace, path }) + return { summary: flow.summary, onBehalfOf: flow.on_behalf_of_email } + } else if (kind === 'app' || kind === 'raw_app') { + const app = await AppService.getAppByPath({ workspace, path }) + return { summary: app.summary, onBehalfOf: app.policy?.on_behalf_of_email } + } else if (kind === 'folder') { + const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') }) + return { summary: folder.summary } + } + return {} + } + async function fetchSummary( kind: string, path: string, workspace: string ): Promise { try { - if (kind === 'script') { - const script = await ScriptService.getScriptByPath({ workspace, path }) - return script.summary - } else if (kind === 'flow') { - const flow = await FlowService.getFlowByPath({ workspace, path }) - return flow.summary - } else if (kind === 'app' || kind === 'raw_app') { - const app = await AppService.getAppByPath({ workspace, path }) - return app.summary - } else if (kind === 'folder') { - const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') }) - return folder.summary - } + return (await readItem(kind, path, workspace)).summary } catch (error) { console.error(`Failed to fetch summary for ${kind}:${path}`, error) + return undefined } - return undefined } + // Both loops below run one item at a time and outlive the mount that started them: the page + // remounts this component when either side of the pair changes, and whatever was in flight then + // still resolves. It must not be stored — the summary key is kind and path, which the other + // pair spells the same way — and the loop must not keep asking for a pair nobody is looking at. + let alive = true + onDestroy(() => { + alive = false + }) + async function fetchSummaries(diffs: WorkspaceItemDiff[]) { // Only fetch summaries for scripts, flows, and apps const itemsToFetch = diffs.filter((diff) => ['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind) ) + // Read once, so every request in this pass names the pair the pass was started for. Named + // the way the diff names the two sides: `exists_in_fork` is this workspace, `exists_in_source` + // the one it is compared against. + const forkWs = currentWorkspaceId + const sourceWs = parentWorkspaceId for (const diff of itemsToFetch) { + if (!alive) return const key = getItemKey(diff) - // Skip if already cached or loading - if (summaryCache[key]) continue + // Asked for already — including one still in flight, and one whose answer was nothing. + if (summariesRequested.has(key)) continue + summariesRequested.add(key) - // Mark as loading summaryCache[key] = { loading: true } - // Fetch from both workspaces in parallel + // Only the sides that have the item. An item that is only ahead does not exist in the + // other workspace, so asking it there is a 404 by construction — a guaranteed-failing + // request per one-sided row, and a console error for each. const [currentSummary, parentSummary] = await Promise.all([ - fetchSummary(diff.kind, diff.path, currentWorkspaceId), - fetchSummary(diff.kind, diff.path, parentWorkspaceId) + diff.exists_in_fork ? fetchSummary(diff.kind, diff.path, forkWs) : undefined, + diff.exists_in_source ? fetchSummary(diff.kind, diff.path, sourceWs) : undefined ]) + if (!alive) return summaryCache[key] = { current: currentSummary, @@ -561,18 +620,32 @@ (d) => ['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind) ) + const pair = [ + { workspace: currentWorkspaceId, side: 'fork' as const }, + { workspace: parentWorkspaceId, side: 'source' as const } + ] for (const diff of itemsWithOnBehalfOf) { - for (const workspace of [currentWorkspaceId, parentWorkspaceId]) { + for (const { workspace, side } of pair) { + if (!alive) return const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff)) - if (onBehalfOfInfo[workspacedKey] !== undefined) continue + // Marked before the request, not after it answers: a guard keyed on the value it + // stores (`undefined` either way) would ask again on every pass, forever. + if (onBehalfOfRequested.has(workspacedKey)) continue + onBehalfOfRequested.add(workspacedKey) + // The side that does not have the item has nothing to say about who it runs as, and + // asking produces a 404 rather than an answer. + if (!(side === 'fork' ? diff.exists_in_fork : diff.exists_in_source)) continue try { - onBehalfOfInfo[workspacedKey] = await getOnBehalfOf( - diff.kind as Kind, - diff.path, - workspace - ) + // The same read the summary loop does, for the kinds that answer both from one + // object; the rest keep their own endpoint. + const email = SHARED_READ_KINDS.includes(diff.kind) + ? (await readItem(diff.kind, diff.path, workspace)).onBehalfOf + : await getOnBehalfOf(diff.kind as Kind, diff.path, workspace) + if (!alive) return + onBehalfOfInfo[workspacedKey] = email } catch { + if (!alive) return onBehalfOfInfo[workspacedKey] = undefined } } @@ -1004,11 +1077,16 @@ ) ) - // Fetch summaries and on_behalf_of_email when comparison data loads + // Fetch summaries and on_behalf_of_email when comparison data loads. `untrack`: both fetchers + // write the state they also read, and without this the effect depends on their writes and + // re-runs on each one. $effect(() => { - if (comparison?.diffs) { - fetchSummaries(comparison.diffs) - fetchOnBehalfOfInfo(comparison.diffs) + const diffs = comparison?.diffs + if (diffs) { + untrack(() => { + fetchSummaries(diffs) + fetchOnBehalfOfInfo(diffs) + }) } }) @@ -1229,6 +1307,7 @@ onSelectAll={selectAll} onDeselectAll={deselectAll} emptyMessage={emptyDeployMessage} + loading={comparisonLoading} > {#snippet header()}
@@ -1650,13 +1729,13 @@ {#if isConflict || existsInBothWorkspaces} {#if diff.ahead > 0} - + {diff.ahead} ahead {/if} {#if diff.behind > 0} - + {diff.behind} behind {/if} diff --git a/frontend/src/lib/components/ContextBridge.svelte b/frontend/src/lib/components/ContextBridge.svelte new file mode 100644 index 0000000000..069bec0f42 --- /dev/null +++ b/frontend/src/lib/components/ContextBridge.svelte @@ -0,0 +1,18 @@ + + + +{@render children()} diff --git a/frontend/src/lib/components/DevWorkspaceBadge.svelte b/frontend/src/lib/components/DevWorkspaceBadge.svelte new file mode 100644 index 0000000000..e86e4593c8 --- /dev/null +++ b/frontend/src/lib/components/DevWorkspaceBadge.svelte @@ -0,0 +1,56 @@ + + + +{devBadgeText(label)} diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index e148d90c22..ff9932fa87 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -1,7 +1,8 @@ -
+
{#if !hidePath}
@@ -162,9 +111,9 @@ {#if segments} {#each segments.dirs as dir, i (dir.fullPath)} {@const dKey = dirKey('all', dir.fullPath)} + {/each} + {/if} + {#if segments?.leaf} {@const leafKey = leafKeyFor(kind, segments.leaf.fullPath)} {@const leafParent = segments.dirs[segments.dirs.length - 1]?.fullPath} + - + {#if pathEditable} - pathPopoverOpen, setPathPopoverOpen} - > - {#snippet trigger()} -
{/if} diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index b4bc4471a2..0a65701b06 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -303,7 +303,12 @@ {#if showBanner} - -
-
- -
-
- -
- - {isDevWorkspace - ? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of` - : 'Fork of'} - {parentWorkspaceData?.name}{#if parentWorkspaceData?.name !== parentWorkspaceId} - ({parentWorkspaceId}){/if} - -
- - {#if loading} - Checking for changes... - {:else if error} - - {error} - - {:else if comparison} -
- {#if comparison.summary.total_diffs > 0} - - {forkAheadBehindMessage(changesAhead, changesBehind)} - {parentWorkspaceId} over {comparison - .summary.total_diffs} items - - - - - {#if ciTestTotal > 0} - - - {#if ciTestFailing > 0} -
- - CI: {ciTestFailing} failing -
- {:else if ciTestRunning > 0} -
- - CI: {ciTestRunning} running -
- {:else} -
- - CI: {ciTestPassing} passing -
- {/if} - {/if} - - {#if comparison.summary.conflicts > 0} - - -
- - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 - ? 's' - : ''} -
- {/if} - {:else if comparison.skipped_comparison} - - This {currentNoun} was created before the addition of certain windmill features, and - therefore the changes with its parent workspace cannot be displayed. - {:else if showDraftsOnly} - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - - {:else} - Everything is up to date - {/if} -
- {/if} -
- -
- -
-
-
-
+ + {/if} + +{#snippet forkAction()} + {@const total = comparison?.summary.total_diffs ?? 0} + {@const conflicts = comparison?.summary.conflicts ?? 0} + + {#if loading} + Checking for changes… + {:else if error} + Comparison failed + {:else if showDraftsOnly} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + {:else if total > 0} + + + {#if changesAhead > 0} + openCompareDir('deploy_to')} + title="{changesAhead} ahead of {parentWorkspaceId} — review what this {currentNoun} has to give" + > + + {changesAhead}{compactCounts ? '' : ' ahead'} + + {/if} + {#if changesBehind > 0} + openCompareDir('update')} + title="{changesBehind} behind {parentWorkspaceId} — review what this {currentNoun} has yet to take" + > + + {changesBehind}{compactCounts ? '' : ' behind'} + + {/if} + {#if conflicts > 0} + + openCompareDir('deploy_to')} + title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''} — review them" + > + + {conflicts}{compactCounts ? '' : ` conflict${conflicts !== 1 ? 's' : ''}`} + + {/if} + + {#if draftCount > 0} + + + {draftCount}{compactCounts ? '' : ` draft${draftCount !== 1 ? 's' : ''}`} + + {/if} + {/if} + {#if showDraftsOnly} + + {:else} + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte new file mode 100644 index 0000000000..889fe17377 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -0,0 +1,425 @@ + + + +{#snippet workspaceDisc()} + + + + + + + +{/snippet} + +{#snippet itemKindIcon()} + + {#if item?.kind}{/if} +{/snippet} + +{#snippet sectionKindIcon()} + {#if section?.kind}{/if} +{/snippet} + +{#snippet folderIcon()} + +{/snippet} + +{#snippet userIcon()} + +{/snippet} + +{#snippet envBadgeMark()} + {#if envBadge === 'dev'} + + {:else if envBadge === 'prod'} + prod + {/if} +{/snippet} + +{#snippet slash(extra = '')} + + +{/snippet} + + diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index d1017f7347..a3a249caf5 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -16,7 +16,8 @@ import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' import { devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel' import { switchWorkspace } from '$lib/storeUtils' - import { Badge, Button } from './common' + import { Button, ButtonType } from './common' + import { twMerge } from 'tailwind-merge' import Popover from './meltComponents/Popover.svelte' import Toggle from './Toggle.svelte' import { GitFork, Lock, ShieldOff } from 'lucide-svelte' @@ -83,28 +84,35 @@ ? `Edits in ${devLabelKey(canonicalDev.dev_workspace_label)}` : 'Edits restricted' ) + + // The Popover renders its trigger as a - {#if customUi?.topBar?.path != false} -
- onNavigate?.(item)} - /> -
+ {#if ownsPageHeader} + + + + {:else} +
+
+
+ {@render languageButton(condensedHeader ? 18 : 24)} + {#if customUi?.topBar?.path != false} +
+ onNavigate?.(item)} + /> +
+ {/if} + {@render autosaveIndicator()} + {#if !condensedHeader} + {@render settingsBadges()} + {/if} +
+ + +
+ + {#if $enterpriseLicense && initialPath != '' && !inSessionPane} + {/if} - {#if opWorkspace} - - {/if} - {#if !condensedHeader} - {@const canOpenRuntime = - customUi?.topBar?.settings != false && - customUi?.settingsPanel?.disableRuntime !== true} - { - selectedTab = 'runtime' - metadataOpen = true - } - : undefined} + + +
+ + {@render rightButtons()} +
+
+ {/if} + + {#snippet scriptSummary()} + + {#if script} + +
+ {#snippet summaryText()} + {emptyString(script.summary) ? 'Add a summary...' : script.summary} + {/snippet} + {#if customUi?.topBar?.editablePath != false || customUi?.topBar?.editableSummary != false} + + {:else} + {@render summaryText()} {/if}
+ {/if} + {/snippet} - -
+ + {#snippet scriptPathTrigger(pathLabel: Snippet, triggerClass: string)} + + {#if script} + + {:else} + {@render pathLabel()} + {/if} + {/snippet} + {#snippet scriptMarks()} + {#if script} + + {@render settingsBadges('pl-1.5')} + {/if} + {/snippet} + + {#snippet scriptHeaderActions()} + {#if script} + + {@render autosaveIndicator()} {#if $enterpriseLicense && initialPath != '' && !inSessionPane} {/if} + {@render rightButtons()} + {/if} + {/snippet} - -
+ {#snippet languageButton(size: number)} + + {/snippet} - {#snippet settingsButton()} - {#if customUi?.topBar?.settings != false} - - {/if} - {/snippet} - {#snippet diffButton()} - {#if customUi?.topBar?.diff != false} - {@const isDraftOnly = savedScript?.no_deployed === true} - {@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly} - {@const diffTitle = isDraftOnly - ? 'Deploy this script once to compare against the deployed version' - : 'Diff'} - -
- -
- {/if} - {/snippet} - {#if compactTopbar} - - {#snippet buttonReplacement()} - + + {/if} + {/snippet} + {#if compactTopbar} + + {#snippet buttonReplacement()} + - - - + + {/if} + +{#snippet draftsAction()} + + {#if phone} + + {:else} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemKindIcon.svelte b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte new file mode 100644 index 0000000000..9d07f6ef89 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte @@ -0,0 +1,19 @@ + + + +{#if kind === 'flow'} + +{:else if kind === 'app'} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte index 77fe52b421..37a27f3812 100644 --- a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte +++ b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte @@ -2,8 +2,8 @@ import { userWorkspaces, type UserWorkspace } from '$lib/stores' import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy' import { forkAccentStyle } from '$lib/utils/forkColor' - import { Badge, Button } from '$lib/components/common' - import { devBadgeText } from '$lib/utils/devWorkspaceLabel' + import { Button } from '$lib/components/common' + import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte' import { Building, ChevronDown, EllipsisVertical, GitFork } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { type Item } from '$lib/utils' @@ -171,12 +171,12 @@ {#if pendingFork} (new) {:else if currentWs?.is_dev_workspace} - {devBadgeText(currentWs.dev_workspace_label)} + {/if} {/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 073d6c3e7d..ac0d2b1cb6 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -86,7 +86,8 @@ othersDraftsCount = 0, onOpenOthersDrafts, onRestore, - onDeploy + onDeploy, + ownsPageHeader = false }: AppEditorProps = $props() migrateApp(untrack(() => app)) @@ -885,6 +886,7 @@ {#if !$userStore?.operator} {#if $appStore} hideBottomPanel()} /> {#if $mode === 'preview'} - +
{/if} - +
void + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -142,10 +147,11 @@ othersDraftsCount = 0, onOpenOthersDrafts, onRestore, - onDeploy + onDeploy, + ownsPageHeader = false }: Props = $props() - /** Mirror of the path the user is editing in the pen popover. Initialized + /** Mirror of the path the user is editing in the rename popover. Initialized * once from `newPath` (or a synthesized path for new apps) and only * updated by user input from then on — we deliberately do NOT sync from * `newPath` afterwards so the user's in-flight rename isn't clobbered by @@ -223,6 +229,18 @@ // the preview to the deployed version. const inSessionPane = !!getContext('aiChatManager') + // The header's buttons render under the page header, not under this component, so the contexts + // they look up have to travel with them. The app's own two carry everything the canvas toggles, + // the panel buttons, Debug runs and the preview switch read. + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) + + const headerContexts = new Map([ + ['AppViewerContext', getContext('AppViewerContext')], + ['AppEditorContext', getContext('AppEditorContext')] + ]) + const loading = $state({ publish: false, save: false @@ -243,7 +261,24 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the buttons share the row with the breadcrumb, so they shed the canvas + // toggles and fold Debug runs into the menu sooner: the whole group is ~840px and the trail + // with the summary takes ~430px of the same row. + // Measured on the band rather than estimated: with the canvas toggles shown the actions alone + // are 826-911px and the trail with the summary another ~500, so the row overflows the bar at + // every width up to about 1470 — 1350 was not high enough either. Below this the toggles stand + // down and the menu carries all three, which is what keeps them reachable rather than merely + // out of the way. + const compactBelow = $derived(ownsPageHeader ? 1500 : 720) + const compactTopbar = $derived.by(() => { + const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth + return w > 0 && w < compactBelow + }) + // A phone's bar holds the trail and Deploy. Which panels are open, the export and the + // editor/preview switch join the menu compact already shows. + const phoneTopbar = $derived( + ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + ) function closeSaveDrawer() { saveDrawerOpen = false @@ -590,7 +625,49 @@ selectedJobId = $jobs[$jobs.length - 1] } $jobsDrawerOpen = true - }, + } + }, + // The canvas toggles stand down with the bar, and the breakpoint among them is the + // only way onto the mobile layout — so each one keeps an entry here. + { + displayName: $breakpoint === 'sm' ? 'Computer view' : 'Mobile view', + icon: $breakpoint === 'sm' ? Laptop2 : Smartphone, + action: () => ($breakpoint = $breakpoint === 'sm' ? 'lg' : 'sm') + }, + // The setting the mobile view is edited for, which otherwise lives beside the + // toggle group that stood down: without it here a deployed app could be put on + // the mobile layout at this width and never taken off it. + ...($breakpoint === 'sm' + ? [ + { + displayName: $app?.mobileViewOnSmallerScreens + ? 'Mobile view on small screens: on' + : 'Mobile view on small screens: off', + icon: Smartphone, + action: () => { + if ($app) $app.mobileViewOnSmallerScreens = !$app.mobileViewOnSmallerScreens + } + } + ] + : []), + { + displayName: $app?.fullscreen ? 'Centered canvas' : 'Full-width canvas', + icon: $app?.fullscreen ? AlignHorizontalSpaceAround : Expand, + action: () => { + if ($app) $app.fullscreen = !$app.fullscreen + } + }, + { + displayName: + $app?.darkMode === undefined + ? 'Theme: automatic' + : $app.darkMode + ? 'Theme: dark' + : 'Theme: light', + icon: $app?.darkMode === undefined ? SunMoon : $app.darkMode ? Moon : Sun, + // Cycles auto → light → dark, the order the toggle group reads in. + action: () => + setTheme($app?.darkMode === undefined ? false : $app.darkMode ? undefined : true), separatorBottom: true } ] @@ -913,141 +990,232 @@ -
- + ` placeholder, and the trail would name that instead of the path Deploy + // will create. Same fallback chain the rename sites read. + // Frozen while the rename popover is open so the trail holds still as the user types. + kind: 'app', + path: pathSnapshot ?? (newEditedPath || $appPath || newPath || undefined), + summaryContent: appSummary, + pathTrigger: appPathTrigger + }} + actions={appHeaderActions} + contexts={headerContexts} + separator="always" + /> +{:else} +
+ -
-
- (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} - /> -
-
- {#if $app} - { - $app.fullscreen = detail === 'true' - }} - > - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if $app} - { - const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined - setTheme(theme) - }} - selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'} - > - {#snippet children({ item })} - - - - {/snippet} - - {/if} -
- - {#snippet children({ item })} - - - {#if $breakpoint === 'sm'} - - {/if} - {/snippet} - -
-
- {#if $workspaceStore} -
- +
+ (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
- {/if} -
+ {@render canvasToggles()} + {@render autosaveIndicator()} +
+ {@render panelButtons()} + {@render awarenessMark()} + {@render appActions()} +
+{/if} + + +{#snippet appPathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + +{#snippet appSummary()} + +
+ {#snippet summaryText()} + {emptyString($summary) ? 'Add a summary...' : $summary} + {/snippet} + +
+{/snippet} + +{#snippet appHeaderActions()} + +
+ {@render canvasToggles()} + {@render autosaveIndicator()} + {#if !phoneTopbar} + {@render panelButtons()} + {@render awarenessMark()} + {/if} + {@render appActions()} +
+{/snippet} + +{#snippet canvasToggles()} +
+ {#if $app} + { + $app.fullscreen = detail === 'true' + }} + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if $app} + { + const theme = detail === 'dark' ? true : detail === 'sun' ? false : undefined + setTheme(theme) + }} + selected={$app.darkMode === undefined ? 'auto' : $app.darkMode ? 'dark' : 'sun'} + > + {#snippet children({ item })} + + + + {/snippet} + + {/if} +
+ + {#snippet children({ item })} + + + {#if $breakpoint === 'sm'} + + {/if} + {/snippet} + +
+
+{/snippet} + +{#snippet autosaveIndicator()} + {#if $workspaceStore} +
+ +
+ {/if} +{/snippet} + +{#snippet panelButtons()} {#if $mode !== 'preview'}
{/if} +{/snippet} + +{#snippet awarenessMark()} {#if $enterpriseLicense && $appPath != '' && !inSessionPane}
{/if} +{/snippet} + +{#snippet appActions()}
@@ -1131,14 +1305,19 @@
+ - +
-
+{/snippet} diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index e004c17a04..ecc1e693e1 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -13,13 +13,18 @@ import { sendUserToast } from '$lib/toast' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte' + import type { Item } from '$lib/utils' import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' import type { UserExt } from '$lib/stores' import { canWrite } from '$lib/utils' import { getUserExt } from '$lib/user' - import { Pen } from 'lucide-svelte' + import { ExternalLink, Pen } from 'lucide-svelte' import { page } from '$app/state' + import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte' import { setOperatingWorkspace, useOperatingUser @@ -30,10 +35,15 @@ path, onEdit, onLoadState, - syncHashToUrl = true + syncHashToUrl = true, + ownsPageHeader = false }: { workspace: string path: string + /** True for the route's own page: the app becomes what the page header names. A host that + * renders this inside something else — a session's preview panel, whose band names the + * session — leaves it false, and the app keeps its Edit over its own canvas. */ + ownsPageHeader?: boolean /** Handle Edit in place instead of following `editHref`. An AI session shows * this viewer inside a preview tab, where a plain link would navigate the whole * page out of the session rather than flipping the tab to its editor. */ @@ -70,6 +80,12 @@ * app point at /apps/get all over the app), so only the app can say which * editor the Edit button must open. */ let isRawApp = $state(false) + /** The app's execution mode, from the same lite read as the permissions and for the same + * reason: a sandboxed low-code app loads inside the opaque iframe, so `app` here stays + * undefined and cannot say whether the app answers at a public url. `policy.execution_mode` is + * where the lite payload carries it; the flat field beside it is the apps *list* row's + * spelling (ListableApp, which is what AppRow reads). */ + let executionMode = $state(undefined) let editHref = $derived(`${base}/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`) let refresh: (() => void) | undefined @@ -85,6 +101,11 @@ const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true' const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true' + const showEdit = $derived(canWriteApp && !hideEditBtn) + // Decides where Edit goes, not whether it appears: without the workspace navigation there is + // no band to put it in. Same rule the layout hides the sidebar by. + const menuHidden = $derived(isMenuHidden(page.url)) + // Embedder side: mint a scoped embed token (by path) from the member's session. async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> { const headers: Record = {} @@ -155,48 +176,142 @@ const lite: any = await AppService.getAppLiteByPath({ workspace, path }) appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} } isRawApp = !!lite?.raw_app + executionMode = lite?.policy?.execution_mode ?? lite?.execution_mode } catch (_) { appPerms = undefined } } + /** Whether the app answers at a public url at all. Read from the app this viewer has already + * loaded, the way the apps table decides the same thing (AppRow) — the secret itself is a + * request, and asking for one on every app view to decide whether to draw a button is a + * request per view that most views never use. */ + const hasPublicUrl = $derived(executionMode === 'anonymous') + /** Resolved when the button is used, not when it is drawn. */ + async function openPublicUrl() { + // The tab is opened inside the click itself and filled once the secret arrives. Safari only + // lets a gesture open the tab it opens synchronously, so opening it after the await is + // blocked there — silently, since a blocked popup reports nothing. + const tab = window.open('about:blank', '_blank') + try { + const secret = await AppService.getPublicSecretOfApp({ workspace, path }) + if (!secret) { + tab?.close() + sendUserToast('This app has no public url', true) + return + } + // Built from this viewer's workspace rather than the navigation one, like everything + // else here: the two differ inside a session's preview panel. + const url = `${window.location.origin}${base}/public/${workspace}/${secret}` + if (tab) tab.location.href = url + else sendUserToast('Allow pop-ups for this site to open the app’s public url', true) + } catch (e: any) { + tab?.close() + sendUserToast('Could not open the public url: ' + (e?.body ?? e?.message ?? e), true) + } + } + $effect(() => { if (workspace && path) loadPerms() }) + + // Both of this page's buttons carry a label, and a phone's bar has room for neither beside the + // app's path — so below that width they become one menu. Unmeasured (0) counts as wide: the bar + // measures itself on mount, and starting compact would pop them out a frame later. + const compact = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) + const compactItems: Item[] = $derived([ + ...(hasPublicUrl + ? [ + { + displayName: 'Public url', + icon: ExternalLink, + action: () => openPublicUrl() + } + ] + : []), + { + displayName: 'Edit', + icon: Pen, + href: onEdit ? undefined : editHref, + action: onEdit ? () => onEdit() : undefined + } + ]) - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
-{#if canWriteApp && !hideEditBtn} -
- + +{#if ownsPageHeader} + +{/if} + + +{#if showEdit && (menuHidden || !ownsPageHeader)} +
+ {@render editAction()}
{/if} + +{#snippet editAction()} + {#if compact} + + + {:else} + {#if hasPublicUrl} + + + {/if} + + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/apps/editor/PreviewToggle.svelte b/frontend/src/lib/components/apps/editor/PreviewToggle.svelte index 7a72ac62b3..4e20301043 100644 --- a/frontend/src/lib/components/apps/editor/PreviewToggle.svelte +++ b/frontend/src/lib/components/apps/editor/PreviewToggle.svelte @@ -8,9 +8,11 @@ interface Props { loading?: boolean + /** Drops the two labels, for a bar with no room for them: the pen and the eye carry it. */ + iconOnly?: boolean } - let { loading = false }: Props = $props() + let { loading = false, iconOnly = false }: Props = $props() const { mode, jobs, jobsById } = getContext('AppViewerContext') @@ -24,7 +26,8 @@ > {#snippet children({ item })} } - {syncHashToUrl} - /> + +
+ } + {syncHashToUrl} + /> +
{:else if app.raw_app && !effectiveWorkspace}
@@ -181,10 +187,12 @@
void + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } export type App = { diff --git a/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte index f1c123bb11..f3d84f872b 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogMobileFilters.svelte @@ -14,7 +14,7 @@ contentClasses="p-4 flex flex-col w-80 pt-8 bg-surface" > {#snippet trigger()} - {/snippet} diff --git a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte index 9817b667f7..e2e502b30c 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsFilters.svelte @@ -18,7 +18,10 @@ import type { ActionKind } from '$lib/common' import Button from '$lib/components/common/button/Button.svelte' - import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte' + import TimeframeSelect, { + auditTimeframes, + useSyncedTimeframe + } from '$lib/components/runs/TimeframeSelect.svelte' import { type AuditLog, ResourceService, @@ -29,7 +32,7 @@ } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' - import { ChevronDown, Download, Loader2, RefreshCcw } from 'lucide-svelte' + import { ChevronDown, Download } from 'lucide-svelte' import { onDestroy, onMount, untrack } from 'svelte' import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' @@ -52,7 +55,7 @@ actionKind?: ActionKind | 'all' scope?: undefined | 'all_workspaces' | 'instance' loading?: boolean - /** Lay the filters out in a row (page header) rather than stacked (popover). */ + /** Lay the filters out in a row (the page header band) rather than stacked (the popover). */ inline?: boolean onRefresh?: () => void } @@ -265,12 +268,35 @@ ;[username, perPage, before, after, operation, resource, actionKind, scope] untrack(() => updateQueryParams()) }) + + // The bounds stay the filters' own `after`/`before`, because the timeline's zoom writes them + // too; picking a preset resolves it to a concrete pair here rather than keeping a label that + // the chart could then contradict. + const _timeframe = useSyncedTimeframe( + auditTimeframes, + () => ({ minTs: after, maxTs: before }), + (v) => { + const { minTs, maxTs } = v.timeframe + ? (auditTimeframes.find((t) => t.label === v.timeframe)?.computeMinMax() ?? { + minTs: null, + maxTs: null + }) + : { minTs: v.minTs ?? null, maxTs: v.maxTs ?? null } + after = minTs ?? undefined + before = maxTs ?? undefined + } + ) -
+ +
{#if $workspaceStore == 'admins'} -
- Scope +
{ @@ -300,49 +326,31 @@
{/if} -
- From - - { - after = new Date(detail).toISOString() - }} - on:clear={() => { - after = undefined - }} - /> -
-
- To - - { - before = new Date(detail).toISOString() - }} - on:clear={() => { - before = undefined - }} - /> -
+ + { + loadUsers() + resources.refresh() + onRefresh?.() + }} + /> -
- Username +
(resources.value?.push(r), (resource = r))} createText="Press enter to use this value" bind:value={resource} - items={safeSelectItems(['all', ...(resources.value ?? [])])} + items={[{ value: 'all', label: 'all resources' }, ...safeSelectItems(resources.value ?? [])]} inputClass="dark:!bg-gray-700" RightIcon={ChevronDown} />
-
- Operation - +
-
+
diff --git a/frontend/src/lib/components/breadcrumbPath.test.ts b/frontend/src/lib/components/breadcrumbPath.test.ts new file mode 100644 index 0000000000..427b153800 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { splitItemPath } from './breadcrumbPath' + +describe('splitItemPath', () => { + it('walks the scope, each subfolder, then the item, each with its cumulative path', () => { + expect(splitItemPath('f/demo/sub/weather_report')).toEqual({ + dirs: [ + { name: 'f/demo', fullPath: 'f/demo' }, + { name: 'sub', fullPath: 'f/demo/sub' } + ], + leaf: { name: 'weather_report', fullPath: 'f/demo/sub/weather_report' } + }) + }) + + it('gives up before there is a scope', () => { + expect(splitItemPath('f/demo')).toBeUndefined() + }) + + it('keeps the folders while the name is still being typed', () => { + // The editor binds the path live, so every keystroke reaches the breadcrumb: the trail must + // not drop out and come back on the first character of the name. + expect(splitItemPath('f/demo/')).toEqual({ + dirs: [{ name: 'f/demo', fullPath: 'f/demo' }], + leaf: undefined + }) + }) +}) diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..880d558360 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -0,0 +1,30 @@ +/** A directory level of an item's path, with the full path down to it. */ +export type PathDir = { name: string; fullPath: string } + +/** + * Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/` or + * `u/`), then each subfolder, then the item itself. Each level carries the cumulative path, + * which is what the picker opens and highlights. + * + * Returns undefined before there is even a scope. The leaf is undefined while the path names a + * folder but no item yet (`f/demo/`) — the levels it does name still come back. + */ +export function splitItemPath( + path: string +): { dirs: PathDir[]; leaf: PathDir | undefined } | undefined { + const parts = path.split('/') + if (parts.length < 3) return undefined + const scope = parts.slice(0, 2).join('/') + const slug = parts.slice(2) + const dirs: PathDir[] = [{ name: scope, fullPath: scope }] + let acc = scope + for (let i = 0; i < slug.length - 1; i++) { + acc = `${acc}/${slug[i]}` + dirs.push({ name: slug[i], fullPath: acc }) + } + // A leaf with an empty name draws a blank segment. The editor binds the path live, so the + // breadcrumb sees every keystroke: the folders stay put while the name is being typed rather + // than the whole trail dropping out and coming back. + const name = slug[slug.length - 1] + return { dirs, leaf: name ? { name, fullPath: path } : undefined } +} diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index f684d79a9f..f3cfc78478 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -148,6 +148,13 @@ }: Props = $props() const activeTone = $derived(dropdownItems ? undefined : tone) + // `dropdownItems` may be a function, whose `.length` is its arity — 0 for the `() => [...]` + // every caller writes. Asking it for its items is the only way to know whether there are any, + // and a split button that answers `.length` instead renders no caret at all. + const hasDropdown = $derived( + !!dropdownItems && + (typeof dropdownItems === 'function' ? dropdownItems().length : dropdownItems.length) > 0 + ) function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] { const items = typeof menuItems === 'function' ? menuItems() : menuItems @@ -197,12 +204,7 @@ ] : ButtonType.VariantStyles[variant] // For default variant with dropdowns, remove border from button since it's on wrapper - if ( - variant === 'default' && - dropdownItems && - ((typeof dropdownItems === 'function' && dropdownItems().length > 0) || - dropdownItems.length > 0) - ) { + if (variant === 'default' && hasDropdown) { style = style.replace('border border-border-light', '') } return style @@ -278,7 +280,7 @@ getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize), unifiedSize ? ButtonType.UnifiedFontSizes[unifiedSize] : '', 'focus-visible:ring-2', - dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md' : 'rounded-md', + hasDropdown ? 'rounded-l-md' : 'rounded-md', 'justify-center items-center text-center inline-flex gap-2', 'active:opacity-80 transition-[background-color,opacity] duration-150', disabled @@ -345,7 +347,7 @@
0 ? dividerClass : '', + hasDropdown ? dividerClass : '', 'shrink-0', wrapperClasses, 'flex flex-row rounded-md', @@ -385,6 +387,7 @@ class={buttonClass} {id} {target} + {title} tabindex={disabled ? -1 : 0} {...rest} {style} @@ -484,7 +487,7 @@ {/if} {/if} - {#if dropdownItems && dropdownItems.length > 0} + {#if hasDropdown && dropdownItems} = { '2xs': 'px-1', // Compact horizontal padding xs: 'px-2', - sm: 'px-2', // Regular horizontal padding + // One step of its own rather than sharing xs's: sm is the size a page's header buttons are, + // where a label needs room to read as a button rather than as text with a box around it. + sm: 'px-3', md: 'px-4', lg: 'px-6' } diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte index f3870b3bb6..ae2c4c4bae 100644 --- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -50,6 +50,9 @@ /** Render after the right-pinned tabs, outside the scroll area so it stays * pinned (e.g. a "Split with Preview" toggle). */ trailing?: import('svelte').Snippet + /** Tab height. `sm` (the default) is the 24px pill the panel strips use; `md` is 28px, for a + * strip that fills a 44px bar, where the smaller pill leaves too much air around it. */ + size?: 'sm' | 'md' /** Render inside each tab (after the label; receives the tab + isActive). Clicks * bubble to the tab unless the accessory stops them. The tab is position:relative * so an `absolute inset-0 pointer-events-none` child can anchor a whole-tab @@ -65,6 +68,7 @@ onReorder, onActiveClick, class: c = '', + size = 'sm', afterTabs, trailing, tabAccessory @@ -180,7 +184,8 @@ function tabClasses(isActive: boolean) { return twMerge( - 'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + 'group relative inline-flex items-center gap-1.5 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + size === 'md' ? 'h-7 px-3 gap-2' : 'h-6 px-2.5', isActive ? 'bg-surface-tertiary text-emphasis' : 'bg-transparent text-hint hover:text-secondary' diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 1300dc4322..86f04dfa9f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -70,7 +70,14 @@
- + +
diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 1a1412b9f8..3e0351f249 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -3,14 +3,12 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte' import { zIndexes } from '$lib/zIndexes' - import { userStore, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { chatState } from './sharedChatState.svelte' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { copilotInfo } from '$lib/aiStore' import { aiChatManager } from './AIChatManager.svelte' import { onDestroy } from 'svelte' - import Button from '$lib/components/common/button/Button.svelte' - import { Menu } from 'lucide-svelte' import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte' interface Props { @@ -20,7 +18,6 @@ transitionClass?: string isMobile?: boolean children: any - onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than // the docked pane (code completion, metadata generation, the "open in AI @@ -40,15 +37,14 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, - onMenuOpen, disableAi, loadAiConfig = true, showSessionsBetaBanner = false }: Props = $props() // The desktop rail is fixed-positioned, so the content is offset by a matching - // left padding (in rem, matching the rail). Mobile/operator/borderless: no rail. - let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth) + // left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail. + let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth) $effect(() => { chatState.dockedChatAvailable = !disableAi @@ -83,23 +79,6 @@ }) -{#snippet burgerRow()} -
-
-{/snippet} - {#if !disableAi} @@ -111,7 +90,6 @@ >
- {@render burgerRow()}
{@render children?.()}
@@ -138,12 +116,14 @@ {/if} {:else} +
- {@render burgerRow()}
{@render children?.()}
diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 23a80c4026..3e3baa36d7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -2719,7 +2719,7 @@ function allowedTriggerKinds(): PageTriggerKind[] { } // Pages an operator may reach: exactly the ones enabled in the workspace's -// operator_settings, the same source OperatorMenu gates on. Operators are never admins, so +// operator_settings, the same source the sidebar gates on. Operators are never admins, so // workspace_settings is excluded whatever the settings say. function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] { const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 72c39ba512..34a7fab355 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -3,10 +3,10 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte' - import { twMerge } from 'tailwind-merge' - import { userStore } from '$lib/stores' - import { createEventDispatcher, getContext, tick } from 'svelte' + import { createEventDispatcher, getContext, tick, type Snippet } from 'svelte' import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import type { TriggerContext } from '../triggers' import type { Item } from '$lib/utils' import { Bell, BellOff, Calendar } from 'lucide-svelte' @@ -37,9 +37,10 @@ } // An agent's page has no triggers, and so no context for them. - const { triggersCount, triggersState } = $state( - getContext('TriggerContext') ?? ({} as Partial) - ) + const triggerContext = getContext('TriggerContext') + const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial)) + // The buttons below render in the page header, away from this page's tree. + const headerContexts = new Map([['TriggerContext', triggerContext]]) interface Props { mainButtons?: MainButton[] @@ -49,6 +50,9 @@ tag?: string | undefined /** Unset for what has no workspace error handler to mute, such as an agent. */ errorHandlerKind?: 'flow' | 'script' + /** What the rename popover is renaming, when that is not the error handler's kind — an + * agent has no error handler but is still renamed from here. */ + itemKind?: 'flow' | 'script' | 'agent' scriptOrFlowPath?: string errorHandlerMuted?: boolean | undefined labels?: string[] | undefined @@ -56,6 +60,10 @@ onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** True for the route's own page: its header becomes the page header. A host that renders + * this inside something else — a session's preview panel — leaves it false and gets the + * row below. */ + ownsPageHeader?: boolean /** Whether the bar is wide enough to hold every button beside the summary, decided by * the layout from its own width. Not a viewport media query: the same page also renders * inside an AI session's preview panel, where the window is wide and the pane is not. */ @@ -71,6 +79,7 @@ path, tag, errorHandlerKind, + itemKind, scriptOrFlowPath, errorHandlerMuted = $bindable(), labels = $bindable(), @@ -78,13 +87,30 @@ onSaved, children, trigger_badges, + ownsPageHeader = false, wide = true, leading_actions }: Props = $props() const dispatch = createEventDispatcher() - const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow)) + // A flow and a script are renamed as what their error handler already names them; an agent has + // no error handler and says so itself. + const renameKind = $derived(itemKind ?? errorHandlerKind) + + // Two reasons the row can be too tight, and either one collapses it. The host knows when its + // own box is narrow while the window is not — a session's preview panel — and says so through + // `wide`. The band knows its own width, which the host cannot see: these buttons share that row + // with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured + // (0) counts as wide, because the bar measures itself on mount and starting narrow would pop + // the buttons out of the menu a frame later. + // Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row + // has to fold before that rather than at the ~1150 the two parts add up to on paper. + const COLLAPSE_BELOW = 1250 + const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW) + const wideRow = $derived(wide && roomInBar) + + const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow)) function dropdownHost(btn: MainButton): MainButton | undefined { if (typeof btn.narrow !== 'object') return undefined @@ -104,7 +130,7 @@ } const allMenuItems: Item[] = $derived([ - ...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ + ...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ displayName: b.label, description: b.description, icon: b.buttonProps.startIcon, @@ -113,7 +139,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide || !errorHandlerKind + ...(wideRow || !errorHandlerKind ? [] : [ { @@ -129,12 +155,12 @@ action: item.onclick, disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), - separatorTop: i === 0 && !wide + separatorTop: i === 0 && !wideRow })) ]) function dropdownItemsOf(host: MainButton) { - if (wide) return undefined + if (wideRow) return undefined const items = mainButtons .filter((b) => dropdownHost(b) === host) .map((b) => ({ @@ -149,74 +175,138 @@ } -
-
-
+{/snippet} + + +{#snippet pathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + + +{#snippet badges(size: 'sm' | 'md')} + {#if tag} + tag: {tag} + {/if} + {@render children?.()} + {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} + {@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)} + + {/if} + {@render trigger_badges?.()} +{/snippet} + +{#snippet controls(size: 'sm' | 'md')} + {@render leading_actions?.()} + {#if allMenuItems.length > 0} + {#key allMenuItems} + + {/key} + {/if} + {#if wideRow && errorHandlerKind && scriptOrFlowPath} + + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} + + {/each} +{/snippet} + +{#snippet actions()} + {@render badges('sm')} + {@render controls('sm')} +{/snippet} + +{#if ownsPageHeader} + + +{:else} + +
+
+
+
+
+ +
+ {@render badges('md')} +
+
+ {@render controls('md')}
- {#if tag} - tag: {tag} - {/if} - {@render children?.()} - {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} - {@const primarySchedule = triggersState.triggers.findIndex( - (t) => t.isPrimary && !t.isDraft - )} - - {/if} - {@render trigger_badges?.()} -
-
- {@render leading_actions?.()} - {#if allMenuItems.length > 0} - {#key allMenuItems} - - {/key} - {/if} - {#if wide && errorHandlerKind && scriptOrFlowPath} - - {/if} - {#each barButtons as btn (btn.label)} - {@const dropdownItems = dropdownItemsOf(btn)} - - {/each}
-
+{/if} diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte index 49622c8af2..48e833e2ff 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte @@ -13,9 +13,16 @@ scriptOrFlowPath: string errorHandlerMuted: boolean | undefined iconOnly?: boolean + unifiedSize?: 'xs' | 'sm' | 'md' | 'lg' } - let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props() + let { + kind, + scriptOrFlowPath, + errorHandlerMuted = $bindable(), + iconOnly = true, + unifiedSize = 'md' + }: Props = $props() async function toggleErrorHandler(): Promise { const next = await toggleWorkspaceErrorHandler( @@ -32,7 +39,7 @@ title={errorHandlerMuted === undefined || !errorHandlerMuted ? 'Disable workspace error handler for this script' : 'Enable workspace error handler for this script'} - unifiedSize="md" + {unifiedSize} on:click={toggleErrorHandler} variant="subtle" startIcon={{ diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 0e9f47816b..ce7378ede4 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -656,6 +656,7 @@ {#snippet header({ wide }: { wide: boolean })} { detailLayout?.showTriggers() }} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 078ab20a47..f33f00fb8f 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -838,6 +838,7 @@ {#snippet header({ wide }: { wide: boolean })} - import { - ChevronRight, - FlaskConical, - Settings, - FormInput, - History, - MessageSquare, - Save - } from 'lucide-svelte' + import { FlaskConical, Settings, FormInput, History, MessageSquare, Save } from 'lucide-svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { onDestroy, untrack } from 'svelte' + import { onDestroy, untrack, type Snippet } from 'svelte' import { resource } from 'runed' import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte' import PagedContent from '$lib/components/common/modal/PagedContent.svelte' @@ -39,6 +31,10 @@ import AgentSettings from './AgentSettings.svelte' import AgentEvalsModal from './AgentEvalsModal.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { emptyString } from '$lib/utils' + import Tooltip from '$lib/components/Tooltip.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -93,6 +89,9 @@ let host = $state | undefined>(undefined) let versionDrawer: Drawer | undefined = $state(undefined) let settingsDrawer: Drawer | undefined = $state(undefined) + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) let evalsModal: AgentEvalsModal | undefined = $state(undefined) let saving = $state(false) @@ -136,9 +135,10 @@ let canEvaluate = $derived( draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused ) - // A never-deployed agent is stored at a minted `draft_` path, so it is named by the path - // its first deploy will create, as the home list names it. - let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path) + // The draft's path, so a rename shows where it is typed — including a never-deployed agent, + // stored at a minted `draft_` path and named by the path its first deploy will create, + // as the home list names it. Falls back to the target while the draft is still loading. + let shownPath = $derived(draft?.state?.path || target?.path) // Where the evals pane is within itself, so its levels extend this dialog's trail rather than // opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on @@ -312,34 +312,22 @@ {@render body()} {:else} + +
-
-
-
- {#each trail as segment, i (i)} - {#if i > 0} - - {/if} - {#if segment.onclick} - - {:else} - {segment.label} - {/if} - {#if i === 0} - {@render titleBadge()} - {/if} - {/each} - {@render levelBadge()} -
- {#if description} - {description} - {/if} -
-
- {@render settings()} -
{@render body()}
@@ -347,6 +335,95 @@ {/if} {/key} + + {#snippet agentPathTrigger(pathLabel: Snippet, triggerClass: string)} + + {#if draft?.state} + draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + /> + {:else} + {@render pathLabel()} + {/if} + {/snippet} + + {#snippet agentSummary()} + +
+ {#if draft?.state} + {#snippet summaryText()} + {emptyString(draft.state?.description) + ? 'Add a summary...' + : draft.state?.description} + {/snippet} + draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + penVisibility={emptyString(draft.state.description) ? 'always' : 'hover'} + /> + {/if} + {@render titleBadge()} +
+ {/snippet} + {#snippet agentHint()} + {#if description} + {description} + {/if} + {/snippet} {#snippet titleBadge()} diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index a8e4d8b973..62479b70d1 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -46,10 +46,20 @@ import HomeConnectDrawer from './HomeConnectDrawer.svelte' import { USER_SETTINGS_HASH } from '../sidebar/settings' import { prefersSessionHandoff } from '../copilot/chat/global/gate' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { onboardingProfile } from '$lib/onboardingProfile' const COLLAPSED_SETTING = 'home-ai-composer-collapsed' + /** Bar width from which the two side trips stand in the bar rather than folding into the menu. + * Home's trail is ~250px and the rest of its actions ~400, so this leaves room for both + * labelled buttons without the trail starting to truncate. Below it they fold back. */ + const BAR_FITS_SIDE_TRIPS = 1024 + const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS) + const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB) + let value = $state('') // The stock examples, unless the invite that brought this person here wrote prompts for // them — those replace the set outright rather than joining it, since a prompt written @@ -73,6 +83,26 @@ storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined) } + // Removing puts the composer away rather than throwing it out, and the way back is one item in + // the band's menu — so the ellipsis flashes as it appears, in a corner nobody is watching while + // the hero collapses in the middle of the page. + // + // A keyframe animation, not a transition: this mounts in the same flush that collapses the + // hero, so the frame that would paint it at rest is the frame it is already told to leave, and + // one `requestAnimationFrame` is not reliably past that paint. The flash then goes straight to + // invisible without ever being drawn. Mounted only while pulsing, so each removal replays it. + const PULSE_MS = 1000 + let pulsing = $state(false) + let pulseEndTimer: ReturnType | undefined + + function removeComposer() { + setCollapsed(true) + if (reducedMotion.val) return + clearTimeout(pulseEndTimer) + pulsing = true + pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS) + } + // In global-AI mode the layout's chat panel is disabled and never loads the copilot // config, so the home chat loads it for the current workspace itself. $effect(() => { @@ -107,8 +137,9 @@ // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - // The composer hands off to /sessions, which refuses operators — so hide it from them (the - // prompt would be silently dropped) while the AI-independent CLI/MCP row below stays. + // The composer hands off to /sessions, which refuses operators and users opted out of the + // sessions beta — so hide it from them (the prompt would be silently dropped) while the + // AI-independent CLI/MCP row below stays. let showComposer = $derived( prefersSessionHandoff($userStore?.operator) && !runOnlyWorkspace && @@ -116,7 +147,7 @@ ) // The hero's margins and centered column are for the full block. The lone button row left - // by a collapsed, operator, run-only or hidden-assistant view is a hint line and should + // by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should // cost the page almost nothing: no top margin, and the content column's full width so it // hugs the right edge instead of floating centered in empty space. let hero = $derived(showComposer && !collapsed) @@ -149,7 +180,7 @@ const FADE_MS = 600 // Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in. - // Only while the composer is shown — otherwise (operators) it would loop forever driving an + // Only while the composer is shown — otherwise it would loop forever driving an // unrendered input — and not under reduced motion, where the first prompt simply stays put. // The index lives outside the effect so re-showing the composer resumes the rotation from the // prompt currently displayed rather than restarting it. @@ -185,7 +216,7 @@
- setCollapsed(true)} /> +
{/if}
@@ -272,9 +303,7 @@ {freeTierExhausted ? 'Add your own API key' : 'Configure AI'} {/if} - +
{/if} @@ -295,49 +324,106 @@ {/each}
- {:else if showComposer} - - - {:else} -
{/if} - - -
- - {#if !$userStore?.operator && HOME_SHOW_HUB} - - {/if} -
+ + + + + +{#snippet homeMenu()} + + + + {#if sideTripsInBar} + + + {#if hubOffered} + + {/if} + {/if} + + + {#if !sideTripsInBar || (showComposer && collapsed)} + + + {#if pulsing} + + + {/if} + setCollapsed(false) + } + ] + : []), + ...(sideTripsInBar + ? [] + : [ + { + displayName: 'CLI / MCP', + icon: PlugZap, + action: () => homeConnectDrawer?.openDrawer?.() + }, + ...(hubOffered + ? [ + { + displayName: 'Hub', + icon: Globe2, + href: $hubBaseUrlStore, + hrefTarget: '_blank' as const + } + ] + : []) + ]) + ]} + /> + + {/if} + +{/snippet} diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index ab6de64123..02ed1d90f7 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -16,6 +16,10 @@ interface Props { placement?: Placement + /** Inline style on the panel. A margin here shifts it after floating-ui has placed it, + * which is the only way to anchor it to a box wider than the trigger: melt's menubar + * menu ignores `positioning.offset`. */ + contentStyle?: string justifyEnd?: boolean lightMode?: boolean maxHeight?: number @@ -45,6 +49,7 @@ let { placement = 'right-start', + contentStyle = '', justifyEnd = false, lightMode = false, maxHeight = 900, @@ -221,6 +226,7 @@ onmouseenter={cancelPendingClose} onmouseleave={scheduleClose} transition:placementFly={{ duration: 100, placement }} + style={contentStyle} class={twMerge( 'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none', // Default: scroll on the melt element. submenuSafe moves it to the inner diff --git a/frontend/src/lib/components/moveRenameManager.ts b/frontend/src/lib/components/moveRenameManager.ts index 1500f8fc69..e5844a3351 100644 --- a/frontend/src/lib/components/moveRenameManager.ts +++ b/frontend/src/lib/components/moveRenameManager.ts @@ -1,7 +1,8 @@ -import { AppService, FlowService, ScriptService } from '$lib/gen' +import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen' +import { agentEditorRefusal } from './flows/agentResourceUtils' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' -type ItemKind = 'flow' | 'script' | 'app' +type ItemKind = 'flow' | 'script' | 'app' | 'agent' /** * Check whether a flow uses on_behalf_of_email. @@ -71,6 +72,24 @@ export async function updateItemPathAndSummary(opts: { skip_draft_deletion: true } }) + } else if (kind === 'agent') { + // An agent is a resource, and its summary is the resource's description. The read before + // the write is the same one the agent editor does: an update carries no resource type, so + // a path deleted and recreated as something else in the meantime would otherwise take an + // agent's config. + const current = await ResourceService.getResource({ workspace, path: initialPath }) + const refused = agentEditorRefusal(initialPath, current.resource_type) + if (refused) { + throw new Error(refused) + } + // Only what this popover edits: the backend sets a column per field it is given, so the + // agent's own config, its type and `ws_specific` are left where they are rather than read + // here and written back over whatever changed in between. + await ResourceService.updateResource({ + workspace, + path: initialPath, + requestBody: { path: newPath, description: newSummary, labels } + }) } else if (kind === 'app') { await AppService.updateApp({ workspace, diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index 93a7f2c0de..70f1c63c1c 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -22,6 +22,14 @@ export function setOperatingWorkspace(resolve: () => string | undefined): void { setContext(KEY, outer ? () => resolve() ?? outer() : resolve) } +/** This subtree's operating workspace as an entry for a `ContextBridge` contexts map. A snippet + * handed to the page header's band renders outside the subtree that declared the workspace, so + * without this it would quietly act on the navigation workspace instead. Reads context: call + * during component initialisation. */ +export function operatingWorkspaceContextEntry(): [any, any] { + return [KEY, getContext(KEY)] +} + /** The workspace this component acts on, as a store. Reads context: call during component * initialisation. Falls back to the navigation workspace where no host set one, or where the * host's resolves to nothing. */ diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts new file mode 100644 index 0000000000..3167d636e9 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -0,0 +1,185 @@ +import type { Snippet } from 'svelte' +import type { WorkspaceItemKind } from './workspacePicker' + +/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ +export type PageHeaderItem = { + /** Absent for an item the picker does not file under a kind — an agent's detail page. The + * breadcrumb then names the item without claiming it is one of the three. */ + kind?: WorkspaceItemKind + /** Shown whole. Clicking it opens whatever the page hangs off it with `pathTrigger`, and copies + * the path where there is none. */ + path: string | undefined + /** Wraps the band's own path segment in whatever the page wants behind it — its rename + * popover, anchored there rather than beside the summary, so the editor opens under the half + * of the name that was clicked. The band passes the path as it renders it and the classes its + * own segment wears, so the page's control keeps the breadcrumb's look rather than inventing + * one. Without this the segment copies the path on click. */ + pathTrigger?: Snippet<[Snippet, string]> + summary?: string + /** Rendered in place of the plain summary, for a page whose summary is itself a control — + * a detail page's rename-and-labels popover, an editor's editable title. */ + summaryContent?: Snippet +} + +/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */ +export type PageHeaderSection = { + label: string + /** Rendered in place of `label` when the name is itself a control, such as an editable title. */ + content?: Snippet + /** Scopes the segment's picker, when the section maps to a kind of item. */ + kind?: WorkspaceItemKind +} + +export type PageHeaderContent = { + item?: PageHeaderItem + section?: PageHeaderSection + /** Rendered at the right end of the bar — the page's own buttons. */ + actions?: Snippet + /** Orders these buttons among the other registrations', ascending; registration order breaks a + * tie. For a menu that must stay at the far end however many pages register before it. */ + actionsOrder?: number + /** These actions can give width back under pressure, so the bar lets their box shrink on a + * phone. Two ways to earn it: a control that is narrower when it has to be (the Runs filter + * row, whose search field yields), or a set that scrolls inside its own box (the app and + * pipeline editors, whose rows are too wide for any bar). Without it the box holds its content + * width, because squeezing a plain row of buttons only pushes them out of the bar. */ + actionsFlexible?: boolean + /** These actions are not a row of buttons at the far end but a surface of their own, running + * from the breadcrumb to the bar's right edge: a session in full screen, whose tab strip and + * controls share one shape that opens into the page below. The bar gives up its right padding + * to it, since the surface is what meets the edge. */ + actionsFill?: boolean + /** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name, + * like a documentation tooltip, or a control that acts on the thing named — an app's Edit, + * which at the far end of the bar would be a journey away from what it edits. */ + afterName?: Snippet + /** The workspace this page acts on, when it is not the one the app is pointed at — a session + * running in a fork. The breadcrumb then names that workspace, and its fork part shows only + * when it really is a fork. */ + actingWorkspaceId?: string + /** Width in px, at the right of the viewport, that this page owns from the top down — a + * session's side panel. The band stops there instead of running over it, and the band floats + * above the page rather than pushing it down, so that column starts at the top. */ + barRightInset?: number + /** The bottom edge under the bar, which the bar owns so a page cannot draw a second one. + * `onScroll` (the default) shows it only while something is passing under the bar — at the top + * of a list there is nothing to divide. `always` is for a page whose content starts right + * under the bar and needs the division at rest: an editor, a detail page, a settings pane. + * `none` is for a page that genuinely draws its own first line, such as the script editor's + * toolbar or the flow editor's canvas. */ + separator?: 'onScroll' | 'always' | 'none' + /** Contexts this registration's snippets look up — its actions, what it hangs after the name, + * and its summary. They all render under the header rather than under the page that wrote + * them, so anything the page's tree provides has to travel with them. */ + contexts?: Map +} + +// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb +// and actions by registering here. +// +// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or +// summary the page edits reaches the bar through the layout's own dependency tracking, with no +// effect keeping two copies in step. +// +// Registrations stack, and what the bar shows is their merge (see `content`). Pages register on +// mount, so the first entry is the outermost one — an editor nested in a session pane or a drawer +// is asked to register nothing. +let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([]) +let nextId = 0 + +/** + * Below this bar width a page keeps one button — the thing the page is for — and folds the rest + * into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet + * in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the + * line sits above the tablet rather than between the two. + */ +export const PHONE_BAR = 800 + +/** The bar's height, and the padding a page owes the band when the band floats over it rather than + * sitting in the flow (see `barRightInset`). They are one measurement in two forms: raise one and + * the other has to follow, or the page's own content disappears under the bar. Written as literal + * class names so Tailwind sees them. */ +export const BAR_HEIGHT = 'h-12' +export const BAR_TOP_PAD = 'pt-12' +/** The same height in px, for the places that animate to the bar's bottom edge rather than + * standing under it — a transition's `css` and a measurement's fallback take a number. */ +export const BAR_HEIGHT_PX = 48 + +// The bar's own width, written by the bar and read by the pages that fill it. A page's buttons +// share that row with the breadcrumb, so it is the width they crowd against — not the window's, +// which the sidebar and a page's side panel both take from. +let barWidth = $state(0) + +/** + * One registration's content, or undefined while it cannot answer. + * + * An entry is a getter closing over another component's props, evaluated here, in a tree of its + * own. Nothing orders a parent clearing those props against the child's teardown: a route that + * drops the `{#if}` around an editor and the value it binds into it does both in one flush, and + * this derived re-runs on the same flush, before the editor's own cleanup releases its entry. The + * entry has nothing to say in that window, which is not the same as the page having no header — + * so it is skipped, and whatever registered before it still stands. + */ +function read(e: { get: () => PageHeaderContent }): PageHeaderContent | undefined { + try { + return e.get() + } catch { + return undefined + } +} + +export const pageHeader = { + /** + * The registrations merged, later ones winning field by field: a route sets the frame (where + * the bar goes) and something nested in it — the active session, say — fills the breadcrumb + * and the actions. + */ + get content(): PageHeaderContent | undefined { + if (entries.length === 0) return undefined + const merged: PageHeaderContent = {} + for (const e of entries) { + const c = read(e) + if (!c) continue + if (c.item !== undefined) merged.item = c.item + if (c.section !== undefined) merged.section = c.section + if (c.afterName !== undefined) merged.afterName = c.afterName + if (c.contexts !== undefined) merged.contexts = c.contexts + if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId + if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset + if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible + if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill + if (c.separator !== undefined) merged.separator = c.separator + } + return merged + }, + /** + * Every registration's actions, in registration order: a page and something inside it can both + * put buttons in the band, and each renders with the contexts its own registration named. + */ + get actions(): { render: Snippet; contexts?: Map }[] { + return entries + .flatMap((e, i) => { + const c = read(e) + return c?.actions + ? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }] + : [] + }) + .sort((a, b) => a.order - b.order || a.i - b.i) + }, + /** Width of the bar in px, or 0 before it has been measured. */ + get barWidth(): number { + return barWidth + }, + setBarWidth(w: number) { + barWidth = w + }, + /** Registers on mount and returns the id to release on destroy. */ + register(get: () => PageHeaderContent): number { + const id = nextId++ + entries = [...entries, { id, get }] + return id + }, + release(id: number) { + entries = entries.filter((e) => e.id !== id) + } +} diff --git a/frontend/src/lib/components/pageHeaderRegistry.test.ts b/frontend/src/lib/components/pageHeaderRegistry.test.ts new file mode 100644 index 0000000000..035eabb60e --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' +import type { Snippet } from 'svelte' + +// The merge rule every registration site depends on: a page sets the frame and something nested +// in it fills the breadcrumb, so whether a component registers at all decides whose name the band +// shows. A viewer that registered wherever it was mounted renamed the band of the session hosting +// it. +const snippet = (name: string) => name as unknown as Snippet + +const ids: number[] = [] +function register(c: PageHeaderContent) { + ids.push(pageHeader.register(() => c)) +} +afterEach(() => { + while (ids.length) pageHeader.release(ids.pop()!) +}) + +describe('pageHeader', () => { + it('is empty with nothing registered', () => { + expect(pageHeader.content).toBeUndefined() + }) + + it('lets a later registration win field by field', () => { + register({ section: { label: 'Sessions' }, barRightInset: 400 }) + register({ item: { path: 'u/admin/app' } }) + expect(pageHeader.content).toEqual({ + section: { label: 'Sessions' }, + item: { path: 'u/admin/app' }, + barRightInset: 400 + }) + }) + + it('gives the fields of a released registration back', () => { + register({ section: { label: 'Sessions' } }) + register({ item: { path: 'u/admin/app' } }) + pageHeader.release(ids.pop()!) + expect(pageHeader.content?.item).toBeUndefined() + expect(pageHeader.content?.section).toEqual({ label: 'Sessions' }) + }) + + it('orders actions by actionsOrder, then by registration', () => { + register({ actions: snippet('page'), actionsOrder: 10 }) + register({ actions: snippet('nested') }) + register({ actions: snippet('alsoNested') }) + expect(pageHeader.actions.map((a) => a.render)).toEqual(['nested', 'alsoNested', 'page']) + }) + + it('carries the contexts of each registration with its actions', () => { + const pageContexts = new Map([['TriggerContext', 1]]) + register({ actions: snippet('page'), contexts: pageContexts }) + register({ actions: snippet('nested') }) + expect(pageHeader.actions.map((a) => a.contexts)).toEqual([pageContexts, undefined]) + }) +}) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 2288403438..d0d1b40de1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -154,6 +154,8 @@ * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ pendingDraftPath?: string | undefined // Threaded to the AutosaveIndicator's "Reset to deployed" button. + /** `Exit & see details`: see RawAppEditorHeader. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise /** The app_version the draft forked from, for the deploy-time "new version * deployed" guard: deploying is refused with a confirmation while it is not @@ -198,6 +200,8 @@ // EditorHeader's path/breadcrumb row dropped (summary only). Used by the // session preview to save vertical room. condensedHeader?: boolean + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -221,6 +225,7 @@ autosavePath = undefined, defaultSplitWithPreview = true, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -237,6 +242,7 @@ onRestore, onSavedNewAppPath, condensedHeader = false, + ownsPageHeader = false, version = undefined, onTakeLatest = undefined, draftBaseVersion = undefined @@ -2564,6 +2570,7 @@ addEventListener('message', function (e) { {getBundle} {onNavigate} {onDeploy} + {onDetails} {onResetToDeployed} {loadedFromDraft} {othersDraftsCount} @@ -2576,6 +2583,7 @@ addEventListener('message', function (e) { sidebarCollapsed={sidebarCollapsed.val} onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)} {condensedHeader} + {ownsPageHeader} /> void liveEditorDraftStoragePath?: string /** Indicator-only overrides for the sessions preview: the AutosaveIndicator @@ -180,6 +186,9 @@ pendingDraftPath?: string | undefined // Threaded to the `AutosaveIndicator` popover so its "Reset to // deployed" button can do the same thing the load-time toast offers. + /** `Exit & see details`: leaves the editor for the app's deployed page. A session hosts that + * page itself, so it flips the tab there rather than navigating out of the session. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise // See ScriptBuilderProps — same semantics for the raw-app editor's // indicator. @@ -223,12 +232,14 @@ sidebarCollapsed = false, onToggleSidebar = undefined, condensedHeader = false, + ownsPageHeader = false, onNavigate = undefined, liveEditorDraftStoragePath = undefined, autosaveWorkspace = undefined, autosavePath = undefined, onDeploy = undefined, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -308,11 +319,34 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: + // the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff, + // Jobs, Export, AI and Deploy — is ~560px. + const compactBelow = $derived(ownsPageHeader ? 1350 : 720) + const compactTopbar = $derived.by(() => { + const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth + return w > 0 && w < compactBelow + }) + // A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down — + // the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already + // in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always. + const phoneTopbar = $derived( + ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + ) // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. - const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + // In the page header the buttons ride a 44px band, the same size the condensed session-pane + // header uses. + const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md') + + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) + + // The actions render under the band, outside the subtree RawAppEditor declared the operating + // workspace in, so it has to travel with them: OpenInSessionButton asks who the acting user is. + const headerContexts = new Map([operatingWorkspaceContextEntry()]) function closeSaveDrawer() { saveDrawerOpen = false @@ -903,121 +937,212 @@ errorByComponent={{}} /> -
- + ` placeholder, and the trail would name that instead of the path Deploy + // will create — which is why `newEditedPath` refuses the placeholder. Frozen while the + // rename popover is open so the trail holds still as the user types. + kind: 'app', + path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined), + summaryContent: appSummary, + pathTrigger: rawAppPathTrigger + }} + actions={rawAppHeaderActions} + contexts={headerContexts} + separator="none" + /> +{:else} +
+ -
- {#if onToggleSidebar} -
+{/if} + +{#snippet sidebarToggle()} + {#if onToggleSidebar} + - {/snippet} - +{/snippet} - +{#snippet rawAppPathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + +{#snippet appSummary()} + +
+ {#snippet summaryText()} + {emptyString(summary) ? 'Add a summary...' : summary} + {/snippet} + +
+{/snippet} + +{#snippet rawAppHeaderActions()} + + {@render sidebarToggle()} + {@render autosaveIndicator()} + {@render awarenessMark()} + {@render rawAppActions()} +{/snippet} + +{#snippet rawAppActions()} + + {#snippet buttonReplacement()} + + {/snippet} + + + -
+ -
+ Diff + +
-
- -
- +
({jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})
+
+ +
+ + + {#if !phoneTopbar} {#snippet fallback()} -
-
+ } + ] + : []) + ] + : undefined} + > + Deploy + +{/snippet} diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 557b2a13ad..8a79b4b8f9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -277,6 +277,6 @@ src={iframeSrc} sandbox={sandboxAttr} referrerpolicy={unsandboxed ? undefined : 'no-referrer'} - class="w-full h-full min-h-screen bg-white border-none" + class="w-full h-full bg-white border-none" > {/if} diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index ade1d62d47..3070572785 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -294,6 +294,9 @@ export function unsandboxedRawAppHtml( App + + -{#snippet externalLinkHint()} - -{/snippet} - {#if !session || !runtime}
Session not found
{:else} @@ -411,95 +376,81 @@ sessions have their own empty-state affordances above. --> {#snippet sessionEmptyHint()}{/snippet} + {#snippet sessionHeader()} + renameSession(session.id, v)} + class="text-xs font-normal" + inputClass="!text-xs !font-normal" + /> + + summaryInput?.edit() + }, + ...(session.archived + ? // No Unarchive when the workspace is gone — it can't persist + // (putSession guard) and reconcile would re-archive it. + isUnavailable + ? [] + : [ + { + displayName: 'Unarchive', + icon: ArchiveRestore, + action: () => setSessionArchived(session.id, false) + } + ] + : [ + { + displayName: 'Archive', + icon: Archive, + action: () => archiveAndReset() + } + ]), + { + displayName: 'Delete', + icon: Trash2, + type: 'delete', + action: () => (deleteConfirmOpen = true) + } + ]} + > + {#snippet buttonReplacement()} + + + + {/snippet} + + {/snippet} + + {#if headerInPage} + + + {/if} +
-
- renameSession(session.id, v)} - class="text-sm font-semibold" - inputClass="!text-sm !font-semibold" - /> - summaryInput?.edit() - }, - ...(session.archived - ? // No Unarchive when the workspace is gone — it can't persist - // (putSession guard) and reconcile would re-archive it. - isUnavailable - ? [] - : [ - { - displayName: 'Unarchive', - icon: ArchiveRestore, - action: () => setSessionArchived(session.id, false) - } - ] - : [ - { - displayName: 'Archive', - icon: Archive, - action: () => archiveAndReset() - } - ]), - { - displayName: 'Delete', - icon: Trash2, - type: 'delete', - action: () => (deleteConfirmOpen = true) - } - ]} - > - {#snippet buttonReplacement()} - - - - {/snippet} - - {#if acting && hasFirstUserMessage} - -
- Acting on - - - - -
- {/if} -
{f.name} {#if f.is_dev_workspace} - {devBadgeText(f.dev_workspace_label)} + {/if} {#if isSelected(f.id)} diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a2c20e08a8..294cca19fb 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -47,6 +47,30 @@ export function withMenuHidden(url: string, workspaceId?: string): string { } } +// Remembers `nomenubar` for the rest of this iframe's life. Navigating inside a preview drops +// the query param (client-side routing and full loads both), and the global nav must not pop +// back in. sessionStorage so it survives a reload within the frame; the top window never writes +// it, so an ordinary tab and the oauth callback still toggle normally. +export function rememberMenuHidden(url: URL): void { + if (typeof window === 'undefined' || window.self === window.top) return + if (url.searchParams.get('nomenubar') !== 'true') return + try { + sessionStorage.setItem('nomenubar_embedded', 'true') + } catch {} +} + +// Whether this page is shown without the workspace navigation. The layout drops the sidebar and +// the band's trail, so a page whose only control lives in the band has to carry it itself — see +// InWorkspaceAppViewer's Edit button. Read this rather than the raw query param: a preview tab +// that has navigated no longer carries it. +export function isMenuHidden(url: URL): boolean { + return ( + url.searchParams.get('nomenubar') === 'true' || + url.pathname.startsWith('/oauth/callback/') || + isSessionPreviewFrame() + ) +} + // True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag // the preview always sets and the logged layout stickies into sessionStorage. export function isSessionPreviewFrame(): boolean { diff --git a/frontend/src/lib/components/sidebar/MenuButton.svelte b/frontend/src/lib/components/sidebar/MenuButton.svelte index 3d0286a4af..e6f23361bf 100644 --- a/frontend/src/lib/components/sidebar/MenuButton.svelte +++ b/frontend/src/lib/components/sidebar/MenuButton.svelte @@ -54,6 +54,8 @@ // Classes for the label line only — `class` reaches the button, the label and the // sublabel alike, which is the wrong tool for colouring one line of the two. labelClass?: string | undefined + // Draw the colour disc at 20px rather than 26px, for denser rows such as the page header. + compact?: boolean } let { @@ -79,7 +81,8 @@ emphasizeLabel = false, disableTitle = false, ariaLabel = undefined, - labelClass = undefined + labelClass = undefined, + compact = false }: Props = $props() let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined) @@ -94,7 +97,7 @@ @@ -119,7 +122,8 @@ {href} data-light-mode={lightMode} class={twMerge( - 'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full', + 'group flex items-center px-2 py-2 font-light rounded-md gap-2', + compact ? 'w-auto' : 'w-full', sidebarClasses.hoverBg, 'transition-all relative', sublabel ? 'h-10' : 'h-8', @@ -134,11 +138,23 @@ {#if icon} {@const SvelteComponent = icon} {#if color} - - - + {@const disc = compact ? 20 : 26} + {@const glyph = compact ? 12 : 16} + + + @@ -158,7 +174,7 @@ {/if} {/if} -
+
{#if !isCollapsed && label}
- import { - Home, - Menu as MenuIcon, - Play, - Settings, - LogOut, - Moon, - Sun, - Code2, - LayoutDashboard, - Calendar, - ServerCog, - Table2, - GraduationCap - } from 'lucide-svelte' - import SwitchWorkspaceSubmenu from './SwitchWorkspaceSubmenu.svelte' - import { - OPERATOR_MAIN_LINKS, - OPERATOR_SECONDARY_LINKS, - OPERATOR_TRIGGER_LINKS, - type OperatorMenuLink, - type OperatorTriggerLink - } from './operatorRoutes' - import { base } from '$lib/base' - import { page } from '$app/state' - import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' - - import MultiplayerMenu from './MultiplayerMenu.svelte' - import { Plus } from 'lucide-svelte' - import { - enterpriseLicense, - superadmin, - usedTriggerKinds, - userWorkspaces, - workspaceColor, - workspaceStore - } from '$lib/stores' - import { twMerge } from 'tailwind-merge' - import { getContrastTextColor } from '$lib/utils' - import { USER_SETTINGS_HASH } from './settings' - import { logout } from '$lib/logoutKit' - import DarkModeObserver from '../DarkModeObserver.svelte' - import BarsStaggered from '../icons/BarsStaggered.svelte' - import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents' - import MenuButton, { sidebarClasses } from './MenuButton.svelte' - import MenuLink from './MenuLink.svelte' - import type { FavoriteKind } from './FavoriteMenu.svelte' - let darkMode: boolean = $state(false) - let showExtraTriggers = $state(false) - // The menu opens on hover, so this doubles as "the pointer is on the button": - // it slides the workspace name out beside the icon, and keeps it out once a - // click has pinned the menu open and the pointer has moved away. - let menuOpen = $state(false) - - interface Props { - isCollapsed?: boolean - favoriteLinks?: { - label: string - href: string - kind: FavoriteKind - }[] - } - - let { isCollapsed = false, favoriteLinks = [] }: Props = $props() - - const MAIN_LINK_ICONS: Record = { home: Home, runs: Play, schedules: Calendar } - let activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) - let workspaceName = $derived(activeWorkspace?.name ?? $workspaceStore ?? '') - // Home is where an operator lands and gets their bearings, so the name stays out there - // rather than waiting for a hover. Everywhere else the button is the page's own chrome - // and stays out of the way until asked. - let showWorkspaceName = $derived(menuOpen || page.url.pathname === `${base}/`) - // Most workspaces never get a colour (it is only ever set explicitly), and this button - // floats over page content with nothing else behind it — so the disc falls back to a - // neutral rather than vanishing, keeping the glyph on a ground of its own everywhere. - // A token rather than a hex so it follows the theme; getContrastTextColor only parses - // hex, so it returns undefined here and the glyph keeps its themed `text-hint`. - const NEUTRAL_DISC = 'rgb(var(--color-surface-sunken))' - - let mainMenuLinks = $derived( - OPERATOR_MAIN_LINKS.map((link) => ({ ...link, icon: MAIN_LINK_ICONS[link.id] })).filter( - (link) => link.id === 'home' || filterLink(link) - ) - ) - - function filterLink(link: OperatorMenuLink) { - if (!$userWorkspaces || !$workspaceStore) return false - return activeWorkspace?.operator_settings?.[link.id] === true - } - let secondMenuLinks: OperatorMenuLink[] = $derived(OPERATOR_SECONDARY_LINKS.filter(filterLink)) - let allTriggerLinks: OperatorTriggerLink[] = $derived(OPERATOR_TRIGGER_LINKS.filter(filterLink)) - let secondMenuTriggerLinks = $derived( - allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind)) - ) - let extraTriggerLinks = $derived( - allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind)) - ) - - - - {#snippet children({ createMenu })} - (showExtraTriggers = false)} - > - {#snippet triggr({ trigger, pinned })} - {@const iconColor = getContrastTextColor($workspaceColor)} - -
- -
- {/snippet} - {#snippet children({ item, builders })} -
- {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} -
- - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
-
- - - Account settings - - - - - Take the tour - -
- -
- { - if (!document.documentElement.classList.contains('dark')) { - document.documentElement.classList.add('dark') - window.localStorage.setItem('dark-mode', 'dark') - } else { - document.documentElement.classList.remove('dark') - window.localStorage.setItem('dark-mode', 'light') - } - }} - lightMode - class={twMerge( - 'w-full flex gap-3.5 px-2 py-2', - 'transition-colors', - sidebarClasses.text, - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - {#if darkMode} - - {:else} - - {/if} - Switch theme - - - - {#if $superadmin} - - - Instance settings - - {/if} - - logout()} - class={twMerge( - 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', - 'text-primary text-xs', - 'cursor-pointer', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - - Sign out - -
-
- {#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
- {#if secondMenuLinks.length}
{@render renderSecondMenuLinks(secondMenuLinks)}
{/if} - {#if secondMenuTriggerLinks.length}
{@render renderSecondMenuLinks(secondMenuTriggerLinks)}
{/if} - {#if extraTriggerLinks.length}
- -
{ - // This row expands the list below it instead of acting on the selection, and - // melt keeps the menu open only for a click it sees as defaultPrevented. - // Svelte delegates onclick to the root, which runs after melt's own listener, - // and a capture listener on the item itself would be ordered only by - // registration, so an ancestor's capture phase is what reliably wins. - e.preventDefault() - showExtraTriggers = !showExtraTriggers - }} - > - - - More triggers - -
- {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
{/if} -
- {/if} - {#if $enterpriseLicense} - - {/if} -
-
- {/snippet} -
- {/snippet} -
- - diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index e0951f37cd..fed773c84c 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -18,7 +18,8 @@ Crown, Gauge, Trash2, - KeyRound + KeyRound, + GraduationCap } from 'lucide-svelte' import { base } from '$app/paths' import { goto } from '$lib/navigation' @@ -38,6 +39,8 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' + import { sidebarPageAllowed } from './operatorRoutes' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, workspaceStore, @@ -248,6 +251,15 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, + ...($userStore?.operator + ? [ + { + displayName: 'Take the tour', + icon: GraduationCap, + action: () => goto(`${base}/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`) + } + ] + : []), ...(cloudHosted && $isPremiumStore === false ? [ { @@ -307,6 +319,7 @@ label="Workers" href="{base}/workers" icon={ServerCog} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')} {isCollapsed} aiId="sidebar-menu-link-workers" aiDescription="Button to navigate to workers" @@ -352,7 +365,7 @@ label="Audit logs" href="{base}/audit_logs" icon={Eye} - disabled={$userStore?.operator} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')} {isCollapsed} aiId="sidebar-menu-link-audit-logs" aiDescription="Button to navigate to audit logs" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index b43bc2988e..d8f27ff0c6 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -12,31 +12,25 @@ } from '$lib/stores' import { isForkOwner } from '$lib/utils/workspaceHierarchy' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' + import { NAV_PAGES } from './navPages' import { BookOpen, ServerCog, - Boxes, Calendar, - DollarSign, Eye, Logs, FolderCog, - FolderOpen, Github, HelpCircle, - Home, LogOut, Newspaper, - Play, Route, Settings, UserCog, - Users, Plus, Unplug, AlertCircle, Database, - Pyramid, MailIcon, ChevronDown, ChevronRight @@ -72,6 +66,7 @@ import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte' import AzureIcon from '../icons/triggers/AzureIcon.svelte' import { leaveCurrentWorkspace } from './leaveWorkspace' + import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes' import { markChangelogsOpened, readRecentChangelogs } from './changelogs' const { recent: recentChangelogs, hasNew } = readRecentChangelogs() @@ -188,78 +183,63 @@ return count } + const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) + function allows(key: OperatorPageKey) { + return sidebarPageAllowed($userStore?.operator, currentWs, key) + } + const itemClass = twMerge( 'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' ) - let mainMenuLinks = $derived( - [ - { - label: 'Home', - href: `${base}/`, - icon: Home, - aiId: 'sidebar-menu-link-home', - aiDescription: - "Button to navigate to home which contains all the user's scripts, flows and apps" - }, - { - label: 'Runs', - href: `${base}/runs`, - icon: Play, - aiId: 'sidebar-menu-link-runs', - aiDescription: 'Button to navigate to runs', - onclick: () => { - setTimeout(() => { - window.dispatchEvent(new Event('popstate')) - }, 100) - } - }, - { - label: 'Variables', - href: `${base}/variables`, - icon: DollarSign, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-variables', - aiDescription: 'Button to navigate to variables' - }, - { - label: 'Resources', - href: `${base}/resources`, - icon: Boxes, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-resources', - aiDescription: 'Button to navigate to resources' - }, - { - label: 'Assets', - href: `${base}/assets`, - icon: Pyramid, - aiId: 'sidebar-menu-link-assets', - aiDescription: 'Button to navigate to assets' - }, - { - label: 'Folders', - href: `${base}/folders`, - icon: FolderOpen, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-folders', - aiDescription: 'Button to navigate to folders' - }, - { - label: 'Groups', - href: `${base}/groups`, - icon: Users, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-groups', - aiDescription: 'Button to navigate to groups' + // Labels, paths and icons come from the shared page table, so the nav and the page header's + // breadcrumb always call a page the same thing. Everything else here is nav-only. + const MAIN_MENU_EXTRAS: Record< + string, + { aiId: string; aiDescription: string; onclick?: () => void } + > = { + Home: { + aiId: 'sidebar-menu-link-home', + aiDescription: + "Button to navigate to home which contains all the user's scripts, flows and apps" + }, + Runs: { + aiId: 'sidebar-menu-link-runs', + aiDescription: 'Button to navigate to runs', + onclick: () => { + setTimeout(() => { + window.dispatchEvent(new Event('popstate')) + }, 100) } - ].filter((l) => !excludeMainLabels.includes(l.label)) + }, + Variables: { + aiId: 'sidebar-menu-link-variables', + aiDescription: 'Button to navigate to variables' + }, + Resources: { + aiId: 'sidebar-menu-link-resources', + aiDescription: 'Button to navigate to resources' + }, + Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, + Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, + Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } + } + let mainMenuLinks = $derived( + NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS) + .map((p) => ({ + label: p.label, + href: p.path === '/' ? `${base}/` : `${base}${p.path}`, + icon: p.icon, + disabled: p.operatorKey ? !allows(p.operatorKey) : false, + ...MAIN_MENU_EXTRAS[p.label] + })) + .filter((l) => !excludeMainLabels.includes(l.label)) ) let defaultExtraTriggerLinks = $derived([ { label: 'HTTP', href: '/routes', icon: Route, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'http', aiId: 'sidebar-menu-link-http', aiDescription: 'Button to navigate to HTTP routes' @@ -268,7 +248,7 @@ label: 'WebSockets', href: '/websocket_triggers', icon: Unplug, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'ws', aiId: 'sidebar-menu-link-ws', aiDescription: 'Button to navigate to websocket triggers' @@ -277,7 +257,7 @@ label: 'Postgres', href: '/postgres_triggers', icon: Database, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'postgres', aiId: 'sidebar-menu-link-postgres', aiDescription: 'Button to navigate to Postgres triggers' @@ -286,7 +266,7 @@ label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'), href: '/kafka_triggers', icon: KafkaIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'kafka', aiId: 'sidebar-menu-link-kafka', aiDescription: 'Button to navigate to Kafka triggers' @@ -295,7 +275,7 @@ label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/nats_triggers', icon: NatsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'nats', aiId: 'sidebar-menu-link-nats', aiDescription: 'Button to navigate to NATS triggers' @@ -304,7 +284,7 @@ label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/sqs_triggers', icon: AwsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'sqs', aiId: 'sidebar-menu-link-sqs', aiDescription: 'Button to navigate to SQS triggers' @@ -313,7 +293,7 @@ label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'), href: '/gcp_triggers', icon: GoogleCloudIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'gcp', aiId: 'sidebar-menu-link-gcp', aiDescription: 'Button to navigate to GCP Pub/Sub triggers' @@ -322,7 +302,7 @@ label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'), href: '/azure_triggers', icon: AzureIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'azure', aiId: 'sidebar-menu-link-azure', aiDescription: 'Button to navigate to Azure Event Grid triggers' @@ -331,7 +311,7 @@ label: 'MQTT', href: '/mqtt_triggers', icon: MqttIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'mqtt', aiId: 'sidebar-menu-link-mqtt', aiDescription: 'Button to navigate to MQTT triggers' @@ -340,7 +320,7 @@ label: 'AMQP', href: '/amqp_triggers', icon: AmqpIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'amqp', aiId: 'sidebar-menu-link-amqp', aiDescription: 'Button to navigate to AMQP triggers' @@ -349,7 +329,7 @@ label: 'Email', href: '/email_triggers', icon: MailIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'email', aiId: 'sidebar-menu-link-email', aiDescription: 'Button to navigate to Email triggers' @@ -375,7 +355,7 @@ label: 'Schedules', href: `${base}/schedules`, icon: Calendar, - disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator, + disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'), aiId: 'sidebar-menu-link-schedules', aiDescription: 'Button to navigate to schedules' }, @@ -390,7 +370,6 @@ ) // Admins, superadmins, and fork creators reach workspace settings (the latter // for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user). - const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) const canManageWorkspace = $derived( $userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email) ) @@ -460,7 +439,7 @@ label: 'Workers', href: `${base}/workers`, icon: ServerCog, - disabled: $userStore?.operator, + disabled: !allows('workers'), aiId: 'sidebar-menu-link-workers', aiDescription: 'Button to navigate to workers' }, @@ -509,7 +488,7 @@ label: 'Audit logs', href: `${base}/audit_logs`, icon: Eye, - disabled: $userStore?.operator, + disabled: !allows('audit_logs'), aiId: 'sidebar-menu-link-audit-logs', aiDescription: 'Button to navigate to audit logs' } @@ -551,7 +530,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + {#if extraTriggerLinks.length > 0 && allows('triggers')} {#snippet triggr({ trigger })} - import { untrack } from 'svelte' - import { melt } from '@melt-ui/svelte' - import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte' - import { Building, ChevronRight } from 'lucide-svelte' - import { twMerge } from 'tailwind-merge' - import { sidebarClasses } from './MenuButton.svelte' - import WorkspacePickerBody from './WorkspacePickerBody.svelte' - import { MenuItem } from '$lib/components/meltComponents' - import { clearWorkspaceFromStorage } from '$lib/stores' - import { base } from '$lib/base' - - interface Props { - // Submenu builders from the enclosing melt Menu — createSubmenu must be - // called against this specific menu instance (same pattern as DropdownSubmenuItem). - builders: MenubarMenuBuilders - // Melt uses the parent menu's item builder for submenu rows too, so the - // picker body's MenuItems work here unchanged. - item: MenubarMenuElements['item'] - } - - let { builders, item }: Props = $props() - - const { - elements: { subTrigger, subMenu }, - states: { subOpen } - } = untrack(() => builders).createSubmenu() - - - - -{#if $subOpen} -
- - -
- clearWorkspaceFromStorage()} - class={twMerge( - 'w-full flex flex-row gap-2 px-4 py-2 font-normal', - sidebarClasses.text, - 'hover:bg-surface-hover hover:text-primary', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - All workspaces - -
-
-{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index ddcbd9ca95..3902715d5a 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -14,9 +14,19 @@ createMenu: MenubarBuilders['createMenu'] // When used outside of the side bar, where links to workspace settings and such don't make as much sense. strictWorkspaceSelect?: boolean + // Denser trigger for the page header bar: a smaller disc and a lighter name, so the + // workspace reads as the scope of the breadcrumb beside it rather than a heading. + compact?: boolean } - let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props() + let { + isCollapsed = false, + createMenu, + strictWorkspaceSelect = false, + compact = false + }: Props = $props() + + let menu: Menu | undefined = $state(undefined) // The active workspace's family root — shown in the trigger so a forked active workspace still // surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly @@ -29,7 +39,7 @@ const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces)) - + {#snippet triggr({ trigger })} @@ -41,7 +51,7 @@ name={currentFamily?.name ?? $workspaceStore ?? ''} id={currentFamily?.id ?? $workspaceStore ?? ''} disablePopup={isCollapsed} - class="block w-full" + class={compact ? 'block' : 'block w-full'} > @@ -63,6 +75,11 @@ {#snippet children({ item })} - + menu?.close()} + /> {/snippet} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index fa4468fa6a..e25dc83286 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -1,5 +1,5 @@ -
+
{#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts new file mode 100644 index 0000000000..19568eaddc --- /dev/null +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -0,0 +1,28 @@ +import { fromStore } from 'svelte/store' +import { userStore } from '$lib/stores' +import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + +// One preference per role rather than one per account. The same person is both — an operator in +// one workspace, a developer in the next — and the two want different things from the sidebar, so +// a single key would carry whichever was pressed last into the other role and never give it back. +// Empty until that role presses detach or attach once. +const operatorPref = useLocalStorageValue('nav_detached_operator', '', 'string') +const devPref = useLocalStorageValue('nav_detached_dev', '', 'string') +const user = fromStore(userStore) + +/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ +export const navDetached = { + get val(): boolean { + // Unasked, operators start detached: many of them navigate through a workspace's own app, + // and a docked sidebar would take its width. + const operator = !!user.current?.operator + const pref = operator ? operatorPref : devPref + if (pref.val === 'true') return true + if (pref.val === 'false') return false + return operator + }, + set val(detached: boolean) { + const pref = user.current?.operator ? operatorPref : devPref + pref.val = String(detached) + } +} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts new file mode 100644 index 0000000000..5fb7b43860 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -0,0 +1,50 @@ +import { BAR_HEIGHT_PX } from '../pageHeaderRegistry.svelte' + +// Opening the detached sidebar's card, from the band's own handle or from the corner control that +// stands in for it on a page which hides the band. +let opener: (() => void) | undefined +// Closing it once the pointer is off both the handle and the card, which the layout owns because +// the card is its markup. +let hoverClose: { schedule: () => void; cancel: () => void } | undefined + +// Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than +// the card starting at whatever height the control that opened it happens to have. Measured at open +// time rather than fixed, so the card follows the header's own height. On a page that hides the +// header, the header is still what the card will sit under — it comes down with it — so its height +// is read even while it is off-screen. The fallback matches the header's resting height. +const FALLBACK_CARD_TOP = BAR_HEIGHT_PX +const GAP_UNDER_HANDLE = 6 +let cardTop = $state(FALLBACK_CARD_TOP) + +export const navHandleSlot = { + /** Distance from the top of the viewport to the card's top edge, in px. */ + get cardTop(): number { + return cardTop + }, + /** The root layout owns the card, so it registers how to open and hover-close it. */ + setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) { + opener = open + hoverClose = close + }, + /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ + scheduleClose() { + hoverClose?.schedule() + }, + /** + * Opens the card under the page header, or under `anchor` when there is no header to sit + * beneath — an embed, where whatever reached for the nav is all the card has to hang from. + */ + open(anchor?: Element | null) { + hoverClose?.cancel() + const band = document.querySelector('[data-page-header]') + const bandHeight = band?.getBoundingClientRect().height + if (bandHeight) { + cardTop = Math.round(bandHeight) + } else { + const bottom = anchor?.getBoundingClientRect().bottom + cardTop = + bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP + } + opener?.() + } +} diff --git a/frontend/src/lib/components/sidebar/navPages.test.ts b/frontend/src/lib/components/sidebar/navPages.test.ts new file mode 100644 index 0000000000..6e68912a15 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { navPageFor } from './navPages' + +describe('navPageFor', () => { + it('names the page a route belongs to, under the app base', () => { + expect(navPageFor('/base/runs', '/base')?.label).toBe('Runs') + }) + + it('keeps an item route on its page', () => { + expect(navPageFor('/runs/f/demo/x', '')?.label).toBe('Runs') + }) + + it('does not let Home swallow every route', () => { + expect(navPageFor('/audit_logs', '')?.label).toBe('Audit logs') + expect(navPageFor('/', '')?.label).toBe('Home') + }) + + it('falls back to the first segment for a route the table does not name', () => { + expect(navPageFor('/service_logs/x', '')).toMatchObject({ + label: 'Service logs', + path: '/service_logs' + }) + }) +}) diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts new file mode 100644 index 0000000000..b9dc2eff63 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -0,0 +1,68 @@ +import { + Boxes, + Calendar, + DollarSign, + Eye, + FolderOpen, + Home, + HardHat, + Play, + Pyramid, + Unplug, + Users +} from 'lucide-svelte' +import type { OperatorPageKey } from './operatorRoutes' + +/** + * The names the page header's breadcrumb gives the workspace's own pages, and the rows the + * sidebar's main group is built from. + * + * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries + * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, + * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so + * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * hand. + */ +export type NavPage = { + label: string + /** Path under `base`, matched against the current route by longest prefix. */ + path: string + icon: any + /** The `operator_settings` key that admits an operator, when the page has one. */ + operatorKey?: OperatorPageKey +} + +export const NAV_PAGES: NavPage[] = [ + { label: 'Home', path: '/', icon: Home }, + { label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' }, + { label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' }, + { label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' }, + { label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' }, + { label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' }, + { label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' }, + { label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' }, + // "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP. + { label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' }, + { label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' }, + { label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' } +] + +/** + * The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route + * the table does not name falls back to its first segment (`/service_logs` → "Service logs"). + */ +export function navPageFor(pathname: string, base: string): NavPage | undefined { + const path = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname + const match = NAV_PAGES.filter( + (p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/')) + ).sort((a, b) => b.path.length - a.path.length)[0] + if (match) return match + const first = path.split('/').filter(Boolean)[0] + if (!first) return undefined + const label = first.replace(/_/g, ' ') + return { + label: label.charAt(0).toUpperCase() + label.slice(1), + path: `/${first}`, + icon: undefined + } +} diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts new file mode 100644 index 0000000000..bf14926d00 --- /dev/null +++ b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { sidebarPageAllowed } from './operatorRoutes' +import type { UserWorkspace } from '$lib/stores' + +const ws = (operator_settings: any): UserWorkspace => + ({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace + +describe('sidebarPageAllowed', () => { + it('shows every page to anyone who is not an operator', () => { + expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true) + expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true) + }) + + it('admits an operator only where the workspace turned the page on', () => { + expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true) + expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false) + }) + + it('withholds a page the settings do not mention', () => { + // `=== true`, not truthiness: a right absent from the jsonb, or settings that were never + // written, must not read as granted. + expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 1abd7aeb93..6824c36c62 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -1,61 +1,41 @@ -import { base } from '$lib/base' +import type { OperatorSettings } from '$lib/gen' import type { UserWorkspace } from '$lib/stores' /** - * The pages an operator can reach, each paired with the `operator_settings` key - * that admits them — the operator menu lists only the entries the active - * workspace permits. + * An `operator_settings` key that admits an operator to one page (`triggers` to all of them). + * + * Only the visibility flags: the same object also carries the write rights `builder_flows`, + * `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A + * write right is granted unless withdrawn, so its absence means "never configured" rather than + * "no" — see `docs/operator-write-rights.md`. */ -export type OperatorMenuLink = { - label: string - /** `operator_settings` key gating the page. */ - id: string - href: string +export type OperatorPageKey = Extract< + keyof NonNullable, + | 'runs' + | 'schedules' + | 'resources' + | 'variables' + | 'assets' + | 'audit_logs' + | 'triggers' + | 'groups' + | 'folders' + | 'workers' +> + +/** + * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page + * (other gates, such as admin-only entries, still apply); an operator sees only the pages the + * workspace's `operator_settings` turn on. + */ +export function sidebarPageAllowed( + isOperator: boolean | undefined, + workspace: UserWorkspace | undefined, + key: OperatorPageKey +): boolean { + return !isOperator || workspace?.operator_settings?.[key] === true } -export type OperatorTriggerLink = OperatorMenuLink & { kind: string } - -/** Home needs no setting — it is the one page every operator always has. */ -export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [ - { label: 'Home', id: 'home', href: `${base}/` }, - { label: 'Runs', id: 'runs', href: `${base}/runs` }, - { label: 'Schedules', id: 'schedules', href: `${base}/schedules` } -] - -export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [ - { label: 'Resources', id: 'resources', href: `${base}/resources` }, - { label: 'Variables', id: 'variables', href: `${base}/variables` }, - { label: 'Assets', id: 'assets', href: `${base}/assets` }, - { label: 'Groups', id: 'groups', href: `${base}/groups` }, - { label: 'Folders', id: 'folders', href: `${base}/folders` }, - { label: 'Workers', id: 'workers', href: `${base}/workers` }, - { label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` } -] - -export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [ - { label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' }, - { label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' }, - { - label: 'Postgres triggers', - id: 'triggers', - href: `${base}/postgres_triggers`, - kind: 'postgres' - }, - { label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' }, - { label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' }, - { label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' }, - { label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' }, - { - label: 'Azure Event Grid triggers', - id: 'triggers', - href: `${base}/azure_triggers`, - kind: 'azure' - }, - { label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' }, - { label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' }, - { label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' } -] - /** * Whether the user is an operator in `workspace`. The server nulls * `operator_settings` for a non-operator, so a non-null value narrows an operator diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts index df8e668fba..24925f2198 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts @@ -46,6 +46,43 @@ describe('workspaceMenuHref', () => { ).toBe('/runs?workspace=w') }) + // The compare page reads the workspace it compares from `workspace_id`, so a switch that + // left it behind sent the page back to the workspace just left on the next reload. + it('swaps workspace_id alongside workspace', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + + it('does not add workspace_id to a page that had none', () => { + expect( + workspaceMenuHref({ + pathname: '/runs', + searchParams: new URLSearchParams('job=123'), + id: 'w', + sameFamily: true + }) + ).toBe('/runs?job=123&workspace=w') + }) + + // `target` is the compare page's one-off migration destination, picked for the workspace + // being left. Carried over it can point the new workspace at itself. + it('drops an arbitrary compare target', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&target=wm-fork-b&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + // An operator lands on home in the workspace they switch into. The href has to carry // that too, not just the click handler: this is what a middle-click opens. it('points at home, dropping the current page and its params, when landOnHome is set', () => { diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts index 57342f2b08..589326c0f6 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts @@ -24,6 +24,17 @@ export function workspaceMenuHref(args: { } const params = new URLSearchParams(args.searchParams) params.set('workspace', args.id) + // `workspace_id` names the workspace a page acts on rather than the one the app is pointed at + // — the compare page's source. A switch moves it too, or the link lands on a page still + // working on the workspace just left. Only rewritten, never added: a page that does not use + // it has no business gaining it. + if (params.has('workspace_id')) { + params.set('workspace_id', args.id) + } + // The compare page's one-off migration destination, chosen for the workspace being left. Kept, + // it would survive into a workspace it was never picked for — including the target itself, + // leaving that page comparing a workspace with itself. + params.delete('target') if (!args.sameFamily) { params.delete('session') params.delete('session_name') diff --git a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts index 82130e3f95..c8bc6d070c 100644 --- a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts +++ b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts @@ -1,13 +1,20 @@ import { page } from '$app/state' -import { replaceState } from '$app/navigation' import { goto } from '$lib/navigation' // Post-workspace-switch URL fixup shared by the sidebar workspace pickers. // Item-scoped pages would show a wrong-workspace (or missing) item after a -// switch — go home instead. Otherwise, really rewrite a ?workspace= param in -// the address bar (mutating page.url is a no-op there): left stale, it gets -// re-applied on reload or when exiting session mode restores the route, +// switch — go home instead. Otherwise, really rewrite the params in the address +// bar that name a workspace (mutating page.url is a no-op there): left stale, +// they get re-applied on reload or when exiting session mode restores the route, // silently switching the workspace back. +// +// `workspace_id` names the workspace a page acts on (the compare page's pair) and +// `workspace` the one the app is pointed at; a switch moves both. `target`, the +// compare page's one-off migration destination, is dropped instead of rewritten: +// it was chosen for the workspace being left, and keeping it could point the new +// one at itself. +const WORKSPACE_PARAMS = ['workspace', 'workspace_id'] +const STALE_AFTER_SWITCH = ['target'] const EDIT_PAGES = [ '/scripts/edit/', '/flows/edit/', @@ -28,9 +35,32 @@ export async function fixupUrlAfterWorkspaceSwitch( ): Promise { if (EDIT_PAGES.some((p) => page.route.id?.includes(p) ?? false) || opts?.landOnHome) { await goto('/') - } else if (page.url.searchParams.get('workspace')) { - const url = new URL(window.location.href) - url.searchParams.set('workspace', id) - replaceState(url, page.state) + return } + // `window.location`, never `page.url`: a param written by a previous shallow update + // is in the address bar but not in `page.url`, so rebuilding from it would undo that + // write (see the note on `setQuery` in $lib/navigation). + const url = new URL(window.location.href) + let changed = false + for (const key of WORKSPACE_PARAMS) { + if (url.searchParams.get(key) === id) continue + if (!url.searchParams.has(key)) continue + url.searchParams.set(key, id) + changed = true + } + for (const key of STALE_AFTER_SWITCH) { + if (!url.searchParams.has(key)) continue + url.searchParams.delete(key) + changed = true + } + if (!changed) return + // `goto` rather than `replaceState`: a page reading these params through `page.url` + // (the compare page derives the workspace it compares from them) sees nothing when + // they are written shallowly, since SvelteKit refreshes `page.url` only on a + // router-driven navigation. + await goto(`${url.pathname}${url.search}${url.hash}`, { + replaceState: true, + noScroll: true, + keepFocus: true + }) } diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte index cc7f4ab4a5..8c414cac7a 100644 --- a/frontend/src/lib/components/tutorials/OperatorTour.svelte +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -1,8 +1,7 @@ -
+
| undefined>(undefined) let agent = $derived(host?.draftHandle()) let testPane = $derived(host?.testPaneHandle()) @@ -86,11 +88,25 @@ Every flow that links {path} will fail at its agent step once it is deleted. -
+
+ { + // A rename moves the page; a summary saved in place has to be read again, since + // what is shown here came from the load this page keys on. + if (newPath !== path) await goto(`${base}/agents/get/${newPath}`) + else reloaded++ + } + : undefined} mainButtons={[ // Evaluating an agent builds datasets and runs against it: authoring, as editing is. ...(canEdit @@ -154,7 +170,7 @@ {/snippet}
- {#key `${ws}:${path}`} + {#key `${ws}:${path}:${reloaded}`} +
{ goto(`/apps/edit/${url}`) diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 4ff8670cda..c911a8a64c 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -24,7 +24,7 @@ must fully remount — otherwise the previous app (and in sandbox mode its path-scoped token) sticks around. --> {#key `${workspace}/${path}`} - + {/key} {:else} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index fec356d89a..9ed2ca296a 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -624,9 +624,11 @@ {#if files} {#key redraw} -
+
goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)} onSavedNewAppPath={(savedPath) => { draftSync.remove() goto(`/apps_raw/edit/${savedPath}`) diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index 79c8145731..672b0f9feb 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -18,7 +18,7 @@ {#key `${workspace}/${path}`} - + {/key} {:else} diff --git a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte index 59c961febb..42d8ff996e 100644 --- a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte @@ -7,6 +7,8 @@ import AuditLogsTable from '$lib/components/auditLogs/AuditLogsTable.svelte' import AuditLogMobileFilters from '$lib/components/auditLogs/AuditLogMobileFilters.svelte' import { Alert, DrawerContent, Skeleton } from '$lib/components/common' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte' @@ -64,11 +66,26 @@ ) let auditLogDrawer: Drawer | undefined = $state() - // The inline filters show only when their one-line width fits in the space the title leaves. - // Both are measured, so the sidebar, side panels, zoom and filter values are all accounted for. - let filtersSlotWidth = $state(0) + // The inline filters show only when their one-line width fits the bar. The row is measured, so + // the sidebar, side panels, zoom and the filters' own values are all accounted for; what it is + // measured against is the bar, not the box it sits in. That box is the band's action area, + // whose width is its own content's — measuring the row against it would ask whether the row + // fits inside itself, which it always does, and the filters would never fold. let filtersRowWidth = $state(0) - let inlineFilters = $derived(filtersRowWidth > 0 && filtersRowWidth <= filtersSlotWidth) + /** What the breadcrumb takes before the filters get any: the workspace disc and name, the + * page's name and the hint beside it. Measured at 361-398px across bar widths. */ + const BREADCRUMB_WIDTH = 400 + /** Clear space held between the breadcrumb and the first filter. The breadcrumb yields width + * grudgingly and the filter row not at all, so when the two are sized to just meet they + * overlap instead of compressing — and mid-resize, before the breadcrumb has settled, the + * tooltip at the end of it lands on the timeframe button. Measured without this: the gap + * reached 4px across a 1600->1200 sweep. */ + const BREADCRUMB_GAP = 48 + let inlineFilters = $derived( + filtersRowWidth > 0 && + pageHeader.barWidth > 0 && + filtersRowWidth <= pageHeader.barWidth - BREADCRUMB_WIDTH - BREADCRUMB_GAP + ) // Function to fetch missing job execution audit logs async function fetchMissingJobSpan(jobId: string, jobLogs: AuditLog[]): Promise { @@ -117,19 +134,21 @@

Page not available for operators

{:else} -
-
-
-

Audit logs

- - You can only see your own audit logs unless you are an admin. - -
- -
+ +
+ + + + {#snippet auditHint()} + + You can only see your own audit logs unless you are an admin. + + {/snippet} + + {#snippet auditActions()} +
-
+ {/snippet} {#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 835735c0c8..d724c4873d 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -557,6 +557,7 @@
{:else if renderEditor} { // The version the drawer showed as head; see /scripts/edit. diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index a21fa36835..aa158d2f11 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -1,4 +1,5 @@ + + +
@@ -440,10 +505,12 @@ onModeSelected={selectMode} /> {:else if compareTargetId} - - {#key compareTargetId} + + {#key pairKey} Pipelines — Windmill -
-
-
- -

Pipelines

- - Alpha - -
-
+ + +{#snippet alphaBadge()} + + Alpha + +{/snippet} + +
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index b4ddb8350c..a16b1a1e49 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -2,6 +2,7 @@ import { workspaceStore, userStore } from '$lib/stores' import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' import { base } from '$lib/base' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { page } from '$app/state' import Button from '$lib/components/common/button/Button.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' @@ -98,7 +99,6 @@ FolderSearch, History, Loader2, - NetworkIcon, Play, RefreshCw, Save, @@ -2386,60 +2386,71 @@ Pipeline · {folder} — Windmill -
-
-
- + {:else} + + {#snippet buttonReplacement()} + f/{folder} - - {:else} - - {#snippet buttonReplacement()} - - - f/{folder} - - - {/snippet} - - {/if} - {#if summary.length > 0} - · {summary.join(' · ')} - {/if} -
+ + {/snippet} + + {/if} + {#if summary.length > 0} + · {summary.join(' · ')} + {/if} +{/snippet} + +{#snippet pipelineActions()} + +
{#if !isOperator} - -
+ +
setMode(m)} /> {#if mode === 'view' && pe.drafts.size > 0} + graph — "what View will show once they're deployed". -->
{/if} -
- {#if !isOperator && allPipelineScripts.length > 0} - - {#if recordingMode} + {#if !isOperator && allPipelineScripts.length > 0} + + {#if recordingMode} + + {/if} + + + {/if} + {#if mode === 'edit' && saveErrors.size > 0} + + + {#snippet trigger()} - {/if} - - - {/if} - {#if mode === 'edit' && saveErrors.size > 0} - - - {#snippet trigger()} - - {/snippet} - {#snippet content()} -
- Save errors -
- {#each [...saveErrors.entries()] as [path, message]} -
- {path} - - {message} - -
- {/each} -
+ {/snippet} + {#snippet content()} +
+ Save errors +
+ {#each [...saveErrors.entries()] as [path, message]} +
+ {path} + + {message} + +
+ {/each}
- {/snippet} - - {/if} - {#if mode === 'edit' && pe.drafts.size > 0} - - {#if $workspaceStore} - - {/if} - - {/if} - {#if mode === 'view'} - +
+ {/snippet} + + {/if} + {#if mode === 'edit' && pe.drafts.size > 0} + + {#if $workspaceStore} + {/if}
+ startIcon={{ icon: savingAll ? Loader2 : Save }} + onclick={saveAllDrafts} + disabled={savingAll} + title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`} + > + {savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`} + + {/if} + {#if mode === 'view'} + + {/if} +
+{/snippet} +
{#if graphRes.loading && !graphRes.current}
diff --git a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte index c702f66471..58739ee36a 100644 --- a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte @@ -58,7 +58,7 @@ }) - + {#if status === 'connecting'}
{:else if status === 'connected'} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 183d10a003..48d667cc35 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -70,7 +70,9 @@ import HighlightCode from '$lib/components/HighlightCode.svelte' import JobLoader from '$lib/components/JobLoader.svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import { ActionRow, Button, Skeleton, Tab, Alert, DrawerContent } from '$lib/components/common' + import { Button, Skeleton, Tab, Alert, DrawerContent } from '$lib/components/common' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import JobDetailHeader from '$lib/components/runs/JobDetailHeader.svelte' import ScriptRetryChain from '$lib/components/runs/ScriptRetryChain.svelte' import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte' @@ -648,6 +650,10 @@ } }) onDestroy(resetFavicon) + + // The bar shares its row with the breadcrumb, and this page's name is a uuid that gives way + // grudgingly, so the buttons drop their labels rather than push each other off the end. + const compactBar = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < 1150) @@ -805,289 +811,305 @@ class="max-w-7xl p-4 mx-auto w-full" loading={!job} layout={[ - // 1. Top Action Bar (buttons on right side) - [ - { h: 2.5, w: 60 }, - { h: 2.5, w: 40 } - ], - 1, - // 2. Job Header + // 1. Job Header [{ h: 12, w: 100 }], 1, - // 3. Progress Bar + // 2. Progress Bar [{ h: 2, w: 100 }], 1.5 ]} /> - - {#snippet left()} -

run/{page.params.run}

- {/snippet} - {#snippet right()} - {@const isScript = job?.job_kind === 'script'} - {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} - {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} - {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))} - { - job?.id && deleteCompletedJob(job.id) - }, - type: 'delete' - } - ]} - > - {#snippet buttonReplacement()} - - {/if} - {/if} - {#if job} - job && shareReadLink(job.id) + + + + {#snippet runActions()} + {@const isScript = job?.job_kind === 'script'} + {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} + {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} + {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))} + { + job?.id && deleteCompletedJob(job.id) }, + type: 'delete' + } + ]} + > + {#snippet buttonReplacement()} + + {/if} + {/if} + {#if job} + job && shareReadLink(job.id) + }, + { + displayName: 'Copy public link', + icon: Globe, + disabled: !canSharePublicly, + tooltip: canSharePublicly + ? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked." + : 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.', + action: () => job && sharePublicLink(job.id) + } + ]} + > + {#snippet buttonReplacement()} + + {/snippet} + + {/if} + {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} + {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} + {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} +
+ job && sharePublicLink(job.id) + displayName: 'Show Flow Debug Info', + action: () => { + debugInfo() + } } ]} + class="h-auto" > {#snippet buttonReplacement()} - {/snippet} - {/if} - {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} - {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} - {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} -
- { - debugInfo() - } - } - ]} - class="h-auto" - > - {#snippet buttonReplacement()} - - {/snippet} - -
- {/if} - {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} +
+ {/if} + {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} + + {/if} + {#if persistentScriptDefinition !== undefined} + + {/if} + {#if canRunNow} + + {/if} + {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} + {#if !forceCancel} - {/if} - {#if persistentScriptDefinition !== undefined} - - {/if} - {#if canRunNow} - - {/if} - {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} - {#if !forceCancel} - - {:else} - - {/if} - {/if} - {#if job?.schedule_path} - - {/if} - {#if restartStep !== undefined && canRestart(restart)} - {@render flowRestartButton(restart, restartStep, 'button')} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} - {#if !$userStore?.operator} - {#if canWrite(job?.script_path ?? '', {}, $userStore)} - - {#if showEditButton} - - - {/if} - {/if} - {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} - - {/if} - {/if} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - + {:else} + {/if} - {/snippet} -
+ {/if} + {#if job?.schedule_path} + + {/if} + {#if restartStep !== undefined && canRestart(restart)} + {@render flowRestartButton(restart, restartStep, 'button')} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} + {#if !$userStore?.operator} + {#if canWrite(job?.script_path ?? '', {}, $userStore)} + + {#if showEditButton} + + + {/if} + {/if} + {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} + + {/if} + {/if} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {/snippet}
-
+
{#if job} -{#snippet title()} -
-

Service logs

- - Logs written by the servers, workers and indexers of this instance, grouped by host. - -
-{/snippet} - -
+ +
{#if $devopsRole == false}
- {@render title()} Ask one of them to look at the logs, or to grant you the devops role.
{:else} - + {/if}
diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 1662c15d79..97ed4b95b6 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -1,21 +1,25 @@ - + +{#if $userStore?.operator} +
+ Page not available for operators +
+{:else} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte index 645a928473..408a2c1d55 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte @@ -8,4 +8,4 @@ goto('/') - + diff --git a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte index 010abe2bd8..6e5f674b44 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte @@ -30,7 +30,7 @@ } - +

Token will be posted to {host == 'localhost' ? 'your local machine' : host} to port {port}

diff --git a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte index 2362b7f116..ae22b66147 100644 --- a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte @@ -73,7 +73,7 @@ } - + {#if framed} This page cannot be used inside another page. Open it in its own tab. diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index f055e92ca3..022048bbf7 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores' import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Button, CopyButton, Section, Skeleton, Tab, Tabs } from '$lib/components/common' + import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -13,7 +13,7 @@ import ErrorOrRecoveryHandler, { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte' - import PageHeader from '$lib/components/PageHeader.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -1459,24 +1459,19 @@ ) - + {#if canAdmin || isForkOwner} - - {#snippet titleActions()} - {#if $workspaceStore} - - {/if} - {/snippet} - {#if $superadmin} - - {/if} + +
-
+ +
-
+
{#if !loadedSettings} {:else if tab == 'users'} @@ -2435,5 +2430,13 @@ export async function main( tabMode={true} /> +{#snippet settingsActions()} + {#if $superadmin} + + {/if} +{/snippet} + diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte index b25b8db5c8..62c25fc6b3 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte @@ -31,7 +31,7 @@ - + {#if !success}
diff --git a/frontend/src/routes/a/[...path]/+page.svelte b/frontend/src/routes/a/[...path]/+page.svelte index e9fd20877c..417216608e 100644 --- a/frontend/src/routes/a/[...path]/+page.svelte +++ b/frontend/src/routes/a/[...path]/+page.svelte @@ -187,25 +187,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte index ee83869ee8..85a72dd7e3 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte @@ -78,23 +78,28 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index b2a29b6316..8229c5af6c 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -160,25 +160,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
From c860b025783d7a4fd5bf1ac4a0bbf6677bcf554c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 8 Oct 2026 08:50:32 +0200 Subject: [PATCH 04/27] describe team-plan seat billing accurately in workspace settings (#11603) Co-authored-by: Claude Opus 5.5 --- .../lib/components/settings/PremiumInfo.svelte | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index b96ab5e22b..766b2c6495 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -261,8 +261,7 @@
Developers

- Calculated on the MAXIMUM number of users in a given billing period, see the - Customer Portal for more info. + 1 seat each.

@@ -277,8 +276,7 @@
Operators

- Calculated on the MAXIMUM number of operators in a given billing period, see - the Customer Portal for more info. + Half a seat each.

@@ -293,7 +291,7 @@
Service accounts

- Enabled service accounts, half a seat each like operators, whatever their role. + Half a seat each, whatever their role.

@@ -383,8 +381,9 @@ Used seats (billed)

- Highest between seats from 'Developers + Operators' and 'Seats from - executions'. This is the number of seats used for billing this month. + 'Seats from users' plus 'Extra seats from computations'. Billed on the + highest total reached during the month; your invoices in the Customer Portal + show what was billed.

u + c = {formatNumber(premiumInfo.usedSeats)} @@ -417,7 +416,7 @@

Estimate your monthly cost

This is a rough estimate based on your expected team size and workload. Actual billing is - based on the maximum number of users and executions in a given month. + based on the highest seat total reached during the month.

From 8f7e0c72848b56fa97f6f0141d930c7d15044864 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 8 Oct 2026 08:52:59 +0200 Subject: [PATCH 05/27] feat: add gpt-6.1-sol to the chat, agent steps and weekly ai evals (#11584) Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- .github/workflows/ai-evals-scheduled.yml | 6 +++--- ai_evals/core/models.ts | 9 +++++++++ backend/windmill-ai/src/providers/mod.rs | 8 ++++++-- frontend/src/lib/components/copilot/lib.ts | 1 + frontend/src/lib/components/copilot/reasoningParity.json | 6 ++++++ frontend/src/lib/components/copilot/reasoningRegistry.ts | 5 +++-- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 4780eb02eb..94ecc7c6c8 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -15,8 +15,8 @@ on: description: "Space-separated modes (global, cli, flow, app, script)" default: "global cli" models: - description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" - default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro" + description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"" + default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" default: "3" @@ -28,7 +28,7 @@ concurrency: group: ai-evals-scheduled-${{ github.ref }} env: - MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro' }} + MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro' }} RUNS: ${{ inputs.runs || '3' }} REASONING: ${{ inputs.reasoning }} # Routes on the cloud instance are prefixed with their workspace. diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 22af20d5c3..cf131337fd 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -148,6 +148,15 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-6-astra", }, }, + { + id: "gpt-6.1-sol", + label: "GPT-6.1 Sol", + aliases: ["gpt-6.1-sol"], + frontend: { + provider: "openai", + model: "gpt-6.1-sol", + }, + }, { id: "gpt-6-sol", label: "GPT-6 Sol", diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index e08b038bb0..7dcddc93a3 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -62,9 +62,13 @@ const REASONING_RULES: &[ReasoningRule] = &[ can_disable: false, completions_tools_need_off: false, }, - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. ReasoningRule { - matches: |m| base_id(m).starts_with("gpt-6-astra"), + matches: |m| { + let base = base_id(m); + base.starts_with("gpt-6-astra") || base.starts_with("gpt-6.1-sol") + }, can_disable: false, completions_tools_need_off: true, }, diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index c53028031d..1d111a2f20 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -71,6 +71,7 @@ interface AIProviderDetails { // the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but // still served, so it stays in the list below the 5.6 models. const OPENAI_MODELS = [ + 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-astra', 'gpt-6-luna', diff --git a/frontend/src/lib/components/copilot/reasoningParity.json b/frontend/src/lib/components/copilot/reasoningParity.json index 93b8ac7eb2..1f20def9be 100644 --- a/frontend/src/lib/components/copilot/reasoningParity.json +++ b/frontend/src/lib/components/copilot/reasoningParity.json @@ -47,6 +47,12 @@ "canDisable": false, "completionsToolsNeedOff": true }, + { + "provider": "openai", + "model": "gpt-6.1-sol", + "canDisable": false, + "completionsToolsNeedOff": true + }, { "provider": "openai", "model": "gpt-6-sol", diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 68934c22f3..0cbf935832 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -131,9 +131,10 @@ const BEDROCK_RULES: ReasoningRule[] = [ // Azure names gpt-3.5 `gpt-35-turbo`. `minimal` exists on gpt-5 only, `xhigh` from // gpt-5.5, `max` from gpt-5.6. const OPENAI_RULES: ReasoningRule[] = [ - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. { - match: /(?:^|\/)gpt-6-astra/, + match: /(?:^|\/)gpt-6(?:-astra|\.1-sol)/, levels: LOW_TO_MAX, canDisable: false, completionsToolsNeedOff: true From d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:53:23 +0200 Subject: [PATCH 06/27] feat: improve data table roles and grants UX (#11562) * feat: improve data table roles and grants UX Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep selected bordered badges the same height as unselected ones Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse taking over roles with members and test grant statements Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep grant filter chips while a filter is set Co-Authored-By: Claude Opus 5.5 (1M context) * fix: check a taken-over role can log in with the new password Co-Authored-By: Claude Opus 5.5 (1M context) * feat: select several grant filter tags at once Co-Authored-By: Claude Opus 5.5 (1M context) * feat: explain where schema and table permissions are set Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the grant create row on one line Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show grants as a role and its privileges Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show grants in a table with role, privileges and scope Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide the grant scope when it can only be the object itself Co-Authored-By: Claude Opus 5.5 (1M context) * feat: grant schema usage along with grants inside the schema Co-Authored-By: Claude Opus 5.5 (1M context) * feat: confirm before closing the roles drawer with unsaved changes Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to c4002f777f56d90bb0471164291889850c85179d This commit updates the EE repository reference after PR #848 was merged in windmill-ee-private. Previous ee-repo-ref: 7937634a52d04caeb083da94e189705cd00796b3 New ee-repo-ref: c4002f777f56d90bb0471164291889850c85179d Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../tests/datatable_roles.rs | 83 +++++ .../src/datatable_acl.rs | 43 ++- backend/windmill-api/openapi.yaml | 13 + .../windmill-common/src/datatable_roles.rs | 12 +- .../src/datatable_roles_oss.rs | 3 +- frontend/src/lib/components/DBManager.svelte | 23 +- .../src/lib/components/DBManagerDrawer.svelte | 1 + .../src/lib/components/DropdownV2Inner.svelte | 16 +- .../lib/components/common/badge/Badge.svelte | 3 +- .../ConfirmationModal.svelte | 23 +- .../common/drawer/Disposable.svelte | 32 +- .../components/common/drawer/Drawer.svelte | 6 +- .../datatableAcl/PgAclEditor.svelte | 334 +++++++++++++----- .../datatableAcl/PgGrantBuilder.svelte | 96 +++-- .../components/datatableAcl/aclScopes.test.ts | 30 +- .../lib/components/datatableAcl/aclScopes.ts | 50 +-- .../DataTablePermissionsButton.svelte | 226 +++++++----- .../DataTableRolesSection.svelte | 31 +- .../workspaceSettings/datatableRoleModals.ts | 120 +++++++ 20 files changed, 864 insertions(+), 283 deletions(-) create mode 100644 frontend/src/lib/components/workspaceSettings/datatableRoleModals.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fffcc99883..560bcfb41b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1600d49ad5513af8b54ad65688d3d1c7edc977d9 \ No newline at end of file +6f23819e947b18878000e580ddefccfd01a73166 diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index ddb94f8c2b..9800d97841 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -417,6 +417,89 @@ async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::R outcome } +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_existing_role_is_taken_over_only_when_asked_and_never_with_members( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let plain = format!("wmtest_plain_{suffix}"); + let group = format!("wmtest_group_{suffix}"); + let member = format!("wmtest_member_{suffix}"); + for sql in [ + // An expired password and no connection slot: kept as is, the login Windmill sets is dead. + format!( + "CREATE ROLE \"{plain}\" LOGIN PASSWORD 'theirs' VALID UNTIL '2000-01-01' \ + CONNECTION LIMIT 0" + ), + format!("CREATE ROLE \"{group}\" NOLOGIN"), + format!("CREATE ROLE \"{member}\" LOGIN"), + format!("GRANT \"{group}\" TO \"{member}\""), + ] { + sqlx::query(&sql).execute(&db).await?; + } + + let create = |name: String, take_over: bool| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/settings/datatable_roles" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "name": name, "take_over": take_over })) + .send() + .await?; + Ok::<_, anyhow::Error>((resp.status().as_u16(), resp.text().await?)) + }; + let outcome = async { + let (status, body) = create(plain.clone(), false).await?; + assert_eq!( + status, 409, + "an existing role is not taken without asking: {body}" + ); + + // The group's member would inherit every grant the data table role gets. + let (status, body) = create(group.clone(), true).await?; + assert_eq!(status, 400, "{body}"); + assert!( + body.contains(&member), + "the refusal names the member: {body}" + ); + + let (status, body) = create(plain.clone(), true).await?; + assert_eq!(status, 200, "{body}"); + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; + let taken = catalog + .values() + .find(|r| r.name == plain) + .ok_or_else(|| anyhow::anyhow!("{plain} is not in the catalog: {catalog:?}"))?; + let options = (*db.connect_options()) + .clone() + .username(&plain) + .password(taken.pwd.as_deref().unwrap_or_default()); + let mut login = ::connect_with(&options).await?; + sqlx::query("SELECT 1").execute(&mut login).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide and outlive this test's database, whatever happened above. + for name in [&member, &group, &plain] { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} + #[cfg(all(feature = "private", feature = "enterprise"))] #[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs index ebab6214ce..efaa712481 100644 --- a/backend/windmill-api-workspaces/src/datatable_acl.rs +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts { pub(crate) former_owner: Option, /// For a revoke: each grant it takes back. pub(crate) revoked_grants: Vec, + /// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant + /// would give it nothing it can use. + pub(crate) lacks_schema_usage: bool, } /// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the @@ -1526,7 +1529,24 @@ async fn build_plan( ))); } } - let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants }; + let lacks_schema_usage = match (&change, target) { + (AclChange::Grant { scope, .. }, AclTarget::Schema { schema }) + if !matches!(scope, GrantScope::Target) => + { + !has_schema_usage(client, schema, &pg_role).await? + } + (AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => { + !has_schema_usage(client, schema, &pg_role).await? + } + _ => false, + }; + let facts = CatalogFacts { + other_pg_roles, + existing_objects, + former_owner, + revoked_grants, + lacks_schema_usage, + }; let mut plan = crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?; if matches!(change, AclChange::SetOwner { .. }) { @@ -1540,6 +1560,27 @@ async fn build_plan( Ok(plan) } +/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from. +async fn has_schema_usage( + client: &tokio_postgres::Client, + schema: &str, + pg_role: &str, +) -> Result { + Ok(client + .query_one( + "SELECT has_schema_privilege($1::name, $2::text, 'USAGE')", + &[&pg_role, &schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the role's privileges on schema {schema}: {}", + pg_error_message(&e) + )) + })? + .get(0)) +} + /// Postgres only hands an object to a role that could have created it: a table to one with /// `CREATE` on its schema, a schema to one with `CREATE` on the database. async fn missing_owner_privilege( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1b2aea604e..873fcb7405 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1765,6 +1765,12 @@ paths: type: string cluster: $ref: "#/components/schemas/DatatableRoleCluster" + take_over: + type: boolean + description: > + manage a Postgres role of that name that already exists on the cluster, + replacing its password, instead of refusing it. A role with cluster-wide + attributes or memberships is refused either way responses: "200": description: the created role @@ -1772,6 +1778,8 @@ paths: application/json: schema: $ref: "#/components/schemas/InstanceDatatableRole" + "409": + description: a Postgres role of that name already exists on the cluster and take_over was not set /settings/datatable_roles/{id}: post: @@ -34525,6 +34533,11 @@ components: type: boolean cluster: $ref: "#/components/schemas/DatatableRoleCluster" + in_use: + type: boolean + description: >- + Whether any data table, in any workspace, lets someone connect as this role. Only set + in a data table's available_roles. DatatableRoleTenants: type: object diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index f355e733cb..e7e490a6a4 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -331,6 +331,12 @@ pub async fn converge_connect_grants_with( /// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user` on `cluster`. No /// privileges beyond that — an admin grants them through SQL or the ACL editor. /// +/// A role of that name that already exists is refused with a 409, unless `take_over`: then its +/// password is replaced and it is managed from here on like one created here. A role holding +/// cluster-wide privileges, or inheriting another role's, is refused either way, since every tenant +/// would get them. Returns whether an existing role was taken over: undoing a failed take-over +/// must not drop a role that was there before. +/// /// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external /// cluster is another server: the role is created there before `tx` commits, and callers MUST drop /// it again ([`drop_datatable_role`]) if `tx` then fails to commit. @@ -343,8 +349,10 @@ pub async fn create_datatable_role( cluster: DatatableRoleCluster, name: &str, password: &str, -) -> Result<()> { - crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await + take_over: bool, +) -> Result { + crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password, take_over) + .await } /// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs index a97a936827..33d711e7f9 100644 --- a/backend/windmill-common/src/datatable_roles_oss.rs +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -125,7 +125,8 @@ mod ce { _cluster: DatatableRoleCluster, _name: &str, _password: &str, - ) -> Result<()> { + _take_over: bool, + ) -> Result { Err(unavailable()) } diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 5204a876b9..837569c575 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -375,7 +375,8 @@ } if (!isInstanceDatatable(datatable)) return { - reason: 'Only data tables stored in the instance database have roles and grants.', + reason: + "Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.", ee: false } if (needsAdmin && !canManageDatatable) @@ -458,6 +459,11 @@ // default to open — actually be folded; a plain "expanded" set could never // close them, since the default would keep winning. // What the permissions drawer is open on, if anything: a schema, or a table. + let aclEditor = $state(undefined) + /** The open access editor lists the data table's roles, which the roles drawer can change. */ + export function refreshAcl() { + aclEditor?.refresh() + } let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>( undefined ) @@ -1466,7 +1472,7 @@ (aclDrawer = undefined)}> (aclDrawer = undefined)} tooltip="Who owns this, and what each role may do with it." > @@ -1474,7 +1480,18 @@ {@const dt = aclDrawer.datatable ?? currentDatatable} {#if dt} {#key `${dt}~${JSON.stringify(aclDrawer.target)}`} - + onDatatableAction?.(dt, 'roles'), + blocker: accessBlocker(dt, true)?.reason + } + : undefined} + /> {/key} {/if} {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index bd016544b0..30fc93377d 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -311,6 +311,7 @@ function refreshRoles() { datatables.refetch() usableRoles.refetch() + dbManagerContent?.dbManager()?.refreshAcl() } async function handleExportSchema(explicitSource?: string) { diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 31fd9426f5..70ed83398d 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -107,9 +107,19 @@ {})} /> {:else if item.disabled && item.tooltip} -
- {@render menuItem(item)} + lets the hover reach it so the user learns why the item is disabled. The ⓘ sits + outside the disabled button, the only place it can receive a hover. --> +
+
+ {@render menuItem(item)} +
+
+ + {#snippet text()} + {item.tooltip} + {/snippet} + +
{:else} {@render menuItem(item)} diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index c57d41029c..09a56c427a 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -85,7 +85,8 @@ ['dark-indigo']: 'bg-indigo-700 text-indigo-100 dark:bg-indigo-800 dark:text-indigo-200', ['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200', ['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200', - transparent: 'bg-surface-accent-selected text-accent border-gray-400 dark:border-gray-500' + // Keeps the unselected chip's border, so selecting one does not change its height. + transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected' } const hovers: Partial> = { diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 524817ba34..4b419536fc 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -29,6 +29,8 @@ * up confines it. Off by default: it moves the dialog out of its DOM position, so opt * in per call site rather than assuming every caller wants it. */ alwaysPortal?: boolean + /** For a notice with nothing to decide: the confirm button is the only one. */ + hideCancel?: boolean children?: Snippet onConfirmed?: () => void | Promise onCanceled?: () => void @@ -47,6 +49,7 @@ trashbin = false, zIndexClass = 'z-[9999]', alwaysPortal = false, + hideCancel = false, children, onConfirmed, onCanceled @@ -228,15 +231,17 @@ {/if} {confirmationText} - + {#if !hideCancel} + + {/if}
diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 886c14042b..4fd472729f 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -32,6 +32,9 @@ children?: import('svelte').Snippet<[any]> onOpen?: () => void onClose?: () => void + /** Asked before a click away or Escape closes it; resolving false keeps it open, e.g. to + * confirm discarding unsaved changes. */ + confirmClose?: () => boolean | Promise } let { @@ -42,9 +45,25 @@ minZIndex = 0, children, onOpen, - onClose + onClose, + confirmClose }: Props = $props() + // While the question is on screen, the keys and clicks are its own. + let confirming = false + async function requestClose() { + if (confirming) return + if (confirmClose) { + confirming = true + try { + if (!(await confirmClose())) return + } finally { + confirming = false + } + } + closeDrawer() + } + let offset = $state(untrack(() => initialOffset)) // Note: when a Modal with minZIndex is open, all disposables (including // already-open Drawers) are elevated. This is acceptable — relative @@ -115,7 +134,7 @@ const last = stack.val[stack.val.length - 1] if (last === id) { e.stopPropagation() - closeDrawer() + requestClose() } } @@ -125,7 +144,16 @@ if (open) { switch (event.key) { case 'Escape': + // The confirmation on screen answers this Escape itself, as its Cancel. + if (confirming) break if ((id == stack.val[stack.val.length - 1] || stack.val.length == 0) && !preventEscape) { + if (confirmClose) { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + requestClose() + break + } stack.val.pop() event.preventDefault() event.stopPropagation() diff --git a/frontend/src/lib/components/common/drawer/Drawer.svelte b/frontend/src/lib/components/common/drawer/Drawer.svelte index 76502735ca..45cf99c7be 100644 --- a/frontend/src/lib/components/common/drawer/Drawer.svelte +++ b/frontend/src/lib/components/common/drawer/Drawer.svelte @@ -28,6 +28,8 @@ positionClass?: string | undefined name?: string children?: import('svelte').Snippet<[any]> + /** Asked before a click away or Escape closes it; resolving false keeps it open. */ + confirmClose?: () => boolean | Promise } let { @@ -43,7 +45,8 @@ class: clazz = '', positionClass = undefined, name = undefined, - children: children_render + children: children_render, + confirmClose }: Props = $props() if (open === undefined) { @@ -146,6 +149,7 @@ onOpen={() => dispatch('open')} onClose={() => dispatch('close')} {preventEscape} + {confirmClose} > {#snippet children({ handleClickAway, zIndex, isTop })}
{#if info.editable} - info.owner, + (role) => { + // The select shows what the database says; a pick is a request, and only the + // applied change moves it. + if (role && role !== info.owner) { + confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`) + } } } - } - /> + bottomSnippet={manageRoles ? manageRolesEntry : undefined} + /> + {/snippet} + {@render withDisabledReason(ownerSelect, 'w-64')} {:else} {info.owner} {/if} @@ -168,75 +290,76 @@ : 'What each role may do here, beyond what it owns.'}
- {#if info.editable} - - confirm( - { type: 'grant', role, privileges, scope }, - `Granted ${privileges.join(', ')} to ${role}` - )} - /> + + {#if grantRows.length > 1 || roleFilters.length > 0 || privilegeFilters.length > 0} +
+ {#each roleChips as role (`role:${role}`)} + {@render filterChip(role, roleFilters.includes(role), () => { + roleFilters = toggled(roleFilters, role) + })} + {/each} + {#each privilegeChips as privilege (`privilege:${privilege}`)} + {@render filterChip(privilege, privilegeFilters.includes(privilege), () => { + privilegeFilters = toggled(privilegeFilters, privilege) + })} + {/each} +
{/if} - {#if grantRows.length === 0} - No grants yet. - {:else} - - - - Role - Privileges + + + + Role + Privileges + {#if showCoverage} On - - - - - {#each grantRows as grant (grantKey(grant))} - {@const revokeScope = revokeScopeOf(grant)} - {@const revocable = revocablePrivileges(grant, target)} - {@const blocked = blockingSources(grant, revocable)} - {@const uncovered = uncoveredCreators(grant, info.roles)} - - {grant.grantee} - {grant.privileges.join(', ')} - - {grantScopeLabel(grant)} - {#if blocked.length > 0} - - from {blocked.join(', ')} - - {/if} - {#if uncovered.length > 0} - - · not for what {uncovered.join(', ')} - {uncovered.length === 1 ? 'creates' : 'create'} - - {/if} + {/if} + + + + + {#each shownGrantRows as grant (grantKey(grant))} + {@const revokeScope = revokeScopeOf(grant)} + {@const revocable = revocablePrivileges(grant, target)} + {@const blocked = blockingSources(grant, revocable)} + {@const uncovered = uncoveredCreators(grant, info.roles)} + {@const unrevocable = unrevocableReason(grant, revokeScope, revocable, blocked)} + + {grant.grantee} + +
+ {#each grant.privileges as privilege (privilege)} + {@render tag(privilege)} + {/each} +
+ {#if blocked.length > 0 || uncovered.length > 0} +
+ {[ + blocked.length > 0 && `granted by ${blocked.join(', ')}`, + uncovered.length > 0 && + `not for what ${uncovered.join(', ')} ${uncovered.length === 1 ? 'creates' : 'create'}: a default privilege only covers the roles it was granted for` + ] + .filter(Boolean) + .join(' · ')} +
+ {/if} +
+ {#if showCoverage} + + {grantCoverage(grant, target) ?? targetLabel} - - - {#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE} + {/if} + + {#if info.editable} + {#snippet revokeButton()}
{/if} diff --git a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte index 5e5119abb6..745c1a1db5 100644 --- a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte +++ b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte @@ -1,17 +1,18 @@ -
-
- GRANT - ({ value: p, label: p }))} - placeholder="privileges" - {disabled} - size="sm" - class="min-w-48" - /> - ON + +
+ GRANT + ({ value: p, label: p }))} + placeholder="privileges" + {disabled} + size="md" + class="min-w-0 flex-[4]" + /> + + {#if scopes.length > 1} + ON ({ value: r, label: r }))} - placeholder="role" - {disabled} - size="sm" - class="w-40" - /> - -
- {#if statement} -
{statement}
{/if} + TO + - {#if info?.supported && permissioned && !hasUnsavedChanges} + {#if info?.supported && permissioned}
- + +
+ +
{/if} {/if} @@ -430,10 +506,7 @@
+ {/await} {/if} -{#if notExists} -
There was an error loading the app, is the url correct? Go to Windmill -
-{:else if noPermission} - {#if guestAppPath && !$userStore} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
-
- {#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace} - You are logged in but are not a member of the workspace {effectiveWorkspace} this app is part of - {:else}You must be logged in and have read access to this app{/if}
- {/if} -
- {#if !jwtError} - - {/if} -
+{#if notExists || noPermission} + {#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]} + + {/await} {:else if app} {#key app} {#if app.raw_app && effectiveWorkspace} - -
+ above it falls back to 150px. Styled inline: app.css may not be loaded here. --> +
} @@ -178,58 +118,28 @@ />
{:else if app.raw_app && !effectiveWorkspace} -
- - Unable to load raw app: workspace information is missing. - -
+ {#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]} +
+ + Unable to load raw app: workspace information is missing. + +
+ {/await} {:else} -
- {#await loadAppPreview()} - - {:then Module} - goto(path)} - gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} - /> - {:catch} -
- Reload the page to try again. -
- {/await} -
+ {#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]} + + {/await} {/if} {/key} {:else} - + {#await loadAppCss() then} + + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte new file mode 100644 index 0000000000..9a993b4972 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte @@ -0,0 +1,81 @@ + + +{#if notExists} +
There was an error loading the app, is the url correct? Go to Windmill +
+{:else} + {#if guestAppPath && !user} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+
+ {#if user}You are logged in but have no read access to this app{:else if globalUser && workspace} + You are logged in but are not a member of the workspace {workspace} this app is part of + {:else}You must be logged in and have read access to this app{/if}
+ {/if} +
+ {#if !jwtError} + { + dropPrefetched() + onLoginSuccess() + }} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> + {/if} +
+{/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 934511abce..dbb9b4b88b 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -23,24 +23,44 @@ * bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`. */ import { BROWSER } from 'esm-env' - import { OpenAPI, UserService } from '$lib/gen' + import { OpenAPI } from '$lib/gen/core/OpenAPI' import { page } from '$app/state' import { onDestroy, onMount, setContext, type Snippet } from 'svelte' - import Alert from '$lib/components/common/alert/Alert.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import { base } from '$app/paths' import { goto } from '$app/navigation' - import Login from '$lib/components/Login.svelte' - import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils' + import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils/queryParams' import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types' import { loadAppPreview } from './loadAppPreview' - import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte' + import { dropPrefetched, loadAppCss } from './publicAppApi' import { hasStoredSdkConsent, sdkConsentCovers, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes' + // Everything but a rendered app (sign-in, errors, the SDK consent prompt) loads on + // demand: a raw app that renders right away needs none of it. See publicAppApi.ts. + let frameUi: + | Promise<{ + Alert: typeof import('$lib/components/common/alert/Alert.svelte').default + Login: typeof import('$lib/components/Login.svelte').default + RawAppSdkConsent: typeof import('$lib/components/raw_apps/RawAppSdkConsent.svelte').default + }> + | undefined + function loadFrameUi() { + return (frameUi ??= Promise.all([ + loadAppCss(), + import('$lib/components/common/alert/Alert.svelte'), + import('$lib/components/Login.svelte'), + import('$lib/components/raw_apps/RawAppSdkConsent.svelte') + ]).then(([, alert, login, consent]) => ({ + Alert: alert.default, + Login: login.default, + RawAppSdkConsent: consent.default + }))) + } + type EmbedToken = { token?: string | null raw_app?: boolean @@ -235,7 +255,8 @@ if (offerSignIn && accountSession === 'unknown') { // Workspace-less, so it answers for an account and never for a guest // (pinned to its workspace) or for nobody. - UserService.getCurrentEmail() + import('$lib/gen') + .then(({ UserService }) => UserService.getCurrentEmail()) .then(() => (accountSession = 'held')) .catch(() => (accountSession = 'none')) } @@ -249,8 +270,12 @@ let staleGuestLogoutFailed = $state(false) $effect(() => { if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) { - UserService.logout() - .then(() => (staleGuestCleared = true)) + import('$lib/gen') + .then(({ UserService }) => UserService.logout()) + .then(() => { + dropPrefetched() + staleGuestCleared = true + }) .catch(() => (staleGuestLogoutFailed = true)) } }) @@ -427,7 +452,10 @@ // Render the app directly on this origin: same-origin when unsandboxed // (the default), or a single opaque bundle iframe when it's a sandboxed // raw app. A low-code app's runtime downloads alongside the app payload. - if (!isRaw) loadAppPreview().catch(() => {}) + if (!isRaw) { + loadAppCss().catch(() => {}) + loadAppPreview().catch(() => {}) + } onViewerReady?.(undefined, requestTokenRefresh) } else { // Sandboxed low-code: hand the scoped token to the opaque viewer iframe. @@ -537,6 +565,7 @@ onMount(() => { if (isViewer) { // Only a sandboxed low-code app is ever framed as a viewer. + loadAppCss().catch(() => {}) loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() @@ -569,105 +598,120 @@ {#if viewerReady} {@render viewer()} {:else if viewerOrphaned} -
- - This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own - page, use the app's public URL without the wm_embed parameter. - -
+ {#await loadFrameUi() then { Alert }} +
+ + This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your + own page, use the app's public URL without the wm_embed parameter. + +
+ {/await} {:else} - + {#await loadAppCss() then} + + {/await} {/if} {:else if status === 'loading'} - -{:else if status === 'notExists' && !offerSignIn} -
- - There was an error loading the app, is the url correct? - Go to Windmill - -
-{:else if status === 'sdkPrompt'} - - -{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} - -{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} -
- - The app could not be reached to find out who may open it. Reload to try again. - -
-{:else if offerSignIn} - - {#if signInDidNotHelp} - -
- You are signed in, but this app is not open to you -
-
- It is open to the people it was shared with{guestAppPath - ? ', and to guests who have no Windmill account' - : ''}. Ask the person who shared it to give your account access. -
+ {#await loadAppCss() then} + + {/await} +{:else if status === 'ready'} + {#if unsandboxed} + + {@render viewer()} + {:else if isRaw} + + {@render viewer()} {:else} - {#if guestAppPath} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
- {/if} - {#if signInError} -
- {signInError} -
- {/if} -
- { - signInError = undefined - accountSession = 'unknown' - initEmbedder() - }} - onLoginError={(message) => (signInError = message)} - popup - guestApp={guestAppPath} - rd={page.url.pathname + page.url.search + page.url.hash} - /> -
+ + {/if} -{:else if unsandboxed} - - {@render viewer()} -{:else if isRaw} - - {@render viewer()} {:else} - - + {#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }} + {#if status === 'notExists' && !offerSignIn} +
+ + There was an error loading the app, is the url correct? + Go to Windmill + +
+ {:else if status === 'sdkPrompt'} + + + {:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} + + {:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} +
+ + The app could not be reached to find out who may open it. Reload to try again. + +
+ {:else if offerSignIn} + + {#if signInDidNotHelp} + +
+ You are signed in, but this app is not open to you +
+
+ It is open to the people it was shared with{guestAppPath + ? ', and to guests who have no Windmill account' + : ''}. Ask the person who shared it to give your account access. +
+ {:else} + {#if guestAppPath} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+ {/if} + {#if signInError} +
+ {signInError} +
+ {/if} +
+ { + dropPrefetched() + signInError = undefined + accountSession = 'unknown' + initEmbedder() + }} + onLoginError={(message) => (signInError = message)} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> +
+ {/if} + {/if} + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte new file mode 100644 index 0000000000..f74be67166 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte @@ -0,0 +1,91 @@ + + +
+ {#await loadAppPreview()} + + {:then Module} + goto(path)} + gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} + /> + {:catch} +
+ Reload the page to try again. +
+ {/await} +
diff --git a/frontend/src/lib/components/apps/editor/publicAppApi.ts b/frontend/src/lib/components/apps/editor/publicAppApi.ts new file mode 100644 index 0000000000..674cb0b622 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/publicAppApi.ts @@ -0,0 +1,82 @@ +// The public app routes (`/public/...`, `/a/...`) render a raw app as little more than its +// bundle's iframe, so they must not statically import the generated client, `$lib/stores` +// or `$lib/utils`: each drags in most of the app shell. `vite.config.js` +// (`assertLeanPublicAppRoutes`) fails the build if they do. Anything heavier is imported +// lazily by the branch that needs it. +import { OpenAPI } from '$lib/gen/core/OpenAPI' +import type { AppWithLastVersion, User } from '$lib/gen/types.gen' +import type { UserExt } from '$lib/stores' +import { mapUserToUserExt } from '$lib/userExt' + +export type PublicAppValue = AppWithLastVersion & { value: any; workspace_id?: string } + +declare global { + interface Window { + /** Responses `app.html` requested before any module loaded, keyed by URL. */ + __wmPublicAppPrefetch?: Record> + } +} + +/** Carries `status` like the generated client's `ApiError`, which callers branch on. */ +export class PublicApiError extends Error { + constructor(public status: number) { + super(`Request failed with status ${status}`) + } +} + +/** Prefetched responses answer for the credential the page loaded with; call this when it + * changes (a sign-in or logout on the page) so later reads go to the network. */ +export function dropPrefetched(): void { + delete window.__wmPublicAppPrefetch +} + +function takePrefetched(url: string): Promise | undefined { + const prefetched = window.__wmPublicAppPrefetch + const res = prefetched?.[url] + if (res) delete prefetched![url] + return res +} + +/** GET an API path with the page credential: the bearer token in `OpenAPI.TOKEN` when the + * share link carries one, the session cookie otherwise. A prefetched response is only + * taken in the cookie case, which is the only credential `app.html` sends. */ +export async function publicGet(path: string): Promise { + const url = `${OpenAPI.BASE}${path}` + const token = typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined + const res = await ((!token && takePrefetched(url)) || + fetch(url, { + // As the generated client does: the opaque viewer frame is cross-origin to the + // API, whose `Access-Control-Allow-Origin: *` refuses a credentialed request. + credentials: OpenAPI.WITH_CREDENTIALS ? OpenAPI.CREDENTIALS : undefined, + headers: token ? { Authorization: `Bearer ${token}` } : {} + })) + if (!res.ok) throw new PublicApiError(res.status) + return (res.headers.get('content-type')?.includes('json') ? res.json() : res.text()) as T +} + +export async function getUserExtOrUndefined(workspace: string): Promise { + try { + return mapUserToUserExt( + await publicGet(`/w/${encodeURI(workspace)}/users/whoami`), + workspace + ) + } catch { + return undefined + } +} + +/** Only fades the "Powered by" badge, so a failure is not worth surfacing. */ +export async function getLicenseOrUndefined(): Promise { + try { + return (await publicGet('/ee_license')) || undefined + } catch { + return undefined + } +} + +let appCss: Promise | undefined +/** The app-wide stylesheet, which these routes need only off the raw-app path: sign-in, + * errors, skeletons and low-code apps. A raw app's bundle brings its own styles. */ +export function loadAppCss(): Promise { + return (appCss ??= import('$lib/assets/app.css')) +} diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index c3eadc1f68..9f675664b1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -52,6 +52,11 @@ // Job ids launched by this app instance — see `gateJobIds`. const launchedJobs = new Set() + /** For a host that loads this component after the bundle may already have sent requests. */ + export function handleMessage(event: MessageEvent) { + return listener(event) + } + let listener = async (event) => { // Only accept messages from the bundle iframe (opaque origin) or the // detached preview window we opened, so other frames/extensions can't diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 8a79b4b8f9..9eced2d0e6 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -1,6 +1,6 @@ - +{#await runnerModule then { default: Runner }} + +{:catch} + {#if requestRefused} + +
+ Could not load the app runtime. Reload the page to try again. +
+ {/if} +{/await} {#if iframeSrc} + {/if} diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 006c2ab60c..12d6f237ba 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -1,5 +1,6 @@ -import { type User, UserService } from '$lib/gen' +import { UserService } from '$lib/gen' import type { UserExt } from './stores.js' +import { mapUserToUserExt } from './userExt' async function fetchUserExt(workspace: string): Promise { return mapUserToUserExt(await UserService.whoami({ workspace }), workspace) @@ -42,16 +43,3 @@ export function getWorkspaceRole(workspace: string): Promise { export function clearWorkspaceRoleCache(): void { workspaceRoleCache.clear() } - -function mapUserToUserExt(user: User, workspace: string): UserExt { - const ext: UserExt = { - ...user, - workspace_id: workspace, - groups: user.groups!, - pgroups: user.groups!.map((x) => `g/${x}`) - } - if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { - ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined - } - return ext -} diff --git a/frontend/src/lib/userExt.ts b/frontend/src/lib/userExt.ts new file mode 100644 index 0000000000..d06e1cd04d --- /dev/null +++ b/frontend/src/lib/userExt.ts @@ -0,0 +1,15 @@ +import type { User } from '$lib/gen/types.gen' +import type { UserExt } from './stores' + +export function mapUserToUserExt(user: User, workspace: string): UserExt { + const ext: UserExt = { + ...user, + workspace_id: workspace, + groups: user.groups!, + pgroups: user.groups!.map((x) => `g/${x}`) + } + if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { + ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined + } + return ext +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index ec112607fd..8540b964e8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1322,28 +1322,7 @@ export function isCodeInjection(expr: string | undefined): boolean { return dynamicTemplateRegex.test(expr) } -// Query params Windmill consumes internally and that should not be exposed to -// app logic via the `query` context. Only params we actually own are listed -// here — the `wm_` prefix is a naming convention, not a reserved namespace, so -// we don't strip it wholesale (that would break apps reading their own `wm_*` -// params). `wm_coep` is a transport flag for cross-origin isolation headers; -// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params -// (see PublicAppFrame). -export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin']) - -export function urlParamsToObject( - params: URLSearchParams, - opts?: { stripReserved?: boolean } -): Record { - const result: Record = {} - params.forEach((value, key) => { - if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { - return - } - result[key] = value - }) - return result -} +export { WINDMILL_RESERVED_QUERY_PARAMS, urlParamsToObject } from './utils/queryParams' export async function tryEvery({ tryCode, diff --git a/frontend/src/lib/utils/queryParams.ts b/frontend/src/lib/utils/queryParams.ts new file mode 100644 index 0000000000..ffefc47eea --- /dev/null +++ b/frontend/src/lib/utils/queryParams.ts @@ -0,0 +1,22 @@ +// Query params Windmill consumes internally and that should not be exposed to +// app logic via the `query` context. Only params we actually own are listed +// here — the `wm_` prefix is a naming convention, not a reserved namespace, so +// we don't strip it wholesale (that would break apps reading their own `wm_*` +// params). `wm_coep` is a transport flag for cross-origin isolation headers; +// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params +// (see PublicAppFrame). +export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin']) + +export function urlParamsToObject( + params: URLSearchParams, + opts?: { stripReserved?: boolean } +): Record { + const result: Record = {} + params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } + result[key] = value + }) + return result +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index 75e31a9a00..c9623bc340 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -1,4 +1,6 @@ + +{@render children?.()} diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte similarity index 99% rename from frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte rename to frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index 85a72dd7e3..9b3a9c1e41 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -93,6 +93,7 @@ + // app.css is imported by the layouts below this one, not here: the public app routes + // (`public/`, `a/`) load it only when they render more than a raw app's bundle. + // A production build links a layout's CSS ahead of its page's, but in dev a stylesheet + // lands when its module evaluates, and a page's can evaluate before its layout's. This + // module is imported at startup, so loading it here in dev keeps app.css first. + if (import.meta.env.DEV && !/^\/(public|a)\//.test(location.pathname)) { + import('$lib/assets/app.css') + } + + -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
import { BROWSER } from 'esm-env' - import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen' - import { userStore } from '$lib/stores' - - import { setLicense } from '$lib/enterpriseUtils' - - import { getUserExt } from '$lib/user' + import { OpenAPI } from '$lib/gen/core/OpenAPI' + import type { GuestEntry } from '$lib/gen/types.gen' + import type { UserExt } from '$lib/stores' import { page } from '$app/state' import { base } from '$lib/base' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import { + getLicenseOrUndefined, + getUserExtOrUndefined, + publicGet, + type PublicAppValue + } from '$lib/components/apps/editor/publicAppApi' - let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined) + let app: PublicAppValue | undefined = $state(undefined) + let user: UserExt | undefined = $state(undefined) + let license: string | undefined = $state(undefined) let notExists = $state(false) let noPermission = $state(false) let jwtError = $state(false) @@ -70,35 +75,21 @@ } else if (parsedSecret.jwt) { OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt } - const headers: Record = {} - if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) { - headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}` - } const consent = opts?.sdkConsent ? '?sdk_consent=true' : '' - const res = await fetch( - `${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`, - { headers } - ) - if (!res.ok) { - const err: any = new Error('Failed to fetch embed token') - err.status = res.status - throw err - } - return await res.json() + return await publicGet(`/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`) } // Viewer side: load the app + user using the embed token handed to the iframe. async function loadApp() { + // Neither waits on the other: `app.html` already requested both. + const userLoad = getUserExtOrUndefined(workspace) + const appLoad = publicGet( + `/w/${workspace}/apps_u/public_app/${encodeURI(parsedSecret.secret)}` + ) + appLoad.catch(() => {}) + user = await userLoad try { - userStore.set(await getUserExt(workspace)) - } catch (e) { - console.warn('Anonymous user') - } - try { - app = await AppService.getPublicAppBySecret({ - workspace, - path: parsedSecret.secret - }) + app = await appLoad noPermission = false notExists = false } catch (e) { @@ -126,7 +117,9 @@ if (guestEntrySettled()) return for (let attempt = 0; attempt < 3; attempt++) { try { - const entry = await AppService.getGuestEntry({ workspace, path: parsedSecret.secret }) + const entry = await publicGet( + `/w/${workspace}/apps_u/guest_entry/${encodeURI(parsedSecret.secret)}` + ) guestAppPath = `${workspace}/${entry.app_path}` guestEntry = 'guest' return @@ -156,13 +149,14 @@ } if (BROWSER) { - setLicense() + getLicenseOrUndefined().then((l) => (license = l)) } -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
Object.keys(bundle).some((file) => file.startsWith('_app/immutable/')) /** - * Fail the build if a public app URL statically loads the low-code runtime or monaco. + * Fail the build if a public app URL statically loads more than a raw app needs. * * These pages also serve raw apps, which only need a small shell around their bundle's - * iframe. One static import of AppPreview (or of anything reaching monaco) makes every - * public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts. + * iframe, and the page shows nothing until its static imports have loaded. One static + * import of AppPreview (or of anything reaching monaco) makes every public app page + * preload ~650 chunks; one of the generated client, `$lib/stores` or `$lib/utils` drags + * in most of the app shell. The root layout wraps these pages, so it must not import + * app.css either. See loadAppPreview.ts and publicAppApi.ts. */ function assertLeanPublicAppRoutes() { // Route directories, so +page.js counts as well as +page.svelte. const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/'] const forbidden = [ '/src/lib/components/apps/editor/AppPreview.svelte', - '/node_modules/monaco-editor/' + '/node_modules/monaco-editor/', + '/src/lib/assets/app.css', + '/src/lib/gen/services.gen.ts', + '/src/lib/stores.ts', + '/src/lib/utils.ts', + '/src/lib/components/Login.svelte', + '/node_modules/@melt-ui/' ] return { name: 'wm-assert-lean-public-app-routes', generateBundle(_options, bundle) { if (!isClientBuild(bundle)) return - for (const route of routes) { - assertStaticClosureExcludes(this, bundle, route, forbidden, 'loadAppPreview.ts') + for (const route of [...routes, '/src/routes/+layout.svelte']) { + assertStaticClosureExcludes(this, bundle, route, forbidden, 'publicAppApi.ts') } } } @@ -372,7 +381,19 @@ const config = { // which makes the *chunk* graph cyclic where the module graph is not, and // modules then evaluate against uninitialized bindings. See // docs/frontend-import-cycles.md. - advancedChunks: { groups: [{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }] } + // The client's config is split out for the public app routes, which set the + // credential without loading the client. It imports nothing at runtime, so + // its chunk cannot be part of a cycle. + advancedChunks: { + groups: [ + { + name: 'gen-config', + test: /[\\/]src[\\/]lib[\\/]gen[\\/]core[\\/]OpenAPI\.ts$/, + priority: 1 + }, + { name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ } + ] + } } } }, From 8b9222d778c3d9cf1f842f10b40a53afff6c8d52 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 09:15:49 +0200 Subject: [PATCH 12/27] chore: fix rust client retry param and ai-evals workflow yaml --- .github/workflows/ai-evals-scheduled.yml | 2 +- rust-client/src/client.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 94ecc7c6c8..580a757c21 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -15,7 +15,7 @@ on: description: "Space-separated modes (global, cli, flow, app, script)" default: "global cli" models: - description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"" + description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" diff --git a/rust-client/src/client.rs b/rust-client/src/client.rs index 451dc58d85..bf47e7e95a 100644 --- a/rust-client/src/client.rs +++ b/rust-client/src/client.rs @@ -785,6 +785,7 @@ impl Windmill { None, None, None, + None, ) .await? } else { @@ -808,6 +809,7 @@ impl Windmill { None, None, None, + None, ) .await? }; From ff028babe88c300f2ca8de7a216f6eae5b6989cc Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:30:37 +0200 Subject: [PATCH 13/27] close raw app preview pop-out when the editor tab reloads or closes (#11607) --- .../lib/components/raw_apps/RawAppEditor.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index d0d1b40de1..f708b97efe 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1839,13 +1839,15 @@ addEventListener('message', function (e) { syncExternalPreview() } - onDestroy(() => { - // Don't leave a detached preview behind when the editor unmounts: it - // would stop receiving builds and, once refreshed, has no opener to - // re-feed it — a permanently blank orphan. + // Don't leave a detached preview behind when the editor goes away, by + // unmounting or by its tab reloading or closing (`pagehide`, which skips + // `onDestroy`): it would stop receiving builds, and its document is a blob + // URL owned by this page, so refreshing it gives "not found". + function closeExternalPreview() { if (externalPreviewWindow && !externalPreviewWindow.closed) externalPreviewWindow.close() externalPreviewWindow = null - }) + } + onDestroy(closeExternalPreview) function openExternalPreview() { // Reuse an already-open window instead of spawning duplicates. @@ -2512,7 +2514,7 @@ addEventListener('message', function (e) { } - + { From d5059166099f9e7542ec6f3caf95ccb0f2c605a9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 09:44:54 +0200 Subject: [PATCH 14/27] chore(main): release 1.828.0 (#11611) * chore(main): release 1.828.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 23 +++ backend/Cargo.lock | 178 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 159 insertions(+), 136 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 62ac9abafe..cc72934156 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.827.0" + ".": "1.828.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b157fa61d..c958457c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.828.0](https://github.com/windmill-labs/windmill/compare/v1.827.0...v1.828.0) (2026-10-08) + + +### Features + +* add gpt-6.1-sol to the chat, agent steps and weekly ai evals ([#11584](https://github.com/windmill-labs/windmill/issues/11584)) ([8f7e0c7](https://github.com/windmill-labs/windmill/commit/8f7e0c72848b56fa97f6f0141d930c7d15044864)) +* add wmill user list and document post-suspend approvers context ([#11600](https://github.com/windmill-labs/windmill/issues/11600)) ([1c2667c](https://github.com/windmill-labs/windmill/commit/1c2667cd5530925893cf69fd622938b6ffa55d1d)) +* ask before minting the frontend sdk token in the raw app editor ([#11604](https://github.com/windmill-labs/windmill/issues/11604)) ([6e948b0](https://github.com/windmill-labs/windmill/commit/6e948b07d13b85e9251c3a200bf149f167649bc4)) +* improve data table roles and grants UX ([#11562](https://github.com/windmill-labs/windmill/issues/11562)) ([d7d18f0](https://github.com/windmill-labs/windmill/commit/d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6)) +* one page header across routes, and one sidebar for operators and developers ([#11310](https://github.com/windmill-labs/windmill/issues/11310)) ([41162b2](https://github.com/windmill-labs/windmill/commit/41162b270c70ddac6a256bde9acf31a629990e24)) + + +### Bug Fixes + +* add retry to runScriptByPathAsync and runScriptByHashAsync ([#11485](https://github.com/windmill-labs/windmill/issues/11485)) ([3f3b1c2](https://github.com/windmill-labs/windmill/commit/3f3b1c2c308ddaba377db45308711e7c660d8775)) +* describe exponential retry as multiplier × seconds^attempt ([#11546](https://github.com/windmill-labs/windmill/issues/11546)) ([a2e51cd](https://github.com/windmill-labs/windmill/commit/a2e51cdbaafb7d71b8e8bbd818e40c472098f9ba)) +* keep capturing job logs after a non-UTF-8 output line ([#11595](https://github.com/windmill-labs/windmill/issues/11595)) ([0e7d974](https://github.com/windmill-labs/windmill/commit/0e7d974ce9b468353ecb6618dace710704690687)) + + +### Performance Improvements + +* load public raw apps without the loading screen ([#11608](https://github.com/windmill-labs/windmill/issues/11608)) ([c09764f](https://github.com/windmill-labs/windmill/commit/c09764fa6516badddbdbb520af32e5d1541cf996)) + ## [1.827.0](https://github.com/windmill-labs/windmill/compare/v1.826.0...v1.827.0) (2026-10-07) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aeb7670d17..b5f3d9a306 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6685,9 +6685,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] @@ -9617,7 +9617,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.16+spec-1.1.0", ] [[package]] @@ -13622,9 +13622,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -13644,21 +13644,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.16+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" dependencies = [ "indexmap 2.14.2", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" dependencies = [ "winnow 1.0.4", ] @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.827.0" +version = "1.828.0" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.827.0" +version = "1.828.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15379,7 +15379,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15407,7 +15407,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15429,7 +15429,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15449,7 +15449,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15491,7 +15491,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15520,7 +15520,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.827.0" +version = "1.828.0" dependencies = [ "lazy_static", "serde", @@ -15532,7 +15532,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.827.0" +version = "1.828.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15556,7 +15556,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15570,7 +15570,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15609,7 +15609,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.827.0" +version = "1.828.0" dependencies = [ "chrono", "lazy_static", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15643,7 +15643,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.827.0" +version = "1.828.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15757,7 +15757,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.827.0" +version = "1.828.0" dependencies = [ "chrono", "futures", @@ -15777,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.827.0" +version = "1.828.0" dependencies = [ "regex", "serde", @@ -15794,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "futures", @@ -15838,7 +15838,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.827.0" +version = "1.828.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15854,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -15875,7 +15875,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -15906,7 +15906,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "arc-swap", @@ -15931,7 +15931,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-stream", @@ -15967,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "futures", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.827.0" +version = "1.828.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15994,7 +15994,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16006,7 +16006,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16018,7 +16018,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "gosyn", @@ -16030,7 +16030,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16042,7 +16042,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16054,7 +16054,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "nu-parser", @@ -16065,7 +16065,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16076,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16088,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16099,7 +16099,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16121,7 +16121,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16147,7 +16147,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16164,7 +16164,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16177,7 +16177,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde", @@ -16189,7 +16189,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16207,7 +16207,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16223,7 +16223,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16239,7 +16239,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16253,7 +16253,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16292,7 +16292,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "const_format", @@ -16332,7 +16332,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.827.0" +version = "1.828.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16343,7 +16343,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16378,7 +16378,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16402,7 +16402,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16435,7 +16435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16462,7 +16462,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16495,7 +16495,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16515,7 +16515,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16549,7 +16549,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16586,7 +16586,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16609,7 +16609,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16633,7 +16633,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -16657,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16692,7 +16692,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16745,7 +16745,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16764,7 +16764,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-once-cell", @@ -16883,7 +16883,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.827.0" +version = "1.828.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 06fdc001f0..e0b97275b2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.827.0" +version = "1.828.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.827.0" +version = "1.828.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 99d82bb43b..1bdbdd6097 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.827.0" +version = "1.828.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.827.0" +version = "1.828.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.827.0" +version = "1.828.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 9ac7f81c7f..b2fd850217 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.827.0" +version = "1.828.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d8c217cbc8..9173eae28c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.827.0 + version: 1.828.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 281ba460b6..df2044d419 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.827.0"; +export const VERSION = "v1.828.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 692e5d33f5..c6691e0048 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.827.0", + "version": "1.828.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 287636a9f8..a119b8645d 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.827.0", + "version": "1.828.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index d05c4e60a7..860a3ddede 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.827.0"; +export const VERSION = "1.828.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f5ea5b721..28ecbebf42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 4515459ba3..cc42f5424a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 4509187f28..14c2eb8fac 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.827.0" +wmill = ">=1.828.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0fcf8a61dd..57f1fc5648 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.827.0 + version: 1.828.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 62cf4298d6..927edd49f6 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.827.0' + ModuleVersion = '1.828.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 4ffcbddd89..13ac4753c5 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.827.0" +version = "1.828.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 615e7885eb..45306830f5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.827.0", + "version": "1.828.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 59f4b98719..98caf3895a 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.827.0", + "version": "1.828.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 35d501b3a8..b08184bb19 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.827.0 +1.828.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 295ba3e97a..064996f393 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 05ba30630f..7e8c7a641d 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 588d8463dfa1c8936b8753b5f0fd794ecb115203 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 12:56:33 +0200 Subject: [PATCH 15/27] feat: let a job see that its worker is draining (#11612) * feat: let a job see that its worker is draining * fix: gate worker draining check on job visibility and export sdk helper * fix: keep draining across re-registration and ignore parked jobs * fix: reset draining only on worker startup registration * chore: update ee-repo-ref to 55feb2e84332179faa4e17aeb419f7f480df79a6 This commit updates the EE repository reference after PR #857 was merged in windmill-ee-private. Previous ee-repo-ref: e5010fa046a3aba93595d1bf44eb6a0cef2b5273 New ee-repo-ref: 55feb2e84332179faa4e17aeb419f7f480df79a6 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- ...3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json | 23 +++++++++ ...f7d4b418a9e82be56dfe7dd65d160e0dcfd13.json | 24 +++++++++ ...f20b6971b993e0671b6ccb45db5ef028550bc.json | 33 ------------ ...123a98ec175f9d3dc72e40a1cb40e14dec919.json | 14 +++++ ...64360a5d5c783d6ca0a3c4123ea126ed0e60b.json | 34 +++++++++++++ ...ceec2b6f1ae1a32fcf375161d04419404d9c.json} | 10 +++- backend/ee-repo-ref.txt | 2 +- ...261008070335_worker_ping_draining.down.sql | 1 + ...20261008070335_worker_ping_draining.up.sql | 1 + backend/summarized_schema.txt | 2 +- backend/tests/worker_ping_ip.rs | 32 +++++++++++- backend/windmill-api-auth/src/scopes.rs | 1 + .../tests/jobs_authed.rs | 48 +++++++++++++++++ backend/windmill-api-workers/src/lib.rs | 3 +- backend/windmill-api/openapi.yaml | 24 +++++++++ backend/windmill-api/src/jobs.rs | 51 +++++++++++++++++++ backend/windmill-common/src/worker.rs | 24 ++++++++- backend/windmill-worker/src/common.rs | 1 + backend/windmill-worker/src/worker.rs | 31 +++++++++-- backend/windmill-worker/src/worker_utils.rs | 50 ++++++++++++++++-- cli/src/guidance/skills.gen.ts | 36 +++++++++++++ .../(root)/(logged)/workers/+page.svelte | 11 +++- python-client/wmill/wmill/client.py | 27 ++++++++++ system_prompts/auto-generated/prompts.ts | 20 ++++++++ system_prompts/auto-generated/script.md | 20 ++++++++ system_prompts/auto-generated/sdks/python.md | 12 +++++ .../auto-generated/sdks/typescript.md | 8 +++ .../skills/write-script-bun/SKILL.md | 8 +++ .../skills/write-script-bunnative/SKILL.md | 8 +++ .../skills/write-script-deno/SKILL.md | 8 +++ .../skills/write-script-python3/SKILL.md | 12 +++++ typescript-client/build.jsr.sh | 2 +- typescript-client/build.sh | 4 +- typescript-client/client.ts | 13 +++++ 34 files changed, 548 insertions(+), 50 deletions(-) create mode 100644 backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json create mode 100644 backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json delete mode 100644 backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json create mode 100644 backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json create mode 100644 backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json rename backend/.sqlx/{query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json => query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json} (86%) create mode 100644 backend/migrations/20261008070335_worker_ping_draining.down.sql create mode 100644 backend/migrations/20261008070335_worker_ping_draining.up.sql diff --git a/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json b/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json new file mode 100644 index 0000000000..036610e08c --- /dev/null +++ b/backend/.sqlx/query-1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "draining", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1e3407ad2ec46bd965f2aeede0d3cd4a3f9226a5e0c6b15c2580ddd1dba6199b" +} diff --git a/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json b/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json new file mode 100644 index 0000000000..1c8dfcfa57 --- /dev/null +++ b/backend/.sqlx/query-7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wp.draining FROM v2_job j\n JOIN v2_job_queue q ON q.id = j.id\n JOIN worker_ping wp ON wp.worker = q.worker\n WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL\n AND ($3::text[] IS NULL OR j.tag = ANY($3))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "draining", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7963eff5f7109874b94c0940eaef7d4b418a9e82be56dfe7dd65d160e0dcfd13" +} diff --git a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json b/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json deleted file mode 100644 index 7f6fe7a81c..0000000000 --- a/backend/.sqlx/query-c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "jobs_executed", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "TextArray", - "Varchar", - "Varchar", - "TextArray", - "Varchar", - "Int8", - "Int8", - "Text", - "Bool" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc" -} diff --git a/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json b/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json new file mode 100644 index 0000000000..9ebf9289c2 --- /dev/null +++ b/backend/.sqlx/query-e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE worker_ping SET draining = true WHERE worker = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "e4b9c0e8a46dbfe29bace8d8b12123a98ec175f9d3dc72e40a1cb40e14dec919" +} diff --git a/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json b/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json new file mode 100644 index 0000000000..9f51c69616 --- /dev/null +++ b/backend/.sqlx/query-e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining)\n RETURNING jobs_executed", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jobs_executed", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar", + "Varchar", + "TextArray", + "Varchar", + "Int8", + "Int8", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e7a8c0ee68b8626eb45a7eacdd364360a5d5c783d6ca0a3c4123ea126ed0e60b" +} diff --git a/backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json b/backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json similarity index 86% rename from backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json rename to backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json index 583d2bcc6b..5ee5c7571d 100644 --- a/backend/.sqlx/query-68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca.json +++ b/backend/.sqlx/query-fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3", + "query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -107,6 +107,11 @@ "ordinal": 20, "name": "native_mode", "type_info": "Bool" + }, + { + "ordinal": 21, + "name": "draining", + "type_info": "Bool" } ], "parameters": { @@ -138,8 +143,9 @@ true, true, true, + false, false ] }, - "hash": "68bca8f839e47705b11d312ee874eceaa3d1d24d9053ad4aea94b9f8465585ca" + "hash": "fe22c4609d8b2472c0f83fedb92eceec2b6f1ae1a32fcf375161d04419404d9c" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 5a36bc5e61..ff44a56995 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -e5689bf58a125ff46aabb6b5d0f030a7e5c715d3 +55feb2e84332179faa4e17aeb419f7f480df79a6 diff --git a/backend/migrations/20261008070335_worker_ping_draining.down.sql b/backend/migrations/20261008070335_worker_ping_draining.down.sql new file mode 100644 index 0000000000..64a5f2949d --- /dev/null +++ b/backend/migrations/20261008070335_worker_ping_draining.down.sql @@ -0,0 +1 @@ +ALTER TABLE worker_ping DROP COLUMN IF EXISTS draining; diff --git a/backend/migrations/20261008070335_worker_ping_draining.up.sql b/backend/migrations/20261008070335_worker_ping_draining.up.sql new file mode 100644 index 0000000000..33b5ad5e3e --- /dev/null +++ b/backend/migrations/20261008070335_worker_ping_draining.up.sql @@ -0,0 +1 @@ +ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS draining BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 2a518a7cc8..284a822b47 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -219,7 +219,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work windmill_migrations: name(text), created_at(ts) worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint) FK: (workspace_id) -> workspace(id) -worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]) +worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), draining(bool) workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char), is_dev_workspace(bool) FK: (parent_workspace_id) -> workspace(id) workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts) diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs index 772f4193c3..2421e888ef 100644 --- a/backend/tests/worker_ping_ip.rs +++ b/backend/tests/worker_ping_ip.rs @@ -2,10 +2,19 @@ use sqlx::{Pool, Postgres}; use uuid::Uuid; use windmill_common::{ external_ip::UNKNOWN_IP, - worker::{insert_ping_query, update_worker_ping_main_loop_query}, + worker::{insert_ping_query, set_worker_draining_query, update_worker_ping_main_loop_query}, }; async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> { + register(db, worker, ip, None).await +} + +async fn register( + db: &Pool, + worker: &str, + ip: Option<&str>, + draining: Option, +) -> anyhow::Result<()> { insert_ping_query( "test-instance", worker, @@ -19,12 +28,33 @@ async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> any None, None, false, + draining, db, ) .await?; Ok(()) } +/// A worker re-registers mid-life once its external IP resolves. That registration must not +/// clear a draining flag the same process already published; only a worker starting up under +/// a reclaimed name resets it. +#[sqlx::test] +async fn only_a_starting_worker_resets_draining(db: Pool) -> anyhow::Result<()> { + let draining = || async { + sqlx::query_scalar::<_, bool>("SELECT draining FROM worker_ping WHERE worker = 'wk-drain'") + .fetch_one(&db) + .await + }; + register(&db, "wk-drain", None, Some(false)).await?; + set_worker_draining_query("wk-drain", &db).await?; + register(&db, "wk-drain", Some("1.2.3.4"), None).await?; + assert!(draining().await?); + + register(&db, "wk-drain", None, Some(false)).await?; + assert!(!draining().await?); + Ok(()) +} + /// The external IP resolves in the background, so the initial ping often has none yet. That must /// not blank the address a previous process wrote to the row this one reclaims — worker names are /// stable across restarts under EXIT_AFTER_N_JOBS. diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index dce6a0ff72..b636e11a1c 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -996,6 +996,7 @@ pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uui ["job_metrics", "set_progress", ..] => (http_method == "POST", 2), ["job_metrics", "get_progress", ..] => (http_method == "GET", 2), ["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls", ..] => (http_method == "GET", 2), + ["jobs", "worker_is_draining", ..] => (http_method == "GET", 2), ["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3), ["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3), _ => return false, diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index c3da2f3fb9..2c5d2ee78e 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -702,3 +702,51 @@ async fn test_list_filtered_job_uuids_resolved_excludes_queue( Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_worker_is_draining_follows_the_job_worker(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag, running, started_at, worker) + VALUES ($1, 'test-workspace', now(), 'deno', true, now(), 'wk-drain-test')", + ) + .bind(job) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO worker_ping (worker, worker_instance, ip, worker_group, wm_version) + VALUES ('wk-drain-test', 'host', 'NO IP', 'default', 'v0')", + ) + .execute(&db) + .await?; + + let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/worker_is_draining/{job}"); + let draining = || async { + let resp = authed(client().get(&url)).send().await?; + anyhow::Ok(resp.json::().await?) + }; + assert!(!draining().await?); + + windmill_common::worker::set_worker_draining_query("wk-drain-test", &db).await?; + assert!(draining().await?); + + // A parked job keeps its last worker on the queue row without running on it. + sqlx::query("UPDATE v2_job_queue SET started_at = NULL WHERE id = $1") + .bind(job) + .execute(&db) + .await?; + assert!(!draining().await?); + + Ok(()) +} diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index 0102f1b033..cf0b85609a 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -86,6 +86,7 @@ struct WorkerPing { job_isolation: Option, #[serde(skip_serializing_if = "Option::is_none")] native_mode: Option, + draining: bool, } // #[derive(Serialize, Deserialize)] @@ -118,7 +119,7 @@ async fn list_worker_pings( WorkerPing, "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed, CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id, - custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode + custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation, native_mode, draining FROM worker_ping WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval) ORDER BY ping_at desc LIMIT $2 OFFSET $3", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 9173eae28c..5e635e39bd 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -17322,6 +17322,27 @@ paths: schema: type: string + /w/{workspace}/jobs/worker_is_draining/{id}: + get: + summary: whether the worker running this job is draining + description: | + True once the worker running the job has received its shutdown signal. The worker + still waits for the job to finish, so a long-running job can poll this to exit early. + False when the job is not currently running on a worker. + operationId: workerIsDraining + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: whether the worker running this job is draining + content: + application/json: + schema: + type: boolean + /w/{workspace}/jobs_u/get_logs/{id}: get: summary: get job logs @@ -35901,6 +35922,9 @@ components: type: string native_mode: type: boolean + draining: + type: boolean + description: the worker received its shutdown signal and exits once its current job is done required: - worker - worker_instance diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 7d700c4ab6..5b0da21c50 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -378,6 +378,10 @@ pub fn workspaced_service() -> Router { "/wac_approval_urls/{job_id}/{step_key}", get(get_wac_approval_urls).layer(cors.clone()), ) + .route( + "/worker_is_draining/{id}", + get(worker_is_draining).layer(cors.clone()), + ) .route( "/result_by_id/{job_id}/{node_id}", get(get_result_by_id).layer(cors.clone()), @@ -613,6 +617,53 @@ async fn get_root_job( Ok(Json(res)) } +/// Whether the worker running this job has received its shutdown signal. A draining worker +/// never interrupts its job, so a long-running script polls this to exit on its own terms. +async fn worker_is_draining( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> windmill_common::error::JsonResult { + // A job always may ask about itself. Anyone else must be able to see the job: the + // caller's row policies on v2_job decide, narrowed by the token's tag filter. + let draining = if authed.job_id == Some(id) { + sqlx::query_scalar!( + "SELECT wp.draining FROM v2_job_queue q JOIN worker_ping wp ON wp.worker = q.worker + WHERE q.id = $1 AND q.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL", + id, + w_id + ) + .fetch_optional(&db) + .await? + } else { + let tags = get_scope_tags(&authed) + .map(|tags| tags.into_iter().map(str::to_string).collect::>()); + let mut tx = user_db.begin(&authed).await?; + let draining = sqlx::query_scalar!( + "SELECT wp.draining FROM v2_job j + JOIN v2_job_queue q ON q.id = j.id + JOIN worker_ping wp ON wp.worker = q.worker + WHERE j.id = $1 AND j.workspace_id = $2 AND q.running AND q.started_at IS NOT NULL + AND ($3::text[] IS NULL OR j.tag = ANY($3))", + id, + w_id, + tags.as_deref() + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + draining + } + .unwrap_or(false); + windmill_common::feature_usage::log_feature_usage( + "worker_draining", + "check", + if draining { "draining" } else { "not_draining" }, + ); + Ok(Json(draining)) +} + async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result { let root_job = sqlx::query_scalar!( r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#, diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index e185d1d445..52fa2da905 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1952,6 +1952,7 @@ pub enum PingType { MainLoop, Job, InitScript, + Draining, } #[derive(Serialize, Deserialize)] pub struct Ping { @@ -1974,6 +1975,8 @@ pub struct Ping { pub occupancy_rate_30m: Option, pub job_isolation: Option, pub native_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draining: Option, pub ping_type: PingType, } pub async fn update_ping_http( @@ -2026,6 +2029,7 @@ pub async fn update_ping_http( insert_ping.memory, insert_ping.job_isolation, insert_ping.native_mode.unwrap_or(false), + insert_ping.draining, db, ) .await?; @@ -2054,10 +2058,21 @@ pub async fn update_ping_http( ) .await? } + PingType::Draining => set_worker_draining_query(worker_name, db).await?, } Ok(()) } +pub async fn set_worker_draining_query(worker_name: &str, db: &DB) -> anyhow::Result<()> { + sqlx::query!( + "UPDATE worker_ping SET draining = true WHERE worker = $1", + worker_name + ) + .execute(db) + .await?; + Ok(()) +} + #[derive(Debug, Serialize, Deserialize)] pub struct JobCancelled { pub canceled_by: String, @@ -2156,6 +2171,9 @@ pub async fn fetch_raw_script_from_app_query( /// `started_at` and `jobs_executed` are the only two columns carried over, being the /// continuity itself — plus `ip` for as long as `ip` is `None`, which means the external IP /// lookup has not resolved yet and the predecessor's address is still the best guess. +/// +/// `draining` is `Some(false)` from a worker starting up and `None` from one re-registering +/// mid-life, which must leave alone a flag its own shutdown may have set in the meantime. pub async fn insert_ping_query( worker_instance: &str, worker_name: &str, @@ -2169,6 +2187,7 @@ pub async fn insert_ping_query( memory: Option, job_isolation: Option, native_mode: bool, + draining: Option, db: &DB, ) -> anyhow::Result { // A NULL `ip` means the external IP lookup is still in flight; a later ping fills it in, and @@ -2176,8 +2195,8 @@ pub async fn insert_ping_query( // lookup that has failed reports `external_ip::UNRETRIEVABLE_IP`, which does overwrite it. The // literal below must stay equal to `external_ip::UNKNOWN_IP`. let previous_jobs_executed = sqlx::query_scalar!( - "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker) - DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL + "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, draining) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13, false)) ON CONFLICT (worker) + DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL, draining = COALESCE($13, worker_ping.draining) RETURNING jobs_executed", worker_instance, worker_name, @@ -2191,6 +2210,7 @@ pub async fn insert_ping_query( memory, job_isolation.as_deref(), native_mode, + draining, ) .fetch_one(db) .await?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 01523eba59..745f202826 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -643,6 +643,7 @@ pub async fn update_worker_ping_for_failed_init_script( wm_memory_usage: None, job_isolation: None, native_mode: None, + draining: None, ping_type: PingType::InitScript, }, ) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 2acafa539f..d0d5d8b4e2 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -155,7 +155,7 @@ use crate::{ handle_app_dependency_job, handle_dependency_job, handle_flow_dependency_job, tally_unfinished_dependency_deploy, }, - worker_utils::{insert_ping, queue_vacuum, update_worker_ping_full}, + worker_utils::{insert_ping, mark_worker_draining, queue_vacuum, update_worker_ping_full}, }; #[cfg(feature = "rust")] @@ -2713,7 +2713,7 @@ pub async fn run_worker( let mut last_ping = Instant::now() - Duration::from_secs(NUM_SECS_PING + 1); let mut reported_ip = cached_ip(); - let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, conn) + let previous_jobs_executed = insert_ping(hostname, &worker_name, reported_ip, true, conn) .await .expect("initial ping could be sent"); @@ -3160,6 +3160,31 @@ pub async fn run_worker( let mut killpill_rx2 = killpill_rx.resubscribe(); + // The loop below only sees the killpill once the running job is done, which is too late + // for that job to learn its worker is going away. + { + let mut killpill_rx = killpill_rx.resubscribe(); + let conn = conn.clone(); + let worker_name = worker_name.clone(); + tokio::spawn(async move { + if matches!( + killpill_rx.recv().await, + Err(broadcast::error::RecvError::Closed) + ) { + return; + } + for attempt in 1..=3 { + match mark_worker_draining(&conn, &worker_name).await { + Ok(()) => return, + Err(e) => { + tracing::warn!(worker = %worker_name, "failed to mark worker as draining (attempt {attempt}/3): {e:#}"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + }); + } + loop { let last_processing_duration_secs = last_processing_duration.load(Ordering::SeqCst); if last_processing_duration_secs > 5 { @@ -3235,7 +3260,7 @@ pub async fn run_worker( // which costs at most the last job's id here: no job of this worker is in flight at this // point in the loop, and the next one refills them. if ip_just_resolved && conn.as_sql().is_none() { - if let Err(e) = insert_ping(hostname, &worker_name, ip, &conn).await { + if let Err(e) = insert_ping(hostname, &worker_name, ip, false, &conn).await { tracing::warn!( worker = %worker_name, hostname = %hostname, "failed to re-register with the resolved external IP: {e}" diff --git a/backend/windmill-worker/src/worker_utils.rs b/backend/windmill-worker/src/worker_utils.rs index 3a313e9360..cabf004f4d 100644 --- a/backend/windmill-worker/src/worker_utils.rs +++ b/backend/windmill-worker/src/worker_utils.rs @@ -7,9 +7,9 @@ use windmill_common::{ external_ip::UNKNOWN_IP, worker::{ get_memory, get_vcpus, get_windmill_memory_usage, get_worker_memory_usage, - insert_ping_query, update_job_ping_query, update_worker_ping_from_job_query, - update_worker_ping_main_loop_query, Connection, Ping, PingType, NATIVE_MODE_RESOLVED, - WORKER_CONFIG, WORKER_GROUP, + insert_ping_query, set_worker_draining_query, update_job_ping_query, + update_worker_ping_from_job_query, update_worker_ping_main_loop_query, Connection, Ping, + PingType, NATIVE_MODE_RESOLVED, WORKER_CONFIG, WORKER_GROUP, }, KillpillSender, DB, }; @@ -164,6 +164,7 @@ async fn update_worker_ping_full_inner( wm_memory_usage: get_windmill_memory_usage(), job_isolation: None, native_mode: Some(native_mode), + draining: None, ping_type: PingType::MainLoop, }, ) @@ -179,6 +180,7 @@ pub async fn insert_ping( worker_instance: &str, worker_name: &str, ip: Option<&str>, + reset_draining: bool, db: &Connection, ) -> anyhow::Result { let (tags, dw, dws, native_mode) = { @@ -224,6 +226,7 @@ pub async fn insert_ping( memory, job_isolation, native_mode, + reset_draining.then_some(false), db, ) .await; @@ -256,6 +259,7 @@ pub async fn insert_ping( wm_memory_usage: get_windmill_memory_usage(), job_isolation, native_mode: Some(native_mode), + draining: reset_draining.then_some(false), ping_type: PingType::Initial, }, ) @@ -314,6 +318,7 @@ pub async fn update_worker_ping_from_job( &Ping { last_job_executed: Some(job_id.clone()), last_job_workspace_id: Some(w_id.to_string()), + draining: None, ping_type: PingType::Job, worker_instance: None, ip: None, @@ -342,6 +347,45 @@ pub async fn update_worker_ping_from_job( Ok(()) } +/// Publishes on the worker's ping row that it has received its killpill, so the job it is +/// still running can find out and exit early. +pub async fn mark_worker_draining(conn: &Connection, worker_name: &str) -> anyhow::Result<()> { + match conn { + Connection::Sql(db) => set_worker_draining_query(worker_name, db).await, + Connection::Http(client) => { + client + .post::( + UPDATE_PING_URL, + None, + &Ping { + last_job_executed: None, + last_job_workspace_id: None, + draining: None, + ping_type: PingType::Draining, + worker_instance: None, + ip: None, + tags: None, + dw: None, + dws: None, + version: None, + vcpus: None, + memory: None, + memory_usage: None, + wm_memory_usage: None, + jobs_executed: None, + occupancy_rate: None, + occupancy_rate_15s: None, + occupancy_rate_5m: None, + occupancy_rate_30m: None, + job_isolation: None, + native_mode: None, + }, + ) + .await + } + } +} + pub async fn ping_job_status( conn: &Connection, job_id: &Uuid, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index dc9541b4c5..e5b3619a27 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -839,6 +839,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -1674,6 +1682,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2615,6 +2631,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -4620,6 +4644,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/frontend/src/routes/(root)/(logged)/workers/+page.svelte b/frontend/src/routes/(root)/(logged)/workers/+page.svelte index f7e0f07a54..0fb9243276 100644 --- a/frontend/src/routes/(root)/(logged)/workers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workers/+page.svelte @@ -1022,7 +1022,7 @@ {#if workers} - {#each workers as { worker, custom_tags, last_ping, started_at, jobs_executed, last_job_id, last_job_workspace_id, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, occupancy_rate, wm_version, vcpus, memory, memory_usage, wm_memory_usage, native_mode }} + {#each workers as { worker, custom_tags, last_ping, started_at, jobs_executed, last_job_id, last_job_workspace_id, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, occupancy_rate, wm_version, vcpus, memory, memory_usage, wm_memory_usage, native_mode, draining }} {@const isWorkerAlive = isWorkerMaybeAlive(last_ping)} {@const tagMismatchInfo = getTagMismatchInfo( custom_tags, @@ -1087,6 +1087,15 @@ {#if native_mode} Native {/if} + {#if draining} + + Draining + {#snippet text()} + This worker received a shutdown signal. It no longer pulls jobs + and exits once its current job is done. + {/snippet} + + {/if}
{#if !config || !config.worker_tags || config.worker_tags.length === 0} diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index ffcb6a3fe1..97da60aadc 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -823,6 +823,22 @@ class Windmill: else: return r.json() + def worker_is_draining(self, job_id: Optional[str] = None) -> bool: + """Whether the worker running the job has received its shutdown signal. + + A draining worker waits for its current job to finish before exiting. A long-running + script can poll this and return early so the worker can be replaced. + + Args: + job_id: Job ID (defaults to current WM_JOB_ID) + + Returns: + True if the worker running the job is draining + """ + job_id = job_id or os.environ.get("WM_JOB_ID") + + return self.get(f"/w/{self.workspace}/jobs/worker_is_draining/{job_id}").json() + def set_flow_user_state(self, key: str, value: Any) -> None: """Set the user state of a flow at a given key""" flow_id = self.get_root_job_id() @@ -2027,6 +2043,17 @@ def get_progress(job_id: Optional[str] = None) -> Any: return _client.get_progress(job_id) +@init_global_client +def worker_is_draining(job_id: Optional[str] = None) -> bool: + """ + Whether the worker running this job has received its shutdown signal. + + A draining worker waits for its current job to finish before exiting. A long-running + script can poll this and return early so the worker can be replaced. + """ + return _client.worker_is_draining(job_id) + + def set_shared_state_pickle(value: Any, path="state.pickle") -> None: """ Set the state in the shared folder using pickle diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 00e5655154..2a5e48d5fe 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1724,6 +1724,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2396,6 +2404,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index cb5ae40490..14ca869180 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1639,6 +1639,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state @@ -2311,6 +2319,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 4241d49efa..13a93ed063 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -240,6 +240,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 47b0be888d..55df35ccf7 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -196,6 +196,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 21b2f382c3..6ad44db068 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -392,6 +392,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index f9e902fe78..a5f599a946 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -404,6 +404,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index 71608cd16e..ce0c610da7 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -394,6 +394,14 @@ async setProgress(percent: number, jobId?: any): Promise */ async getProgress(jobId?: any): Promise +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +async workerIsDraining(jobId?: string): Promise + /** * Set a flow user state * @param key key of the state diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index decb356ba6..4946ed0f6e 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -448,6 +448,18 @@ def set_progress(value: int, job_id: Optional[str] = None) # Progress value (0-100) or None if not set def get_progress(job_id: Optional[str] = None) -> Any +# Whether the worker running the job has received its shutdown signal. +# +# A draining worker waits for its current job to finish before exiting. A long-running +# script can poll this and return early so the worker can be replaced. +# +# Args: +# job_id: Job ID (defaults to current WM_JOB_ID) +# +# Returns: +# True if the worker running the job is draining +def worker_is_draining(job_id: Optional[str] = None) -> bool + # Set the user state of a flow at a given key def set_flow_user_state(key: str, value: Any) -> None diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index 92e11640f3..41a8eac37e 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -22,6 +22,6 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, workerIsDraining, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI } from "./client";' >> "${script_dirpath}/src/index.ts" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 127afe2154..f927acd26e 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -46,7 +46,7 @@ cp "${script_dirpath}/sqlUtils.ts" "${script_dirpath}/src/" echo "" >> "${script_dirpath}/src/index.ts" echo 'export type { DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts" echo "" >> "${script_dirpath}/src/index.ts" -echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type TaskRetry, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" +echo 'export { type Base64, setClient, getWorkspace, workerHasInternalServer, SHARED_FOLDER, getStatePath, getInternalState, setInternalState, getResumeEndpoints, getResult, getResultMaybe, resolveDefaultResource, databaseUrlFromResource, base64ToUint8Array, uint8ArrayToBase64, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, workerIsDraining, getState, getIdToken, denoS3LightClientSettings, cancelJob, loadS3FileStream, loadS3File, writeS3File, deleteS3File, signS3Objects, signS3Object, getPresignedS3PublicUrls, getPresignedS3PublicUrl, task, taskScript, taskFlow, workflow, step, sleep, parallel, waitForApproval, getApprovalUrls, type TaskOptions, type TaskRetry, type Jsonified, type JsonifiedFn, WorkflowCtx, _workflowCtx, setWorkflowCtx, StepSuspend, runScript, runScriptAsync, runScriptByPath, runScriptByHash, runScriptByPathAsync, runScriptByHashAsync, type RunScriptAsyncOptions, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, type Sql, requestInteractiveTeamsApproval, appendToResultStream, streamResult, datatable, ducklake, upsertPartition, appendPartition, type DucklakeMaterializeOptions, type SqlStatement, type DatatableSqlTemplateFunction, type SqlTemplateFunction, type S3Object, type S3ObjectRecord, type S3ObjectURI, commitKafkaOffsets } from "./client";' >> "${script_dirpath}/src/index.ts" # Build default export by combining client utilities + services # This preserves backward compatibility for `import wmill from "windmill-client"` @@ -63,6 +63,7 @@ import { setState, setProgress, getProgress, + workerIsDraining, getState, getIdToken, denoS3LightClientSettings, @@ -153,6 +154,7 @@ const wmill = { setState, setProgress, getProgress, + workerIsDraining, getState, getIdToken, denoS3LightClientSettings, diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 4ec07d6fa6..3f7b03117a 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -724,6 +724,19 @@ export async function getProgress(jobId?: any): Promise { }); } +/** + * Whether the worker running this job has received its shutdown signal. + * A draining worker waits for its current job to finish before exiting, so a + * long-running script can poll this and return early to let the worker be replaced. + * @param jobId? Job to check, defaults to the current job + */ +export async function workerIsDraining(jobId?: string): Promise { + return await JobService.workerIsDraining({ + id: jobId ?? getEnv("WM_JOB_ID") ?? "NO_JOB_ID", + workspace: getWorkspace(), + }); +} + /** * Set a flow user state * @param key key of the state From 2758920f1182ddd12e6473a644a4a966a435522e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 13:02:26 +0200 Subject: [PATCH 16/27] fix: sync back volume files rewritten to the same size (#11620) * fix: upload volume files rewritten to the same size Co-Authored-By: Claude Opus 5.5 * fix: never match a volume file stamp with an unreadable change time Co-Authored-By: Claude Opus 5.5 * chore: bump ee ref Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 127575148c84bb6132d2946c0a95c6dea0f58bb2 This commit updates the EE repository reference after PR #860 was merged in windmill-ee-private. Previous ee-repo-ref: e06755ddd48c834e75537cf44da017fdc3a9585b New ee-repo-ref: 127575148c84bb6132d2946c0a95c6dea0f58bb2 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-worker-volumes/src/lib.rs | 34 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ff44a56995..14bd926cf5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -55feb2e84332179faa4e17aeb419f7f480df79a6 +127575148c84bb6132d2946c0a95c6dea0f58bb2 diff --git a/backend/windmill-worker-volumes/src/lib.rs b/backend/windmill-worker-volumes/src/lib.rs index e2e4ab866b..364795510a 100644 --- a/backend/windmill-worker-volumes/src/lib.rs +++ b/backend/windmill-worker-volumes/src/lib.rs @@ -54,11 +54,45 @@ pub struct VolumeMount { pub target: String, } +/// A file's size and last change time. Sync-back trusts a pulled file's stored hash only while +/// its stamp is the one taken at hand-off: size alone misses a rewrite of the same length. A +/// write within the filesystem's timestamp granularity of the hand-off is not seen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FileStamp { + size: u64, + changed: Option<(i64, i64)>, +} + +impl FileStamp { + /// Whether the file is as it was at `earlier`. An unreadable change time never matches. + pub fn unchanged_since(&self, earlier: &FileStamp) -> bool { + self.changed.is_some() && self == earlier + } + + pub fn of(meta: &std::fs::Metadata) -> Self { + // ctime rather than mtime: a tool that restores mtime (cp -p, tar, rsync -t) cannot set it. + #[cfg(unix)] + let changed = { + use std::os::unix::fs::MetadataExt; + Some((meta.ctime(), meta.ctime_nsec())) + }; + #[cfg(not(unix))] + let changed = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| (d.as_secs() as i64, d.subsec_nanos() as i64)); + Self { size: meta.len(), changed } + } +} + pub struct VolumeState { pub mount: VolumeMount, pub local_dir: PathBuf, pub manifest: HashMap, pub symlinks: HashMap, + /// Stamp of each pulled file as the job received it, keyed like `manifest`. + pub stamps: HashMap, } pub struct DownloadStats { From a85d0b76cc494aaa4a484a273ec534bdc2958102 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 13:03:47 +0200 Subject: [PATCH 17/27] feat: add Pi template to the AI sandbox picker (#11619) Co-authored-by: Claude Opus 5.5 --- frontend/src/lib/components/EditorBar.svelte | 1 + .../src/lib/components/ScriptBuilder.svelte | 22 +++++- .../src/lib/components/ScriptEditor.svelte | 1 + .../common/languageIcons/LanguageIcon.svelte | 33 ++++++++- .../flows/content/FlowInputs.svelte | 12 +++ .../flows/content/FlowInputsQuick.svelte | 73 +++++++++--------- .../components/flows/flowStateUtils.svelte.ts | 2 +- .../flows/map/InsertModuleInner.svelte | 28 ++++--- .../flows/pickers/FlowScriptPicker.svelte | 2 +- .../pickers/FlowScriptPickerQuick.svelte | 2 +- .../flows/pickers/TopLevelNode.svelte | 1 + .../components/graph/graphBuilder.svelte.ts | 2 +- frontend/src/lib/components/script_builder.ts | 1 + frontend/src/lib/script_helpers.ts | 8 ++ .../src/lib/templates/pi_sandbox.ts.template | 74 +++++++++++++++++++ 15 files changed, 208 insertions(+), 54 deletions(-) create mode 100644 frontend/src/lib/templates/pi_sandbox.ts.template diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 81ef6c1c96..2db471e2c8 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -105,6 +105,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index ad869ed492..f69cf37f1d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -596,6 +596,7 @@ | 'powershell' | 'bunnative' | 'claudesandbox' + | 'pisandbox' | 'wac_python' | 'wac_typescript' | 'ci_test_bun' @@ -1543,10 +1544,12 @@ (lang == script.language && template != 'bunnative' && template != 'docker' && - template != 'claudesandbox') || + template != 'claudesandbox' && + template != 'pisandbox') || (template == 'bunnative' && lang == 'bunnative') || (template == 'docker' && lang == 'docker') || - (template == 'claudesandbox' && lang == 'bun')} + ((template == 'claudesandbox' || template == 'pisandbox') && + lang == 'bun')} @@ -1597,6 +1600,21 @@ > Claude Sandbox +
{/if} + {#if lang === 'pisandbox'} +
+ +
+ {/if}
diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index f86d870a1a..41d860530a 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -291,6 +291,18 @@ }) }} /> + { + dispatch('new', { + language: 'bun', + kind, + subkind: 'pisandbox', + summary + }) + }} + />
{/if} diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index 0b78193c6a..ecb3187c5c 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -267,28 +267,33 @@ ) let aiLength = $derived(showAiRows ? 2 : 0) - // Must match the heading and label rendered for the AI Sandbox section const aiSandboxHeading = 'AI Sandbox' - const aiSandboxLabel = 'Claude Code' - let matchesAiSandbox = $derived.by(() => { + const aiSandboxes = [ + { label: 'Claude Code', subkind: 'claudesandbox' }, + { label: 'Pi', subkind: 'pisandbox' } + ] as const + // A query matching the heading keeps every sandbox, otherwise only the ones it names. + let matchingAiSandboxes = $derived.by(() => { const query = funcDesc?.trim().toLowerCase() ?? '' - if (query.length == 0) { - return true - } - return [aiSandboxHeading, aiSandboxLabel].some((term) => { + const matches = (term: string) => { const lowered = term.toLowerCase() return lowered.startsWith(query) || lowered.split(' ').some((w) => w.startsWith(query)) - }) + } + if (query.length == 0 || matches(aiSandboxHeading)) { + return aiSandboxes + } + return aiSandboxes.filter((s) => matches(s.label)) }) - // Gates the AI Sandbox row and its slot in the index space; both must agree or arrow keys land - // on an index that renders nothing. - let showAiSandbox = $derived( + // Gates the AI Sandbox rows and their slots in the index space; both must agree or arrow keys + // land on an index that renders nothing. + let aiSandboxRows = $derived( selectedKind === 'script' && preFilter === 'all' && !selected && customUi?.aiSandbox != false && - !$operatorBuilderFlows && - matchesAiSandbox + !$operatorBuilderFlows + ? matchingAiSandboxes + : [] ) // Hub runnables carry code the workspace never reviewed, and the backend refuses them in a @@ -301,7 +306,7 @@ let aiOffset = $derived(inlineOffset + (inlineScripts?.length ?? 0)) let workspaceOffset = $derived(aiOffset + aiLength) let aiSandboxOffset = $derived(workspaceOffset + (filteredWorkspaceItems?.length ?? 0)) - let hubOffset = $derived(aiSandboxOffset + (showAiSandbox ? 1 : 0)) + let hubOffset = $derived(aiSandboxOffset + aiSandboxRows.length) @@ -530,27 +535,29 @@ {/await}
{/if} - {#if showAiSandbox} + {#if aiSandboxRows.length > 0}
{aiSandboxHeading}
- hover(aiSandboxOffset)} - enterpriseLangs={[]} - label={aiSandboxLabel} - lang="claudesandbox" - on:click={() => { - dispatch('new', { - kind: selectedKind, - inlineScript: { - language: 'bun', + {#each aiSandboxRows as sandbox, i (sandbox.subkind)} + hover(aiSandboxOffset + i)} + enterpriseLangs={[]} + label={sandbox.label} + lang={sandbox.subkind} + on:click={() => { + dispatch('new', { kind: selectedKind, - subkind: 'claudesandbox', - summary - } - }) - }} - /> + inlineScript: { + language: 'bun', + kind: selectedKind, + subkind: sandbox.subkind, + summary + } + }) + }} + /> + {/each} {/if} {#if showHub && selectedKind != 'preprocessor' && selectedKind != 'flow'} {#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')} diff --git a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts index 9d85a07452..030897a63b 100644 --- a/frontend/src/lib/components/flows/flowStateUtils.svelte.ts +++ b/frontend/src/lib/components/flows/flowStateUtils.svelte.ts @@ -108,7 +108,7 @@ export async function pickFlow( export async function createInlineScriptModule( language: RawScript['language'], kind: Script['kind'], - subkind: 'pgsql' | 'flow' | 'claudesandbox' | undefined, + subkind: 'pgsql' | 'flow' | 'claudesandbox' | 'pisandbox' | undefined, id: string, summary?: string ): Promise<[FlowModule, FlowModuleState]> { diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 9cf4bcd264..62ec111b6c 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -117,17 +117,21 @@ logReusableAgentUsage('linked') dispatch('new', { kind: 'aiagent', agentPath }) } - function newClaudeSandbox() { + function newAiSandbox(subkind: 'claudesandbox' | 'pisandbox') { dispatch('close') dispatch('new', { kind: 'script', inlineScript: { language: 'bun', kind: 'script', - subkind: 'claudesandbox' + subkind } }) } + const aiSandboxes = [ + { label: 'Claude Code', subkind: 'claudesandbox' }, + { label: 'Pi', subkind: 'pisandbox' } + ] as const // Rows of the AI panes in render order. FlowInputsQuick owns the keyboard index for the other // panes and is unmounted while these render, so the two never fight over Enter or the arrows. @@ -137,7 +141,7 @@ return [newBlankAgent, ...saved.map((a) => () => newLinkedAgent(a.path))] } if (selectedKind === 'aisandbox') { - return [newClaudeSandbox] + return aiSandboxes.map((s) => () => newAiSandbox(s.subkind)) } return [] }) @@ -418,14 +422,16 @@
{:else if selectedKind === 'aisandbox'}
- (selectedByKeyboard = 0)} - /> + {#each aiSandboxes as sandbox, i (sandbox.subkind)} + newAiSandbox(sandbox.subkind)} + onHover={() => (selectedByKeyboard = i)} + /> + {/each}
{:else} ", + // set the API key env var it reads, and name it in `model` below. + process.env.ANTHROPIC_API_KEY = anthropic.apiKey; + const models = createModels(); + models.setProvider(anthropicProvider()); + const model = { provider: "anthropic", modelId: "claude-opus-5-5" }; + + // read, write, edit and bash, running in this job's sandbox + const registry = createRegistry(); + registry.install(CodingTools); + + const context = BACKGROUND_CONTEXT; + + // The conversation is stored in the volume, so each run continues the previous one. + const harness = await Harness.open( + await openNodeJsonlStorage(".pi/session", context), + { models, registry, env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? process.cwd() }) }, + context, + ); + + try { + const root = await harness.root(context); + await root.configure({ model, instructions: instructions ?? null }, context); + + const isResume = (await root.viewState(context)).value.entries.length > 0; + + // you can hardcode the prompt or pass it as input. + const prompt = !isResume ? + "What is the fastest OSS workflow engine?" : + "What did I ask you before?" + + const events = await watchEvents(harness, root.id, context); + events.start(async (batch) => { + for (const event of batch) { + if (event.type === "tool_execution_start") { + console.log(`${event.toolName} ${JSON.stringify(event.args)}`); + } + } + }); + + const submission = await root.submit({ type: "input", content: prompt }, context); + const settled = await submission.wait(context); + if (settled.status !== "done" || settled.type !== "input") { + throw new Error(`Pi did not answer: ${JSON.stringify(settled)}`); + } + + const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context); + const response = (answer?.model?.[0]?.content ?? []) + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join(""); + + return { + is_resume: isResume, + prompt, + response, + usage: (await harness.usage(context)).models, + }; + } finally { + await harness.close(context); + } +} From 3dae553576ba23b2c1f56e4deac01305c4645c01 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:23:29 +0200 Subject: [PATCH 18/27] feat: pipeline editor: assets-only view, Fix actions and draft triggers (#11440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: one sidebar for operators and developers, with a detach mode Co-Authored-By: Claude Opus 5 (1M context) * feat: snap-detach the operator sidebar, and join the handle to the picker Dragging the sidebar into the left snap zone detaches it for an operator, who has no icon-only rail to collapse into; the width the drag started from is what gets persisted, so re-attaching restores the sidebar's size. The handle's colour pastille slides onto the card's workspace pastille as the card opens, alongside the workspace name, and both buttons that attach or detach the sidebar carry a tooltip. Co-Authored-By: Claude Opus 5 (1M context) * feat: attach and detach from the sidebar header, open the card under its handle The button that swaps the sidebar between docked and detached moves to the header, next to the workspace name, so it stays in one place across the swap; the icon-only rail has no room for it and keeps the footer copy. The handle itself becomes a component of its own, carries the workspace icon its picker uses, and opens the card rather than docking — docking is the header button's job. The card is placed under the handle measured at open time instead of at a fixed offset. Co-Authored-By: Claude Opus 5 (1M context) * feat: one page header across routes, with the workspace picker in it Every route now wears the same bar above its content: the sidebar toggle, the workspace picker, and whatever the route registers — an item's breadcrumb and summary, or a section name — with the route's own buttons at the far end. The bar's row is level with the sidebar's own top row, which the workspace ⇄ sessions switch now owns, so the two read as one band. A route fills the bar by mounting PageHeaderContent, which registers a getter rather than a snapshot: the layout calls it while rendering, so a path or summary the page edits reaches the bar without an effect keeping two copies in step. Nothing registers yet; the routes follow. Co-Authored-By: Claude Opus 5 (1M context) * feat: detail pages fill the page header, which now spans above the sidebar A script or flow detail page no longer draws a bar of its own: its path becomes the header's breadcrumb, its summary keeps the rename-and-labels popover next to it, and its badges, menu and buttons ride along as the header's actions. The band runs the full width with the sidebar hanging below it. A snippet rendered in the header initialises its components under the header, not under the page that wrote it, so anything the page's tree provides is lost — the detail page's buttons read TriggerContext and threw. ContextBridge carries the entries a registration names back around the render. The floating handle is gone with it: the header's own toggle is the one control, hovering it peeks the sidebar in and clicking pins it. The workspace picker in the header is a denser variant of the sidebar's, and the card's outside-click catcher starts at the card rather than the viewport top, so it no longer swallows clicks meant for the toggle. Co-Authored-By: Claude Opus 5 (1M context) * feat: one breadcrumb in the page header, and the banners that used to sit under it The header band now carries a single breadcrumb — workspace, fork, then the item's path — built from one component, so every segment has the same size, weight and icon size, and slashes separate them. The item's kind icon rides with its summary, after a dot, rather than with the path. The fork and drafts banners move into the band as badges plus a subtle Review button: ahead/behind counts instead of a bare change total, and the per-kind breakdown stays on the compare page. The home hero's CLI, MCP and Hub links move into an ellipsis menu, alongside the toggle that pins the session chat to the page. The flow editor and the app viewer hand their bar to the band too, and a route can drop the band's bottom border when its own content draws one — the border stays in the box with a transparent colour, so contents never shift a pixel across a navigation. Co-Authored-By: Claude Opus 5 (1M context) * feat: the runs page's own header row moves into the page header The title, the queue counters, the kind and status filters, the schedule and future-job switches, the search field and the timeframe picker all ride the band now, and the page starts at the chart — one row of vertical space back. The two toggle groups appear from 1500px: below that the bar cannot hold them next to the search field, and the same filtering is reachable from the search field's own kind and status chips. The search field is what gives width back, and the breadcrumb yields last, so a page's own name stays whole while the workspace and fork names truncate. The queue counters keep their icon form at every width, since the bar has room for two counters and never for the two labelled blocks. Their counts hang outside their icons, so the group carries the margins that keep a badge off its neighbour, and the suspended counter answers a hover at zero rather than sitting there as a dead icon. The band is 44px rather than 40, and a page can hand the breadcrumb a hint — the documentation tooltip that belongs with the page's name rather than with its buttons. Co-Authored-By: Claude Opus 5 (1M context) * feat: no border under the page header, and the peek card closes on the way out The band draws no bottom edge and no page asks it to, so `barBorder` goes with it — a page that needs the separation draws its own line, as the detail pages now do above the run form and the tab strip. With nothing toggling a border, the band is a flat 44px on every route. The detached sidebar's card is a hover affordance, so it leaves when the pointer does: off the handle and off the card both, on the same debounce a hover menu uses, because the two do not touch and the leave of one fires before the enter of the other. The mobile drawer is opened deliberately and still stays until it is dismissed. Co-Authored-By: Claude Opus 5 (1M context) * feat: one band, one workspace part, and the pickers that hang off it The band is a single element whose placement is styled rather than branched: a page registering a side-panel inset used to swap it between two `{#if}` arms, destroying the header and rebuilding it a frame later — a blink of no header with the content sliding up into the gap. The workspace and its fork become one breadcrumb part: the name leads home (inert in a session, where the workspace is the session's scope rather than a place to go) and one chevron opens the picker, which already expands a family to reach its forks. The picker's panel hangs from the part's left edge, opens with the active family expanded, and closes before the switch runs — the switch renames the workspace, which moves the trigger the panel hangs from, and floating-ui was chasing it across the viewport. The item's path is a plain label rather than a row of pickers: same separators and weight, and a click copies it with its own confirmation. The kind icon goes, the page below says what kind it is. Around them: a "prod" badge for a root workspace, the session's acting fork beside the session's name rather than in the breadcrumb, the edit-in-dev affordance as a button in the header, the drafts count as a sentence, and a fade when the workspace changes under a page that stays mounted. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep a page's own controls when the workspace navigation is hidden `menuHidden` means "this is an embed, don't show the workspace's navigation" — but Runs, the detail pages and the flow editor now keep their controls in the band and nowhere else, so hiding it took the filters, the timeframe and the page's buttons with it. A session's preview iframe opens exactly that way. The band now renders for an embed too, without its breadcrumb, so the page keeps its controls and the embed still gains no way to navigate the host's workspace. The flow editor's autosave indicator moves into the header's actions with the rest of its bar: saving state, the autosave toggle and other people's drafts were left behind in the branch the full-page editor no longer takes. Co-Authored-By: Claude Opus 5 (1M context) * fix: the header carries what it promised, and nothing it does not The registry's item described pickers the breadcrumb no longer has: nine fields — the editability flags, the change callbacks, the navigation hook — that nothing read, while the flow editor passed eight of them and quietly lost its inline rename. The type now says what the bar does: a path, and either a summary or the page's own control for one. The flow editor renames through that control, as the detail page already did. `barPlacement` went the same way: no page registered 'inline', so the layout's guard never fired. The sessions page registered an inset of 0 whenever its preview pane never mounted — the "no session selected" and opted-out arms — and 0 reads as "a page owns the right edge", floating the band over the page's first line. It registers an inset only once the pane has a width. The band's sidebar handle docks through `setDetached` like the card's own toggle, so docking still suppresses the rail's entry animation and closes the card; a summary without its breadcrumb no longer leads with a stray separator in an embed; a page's hint sits with its name whichever way the page named itself; and `Menu.closeInstantly` and `EditorHeader`'s `inline`/`hideSummary` go, their callers having been replaced. Co-Authored-By: Claude Opus 5 (1M context) * feat: a deployed app gets the viewport, and the header waits in the corner An app is the page, not a page inside Windmill's chrome, so the band leaves the flow and floats above the viewport until the handle in the app's corner calls it down. The handle is faded until the pointer nears it, hovering peeks the band over the app, and a click pins it — one flag for every deployed app, in this browser, because someone who wants the breadcrumb over a dashboard wants it over the next one too. Peeking overlays rather than pushes: the app is an iframe, and taking 44px off its box on every pass of the pointer would re-lay-out whatever it draws. Pinned, the band is in flow like any other route's and the app starts under it. The sidebar is untouched — docked stays docked, detached stays a peek — since the pin is about the header alone. A page says it wants the viewport with one registry field, so the layout keeps asking the registry rather than knowing about apps. Co-Authored-By: Claude Opus 5 (1M context) * feat: one corner control for the chrome an app page hides Hovering the button overlaid on a deployed app brings back whatever that page is hiding: the band always, and the sidebar card too when the sidebar is detached. The card hangs from this button rather than from the header's own handle, which is off-screen with the band. The handle in the band no longer attaches the sidebar — it reveals it and nothing more. A control that hides the thing it sits on cannot also be the way to bring it back, so attaching stays with the toggle in the sidebar's own footer, where detaching happens. The band's two buttons go in the order the things nest: the sidebar's handle, then the header's pin. The overlaid button stands where the pin will be once pinned — same 28px box, and a slot further along when the sidebar's handle takes the first — so pinning moves nothing. It wears the panel pair the sidebar's toggle uses: solid while the band is away, dashed while it is shown. Co-Authored-By: Claude Opus 5 (1M context) * feat: the access point sits in the corner, and the header covers it The button is in the page's top-left corner — 8px in, or just past the rail when the sidebar is docked — rather than shifted along to where the header's pin would land. Hovering brings the header down over it: an access point that the regular view covers once it arrives, so there is nothing to line up and nothing to move. Because the header lands on the button, the pointer leaves it the instant it arrives. The band holds both itself and the sidebar card open while the pointer is anywhere on it, or the card would retract under a header that stayed. Co-Authored-By: Claude Opus 5 (1M context) * feat: on an app page the floating button owns both, and they move together The band drops its sidebar handle there: the floating control in the corner reveals the sidebar along with the band, and two handles for one gesture is one too many. The two are then linked — the pointer resting on either holds both, and leaving either collapses both — so they behave as the single thing the one button revealed. The card meets the header in a line: it hangs from the header's bottom edge rather than from whichever control opened it, which had it starting a couple of pixels high. With no header to sit under — an embed — it still hangs from the control that reached for it. Co-Authored-By: Claude Opus 5 (1M context) * feat: the revealed sidebar is a panel under the header, not a card over the page No rounded corners, no inset from the window: it starts at the header's bottom edge and runs to the left and bottom edges, with one border down its right side and a shadow to lift it off the page. A card floating in from nowhere read as a separate thing; this reads as the sidebar the header sits on top of. Co-Authored-By: Claude Opus 5 (1M context) * fix: only an app page gets the flush panel; elsewhere the card still floats Over an app the sidebar reads as the panel the header sits on — flush left and bottom, square, one border down its right side. On every other page it is what it was: a card inset from the window with rounded corners and a border all round. Co-Authored-By: Claude Opus 5 (1M context) * fix: an app opens with the header already away Opening an app showed the header and then slid it out: the page claimed the viewport on mount, and the band animated from where it had been rendering. The slide belongs to the hover, not to arriving, so it is armed a frame after the claim — the first hide is a cut. The claim moves to the route, which mounts before the viewer waiting on the workspace to resolve. A cold load now paints the band away from the first frame instead of showing a header that leaves again. Co-Authored-By: Claude Opus 5 (1M context) * feat: an app's Edit sits with its name At the far end of the bar it was a journey across the window from the thing it edits — on a page whose header is only there while the pointer holds it, a journey with a time limit. It goes beside the app's path instead. The slot it uses is the one Runs' documentation tooltip already had, renamed from `hint` to `afterName` and documented for both: what belongs to the page's name, whether that is a mark or a control that acts on what the name points at. Co-Authored-By: Claude Opus 5 (1M context) * feat: developers collapse, operators detach One control each, rather than three for everyone. A developer switches the sidebar between icon-only and full width and never detaches it; an operator, who has no icon-only rail to collapse to, hides it instead — by the button in its footer or by the same drag that collapses it for a developer. The way back survives either way: a detached sidebar keeps its "Show sidebar" button for anyone, so a developer who detached before this was so is not left with a sidebar they cannot return. Co-Authored-By: Claude Opus 5 (1M context) * feat: configure pipeline triggers and outputs as drafts in the editor Co-Authored-By: Claude Opus 5.5 (1M context) * feat: one card for pipeline nodes, and the pipeline pages in the page header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop a removed node's trigger drafts, and confirm triggers when leaving Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show pipeline assets red when unused or awaited but never produced Co-Authored-By: Claude Opus 5.5 (1M context) * fix: route long pipeline edges around nodes in the target column, and flag scripts waiting on unproduced assets Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a page is as tall as it is given, not as tall as the screen The band takes 44px off the top of every route, so a page sized to the viewport overflows by exactly that much: h-screen inside a column that already lost the band's height. These pages measure the box they are in instead. CenteredModal gains `inPage` for the five pages that render it inside the layout rather than over it — the same modal, centred in what is left of the page rather than in the window. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band narrows, wraps, and draws its own edge The band publishes its width, so the pages filling it can answer it. Two tiers: under 1000px the breadcrumb keeps the workspace disc and its env badge and drops the name, and under PHONE_BAR (800) the trail stops yielding at 10rem — that floor is what makes the bar wrap, so a page's buttons take the line below rather than squeezing the name to an ellipsis. The band also draws the line between itself and the page, and only while something is scrolled under it. One capture listener on the window sees every scroller, since most pages scroll inside their own box and never move the layout's. An inset shadow, not a border: a border would move every page down a pixel the moment the line appeared. DetailPageLayout and InWorkspaceAppViewer drew that first line themselves; two lines would stack, so theirs go. Co-Authored-By: Claude Opus 5 (1M context) * feat: the script, app, runs and audit pages fill the band Each of these kept a header of its own above the page. They register with the band instead: the breadcrumb names what the page is on, and the page's buttons sit at the far end of the one row. They answer the band's width rather than the window's, since the buttons share that row with the breadcrumb. The script editor folds Diff and Settings into the menu it already shows when compact, the app editor drops the panel buttons, Awareness and Export and goes icon-only on Deploy and the mode switch, the flow editor menus its diff, and runs hides its counters. Runs also declares `actionsFlexible`: its search is the one control here that can give width back, and a box that refused to shrink would make it take its maximum instead. The run detail page loses its ActionRow, and the audit log its big title — the breadcrumb says where you are, and the page keeps its tooltip. Co-Authored-By: Claude Opus 5 (1M context) * feat: one sidebar column, shown as a rail or as a panel The rail and the drawer held two copies of the same column — the same sections, in the same order, in two pieces of markup, so a change to one silently skipped the other. Both now render `sidebarColumn`, and the shells keep only what differs: the rail its width, resize handle and collapse button, the panel its outside-click catcher and its transform. A phone gets that panel rather than a modal. There was a width test deciding between two presentations; the card under the band's handle works at any width, so it goes. What is left of the width test is the choice between a rail and a panel, plus a `(hover: hover)` test — the old `innerWidth >= 768` was standing in for "has a pointer", which is what decides whether the panel closes when the pointer leaves it. With the card everywhere, the backdrop, the `aria-modal` (nothing dims and nothing traps focus) and the drawer's close cross all go: the band's handle opens the panel and a tap outside closes it. The burger row goes with them. Entering panel width now collapses the rail transiently, so coming back to a wide window does not land on a rail the page was resized away from. The band also stops easing its right edge. That transition belongs to the rail, which moves the band's left; its right follows a splitter the page owns, and easing it left the band lying over a session's side panel — a blank strip across that panel's own header for 200ms after every drag. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home composer flies to the menu that restores it Removing the composer puts it away rather than throws it out, and the way back is one item in the band's menu — easy to miss on a page the composer has just vacated. A mark flies from the control that removed it to that menu, and the menu answers with one ring as the mark dissolves, so where it went is witnessed rather than explained. The cross and the menu item now read "Remove session chat", one name for one thing whichever a reader reaches for. Reduced motion skips both. Co-Authored-By: Claude Opus 5 (1M context) * fix: give pipeline asset nodes the trigger nodes' background again Co-Authored-By: Claude Opus 5.5 (1M context) * fix: taller pipeline nodes, with the asset + back on the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) * fix: judge dbt relations by provenance, keep dynamic_skip on reopen, refresh saved scripts' titles Co-Authored-By: Claude Opus 5.5 (1M context) * feat: label the pipeline + buttons: add data source, add downstream step on hover Co-Authored-By: Claude Opus 5.5 (1M context) * feat: name a schedule node's cadence, like "Every day at 12:00" Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the downstream-step pill open while its menu is, without flicker Co-Authored-By: Claude Opus 5.5 (1M context) * fix: room between pipeline node chips and the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse renaming a pipeline draft out of the pipeline's folder Co-Authored-By: Claude Opus 5.5 (1M context) * feat: title pipeline schedule nodes with the schedule's summary Co-Authored-By: Claude Opus 5.5 (1M context) * revert: keep operators out of AI sessions That change is its own PR; this one is about the navigation. `prefersSessionHandoff` takes the role again and every entry point passes it, the sessions page refuses an operator arriving by URL, and the layout leaves them on the docked chat. Two clauses are new rather than restored, because the nav rework is what gives an operator a sidebar at all: the Workspace/AI Sessions switch and session mode are both hidden from them, since the page behind them declines. `askAiShown` gains the operator case for the same reason — they keep the docked pane whatever the beta gate says, so the sidebar has to offer a way into it. Co-Authored-By: Claude Opus 5 (1M context) * feat: assets-only view of a pipeline, with producer and trigger chips Co-Authored-By: Claude Opus 5.5 (1M context) * fix: default schedule handlers, unique trigger draft paths, zoned cadence, valid script names Co-Authored-By: Claude Opus 5.5 (1M context) * fix: setup banner without icon, button actions, plainer activity rows Co-Authored-By: Claude Opus 5.5 (1M context) * feat: assets-only trigger chip under the title, editable and selectable Co-Authored-By: Claude Opus 5.5 (1M context) * fix: await schedule defaults before confirming, refuse colliding trigger renames Co-Authored-By: Claude Opus 5.5 (1M context) * feat: size assets-only cards to their content Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipeline draft sync status beside the folder name in edit mode Co-Authored-By: Claude Opus 5.5 (1M context) * fix: autosave edits to a deployed pipeline script from the first keystroke Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show schedule handlers once their defaults load, draft suffix on static chips Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the pipeline folder while the page tears down on leaving Co-Authored-By: Claude Opus 5.5 (1M context) * fix: discard the open pane's autosaved edits with Discard all Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipeline node context menus, add-at-cursor, and assets-only delete Co-Authored-By: Claude Opus 5.5 (1M context) * style: borderless pipeline cards on the main surface, like flow steps Co-Authored-By: Claude Opus 5.5 (1M context) * feat: remember which pipelines are in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) * feat: edit an asset's producing script from its pane and multi-script chip Co-Authored-By: Claude Opus 5.5 (1M context) * style: more room between pipeline layers for the bottom + button Co-Authored-By: Claude Opus 5.5 (1M context) * style: background on the trigger chip only when it opens an editor Co-Authored-By: Claude Opus 5.5 (1M context) * feat: assets-only delete in both modes, why it is blocked, and a Show scripts toggle Co-Authored-By: Claude Opus 5.5 (1M context) * fix: remove the script before its trigger, archive it for non-admins Co-Authored-By: Claude Opus 5.5 (1M context) * fix: scope assets-only memory by workspace, no delete for operators, flow reason Co-Authored-By: Claude Opus 5.5 (1M context) * style: fly in the pipeline node menus Co-Authored-By: Claude Opus 5.5 (1M context) * style: size the Show drafts toggle like the other header buttons Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a No asset node for scripts that build nothing, in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) * feat: describe a pipeline edge's relationship on hover Co-Authored-By: Claude Opus 5.5 (1M context) * fix: name assets-only edges by what is built from what Co-Authored-By: Claude Opus 5.5 (1M context) * feat: open an asset's upstream trigger from its pane header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reopen the edit-mode pane when coming back from View Co-Authored-By: Claude Opus 5.5 (1M context) * style: no red border on errored pipeline nodes Co-Authored-By: Claude Opus 5.5 (1M context) * feat: Fix actions on misconfigured pipeline nodes, and trigger toggles on edges Co-Authored-By: Claude Opus 5.5 (1M context) * feat: folded errors and edge toggles in assets-only view, preset writer wizard Co-Authored-By: Claude Opus 5.5 (1M context) * feat: flag pipeline scripts nothing starts, with schedule/webhook/remove fixes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: an unwritten subscription is fixed on its script only, by removing it Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide subscribed-only unwritten assets, review fixes for the edit pane and edge actions Co-Authored-By: Claude Opus 5.5 (1M context) * fix: an unwritten subscription is the subscriber's error, not the asset's Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a script only started by reads of unwritten assets is flagged too Co-Authored-By: Claude Opus 5.5 (1M context) * feat: no add-downstream + on assets nothing in the workspace writes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: Save all treats a draft identical to the deployed script as saved Co-Authored-By: Claude Opus 5.5 (1M context) * fix: remove-from-View opens its dialog, error chip fits, no link to an undrawn asset Co-Authored-By: Claude Opus 5.5 (1M context) * feat: drag a script's dot onto an asset to subscribe it; unbuilt asset opens its script Co-Authored-By: Claude Opus 5.5 (1M context) * feat: toggle to hide reads that don't trigger runs in the pipeline graph Co-Authored-By: Claude Opus 5.5 (1M context) * feat: drag an asset's + onto a script or the canvas; tell reactive assets-only edges apart Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a programmatic on/mute line joins the existing ones in the header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: address review — live-version save check, bounded writer lookups, script-only trigger writes, dbt sources, hidden pane Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review — compare every deployed field, passive-read filter only with its toggle, drag cancel, asset escape hatch Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep every control the band took over reachable Four controls the page-header conversion could put out of reach, each found by a review pass and reproduced in the browser: - The flow editor's preview buttons. `topbarWidth` is bound only in the branch a full-page editor does not render, so `compactTopbar` was permanently false there; below the 800px phone bar `phoneTopbar` dropped the buttons inline and the graph overlay never took them, leaving FlowPreviewButtons mounted nowhere and step testing, whole-flow testing and "Test flow & record" no-ops through optional calls. It now measures the header the way ScriptBuilder and AppEditorHeader do, and the inline and overlay conditions are exact complements. - The pin on a deployed app. The band lands over the corner handle that called it down, so the pointer that arrives there hits the band. The band carries the pin itself now, on the same pixels, in place of the sidebar handle it hides. - The detached sidebar's own menus. They portal to `body`, so moving into one left the card and closed it 200ms later, sliding it out from under the menu being read. The card holds while the pointer is in a menu and takes the close from the leave of that. - A deployed app's name for a read-only viewer. The breadcrumb was registered behind write access, so without it the trail fell back to the section name "Apps"; and the Edit button rode `afterName`, which the band drops for an embed along with the trail. The name is registered for whoever opened the app, and what sits after it survives `navHidden` as the page's own control. navPageFor gets a table test: it answers both the sidebar links and the breadcrumb label, and its longest-prefix and first-segment rules are what made the "Apps" fallback look like a correct answer. Co-Authored-By: Claude Opus 5 (1M context) * docs: drop what the operator revert and this refactor left behind The operator tour's last step named AI sessions among the pages the sidebar opens, which operators have no way to reach. The nav handle's comment named the AI chat layout's menu button as a second way into the card; that button and its prop are gone, and the corner control on a page that hides the band is what stands in for the handle now. Co-Authored-By: Claude Opus 5 (1M context) * style: prettier the three files this branch left unformatted `prettier --check` fails on all three at the previous head. Whitespace only, apart from one Button tag that fits on a line. Co-Authored-By: Claude Opus 5 (1M context) * feat: assets-only nodes name their script in a header tab instead of a side chip Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no loading spinner in the pane while a clicked script loads Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no subscribe drag dot in view mode Co-Authored-By: Claude Opus 5.5 (1M context) * fix: opening a draft no longer autosaves; header tab styling, selected edge, taller cards Co-Authored-By: Claude Opus 5.5 (1M context) * revert: clicking an unbuilt asset opens the asset again, not its script Co-Authored-By: Claude Opus 5.5 (1M context) * fix: header tab sets card width, edit icons on script header and trigger chip, no pane header shift on open Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no trash in the pane header; card width ignores the run chip so a click never re-lays the graph Co-Authored-By: Claude Opus 5.5 (1M context) * fix: taller trigger chip; node menu and guard badge on the card corner, below the header Co-Authored-By: Claude Opus 5.5 (1M context) * fix: give a page without the band's chrome what the band was carrying Second review round, three reachable defects plus one decision: - A public raw app was 150px tall. The bundle's iframe is `h-full`, and dropping its `min-h-screen` left nothing to resolve against on the share, embed and custom-path routes — none of which has a layout above them. Measured: 150px in a 900px viewport. The floor goes back for those routes and stays off inside the workspace, where the layout hands a height down and `min-h-screen` would overflow it by the band. - The band retracted out from under its own open menu on a deployed app. Last round taught the sidebar card to hold while the pointer is in a portaled menu and left its sibling alone, but the band opens menus too — the workspace disc, the fork family, every path level. One helper now, used by both closers. - `bandPeeked` outlived the page it belonged to. Clicking a control in the band navigates away, and the next page may not be one the band floats over, so the hover handlers that would clear it are gone — leaving the band already down when a full-bleed page came back. `beforeNavigate` clears it. - An embed has no band to carry a control, so the app's Edit floats over the canvas there, where it sat before the band existed. Registering it as the band's `afterName` under `navHidden` instead would have cost the app 44px of the iframe for a row holding one button. Reading "is the workspace navigation hidden" now has one definition (`isMenuHidden`), since a page has to answer it too: the flag is sticky inside an iframe, so the raw query param is the wrong thing to read. Also inset the band by the rail only when there is a rail — an embed reserved its width and crowded its own controls into a compact layout early. Co-Authored-By: Claude Opus 5 (1M context) * fix: a path that names nothing yet has no breadcrumb leaf `splitItemPath` documents that it gives up on a path still being typed, but `f/demo/` is three parts and returned a leaf with an empty name — a blank segment in the trail. The editor binds the path live, so the breadcrumb sees every keystroke. Tested here rather than `navPageFor`'s sort: no two `NAV_PAGES` paths nest, so the longest-prefix case that test claimed to pin has only one candidate. Renamed it to what it actually checks. Co-Authored-By: Claude Opus 5 (1M context) * style: prettier the four remaining files this branch left unformatted All four fail `prettier --check` on the branch and pass at the merge base. `raw_apps/utils.ts` also fails but already did at the base, so it is left alone. Co-Authored-By: Claude Opus 5 (1M context) * feat: pipelines open in the assets-only view by default Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a No asset node per script, banner-only when writes start it; no On asset change chip Co-Authored-By: Claude Opus 5.5 (1M context) * feat: delete a node's script, trigger, or both from its menu; red header for a script with errors Co-Authored-By: Claude Opus 5.5 (1M context) * fix: an errored script header stays red when selected Co-Authored-By: Claude Opus 5.5 (1M context) * fix: coloured run badge border; per-script delete when no combined delete; run button centred on the card Co-Authored-By: Claude Opus 5.5 (1M context) * fix: Add data source pill sits one small gap above the graph Co-Authored-By: Claude Opus 5.5 (1M context) * feat: Multiple triggers chip picks the trigger to edit; each folded trigger deletable from the node menu Co-Authored-By: Claude Opus 5.5 (1M context) * fix: add-step menu labels its path field Path Co-Authored-By: Claude Opus 5.5 (1M context) * fix: iterate NodeLists with Array.from so svelte-check passes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no trigger delete for flow-built assets; Multiple triggers chip shows the one being edited Co-Authored-By: Claude Opus 5.5 (1M context) * fix: Archive button for archive-only deletes, single delete doc, inert Multiple triggers label in View Co-Authored-By: Claude Opus 5.5 (1M context) * feat: the raw app editor fills the band like the other three It was the one editor route still rendering its own bar under the band, so it showed two header rows and the band named the app "Apps raw" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. Its top bar now becomes the page header the way the script, flow and low-code app editors' do: the path and summary are the breadcrumb's, the file-sidebar toggle, autosave cloud, presence mark, menu, Diff, Jobs, export, AI and Deploy are the header's actions, and the path is a plain label since this editor renames from its deploy drawer. The actions render under the band, outside the subtree `RawAppEditor` declares the operating workspace in, and `OpenInSessionButton` asks that context who the acting user is — so the workspace travels with them. `operatingWorkspaceContextEntry()` hands it over without exporting the key. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the folders a half-typed path already names The trailing-separator guard gave up on `f/demo/` entirely, which dropped the whole trail to the kind chip and brought `f/demo` back on the first character of the name. The levels the path does name come back now; only the leaf waits. Co-Authored-By: Claude Opus 5 (1M context) * fix: pin holds the first slot, and the nav table says what it shares Pinning brings the sidebar's handle back, and from behind it the unpin button sat one slot over from where the pin had just been clicked — so a second click in the same place opened the sidebar instead. An operator is detached by default, which makes that the default state of the feature rather than an edge case. `NAV_PAGES` claimed one table stops a page being called one thing in the nav and another in the header, which holds for the seven entries the sidebar's main group is built from and not for the four grouped separately — `/routes` was "Triggers" in the header and "HTTP" in the nav. The comment now says which is which, and that entry is named for the page rather than its group. `sidebarPageAllowed` gets a table test: it decides what an operator sees, and its `=== true` is what a future `Option`-style default would silently change. Co-Authored-By: Claude Opus 5 (1M context) * fix: the band names the path Deploy will create, and keeps the summary readable A brand-new app is parked at a `draft_` placeholder, and the band named that rather than the path its Deploy will create — `newEditedPath` refuses the placeholder for exactly this reason, and the editor's own bar read it. Both app editors register it now. Verified: the trail reads `u/admin/gentle_app` while the URL is still the draft. The band's summary gets the `title` the editor's own bar had, so one the bar truncates can still be read on hover. The wrapper that hid AppExportButton on a phone never hid anything: the component draws no trigger — it is the export drawer, which portals to `body` — so the class toggle was inert in both states and the comment read as if something moved. Dropped from both app editors. Two comments corrected: `NAV_PAGES`' `icon` does reach the breadcrumb for all eleven entries (`NavBreadcrumb` renders `routePage.icon`), so only `operatorKey` goes unread for the four the sidebar groups separately; and the raw editor's phone note described folding that does not happen — the file-sidebar toggle stays in the band, Jobs and the export are already in the menu, and AI is what stands down. Co-Authored-By: Claude Opus 5 (1M context) * fix: an item the picker files under no kind keeps its name in the band The merge brought main's agents detail page, which reaches DetailPageHeader with no `errorHandlerKind` — and the band's item required one. `WorkspaceItemKindIcon` falls through to the script glyph for anything it does not know, so defaulting would have labelled an agent a script; the kind is optional now and the trail names the item without claiming it is one of the three. Co-Authored-By: Claude Opus 5 (1M context) * fix: the file sidebar's toggle leaves the band The band is the workspace's header, not the raw editor's layout controls. ⌘B still folds the file sidebar, and the editor's own bar keeps the button where it draws one — a session pane. Co-Authored-By: Claude Opus 5 (1M context) * fix: the scroll edge stops measuring, and the comments describe the code The band's edge listener captures every scroller in the app — Monaco, the audit table, a run's logs — about once a frame while any of them moves, and it took two `getBoundingClientRect()` reads before looking at `scrollTop`. It now answers from `scrollTop` first and only measures when that answer differs from what the band draws, which is the transition rather than the steady state. The rect check still authorizes every change, so a scroller that does not start at the content's top still cannot claim the edge. The two agent routes main just added size themselves to the viewport, which is 44px more than the box the band leaves them. They measure 856 either way — the layout's `min-h-0` column clamps them — so this is the sweep the rest of the branch already had, making the height follow the box rather than depend on a parent's clamp. Six comments described the conversion rather than the code, which AGENTS.md calls out by name. Each now states the constraint a reader could break: why the summary keeps its popover here, why the audit hint is `afterName` and not an action, why a run registers a `section` rather than an item, why the Runs filters are `actionsFlexible`. Co-Authored-By: Claude Opus 5 (1M context) * Revert "fix: the file sidebar's toggle leaves the band" This reverts commit 1da8d16740b9aaef2d604acf06ac6451f2a10c28. A folded file sidebar renders nothing of its own, so the toggle on the bar is the only way back short of knowing ⌘B. Co-Authored-By: Claude Opus 5 (1M context) * feat: the editors name their item in the band, and a pen renames it The band showed each editor's summary as an inline field, which put editing where the page names what it is working on. The summary is text there now, with its `title` for one the band truncates, and the pen that opens the path for editing sits after it — the same popover the editors' own bar carries, at the end of the name it renames. Every editor already has a field for the summary itself: the script's settings, the flow's Settings tab, and both app editors' deploy drawer. The popover moves into `PathEditPopover` rather than being written out four more times. It owns the open state and hands back the path as it was when it opened: the band's trail reads that while the popover is open, so a path growing under the pointer does not drag the pen — and floating-ui the popover — along with it. `EditorHeader` renders the same component, so the session pane and the band cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) * feat: the pen renames the item and its summary The pen opened the path alone, which left the summary with nowhere to be edited from the band. It carries a Summary field above the path now — the item's name first, the address it lives at below — and the band's text follows it as the user types. `TextInput` with a label rather than the bar's inline field: `EditableInput`'s own docs rule itself out inside a popover, and it wraps this same input in its edit mode, so the field, the "Add a summary..." placeholder and the trim on commit are the behaviour that was there, in the shape the deploy drawer's Summary field already uses. The keydown stops at the field because every editor binds keys of its own and a summary is prose. `summaryEditable` carries the hosts' `customUi.topBar.editableSummary` through, and the editors' own bar passes it false: that bar keeps its inline field, and `hidePath` would take the pen with it, leaving a condensed session pane no way to name the item at all. Co-Authored-By: Claude Opus 5 (1M context) * feat: the agent editor fills the band too It was the last editor route drawing its own header row under the band, and the band named it "Agents" — `navPageFor`'s first-segment fallback, because nothing on the route registered an item. The agent's path is the breadcrumb now, its version badge reads after that name, the sentence that was the row's second line became the hint beside it, and the Chat/Form switch, Settings, Evals, version history and Deploy are the band's actions. No trail levels in the registration: this layout opens settings in a drawer and evals over the page, so unlike the dialog it never stands on a level — `trail` is its root alone here. Co-Authored-By: Claude Opus 5 (1M context) * feat: the pipeline page fills the band Found by sweeping every route under the band: the pipeline editor drew its own row beneath it, 48px of a second header, while the band named only the section. Its name is the band's now, the folder it is open on sits with that name — the switcher is how you change pipelines, so it belongs to the name rather than to the actions — and the mode switch, deploy-all, activity, refresh and overflow menu are the bar's actions. The arrow back to the list stays: nothing else on the page points there, and the sidebar does not list pipelines. Co-Authored-By: Claude Opus 5 (1M context) * fix: the app canvas controls stay reachable, and an unnamed item says so Codex found the app editor's canvas controls unreachable on an ordinary laptop: the owning route collapsed at 1350px, and a 1440 window with the rail out leaves 1232, so the width, theme and breakpoint toggles were hidden with nothing in the menu to replace them — and the breakpoint is the only way onto the mobile layout. Measured on the band, the toggles are 240px beside 334 of other actions and a ~430 trail, so they fit to about 1050: the threshold is 1100 now, and below it each of the three keeps a menu entry rather than disappearing. An item with no summary read as a bare `·` with an invisible pen after it, since the plain text replaced a field that had a placeholder. It now reads "Add a summary..." in the italic SummaryPathDisplay uses for the same state, and the pen stops hiding when there is nothing to hover. Three more from the review: - `actionsFlexible` says "these actions can give width back", which is true of the Runs filter row and not of the pipeline's row of fixed buttons. - `pathSnapshot ?? (newEditedPath || …)` keeps the `||` chain the five rename sites read and the declaration documents; `??` had made the rest unreachable, since `newEditedPath` is a string that is never undefined. - The band bridged `contexts` onto the actions alone, while the pen that decides whether a path is the caller's to change renders in `summaryContent`. The breadcrumb's `afterName` and the summary are bridged the same way now, so that permission check reads the tree that registered it. Co-Authored-By: Claude Opus 5 (1M context) * fix: the widest action rows scroll, and the canvas toggles show only where they fit Measuring the band instead of estimating it: with the canvas toggles shown the app editor's actions are 826-911px and the trail with the summary another ~500, so the row overflowed the bar at every width up to about 1470 — the 1100 I set from an estimate was wrong, and the 1350 before it was too. The toggles come out at 1500 now, and the menu entries are what keeps all three reachable below that rather than merely out of the way. The two widest rows — this editor's and the pipeline's — scroll inside their own box, which is the escape the rows they replaced had (`overflow-x-auto whitespace-nowrap`). That answers pi's finding and clears the last 23-40px of band overflow at middling widths, which predated the conversion. Measured after: zero horizontal overflow from 852px of bar to 1392. From the CI round's other findings: - The pen is offered when either the summary or the path is editable, not the path alone: a host that allowed one and refused the other could no longer edit the summary from the band. `PathEditPopover` takes `pathEditable` to match, and its label names whichever it is given. - The four editors' comments called the summary read-only, which stopped being true when the pen beside it started editing it. - The pipeline's "centre group" comment described a layout it no longer has. Co-Authored-By: Claude Opus 5 (1M context) * docs: actionsFlexible covers both ways a row gives width back It read as if a search field were the only way to earn it, which stopped being true when the app and pipeline editors' rows started scrolling inside their own box. Both are the same bargain — the box may shrink because its contents can — and the flag is what the bar reads to allow it. Co-Authored-By: Claude Opus 5 (1M context) * feat: data pipelines in AI sessions (alpha) (#11453) * feat: pipelines in AI sessions with several folders open at once Co-Authored-By: Claude Opus 5.5 (1M context) * feat: deploy and discard a session's pipeline edits as one changes-list row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipelines in the chat's listing and the preview picker; session tab shares the page's asset prefetch Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a masked pipeline preselects only the scripts and triggers of its folder Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review round — exact pipeline scripts in the fork mask, refuse a failed hand-off flush, deploy guard before awaits Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review round — failed bundle save fails the deploy, removing a draft records no change Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a session's pipeline tab opens node details in a modal, as the flow editor does Co-Authored-By: Claude Opus 5.5 (1M context) * feat: route pipeline graph edges orthogonally with spread ports, separate tracks and lanes Co-Authored-By: Claude Opus 5.5 (1M context) * feat: pipeline graph edges merge by shared end, enter nodes by the side, keep fewest crossings Co-Authored-By: Claude Opus 5.5 (1M context) * fix: review round — incremental crossing search and cached routes, retry the leftover draft save, drop the pipeline row when its last draft goes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: retry the leftover draft save with what failed, never an empty draft Co-Authored-By: Claude Opus 5.5 (1M context) * revert: pipeline graph edges back to bezier curves, detours curved too Co-Authored-By: Claude Opus 5.5 (1M context) * feat: hovering a pipeline graph node fades everything outside its lineage Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hover fade eases edges too, through a stylesheet instead of new node objects Co-Authored-By: Claude Opus 5.5 (1M context) * feat: gentler hover fade without flicker on click; implied trigger edges recede in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) * docs: pipeline nodes ingest straight into DuckLake or data tables from Python/TS * fix: pipeline tools callable right after open_preview; write_script refuses pipeline nodes in sessions * feat: delete a data pipeline from the home list; pipeline rows match item rows * fix: upward detour edges keep their gutter lane * fix: pipeline delete takes only its own triggers and discards draft-only nodes by storage key Co-Authored-By: Claude Opus 5.5 (1M context) * feat: AI attaches trigger drafts to the pipeline nodes that declare them; materialize nodes keep no stale output Co-Authored-By: Claude Opus 5.5 (1M context) * feat: AI gives every pipeline node a succinct summary Co-Authored-By: Claude Opus 5.5 (1M context) * fix: session pipeline tools survive another tab taking the panel, reopening the editor on call Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pipeline trigger lookups skip same-path flows; a failed trigger never hides its built node Co-Authored-By: Claude Opus 5.5 (1M context) * fix: deleting a pipeline clears it from open sessions so autosave cannot restore it Co-Authored-By: Claude Opus 5.5 (1M context) * feat: data upload opens on its inputs alone, works in sessions, with a prominent Upload data button Co-Authored-By: Claude Opus 5.5 (1M context) * fix: closing the pipeline pane closes its modal instead of leaving it blank Co-Authored-By: Claude Opus 5.5 (1M context) * feat: a DuckLake asset opens on a Data tab with the database manager Co-Authored-By: Claude Opus 5.5 (1M context) * fix: inputs-only view infers draft inputs, keeps stored lineage, runs the live body Co-Authored-By: Claude Opus 5.5 (1M context) * fix: lineage follows the editor that inferred it; upload runs preview unsaved edits of deployed nodes Co-Authored-By: Claude Opus 5.5 (1M context) * fix: readyDataUploadPaths is a ReadonlySet in the pipeline editor Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) * fix: selected pipeline nodes get their blue edge in dark mode too Co-Authored-By: Claude Opus 5.5 (1M context) * fix: no red flash on load while writer lookups and script bodies are in flight Co-Authored-By: Claude Opus 5.5 (1M context) * fix: deploy an open deployed script's edits, and don't resurrect them after a discard Co-Authored-By: Claude Opus 5.5 (1M context) * refactor: deploy and discard close the open pane first instead of patching its edits Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the pane closed while deploying, and clear its buffer once its edits are a draft Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a draft edited while it deploys keeps the newer edits, chained on the new version Co-Authored-By: Claude Opus 5.5 (1M context) * fix: a draft whose summary or labels changed mid-deploy is kept too Co-Authored-By: Claude Opus 5.5 (1M context) * fix: the app viewer names the page it owns, and stops claiming the viewport A deployed app opened in an AI session's preview panel renamed the band from the session to the app, and put the app's Edit beside the session's name. The viewer registered wherever it was mounted; it takes `ownsPageHeader` now, the way the detail pages already did, and keeps Edit over its own canvas otherwise. Both viewers also claimed the whole viewport under a band that takes 44px of it, so a deployed app scrolled by exactly the height of its own header (the low-code one measured 900px in an 856px box). The height comes from the host now: the three standalone routes floor themselves at the viewport and everything inside them is `h-full`. A raw app reached through `/app_embed` had no height above it at all and collapsed to nothing; it fills the frame now, though that route still fetches no bundle secret, so what it shows is blank — as it is on main. The agent editor's pen was a second editor of the same path that reported nothing: an invalid path never reached the deploy gate the settings drawer's field reports to. It binds to the same slot now. Two things that surfaced with it: a rename never reached the trail, which read the deployed path; and typing the saved path back raised "path already used", since the popover's `initialPath` moves with the rename — `allowedExistingPath` is what says the item already occupies it. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the band's controls come from the design system, and the row has one copy `NavBreadcrumbTrigger` was `MeltButton` written again — same `use:melt` on a plain button, same spread — so the workspace chevron uses the component that already exists, and the sidebar handle is a `Button`. `DetailPageHeader` carried its action row twice, once for the band and once for the row it draws inside a session's preview panel, and the two had already drifted: different dropdown size, no `unifiedSize` on the buttons, and the deprecated `variant="contained" color="light" size="xs"` on the schedule button. Both placements render the same two snippets now, at the size each needs. `OperatorPageKey` admitted every `operator_settings` field, including the three write rights, which `sidebarPageAllowed`'s `=== true` rule answers wrongly: a write right is granted unless withdrawn, so its absence means "never configured". It is the ten visibility flags now. A test over the registry's merge: later registration wins field by field, actions sort by `actionsOrder` then registration, release gives its fields back. That rule is why a component registering where it is not the page renames someone else's band. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band owns its bottom edge, and one pen renames everywhere The page header decides whether a separator is drawn, so a page cannot draw a second line under it. A registration asks for `onScroll` (the default, drawn only while something passes under the bar), `always` for a page whose content starts right beneath it, or `none` where the page genuinely draws its own first line. Detail pages, the agent editor, workspace settings and the pipeline list take `always`; the raw app editor takes `none`, since its splitpanes already draw that line. The pen that opens the rename popover becomes PathEditPen, shared by the editors and the deployed pages so the two cannot drift, and the deployed pages gain it. SummaryPathDisplay makes the pen the only trigger rather than the whole name block, so the name is selectable text again. Renaming an agent goes through ResourceService with only the fields the popover edits: the backend sets a column per field it is given, so writing the config back would clobber a concurrent edit. The popover also stops clearing a real collision verdict by telling Path which path is its own. Two Button fixes, both reaching call sites beyond this work: - `dropdownItems` given as a function was asked for `.length`, which is its arity and therefore 0, so a split button built that way rendered no caret. Both app editors' Deploy dropdowns were unreachable. - the anchor branch dropped `title`, so a link button had no tooltip. The audit log filters move into the header actions at the design system's sizes and trade absolutely positioned labels for the form layout in brand-guidelines.md: a label above its input, 4px apart, fields 8px apart. The deployed app viewer floors itself at `min-h-full` instead of claiming the viewport, which would overhang the page box by the height of the band, and carries Edit and a public-url link that fold into a menu on a narrow bar. The root layout drops the legacy menu block, which rendered a second hidden sidebar and 54 invisible tab stops, and marks the closed detached sidebar `inert`. Co-Authored-By: Claude Opus 5 (1M context) * feat: service logs and the audit filters move into the band Service logs: the page's name is the breadcrumb's, so the `

` is gone and the search field, the timeframe and the two switches sit in the band beside it, with the documentation tooltip after the name. ServiceLogsInner registers the band itself, the way RunsPage does, since that route is its only caller, and the `title` snippet the page used to hand it goes with the heading. The search is the widest control there, because searching is what the page is for, and it is the one that gives width back under pressure. The two switches fold into a popover below a 1200px bar rather than at a phone's width, so the field keeps its full width down to a much narrower window — folded rather than dropped, because the page no longer carries a toolbar they could fall back to. The timeframe control took no size of its own, so it defaulted to `md` beside a `sm` field, and sat in a `w-48` box 20px narrower than its content, whose overflow is visible and so spilled into its neighbours. No `separator`: the two panes below start under the bar with borders of their own, so at rest there is nothing for the band to divide. Audit logs: the filters lose their labels. Each select says what it filters while unset — "all usernames", "all resources" — and once set the value names the field by itself. From and To become the TimeframeSelect the runs page uses, which absorbs the standalone Refresh button, since that control's left half is the refresh. `auditTimeframes` joins the runs and service-logs presets, with "All time" first. The bounds stay the filters' own `after`/`before` rather than a remembered preset label: the timeline's zoom writes them too, and a label could then contradict the chart. That takes the row from 1326px to 898px, so it stays inline down to a ~1500px viewport instead of ~2500. The fold measures the row against the bar less what the breadcrumb takes, plus a gap the two must never close — the breadcrumb yields width grudgingly and the row not at all, so sized to just meet they overlap rather than compress, and mid-resize the tooltip after the page's name lands on the timeframe button. Co-Authored-By: Claude Opus 5 (1M context) * fix: an embedded page keeps its name in the band A page in a session's preview panel loads in a frame that sets `nomenubar`, so the layout renders the band with `navHidden`. That flag hid the whole breadcrumb — which is what carries the page's name — while the actions beside it had no such guard, leaving a row of controls belonging to nothing. Hiding the trail is still right: it leads to the host's workspace and offers a picker that would take the reader out of the session. So the breadcrumb gains a `nameOnly` mode that drops the workspace part and its picker and keeps the page's own name, and the band asks for it instead of rendering nothing. The summary line loses the same guard, so an item in there shows its path and summary. Only the pages whose own title the band took over are affected. Variables, Resources, Schedules and Triggers still draw their own `PageHeader` and render no band when embedded, so nothing is doubled. The bar's left padding is there to sit a button against — the sidebar handle, or the workspace disc, both of which carry their own margin. Embedded there is neither, and the name started as bare text 8px from the edge, so that case takes the same 16px the bar already keeps on its right. Workspace settings drops the copy-id button from the band: the General tab names the workspace ID with a field of its own, which is where someone looks for it. Co-Authored-By: Claude Opus 5 (1M context) * feat: a session in full screen keeps its tabs in the band Full screen used to drop the session's name from the breadcrumb and leave the tab strip inside the panel. The band now carries both: the session's name where "Sessions" stood, and from the breadcrumb to the right edge one filled surface holding the tabs and the preview's controls, the bar's full height. The switch between the two layouts is the panel expanding to the left. The chat column is resized to nothing rather than unmounted, so it can be seen taking its place -- and so a toggle no longer tears down every mounted chat and rebuilds it on the way back. Panes transition their width only while that switch is in flight, since a splitter drag sets the same inline width and would trail the pointer. The band floats over the content in full screen as it already did beside a panel, so only its right edge moves and the page below never shifts by the bar's height. Leaving full screen, the panel travels back across ground the band has already claimed, so it passes one layer above it for the length of the move: pinned to the width the panel will end at, the bar would otherwise show through as a white patch over the panel's left edge. The breadcrumb also gains the acting fork, the way every other page shows it, which replaces the "Acting on" control the session header carried. Co-Authored-By: Claude Opus 5 (1M context) * fix: the band's inset survives a resize made in full screen The panel was remembered as a width, sampled while it stood still. A window resized during full screen left that width describing a layout that no longer existed, and the way out pinned the band to it for the length of the move. It is now remembered as the share of the row the panel takes, which the new row width turns back into the right inset: leaving full screen at 1200px after entering it at 1600px, the band starts at 628 rather than at 909. The pane transition's duration is read from the property the timer is set from, so the class cannot outlive the movement it describes, and two comments that described earlier drafts are rewritten: the band's name in full screen comes from the session's own header once it has mounted, and the tab strip's wrapper carries a fade, not a measured move. Co-Authored-By: Claude Opus 5 (1M context) * fix: an agent's pen keeps the labels the agent already carries The pen on an agent's page was handed the summary and the path but not the labels, so the popover offered an empty list over a resource that had some. It saves every field it shows in one write, and the resource endpoint sets the column it is given, so adding a label there replaced the agent's own: an agent labelled `production` and `scheduled` came back from one edit labelled only with the label just added. A flow's page and a script's page both pass theirs; this one now does too. Verified against the running instance: an agent created with `["production","scheduled"]`, one label added through the pen, reads back `["production","scheduled","new"]` — and `["new"]` with the prop removed again. Co-Authored-By: Claude Opus 5 (1M context) * fix: the sidebar remembers detaching per role, not per account Whether the sidebar starts detached depends on the role: an operator gets it out of the way, a developer gets it docked. That default held only until the control was pressed once, because the choice went to a single key read before the role. From then on both roles wore whichever was pressed last, and the one that had not asked for it could never get its own default back — switch a workspace where you are an operator for one where you are a developer, and the sidebar stayed detached. Each role now keeps its own choice, and falls back to its own default until it has one. Co-Authored-By: Claude Opus 5 (1M context) * docs: the handle's comment describes what the handle does It claimed a click holds the sidebar card open. Nothing distinguishes a click from a hover there — both call the same opener, and the pointer leaving schedules the close either way — so on a desktop the card follows the pointer and goes when it goes. Measured: hovering slides the rail from -213 to 5, the click moves nothing, and the pointer leaving puts it back at -213. Co-Authored-By: Claude Opus 5 (1M context) * feat: a dev workspace's badge wears its fork's own colour Every surface that names a dev workspace drew the same blue badge, which said nothing about which workspace it belonged to. One component, DevWorkspaceBadge, now draws it: a fork with a colour fills the badge with that hue and writes the label in white, and a fork without one keeps whatever its row had before. The hue cannot carry white at a fixed lightness — HSL lightness is not perceived brightness, so yellow and purple at the same L differ by several stops — so forkAccentStyle solves the badge's lightness by bisection instead, taking the lightest shade of the hue that still holds white at WCAG AA. Measured across the wheel the contrast lands between 4.52 and 4.64. The session picker opens with every family collapsed, since the fork a session runs in is the session's own and not something that picker should offer to change. Co-Authored-By: Claude Opus 5 (1M context) * feat: the name is what opens its rename editor, and the trail truncates from its start The pen beside a name is gone. The name itself is the trigger, with a hover fill behind it where the click opens an editor rather than going anywhere — the path segment that only copies keeps no fill, which is what tells the two apart. A bar with a summary field of its own (EditorHeader's condensed pane) still gets the pen, since it has no name to hang the trigger on. Clicking either half of a name opens the editor under that half with the cursor in the matching field. The band draws the path and the page draws the summary, so one popover cannot anchor under both: a page hands the band a `pathTrigger` snippet and the band renders its path segment inside it, passing the classes its own segment wears. Each editor supplies a second PathEditPopover for it, and `focusField` names the field to focus — `#path` explicitly, since melt otherwise lands on the row's first button. Those snippets render from the band's tree, which outlives the values they read by a flush, so each is gated on the value it reads. Without that an agent page whose draft had not loaded threw inside the band and left the breadcrumb empty. The trail now carries two weights: the segment that ends it is what the page is about and takes `font-medium text-emphasis`, everything before it is a step quieter. Which segment that is follows the route — the workspace on home (the fork alone inside a fork), a list page's own name, an item's summary. And it truncates instead of switching to icons at a breakpoint. The workspace part yields first, the family name before the fork, then the path's folders, and the item's own name last. The bar no longer wraps on a phone: a wrapping row never squeezes anything, it hands the trail a line of its own and leaves it whole. Co-Authored-By: Claude Opus 5 (1M context) * fix: a page keeps its path segment when it cannot offer a rename Four things the local reviews caught in the rename work: A detail page handed the band a `pathTrigger` whether or not it could save one. Without `onSaved` — every script, flow or agent page the reader cannot write — `SummaryPathDisplay` fell through to its read-only branch, which ignores the `label` it was given and draws its own block: the trail lost its path and the summary appeared twice, two lines deep, in a 44px bar. The page now offers the trigger only where a rename can be saved, and the component renders what it was handed rather than its own row. The band gives up its own segment the moment a page offers a `pathTrigger`, so a trigger whose own gate is closed left the trail with no path at all — for the whole of an agent draft's load, not just a flush. Each gated snippet now draws the path itself in that case. `openFocus` named the path field as `#path`, which melt resolves with a document-wide query; `Path` hardcodes that id at every call site, and the popover is portalled to the body, so it lost document order to any other `Path` on the page. It is scoped to the popover's own field now. And `open` was an exported `$bindable(false)` on an optional prop, which the frontend's own rules ban. No caller bound it, so it is local state. Co-Authored-By: Claude Opus 5 (1M context) * fix: the badge solves its lightness against the colour the browser paints Three nits from the review round. The contrast bisection measured the hue at full float precision, but a browser paints 8-bit channels: rounding them moved the result up to 0.025 off the target, which took #993388 to 4.478 against white — under the AA floor the comment claims. Quantizing the channels inside `paintedLuminance` puts the worst case across 2880 hue and saturation combinations at 4.500, and your own fork's badge measures 4.53 as painted. An agent nobody can write still offered the rename behind its path segment: an editor with two read-only fields, in place of the click that copies the path. It is gated on `readOnly` now, the way a detail page is gated on `onSaved`. The script and flow editors take the same gate from the `customUi` flags that already decide whether their summary offers one — unreachable today, since the editors own the band only on routes that pass no `customUi`, but the four editors now answer the question the same way. And the registry's doc named an `onEditName` field that has never existed. Co-Authored-By: Claude Opus 5 (1M context) * fix: an operator is not offered a fork of a workspace A fork does not carry the role. The server writes the forker's membership row in the new workspace from the parent's `is_admin` and lets `operator` fall to its `false` default, so an operator who forks lands in a full-rights copy of the workspace they could only run things in. `canCreateFork` refuses an operator, which takes the entry out of the workspace picker, the session family picker and the session changes bar; `editInForkAllowed` refuses one too, so no editor offers "Edit in fork". The fork page refuses on its own, since a URL reaches it whatever the pickers show. And a workspace the user is an operator in is no longer a base the fork form will accept, so the base picker cannot route around the gate. This hides the affordance; it does not enforce the rule. `create_fork` asks only for membership — `require_admin` covers the dev-workspace case alone — so the API still answers an operator who calls it directly. The server-side fix is to carry `operator` into that membership row, the way the member-copy path already does, and it belongs in a backend change rather than here. Co-Authored-By: Claude Opus 5 (1M context) * style: the fork refusal uses the design system's alert The refusal panel was hand-built from a div and two paragraphs with literal colors, copied from the Runs page's own operator refusal. The frontend's rules say to use the design-system component, and `Alert` already draws exactly this. Co-Authored-By: Claude Opus 5 (1M context) * fix: frontend type errors failing npm_check The dev workspace badge accepts the nullable label the API returns, the session strip transitions take the node Svelte passes them, and an unused import is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: the frontend type-checks again `check frontend build` has been red on every head for a day, through four review rounds that each came back clean — none of the reviewers type-check, and they say so in their own coverage sections. Ten errors: The dev badge's `label` and `color` were typed `string | undefined`, but the workspace rows carry `string | null`, so all seven of its call sites failed. `forkAccentStyle` took the same widening. A Svelte transition is called with the node it animates, and `stripArrive` and `stripLeave` were declared with no parameters at all. And `DevWorkspaceSetting` still imported `Badge` after its badge moved into the shared component. `svelte-check` now reports 0 errors. Note that it only agrees with CI once `src/lib/gen` has been regenerated — the gitignored client goes stale against main and invents errors CI never sees. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home bar carries the CLI and hub buttons while it has the room A menu is where a thing goes when the bar cannot hold it, not where it belongs. Above 1024px of bar, "CLI / MCP" and "Hub" stand as their own buttons; below it they fold back into the ellipsis, which is still where they live on a phone. The threshold reads `pageHeader.barWidth`, not the viewport, so it tracks what the sidebar actually leaves behind. Home's trail is ~250px and its other registered actions ~400, so the two labelled buttons fit from about 850 up; 1024 leaves margin before the trail would start truncating, and it does not truncate at any width between 760 and 1600. The ellipsis hides itself when it would open an empty menu, which happens only once both side trips are in the bar and the composer is already on the page. Co-Authored-By: Claude Opus 5 (1M context) * feat: a fork's band counts its drafts beside its drift, and every count opens what it names A fork that had drifted stopped saying anything about its drafts: the banner printed the draft count only while the fork was in sync, and the drift badges replaced it the moment there was anything to deploy. A fork can be 86 ahead, 39 behind and holding 128 drafts; it now says so. All four counts open the page that holds them — ahead the deploy direction, behind `?dir=update`, conflicts the side they are resolved from before deploying, drafts the drafts half. The button beside them still chooses a direction on the reader's behalf; a badge means the one thing it names. The colours and the icons are the compare page's, so the counts in the bar and the rows behind them say the same thing the same way: ahead green, behind blue, conflicts orange. Drafts share blue with behind and are told apart by a pencil and by naming what they count, rather than by an arrow that would read as a third direction. Both arrows are straight, here and on the compare page. "Review changes" replaces "Review & Deploy Changes", and it renders in every state. What the comparison has to say — still counting, or failed — is a mark beside the button rather than a branch that replaces it: a fork whose count is unfinished is exactly when a reader wants the page that counts properly. The drafts banner outside a fork drops "This workspace has", so the count reads the same wherever it appears. Co-Authored-By: Claude Opus 5 (1M context) * feat: the home band keeps its hero's own cross, and the side trips stay quiet The CLI and hub buttons take the shape the hero gave them before the bar did — `xs`, hint-coloured, the hub keeping its outbound mark — so a side trip does not read as loudly as the thing the page is for. The ellipsis is gone while the hero is on the page: the hero's cross already puts it away, so a menu whose only item repeats that is a button that does nothing new. It comes back when it has something of its own to carry, which is the side trips on a narrow bar and the way back to a hero that has been put away. That entry is now "Pin session hero to homepage", and "Remove session chat" survives only as the cross's own words, where one action keeps one name. Co-Authored-By: Claude Opus 5 (1M context) * feat: a fork's counts and the way to review them are one control Four badges beside a button read as four things that happen to sit together. They are one thing — every segment opens the same page on the part it names — so they now share a box: one outline, square corners inside it, and the rounding on the box alone, which means no segment has to know where in the row it sits. The only rule in the strip is the one before the end cap: everything left of it is a reading, that is an action. With room every count says what it counts; below 1280px of bar they keep their icon and their number, which is the part a reader scans for, and the tooltip names them either way. The end cap keeps its words at every width — the counts have icons to fall back on and it has none. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page asks once, says it is working, and knows its own name The page re-requested the same summaries and on-behalf-of emails forever. Two faults compounding: `fetchOnBehalfOfInfo` skipped a key it had already answered with `onBehalfOfInfo[k] !== undefined`, but stored `undefined` on failure — so "asked, and there was nothing" could not be told from "never asked". An item that is only ahead does not exist in the parent, so that side always fails: a permanently un-cacheable set, re-requested on every pass. And the effect that drives both fetchers depended on what they write. Each reads its cache synchronously before its first `await`, which registers that cache as a dependency; the write that follows re-runs the effect. Write, re-run, write. Both now record what they asked for in a plain Set, marked before the request rather than after the answer, and the effect is untracked so it depends on the comparison alone. The caches are cleared when the compared pair changes, since a summary's key carries no workspace and those Sets would otherwise never ask again. While the comparison was in flight the page printed "No changes between this workspace and X" — an empty list and an unanswered one look alike from the layout, and that is a claim with no answer behind it. The page tracks the request now, and the layout spins instead. The breadcrumb called the page "Forks", which is what `navPageFor` falls back to when a route names itself nowhere: the first segment, capitalised. It registers "Compare" instead — in the page rather than in NAV_PAGES, which is what the sidebar renders from. Co-Authored-By: Claude Opus 5 (1M context) * Revert "feat: a fork's counts and the way to review them are one control" This reverts commit e4504d2843. The counts go back to separate badges and "Review changes" to a button of its own. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page follows the fork picked in the trail Switching a fork from the breadcrumb left the page comparing the pair it opened with. Two reasons, neither reactive: the page seeds `currentWorkspaceId` from `?workspace_id` once at mount and never reads it again, and the switch rewrites `?workspace`, a different param that this page does not read. It follows `$workspaceStore` now — picking a fork in the trail means comparing that fork. The param is rewritten so a reload keeps the pair on screen, and an arbitrary `?target=` is dropped, since it named a destination for the workspace just left. Co-Authored-By: Claude Opus 5 (1M context) * feat: the band is a step taller, and the runs row sizes with it The bar goes from h-11 to h-12, taken from one constant beside the top padding a floating band owes the page under it, so the two cannot drift. The runs page's controls now share the sm height the rest of a header's actions use: FilterSearchbar learns the size, dropping the top margin that only centres text at md and taking the shared leading instead, and sm buttons get px-3 so a label reads as a button. The actions sit in their own row with a wider gap, and the chart below meets the header with no gap of its own. The search field holds one width through typing. As flex-1 it was whatever the row had left over, so committing a chip grew it from 226 to 288 and took those pixels off the breadcrumb. A fork's count badges keep their labels while the bar is at least 1280 wide and fall back to icon and number below that. Co-Authored-By: Claude Opus 5 (1M context) * fix: the trail holds a path's place instead of naming its kind A page registers its kind when it mounts and its path once it has loaded one. In between the trail printed "script" or "flow", which reads as the answer and then jumps when the real path lands. A skeleton of a plausible path's width holds the place, with aria-busy and a label naming what is loading. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page reads the workspace it was linked with The effect that follows the trail's fork also ran on mount, so a `?workspace_id` that differs from the navigation workspace was thrown away. That difference is the contract: a session's Review button passes the fork it committed to and leaves the navigation workspace alone, as does the prod -> dev link in UpdateDevWorkspaceModal. Both opened on the sidebar's workspace instead, comparing the wrong pair or none at all. Only a change in the store now retargets the page, measured against what it held when the page opened. The URL rewriting goes with it. The picker's items are links that carry every other param forward, so their navigation lands after the effect and put back the `workspace_id` it had just rewritten: switching fork and reloading left the trail naming one workspace and the page comparing another. The page reads `?workspace=` ahead of `?workspace_id=` instead — the param the picker writes and the root layout already applies to the store — and `?target=` lapses while the page is on another workspace rather than being deleted from a URL someone else is about to replace. Two 44px literals left over from the h-11 band: the detached sidebar card's measured-top fallback and the rail morph's end state, both now from BAR_HEIGHT_PX beside the class the band wears. The runs search field keeps its 18rem without growing, but as `shrink-0` nothing in that row could give width back, so a short bar had no answer but the trail's truncation and an overlong row would have run past the edge. `min-w-0` restores the yield without restoring the growth: a flex item's floor is its min-content width, which the chips inside the field raise. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page's target picker works, and a switch survives reload `?target=` was gated on the page still being on the workspace the URL named. Opened from "Merge into another workspace" in the workspace settings the URL names no workspace at all, so that comparison was ` === undefined` and the param was ignored for good: picking a target left the page on the "no parent workspace" prompt, which is the only place the migration can be started from. Read the param as it was read before. The follow effect writes `?workspace=` again. A plain click in the picker preventDefaults its own link and switches the store directly, so nothing navigates afterwards and nothing overwrites the write — while `fixupUrlAfterWorkspaceSwitch` rewrites the param only once it is already there, which on a page opened with `workspace_id` alone it never is. Without it, switching fork and reloading left the trail naming one workspace and the page comparing another. Exercised on a fork pair: the settings entry's picker loads the comparison it names, a plain-click switch survives a reload, and a `workspace_id` deep link still outranks the navigation workspace. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the compare page reads its pair instead of following it Net effect count: three removed, none added. The page derives the workspace it compares from the URL and the store rather than keeping it in state and running an effect to follow a switch. The workspace-switch link is what moves it: the picker's items carry every param forward, so a page-side rewrite raced that navigation and lost — the trace showed the fixup's replaceState landing first and the link's pushState putting the old params straight back. The href now swaps `workspace_id` alongside `workspace` and drops `target`, and `fixupUrlAfterWorkspaceSwitch` does the same for the paths that do not navigate, so both writers agree and the order stops mattering. Dropping `target` on a switch also closes the case where switching onto the workspace that was the target left the page comparing it with itself. CompareWorkspaces remounts on either side of the pair changing, not just the target: two forks of one parent shared a mount, since the target is the parent they have in common, and with it the selection, the deploy direction, the summaries, the pinned rows, the deploy permissions and the CI results. The cache-clearing effect that stood in for that is gone. Its summary loops outlive their mount, so they read the pair once at the start and stop writing when the instance is destroyed. The app viewer asked for a public secret on every app view to decide whether to draw a button; it reads the mode off the app it has already loaded and fetches the secret when the button is used. The summary popover seeds its fields on the open transition rather than from an effect that also read the item: a save landing while it was open reseeded the fields over what was being typed. Exercised against a running instance, 18 cases: idle issues nothing, a switch moves page, trail and URL together and survives a reload, a deep `workspace_id` link still outranks the navigation workspace, four fast switches settle with no foreign row, and the migration target picker loads the pair it names. Co-Authored-By: Claude Opus 5 (1M context) * fix: the public url opens the tab the click itself opened The secret is fetched when the button is used, and the tab was opened once it arrived — after an await, where Safari no longer counts the click as the gesture that opened it and blocks the popup without saying so. Open the tab synchronously and point it at the url once the secret is known, closing it and saying why when there is none. Co-Authored-By: Claude Opus 5 (1M context) * fix: the public url button survives a sandboxed app A sandboxed low-code app loads inside the opaque /app_embed iframe, so `loadApp` never runs on this page and `app` stays undefined — taking the button and its compact-menu entry with it, for the owner too. The mode now comes from the lite read that already runs for the Edit button, which is there for exactly this reason: it is the one description of the app this page has whether or not the app itself loads here. Exercised on four apps: anonymous sandboxed low-code, anonymous unsandboxed low-code and an anonymous raw app all show the button and fetch the secret only on click; a publisher app shows neither and asks nothing. At 640px the button folds into the menu, which carries it. Co-Authored-By: Claude Opus 5 (1M context) * refactor: the removed hero leaves a blue flash on the ellipsis The mark that flew from the cross to the menu is gone, with its two element refs, its flight state and timer, and the `data-home-ai-menu` hook that existed only as its destination. What is left is the thing it was there to say: the ellipsis holds the way back, so it flashes blue as it appears. Written as a keyframe animation, not a transition. A transition needs the element painted at its start value before the end value is set, and this mounts in the same flush that collapses the hero, so the frame that would have shown it at rest is the frame it is already told to leave — one `requestAnimationFrame` is not reliably past that paint, and the flash was never drawn at all. `animate-fade-out` already exists for this (AssetsDropdownButton flashes a background the same way); mounting it only while pulsing gets a fresh run per removal without that component's reflow juggling. The flash rides the ellipsis rather than the row: with the CLI and hub buttons beside it the row is three controls wide, and marking all three names none of them. Co-Authored-By: Claude Opus 5 (1M context) * fix: the flash rests invisible, and its comment describes what is there `animate-fade-out` carries no fill mode, so at the end of the run the element returns to its own opacity. At the default 1 that is the flash coming back at full strength for as long as the unmount timer lags the animation — which the hero's collapse, happening right there, is well placed to cause. `opacity-0` at rest makes the revert land on invisible. The comment above it still described the mark that used to fly to the menu, and made its point three times over. Co-Authored-By: Claude Opus 5 (1M context) * fix: the compare page asks only about the pair it is showing Switching workspace on this page fetched the previous pair's item paths from the new workspace, where none of them exist: 97 of 174 requests 404ing on one switch. The comparison was plain state, and the card is remounted when the pair changes — in the same render that changes it, before the effect that cleared the comparison could run, so the new mount started from the old pair's diffs. It now carries the pair it describes and is read only while that matches, which leaves nothing to clear. `computeFullScan` had the same shape: it read the target again after its await, so a switch mid-scan invalidated another pair's cache, named the wrong workspace in its toast, and left the scan button spinning on a pair that was never scanned. It captures the pair up front, re-compares only while the page is still on it, and reports it as its own. Each diff says which side holds the item, so the summary and on-behalf-of loops stop asking the side that does not: an item that is only ahead cannot answer in the parent, and asking was a guaranteed 404 and a console error per one-sided row. Those two loops also read the same object — a summary and an identity are fields of one payload — so every row was fetched twice per workspace. One read per item per workspace now serves both. A failed read is not kept: held, it would read as "this item has no identity", which is what decides whether a deploy offers to preserve one, and an item deployed without it silently becomes the deployer's. The header names the workspace being compared when that is not the one the app is pointed at, which is how a session's Review button and the prod -> dev link open this page. The trail said the parent while the page compared the fork. Measured on a 90-item pair, before -> after: a cold load 247 -> 170 requests and 167 -> 90 item reads; a workspace switch 174 requests with 97 404s -> 71 with none. Sixteen cases over cold load, idle, switching in both directions, four switches with no time to settle, a cross-family switch, reload, deep link, the migration picker and leaving an arbitrary target: no request in any of them names a pair the page has left. Co-Authored-By: Claude Opus 5 (1M context) * fix: the scan toast reports the pair it scanned Off the scanned pair the re-compare is skipped, so there is nothing to report as compared — but the success branch said "Compared N items" anyway, and the error it checked first belonged to whichever pair the page had moved to. Each outcome now says what happened: the seed alone when the page has moved on, and the comparison's result only where one was asked for. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Guilhem Lemouel Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Ruben Fiszel --- cli/src/guidance/skills.gen.ts | 42 +- .../lib/components/AutosaveIndicator.svelte | 8 +- .../src/lib/components/CronBuilder.svelte | 2 +- .../src/lib/components/DatatablePicker.svelte | 9 +- .../src/lib/components/DucklakePicker.svelte | 9 +- frontend/src/lib/components/Editor.svelte | 3 + .../src/lib/components/ScriptEditor.svelte | 71 +- .../lib/components/SummaryPathDisplay.svelte | 23 +- .../AssetGraph/AddDataSourceMenu.svelte | 150 ++ .../AssetGraph/AddDownstreamMenu.svelte | 73 + .../AssetGraph/AddDownstreamPill.svelte | 23 + .../assets/AssetGraph/AddNode.svelte | 167 +- .../assets/AssetGraph/AssetGraphCanvas.svelte | 1229 ++++++++++++- .../AssetGraph/AssetGraphDetailsPane.svelte | 387 ++-- .../assets/AssetGraph/AssetGraphEdge.svelte | 54 +- .../assets/AssetGraph/AssetNode.svelte | 737 ++++++-- .../AssetGraph/DucklakeAssetPanel.svelte | 44 +- .../assets/AssetGraph/InitialFitView.svelte | 4 +- .../assets/AssetGraph/NodeActionsMenu.svelte | 68 + .../assets/AssetGraph/NodeFixButton.svelte | 124 ++ .../assets/AssetGraph/NodeOnDot.svelte | 23 + .../assets/AssetGraph/PanToNode.svelte | 5 +- .../AssetGraph/PipelineActivityPanel.svelte | 8 +- .../AssetGraph/PipelineDbtSignpost.svelte | 40 - .../AssetGraph/PipelineDeployErrors.svelte | 49 + .../PipelineDeployTriggersModal.svelte | 47 + .../AssetGraph/PipelineFolderList.svelte | 93 +- .../AssetGraph/PipelineGraphEditor.svelte | 447 ++++- .../AssetGraph/PipelineInsertMenu.svelte | 501 +++++- .../assets/AssetGraph/PipelineNodeCard.svelte | 174 ++ .../AssetGraph/PipelineScriptView.svelte | 41 +- .../AssetGraph/PipelineSetupSignpost.svelte | 83 +- .../AssetGraph/PipelineTriggerEditors.svelte | 102 +- .../assets/AssetGraph/RunStateChip.svelte | 37 + .../assets/AssetGraph/RunnableNode.svelte | 284 ++- .../assets/AssetGraph/TriggerNode.svelte | 352 ++-- .../AssetGraph/assetGraphLayout.test.ts | 14 + .../assets/AssetGraph/assetGraphLayout.ts | 68 +- .../assets/AssetGraph/assetNodeWidth.ts | 66 + .../assets/AssetGraph/assetsOnlyFolders.ts | 25 + .../assets/AssetGraph/assetsOnlyView.test.ts | 162 ++ .../assets/AssetGraph/assetsOnlyView.ts | 234 +++ .../assets/AssetGraph/edgeDescription.test.ts | 51 + .../assets/AssetGraph/edgeDescription.ts | 110 ++ .../assets/AssetGraph/graphTraversal.test.ts | 43 + .../assets/AssetGraph/graphTraversal.ts | 73 + .../AssetGraph/onDirectiveTargets.test.ts | 22 + .../assets/AssetGraph/onDirectiveTargets.ts | 34 + .../AssetGraph/pipelineAiHelpers.test.ts | 53 +- .../assets/AssetGraph/pipelineAiHelpers.ts | 122 +- .../pipelineAnnotationEdits.test.ts | 66 + .../AssetGraph/pipelineAnnotationEdits.ts | 149 ++ .../pipelineAssetPrefetch.svelte.ts | 164 ++ .../AssetGraph/pipelineDataUploads.svelte.ts | 87 + .../assets/AssetGraph/pipelineDelete.test.ts | 52 + .../assets/AssetGraph/pipelineDelete.ts | 135 ++ .../AssetGraph/pipelineDeploy.svelte.ts | 230 +++ .../assets/AssetGraph/pipelineDeploy.test.ts | 56 + .../AssetGraph/pipelineEditorState.svelte.ts | 142 +- .../AssetGraph/pipelineEditorState.test.ts | 69 + .../AssetGraph/pipelineTriggerDraftDeploy.ts | 89 + .../AssetGraph/pipelineTriggerDrafts.ts | 26 + .../assets/AssetGraph/resolveGraph.test.ts | 233 ++- .../assets/AssetGraph/resolveGraph.ts | 198 ++- .../lib/components/assets/AssetGraph/types.ts | 30 + .../assets/AssetGraph/upstreamScriptItems.ts | 32 + .../lib/components/common/alert/Alert.svelte | 21 +- .../common/contextmenu/fromMenuItems.ts | 18 + .../common/table/PipelineRow.svelte | 155 ++ .../copilot/chat/AIChatManager.svelte.ts | 81 +- .../copilot/chat/AIChatManager.test.ts | 22 +- .../lib/components/copilot/chat/anthropic.ts | 10 +- .../lib/components/copilot/chat/chatLoop.ts | 3 +- .../components/copilot/chat/global/core.ts | 47 +- .../chat/global/globalAssembly.test.ts | 19 +- .../copilot/chat/global/globalAssembly.ts | 11 +- .../chat/global/sessionToolset.test.ts | 4 +- .../copilot/chat/openai-responses.ts | 10 +- .../copilot/chat/pipeline/core.test.ts | 266 ++- .../components/copilot/chat/pipeline/core.ts | 342 +++- .../components/copilot/chat/shared.test.ts | 24 + .../src/lib/components/copilot/chat/shared.ts | 24 +- frontend/src/lib/components/copilot/lib.ts | 5 +- .../graph/renderers/nodes/NodeWrapper.svelte | 17 +- .../components/home/CreateActionsMenu.svelte | 218 ++- .../src/lib/components/home/ItemsList.svelte | 13 +- .../src/lib/components/home/TreeView.svelte | 25 +- .../sessions/PipelineEditorView.svelte | 301 +++- .../sessions/PreviewRouterPicker.svelte | 32 +- .../components/sessions/PreviewTabHost.svelte | 5 +- .../sessions/SessionChangesBar.svelte | 9 +- .../sessions/SessionDiffDrawer.svelte | 9 +- .../components/sessions/SessionPicker.svelte | 4 +- .../sessions/modifiedItemsMask.test.ts | 6 + .../components/sessions/modifiedItemsMask.ts | 16 + .../sessions/openInSessionContext.ts | 19 +- .../lib/components/sessions/previewPaths.ts | 9 + .../lib/components/sessions/previewRouter.ts | 16 +- .../sessions/sessionDeployModel.svelte.ts | 35 +- .../sessions/sessionDeployModel.test.ts | 9 + .../components/sessions/sessionDeployModel.ts | 4 +- .../components/sessions/sessionMode.svelte.ts | 4 + .../components/sessions/sessionMode.test.ts | 8 +- .../sessions/sessionPreviewTabs.svelte.ts | 21 +- .../sessions/sessionPreviewTabs.test.ts | 23 +- .../sessions/sessionRuntime.svelte.ts | 121 +- .../sessions/sessionSwitch.svelte.ts | 36 +- .../components/sessions/sessionSwitch.test.ts | 17 +- .../triggers/TriggerEditorToolbar.svelte | 6 +- .../triggers/amqp/AmqpTriggerEditor.svelte | 15 + .../amqp/AmqpTriggerEditorInner.svelte | 24 +- .../triggers/email/EmailTriggerEditor.svelte | 15 + .../email/EmailTriggerEditorInner.svelte | 24 +- .../triggers/gcp/GcpTriggerEditor.svelte | 15 + .../triggers/gcp/GcpTriggerEditorInner.svelte | 24 +- .../triggers/kafka/KafkaTriggerEditor.svelte | 15 + .../kafka/KafkaTriggerEditorInner.svelte | 24 +- .../triggers/mqtt/MqttTriggerEditor.svelte | 15 + .../mqtt/MqttTriggerEditorInner.svelte | 24 +- .../triggers/nats/NatsTriggerEditor.svelte | 15 + .../nats/NatsTriggerEditorInner.svelte | 24 +- .../postgres/PostgresTriggerEditor.svelte | 15 + .../PostgresTriggerEditorInner.svelte | 24 +- .../schedules/ScheduleAdvancedOptions.svelte | 670 +++++++ .../triggers/schedules/ScheduleEditor.svelte | 12 + .../schedules/ScheduleEditorInner.svelte | 662 +------ .../triggers/sqs/SqsTriggerEditor.svelte | 15 + .../triggers/sqs/SqsTriggerEditorInner.svelte | 24 +- frontend/src/lib/pipelinePaths.ts | 5 + frontend/src/lib/utils/describeCron.test.ts | 37 + frontend/src/lib/utils/describeCron.ts | 128 ++ frontend/src/lib/utils_workspace_deploy.ts | 3 +- .../(root)/(logged)/pipeline/+page.svelte | 32 +- .../(logged)/pipeline/[folder]/+page.svelte | 1583 +++++++++++------ system_prompts/auto-generated/prompts.ts | 42 +- .../skills/write-pipeline/SKILL.md | 42 +- system_prompts/base/pipeline-base.md | 42 +- 137 files changed, 11099 insertions(+), 2864 deletions(-) create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/NodeActionsMenu.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/NodeFixButton.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/NodeOnDot.svelte delete mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineDeployErrors.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineDeployTriggersModal.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/PipelineNodeCard.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/RunStateChip.svelte create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetNodeWidth.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetsOnlyFolders.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/assetsOnlyView.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/edgeDescription.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/edgeDescription.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/onDirectiveTargets.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineAnnotationEdits.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineAssetPrefetch.svelte.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDataUploads.svelte.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDelete.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDelete.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.svelte.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineDeploy.test.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/pipelineTriggerDrafts.ts create mode 100644 frontend/src/lib/components/assets/AssetGraph/upstreamScriptItems.ts create mode 100644 frontend/src/lib/components/common/contextmenu/fromMenuItems.ts create mode 100644 frontend/src/lib/components/common/table/PipelineRow.svelte create mode 100644 frontend/src/lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte create mode 100644 frontend/src/lib/utils/describeCron.test.ts create mode 100644 frontend/src/lib/utils/describeCron.ts diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index e5b3619a27..eb5d46ef57 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -7915,10 +7915,48 @@ A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates step A pipeline node that produces a table should almost always be a **\`duckdb\`** node that materializes its output into a **DuckLake** table with \`-- materialize ducklake:///\` (in a DuckDB node the annotation uses SQL \`--\` comment syntax; write the body as a bare \`SELECT\` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else: - \`postgresql\` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live). -- \`bun\` / \`python3\` — only for non-tabular work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node still produces tabular data for downstream steps, land it in DuckLake (write it with the wmill SDK / ducklake helpers) rather than inventing a parallel store. +- \`bun\` / \`python3\` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see "Ingesting from Python / TypeScript" below — rather than inventing a parallel store. Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal. +## Ingesting from Python / TypeScript + +A node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with \`wmill.ducklake()\`, or into a data table with \`wmill.datatable()\`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second "load the raw file" node. One ingestion node, one table. + +Pass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema: + +\`\`\`python +# pipeline +# on schedule +import wmill + +def main(): + rows = fetch_charges() # list[dict] + lake = wmill.ducklake() + lake.query(""" + CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json($rows, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true) + """, rows=rows).fetch() +\`\`\` + +\`\`\`ts +// pipeline +// on schedule +import * as wmill from "windmill-client" + +export async function main() { + const rows = await fetchCharges() + const lake = wmill.ducklake() + await lake\`CREATE OR REPLACE TABLE stripe_charges AS + SELECT unnest(from_json(\${rows}, '[{"id":"VARCHAR","amount":"BIGINT","created":"BIGINT"}]'), recursive := true)\`.fetch() +} +\`\`\` + +- Use \`INSERT INTO
SELECT ...\` instead of \`CREATE OR REPLACE\` to append (an incremental pull), with a one-time \`CREATE TABLE IF NOT EXISTS\` first. +- Assign the client to a variable (\`lake = wmill.ducklake()\`) and keep the SQL a **string literal** (Python) or a template without \`sql.raw\` (TS): that is what lets the graph record the node's write to \`ducklake://main/
\`. A chained \`wmill.ducklake().query(...)\` or SQL built in a variable records no output, so the downstream edge never forms. +- \`wmill.ducklake("")\` targets a non-default catalog; \`wmill.datatable("")\` writes a Postgres data table the same way (Python \`db.query(sql, *args)\` with \`$1\` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above). +- The downstream node triggers on the table itself: \`-- on ducklake://main/stripe_charges\`. + ## Storage prerequisites A DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and \`s3://\` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build. @@ -7962,7 +8000,7 @@ The key must be a **string literal** — the graph parser is static and cannot f ## Materialize (the managed output) -> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake:///
\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the output via the SDK (\`wmill.writeS3File(...)\`, ducklake helpers, …) and let it be inferred. Use \`duckdb\` when a node should materialize a DuckLake table. +> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake:///
\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the table itself with \`wmill.ducklake()\` (see "Ingesting from Python / TypeScript") and let the output be inferred. Use \`duckdb\` when a node should materialize a DuckLake table. > > The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: \`// materialize manual dbt:////\`, where \`\` is a warehouse the workspace configures under Settings → dbt. \`manual\` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql \`CREATE TABLE\` / \`INSERT\`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a \`source\`: the declared relation and the dbt model land on ONE graph node, and a downstream \`// on dbt:////\` fires when the ingestion node completes. diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 76dc030c58..1e318ed9b2 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -142,7 +142,13 @@ let hintTimer: ReturnType | undefined const kindLabel = $derived( - itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script' + itemKind === 'flow' + ? 'flow' + : itemKind === 'app' || itemKind === 'raw_app' + ? 'app' + : itemKind === 'data_pipeline' + ? 'pipeline' + : 'script' ) // Each false → true transition fires a hint (others > loaded). Seeded to diff --git a/frontend/src/lib/components/CronBuilder.svelte b/frontend/src/lib/components/CronBuilder.svelte index bcf8508c12..3bceabb417 100644 --- a/frontend/src/lib/components/CronBuilder.svelte +++ b/frontend/src/lib/components/CronBuilder.svelte @@ -15,7 +15,7 @@ contentClasses="p-4" > {#snippet trigger()} - {/snippet} diff --git a/frontend/src/lib/components/DatatablePicker.svelte b/frontend/src/lib/components/DatatablePicker.svelte index 53617acf36..17ad6f3361 100644 --- a/frontend/src/lib/components/DatatablePicker.svelte +++ b/frontend/src/lib/components/DatatablePicker.svelte @@ -17,6 +17,9 @@ selectInputClass?: string class?: string onClear?: () => void + /** Size the input to its content, like Path's folder picker. */ + useContentEditable?: boolean + RightIcon?: any } let { @@ -27,7 +30,9 @@ placeholder = undefined, selectInputClass = '', class: className = '', - onClear = undefined + onClear = undefined, + useContentEditable = false, + RightIcon = undefined }: Props = $props() let datatables = usePromise(() => @@ -47,6 +52,8 @@ {placeholder} inputClass={selectInputClass} {onClear} + {useContentEditable} + {RightIcon} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })} diff --git a/frontend/src/lib/components/DucklakePicker.svelte b/frontend/src/lib/components/DucklakePicker.svelte index 3a6f2bf739..d6ed4d0cdf 100644 --- a/frontend/src/lib/components/DucklakePicker.svelte +++ b/frontend/src/lib/components/DucklakePicker.svelte @@ -17,6 +17,9 @@ selectInputClass?: string class?: string onClear?: () => void + /** Size the input to its content, like Path's folder picker. */ + useContentEditable?: boolean + RightIcon?: any } let { @@ -27,7 +30,9 @@ placeholder = undefined, selectInputClass = '', class: className = '', - onClear = undefined + onClear = undefined, + useContentEditable = false, + RightIcon = undefined }: Props = $props() let ducklakes = usePromise(() => @@ -45,6 +50,8 @@ {placeholder} inputClass={selectInputClass} {onClear} + {useContentEditable} + {RightIcon} /> {#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })} diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index f9df8adcef..03bf5ce87e 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1523,6 +1523,9 @@ } catch (e) { console.log('error initializing services', e) } + // Unmounted while the services loaded: the container is detached, and + // creating the editor on it throws. + if (destroyed || !divEl?.isConnected) return // vscode.languages.registerDefinitionProvider('*', { // provideDefinition(document, position, token) { diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 8990f70ae5..8e60d40f72 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2101,43 +2101,15 @@ {/if} {#if customUi?.previewPanel?.hideArgs} - -
- {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} - - - {/if} -
+ +
+ +
+
{#if testIsLoading} {@render cancelTestButton('sm', 'shadow-md')} {:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun} @@ -2271,6 +2243,31 @@ {@render runTestButton('sm', 'shadow-md')} {/if}
+ {#if testJob?.id && testJob.type === 'CompletedJob' && opWs} + + + {/if} +
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
string | undefined /** Header variant: no path line (the host shows it) and a lighter summary. */ compact?: boolean /** Rendered inside the trigger in place of the summary, for a host that hangs a second copy @@ -51,6 +58,7 @@ editable = false, onSaved, kind = 'flow', + saveOverride = undefined, compact = false, label, triggerClass = 'block min-w-0 max-w-full px-1 py-0.5 rounded text-left cursor-pointer hover:bg-surface-hover transition-colors', @@ -67,7 +75,9 @@ let popoverOpen = $state(false) // Resolved before the popover opens, not on opening it: the path field only exists for an owner, // and `openFocus` looks for it in the same flush the popover renders in. - const own = $derived(isOwner(path ?? '', actingUser, $operatingWorkspace)) + const own = $derived( + saveOverride !== undefined || isOwner(path ?? '', actingUser, $operatingWorkspace) + ) let onBehalfOfEmail = $state(undefined) let summaryInput: ReturnType | undefined = $state() let labelsDirty = $state(false) @@ -101,6 +111,17 @@ async function save(close: () => void) { const initialPath = path ?? '' const newPath = own ? editPath : initialPath + if (saveOverride) { + const error = saveOverride({ path: newPath, summary: editSummary, labels }) + if (error) { + sendUserToast(error, true) + return + } + labelsDirty = false + close() + onSaved?.(newPath) + return + } try { await updateItemPathAndSummary({ diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte new file mode 100644 index 0000000000..49a222148b --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDataSourceMenu.svelte @@ -0,0 +1,150 @@ + + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte new file mode 100644 index 0000000000..2a54fe11d4 --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamMenu.svelte @@ -0,0 +1,73 @@ + + + + + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte new file mode 100644 index 0000000000..8ebd24455c --- /dev/null +++ b/frontend/src/lib/components/assets/AssetGraph/AddDownstreamPill.svelte @@ -0,0 +1,23 @@ + + + + + + + Add downstream step + + diff --git a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte index 880abb685a..57a31e34d8 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AddNode.svelte @@ -1,145 +1,36 @@ - - {#snippet trigger()} - - - {/snippet} - + +
+ + {#snippet trigger()} + + + {/snippet} + +
diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 27f6ce6be7..87b085593c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -1,8 +1,11 @@ -
+
onNodePointerEnter(node.id)} + onnodepointerleave={onNodePointerLeave} + onedgepointerenter={onEdgeEnter} + onedgepointerleave={onEdgeLeave} --background-color={false} >
- + + {#if assetsOnlyToggle} + + { + assetsOnly = !e.detail + if (assetsOnlyFolder != undefined) setAssetsOnlyFolder(assetsOnlyFolder, assetsOnly) + }} + /> + {#if withoutPassive.dropped > 0} + { + showPassiveReads = e.detail + try { + localStorage.setItem(SHOW_PASSIVE_READS_KEY, String(e.detail)) + } catch {} + }} + /> + {/if} + + {/if} {#if showMinimap} + +{/if} + +{#if scheduleWizard} + +{/if} + +{#if onAddPipelineScript} + +{/if} +