mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
* wip: one sidebar for operators and developers, with a detach mode Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: configure pipeline triggers and outputs as drafts in the editor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: one card for pipeline nodes, and the pipeline pages in the page header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop a removed node's trigger drafts, and confirm triggers when leaving Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: show pipeline assets red when unused or awaited but never produced Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: give pipeline asset nodes the trigger nodes' background again Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: taller pipeline nodes, with the asset + back on the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: judge dbt relations by provenance, keep dynamic_skip on reopen, refresh saved scripts' titles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: label the pipeline + buttons: add data source, add downstream step on hover Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: name a schedule node's cadence, like "Every day at 12:00" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the downstream-step pill open while its menu is, without flicker Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: room between pipeline node chips and the node's edge Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: refuse renaming a pipeline draft out of the pipeline's folder Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: title pipeline schedule nodes with the schedule's summary Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: assets-only view of a pipeline, with producer and trigger chips Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: default schedule handlers, unique trigger draft paths, zoned cadence, valid script names Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: setup banner without icon, button actions, plainer activity rows Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: assets-only trigger chip under the title, editable and selectable Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: await schedule defaults before confirming, refuse colliding trigger renames Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: size assets-only cards to their content Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pipeline draft sync status beside the folder name in edit mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: autosave edits to a deployed pipeline script from the first keystroke Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: show schedule handlers once their defaults load, draft suffix on static chips Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep the pipeline folder while the page tears down on leaving Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: discard the open pane's autosaved edits with Discard all Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: pipeline node context menus, add-at-cursor, and assets-only delete Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: borderless pipeline cards on the main surface, like flow steps Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: remember which pipelines are in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: edit an asset's producing script from its pane and multi-script chip Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: more room between pipeline layers for the bottom + button Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: background on the trigger chip only when it opens an editor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: remove the script before its trigger, archive it for non-admins Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: scope assets-only memory by workspace, no delete for operators, flow reason Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: fly in the pipeline node menus Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: size the Show drafts toggle like the other header buttons Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: a No asset node for scripts that build nothing, in the assets-only view Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: describe a pipeline edge's relationship on hover Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: name assets-only edges by what is built from what Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: open an asset's upstream trigger from its pane header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: reopen the edit-mode pane when coming back from View Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * style: no red border on errored pipeline nodes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: Fix actions on misconfigured pipeline nodes, and trigger toggles on edges Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: folded errors and edge toggles in assets-only view, preset writer wizard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: flag pipeline scripts nothing starts, with schedule/webhook/remove fixes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: an unwritten subscription is fixed on its script only, by removing it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: hide subscribed-only unwritten assets, review fixes for the edit pane and edge actions Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: an unwritten subscription is the subscriber's error, not the asset's Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a script only started by reads of unwritten assets is flagged too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: no add-downstream + on assets nothing in the workspace writes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: Save all treats a draft identical to the deployed script as saved Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: toggle to hide reads that don't trigger runs in the pipeline graph Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: a programmatic on/mute line joins the existing ones in the header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: no loading spinner in the pane while a clicked script loads Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no subscribe drag dot in view mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: opening a draft no longer autosaves; header tab styling, selected edge, taller cards Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: clicking an unbuilt asset opens the asset again, not its script Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: pipelines open in the assets-only view by default Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: an errored script header stays red when selected Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: Add data source pill sits one small gap above the graph Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: add-step menu labels its path field Path Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: iterate NodeLists with Array.from so svelte-check passes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: the band names the path Deploy will create, and keeps the summary readable A brand-new app is parked at a `draft_<uuid>` 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Revert "fix: the file sidebar's toggle leaves the band" This reverts commit1da8d16740. 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: deploy and discard a session's pipeline edits as one changes-list row Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: a masked pipeline preselects only the scripts and triggers of its folder Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: review round — failed bundle save fails the deploy, removing a draft records no change Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: route pipeline graph edges orthogonally with spread ports, separate tracks and lanes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: retry the leftover draft save with what failed, never an empty draft Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: pipeline graph edges back to bezier curves, detours curved too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: hovering a pipeline graph node fades everything outside its lineage Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: hover fade eases edges too, through a stylesheet instead of new node objects Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: AI gives every pipeline node a succinct summary Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: session pipeline tools survive another tab taking the panel, reopening the editor on call Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: deleting a pipeline clears it from open sessions so autosave cannot restore it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: closing the pipeline pane closes its modal instead of leaving it blank Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: a DuckLake asset opens on a Data tab with the database manager Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: inputs-only view infers draft inputs, keeps stored lineage, runs the live body Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: readyDataUploadPaths is a ReadonlySet in the pipeline editor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: selected pipeline nodes get their blue edge in dark mode too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: no red flash on load while writer lookups and script bodies are in flight Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * refactor: deploy and discard close the open pane first instead of patching its edits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: a draft whose summary or labels changed mid-deploy is kept too Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: service logs and the audit filters move into the band Service logs: the page's name is the breadcrumb's, so the `<h1>` 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * Revert "feat: a fork's counts and the way to review them are one control" This reverts commite4504d2843. The counts go back to separate badges and "Review changes" to a button of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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 `<id> === 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Guilhem Lemouel <guilhemlemouel@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
860 lines
32 KiB
Svelte
860 lines
32 KiB
Svelte
<script lang="ts">
|
|
import { untrack, type Snippet } from 'svelte'
|
|
import { MousePointerClick } from 'lucide-svelte'
|
|
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
|
|
import Portal from '$lib/components/Portal.svelte'
|
|
import { getOverlayHost } from '$lib/components/common/overlayHost.svelte'
|
|
import { useFlowPanelMode } from '$lib/components/flows/flowPanelMode.svelte'
|
|
import {
|
|
PIPELINE_DRAFT_KIND,
|
|
pipelineBundlePath,
|
|
pipelineLocalMirrorKey
|
|
} from '$lib/pipelinePaths'
|
|
import { Loader2 } from 'lucide-svelte'
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
import { DraftService } from '$lib/gen'
|
|
import { decodeState, encodeState } from '$lib/utils'
|
|
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
|
import { extractReads, extractWrites } from '$lib/components/assets/lib'
|
|
import type { PipelineDraft } from './pipelineAiHelpers'
|
|
import type { ColumnLineageGraph } from './columnLineageGraph'
|
|
import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte'
|
|
import AssetGraphCanvas from './AssetGraphCanvas.svelte'
|
|
import { TRIGGER_NODE_STYLE } from './TriggerNode.svelte'
|
|
import { describeSchedule } from '$lib/utils/describeCron'
|
|
import type { UpstreamTriggerAction } from './upstreamScriptItems'
|
|
import type { AssetUpstreamDelete } from './assetsOnlyView'
|
|
import type { NodeFixSpec } from './NodeFixButton.svelte'
|
|
import AssetGraphDetailsPane from './AssetGraphDetailsPane.svelte'
|
|
import PipelineEventLog from './PipelineEventLog.svelte'
|
|
import type {
|
|
AssetGraphResponse,
|
|
AssetGraphSelection,
|
|
NativeTriggerKind,
|
|
PipelineMode,
|
|
PipelineTriggerDraft,
|
|
DbtAssetProvenance
|
|
} from './types'
|
|
import { isDraftableTriggerKind, triggerDraftKey } from './pipelineTriggerDrafts'
|
|
import type { AssetKind, Script, ScriptLang } from '$lib/gen'
|
|
import type { RunnableRunState, PipelineEvent } from './activeRunnables.svelte'
|
|
import type { PipelineOutputKind } from './pipelineTemplates'
|
|
import type { PipelineEditorState } from './pipelineEditorState.svelte'
|
|
import type { SchemaContractGraphContext } from './schemaContracts'
|
|
|
|
type RunProducer = { kind: 'script' | 'flow'; path: string; unsaved?: boolean; cascade?: boolean }
|
|
|
|
let {
|
|
editor,
|
|
displayGraph,
|
|
mode,
|
|
workspace,
|
|
folder,
|
|
viewportFitKey = undefined,
|
|
stackBelow = 680,
|
|
persistDrafts = false,
|
|
pathPrefix,
|
|
defaultPathSuffix = 'new_pipeline_script',
|
|
panelHidden = false,
|
|
onTogglePanelHidden,
|
|
modalPanel = false,
|
|
prefetchingAssets = false,
|
|
hoveredPaths = [],
|
|
selectedRunPaths = [],
|
|
activeRunnable,
|
|
activeRunnableIds,
|
|
runStates,
|
|
eventLogEvents = [],
|
|
runsRefreshKey = 0,
|
|
runsPendingJobId,
|
|
boundPick,
|
|
validStartPaths,
|
|
onStartBoundedRun,
|
|
onPickEnd,
|
|
panToNodeId,
|
|
boundBar,
|
|
onCreateMissingTrigger,
|
|
onEditTrigger,
|
|
onDeleteTrigger,
|
|
onOpenWebhook,
|
|
onOpenDataUpload,
|
|
onSelect,
|
|
onAddScriptForAsset,
|
|
onAddPipelineScript,
|
|
onRunnableMenuRemove,
|
|
onDeleteAssetUpstream,
|
|
nodeFixes,
|
|
assetDeleteVerb,
|
|
onEdgeAction,
|
|
onAddAssetTrigger,
|
|
onRunProducer,
|
|
idlePane,
|
|
onRequestEdit,
|
|
canRunByPath = false,
|
|
onRunByPath,
|
|
onRunCascadeByPath,
|
|
runFormInitialArgs,
|
|
onRunFormArgsChange,
|
|
inputsOnly = false,
|
|
onRunInputs,
|
|
onShowScript,
|
|
readyDataUploadPaths,
|
|
resolveLocalScript,
|
|
localScriptsVersion,
|
|
selectionProducers = [],
|
|
selectionColumnGraph,
|
|
selectionColumnLoading = false,
|
|
selectionColumnTruncated = false,
|
|
selectionColumnFailed = false,
|
|
selectionDbt,
|
|
schemaCanEvolve = true,
|
|
selectionForkMaterialization = undefined,
|
|
schemaContractContext = undefined,
|
|
downstreamSubscribers = 0,
|
|
onStartBoundedRunForOpen,
|
|
canBoundedRunOpenScript = false,
|
|
onRunCompleted,
|
|
onTestStateChange,
|
|
requestRemoveSignal = 0,
|
|
requestRunSignal = 0,
|
|
requestRunCascadeSignal = 0,
|
|
focusUploadSignal = 0,
|
|
onDraftPathChange,
|
|
onClose = () => {},
|
|
onDiscard,
|
|
onDraftSaved,
|
|
onPersistedSaved,
|
|
onScriptRenamed,
|
|
onScriptRemoved
|
|
}: {
|
|
editor: PipelineEditorState
|
|
displayGraph: AssetGraphResponse
|
|
mode: PipelineMode
|
|
workspace: string | undefined
|
|
/** Folder the pipeline is scoped to — drives the autosave draft path. */
|
|
folder: string
|
|
/** Folder whose graph is actually *loaded* (not the route param). On an
|
|
* in-place folder switch the stale graph stays rendered while the new
|
|
* fetch is in flight, so keying the canvas's one-shot initial fit on
|
|
* `folder` would consume the new folder's fit on the old graph. Pages
|
|
* that stale-while-revalidate should pass the folder captured when the
|
|
* fetch resolved; defaults to `folder`. */
|
|
viewportFitKey?: string
|
|
/** Below this container width (px) the graph/details split stacks
|
|
* vertically instead of side-by-side — for narrow side panels / AI
|
|
* session previews. Defaults to 680. */
|
|
stackBelow?: number
|
|
/** Persist drafts as a per-folder `data_pipeline` DraftService bundle (enabled
|
|
* by both the route page and the in-session preview). FlowBuilder's autosave
|
|
* analogue. */
|
|
persistDrafts?: boolean
|
|
pathPrefix: string
|
|
defaultPathSuffix?: string
|
|
panelHidden?: boolean
|
|
/** Let the details pane open as a modal over a full-width graph when the editor
|
|
* is too narrow to dock it — the flow editor's step panel rule. */
|
|
modalPanel?: boolean
|
|
onTogglePanelHidden?: () => void
|
|
prefetchingAssets?: boolean
|
|
hoveredPaths?: string[]
|
|
selectedRunPaths?: string[]
|
|
activeRunnable?: { kind: 'script' | 'flow'; path: string } | undefined
|
|
activeRunnableIds?: ReadonlySet<string>
|
|
runStates?: ReadonlyMap<string, RunnableRunState>
|
|
eventLogEvents?: PipelineEvent[]
|
|
runsRefreshKey?: any
|
|
runsPendingJobId?: string | undefined
|
|
boundPick?: any
|
|
validStartPaths?: Set<string>
|
|
onStartBoundedRun?: (path: string) => void
|
|
onPickEnd?: (id: string) => void
|
|
panToNodeId?: string | undefined
|
|
boundBar?: Snippet
|
|
onCreateMissingTrigger?: (kind: NativeTriggerKind, scriptPath: string) => void
|
|
onEditTrigger?: (...args: any[]) => void
|
|
onDeleteTrigger?: (...args: any[]) => void
|
|
onOpenWebhook?: (...args: any[]) => void
|
|
onOpenDataUpload?: (...args: any[]) => void
|
|
onSelect: (selection: AssetGraphSelection | undefined) => void
|
|
onAddScriptForAsset?: (
|
|
asset: { kind: AssetKind; path: string },
|
|
language: ScriptLang,
|
|
scriptPath: string,
|
|
outputKind: PipelineOutputKind,
|
|
aiPrompt?: string,
|
|
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
|
) => void
|
|
onAddPipelineScript?: (
|
|
language: ScriptLang,
|
|
path: string,
|
|
source: { kind: NativeTriggerKind; path: string | undefined },
|
|
outputKind: PipelineOutputKind,
|
|
aiPrompt?: string,
|
|
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
|
|
) => void
|
|
onRunnableMenuRemove?: (...args: any[]) => void
|
|
onDeleteAssetUpstream?: (target: AssetUpstreamDelete) => void
|
|
nodeFixes?: ReadonlyMap<string, NodeFixSpec>
|
|
assetDeleteVerb?: 'Delete' | 'Archive'
|
|
onEdgeAction?: (a: {
|
|
action: 'listen' | 'stop'
|
|
targets: Array<{ script: string; asset: { kind: AssetKind; path: string }; reads: boolean }>
|
|
}) => void
|
|
onAddAssetTrigger?: (a: {
|
|
scripts: Array<{ script: string; reads: boolean }>
|
|
asset: { kind: AssetKind; path: string }
|
|
}) => void
|
|
onRunProducer?: (producer: RunProducer) => Promise<string | undefined>
|
|
idlePane?: Snippet
|
|
onRequestEdit?: () => void
|
|
canRunByPath?: boolean
|
|
onRunByPath?: (path: string, args: Record<string, any>) => Promise<string | undefined>
|
|
// Run the open script AND its downstream closure (dev preview only; the
|
|
// deployed pane leaves this unset — its single run cascades via the backend).
|
|
onRunCascadeByPath?: (path: string, args: Record<string, any>) => Promise<string | undefined>
|
|
// Persisted run-form args for the currently-open data-upload entry, and a
|
|
// callback to persist them as they change — see the page's dataUploadArgs.
|
|
runFormInitialArgs?: Record<string, any>
|
|
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
|
|
/** See AssetGraphDetailsPane's `inputsOnly`. */
|
|
inputsOnly?: boolean
|
|
onRunInputs?: (
|
|
path: string,
|
|
args: Record<string, any>,
|
|
live?: { content: string; language: ScriptLang }
|
|
) => Promise<string | undefined>
|
|
onShowScript?: () => void
|
|
// Data-upload entry scripts whose staged upload is ready (green node).
|
|
readyDataUploadPaths?: ReadonlySet<string>
|
|
/** Local-dev (`/pipeline_dev`): resolve a node to its working-tree content
|
|
* so the details pane skips the (nonexistent) deployed-script fetch. May
|
|
* be async (to infer the args schema for the run form). */
|
|
resolveLocalScript?: (path: string) => Script | undefined | Promise<Script | undefined>
|
|
/** Bumped on each `pipeline dev` bundle so the open details pane re-resolves
|
|
* the selected node's source on live-reload. */
|
|
localScriptsVersion?: unknown
|
|
selectionProducers?: Array<{ kind: 'script' | 'flow'; path: string; unsaved?: boolean }>
|
|
/** Transitive column-lineage trace for the selected asset (route page). */
|
|
selectionColumnGraph?: ColumnLineageGraph
|
|
/** That trace still being fetched — a dbt relation's is a request of its
|
|
* own, so it arrives after the selection does. */
|
|
selectionColumnLoading?: boolean
|
|
/** That trace cut at the part nearest the selection. */
|
|
selectionColumnTruncated?: boolean
|
|
/** That trace could not be fetched. Distinguished from an empty one: a
|
|
* project without the analysis pass draws nothing either. */
|
|
selectionColumnFailed?: boolean
|
|
/** dbt provenance of the selected relation — carries its SQL. */
|
|
selectionDbt?: DbtAssetProvenance
|
|
schemaCanEvolve?: boolean
|
|
/** Fork workspaces: data-environment state of the selected ducklake asset (route page). */
|
|
selectionForkMaterialization?: 'fork' | 'deferred'
|
|
/** Producer-side facts for the editor's live schema-contract diagnostics
|
|
* (ignore suppression + scd2 `_current` fallback), from the route page. */
|
|
schemaContractContext?: SchemaContractGraphContext
|
|
downstreamSubscribers?: number
|
|
onStartBoundedRunForOpen?: (path: string) => void
|
|
canBoundedRunOpenScript?: boolean
|
|
onRunCompleted?: () => void
|
|
onTestStateChange?: (running: boolean) => void
|
|
requestRemoveSignal?: number
|
|
requestRunSignal?: number
|
|
requestRunCascadeSignal?: number
|
|
focusUploadSignal?: number
|
|
onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string
|
|
onClose?: () => void
|
|
onDiscard?: () => void
|
|
onDraftSaved?: (savedPath: string) => void
|
|
onPersistedSaved?: (savedPath: string) => void
|
|
onScriptRenamed?: (oldPath: string, newPath: string) => void
|
|
onScriptRemoved?: (removedPath: string) => void
|
|
} = $props()
|
|
|
|
let leftPaneSize = $state(60)
|
|
let rightPaneSize = $state(40)
|
|
// 0 = "no user-chosen size yet" so the orientation-aware default (55% stacked /
|
|
// 40% side-by-side) applies on first open; a real drag overwrites it below.
|
|
let storedRightPaneSize = $state(0)
|
|
|
|
// Container width drives the split orientation: stack vertically (graph over
|
|
// details) when too narrow for a comfortable side-by-side split.
|
|
let containerWidth = $state(0)
|
|
let stacked = $derived(containerWidth > 0 && containerWidth < stackBelow)
|
|
|
|
let effectiveSelection = $derived<AssetGraphSelection | undefined>(
|
|
editor.activeDraftPath
|
|
? { kind: 'runnable', runnable_kind: 'script', path: editor.activeDraftPath }
|
|
: editor.selection
|
|
)
|
|
let activeDraft = $derived(editor.activeDraft)
|
|
// Edit mode opens the right pane only for a selection/draft; view mode keeps it
|
|
// open permanently (it shows the activity idlePane when nothing is selected,
|
|
// and swaps to the details pane on node select). Matches the route page's
|
|
// original behaviour — NOT "always open in edit mode".
|
|
let detailsPaneOpen = $derived(
|
|
mode === 'edit'
|
|
? (editor.selection != undefined || editor.activeDraftPath != undefined) && !panelHidden
|
|
: !panelHidden
|
|
)
|
|
// Docked beside the graph, or in a modal over it (modal mode). In modal mode the
|
|
// details only show once asked for: a double-click on a node, a click on the
|
|
// node already selected, or a script's open button — never on a selection the
|
|
// editor makes itself (an AI-built node), which would pop a modal unasked.
|
|
const panelController = useFlowPanelMode({ enabled: () => modalPanel })
|
|
$effect(() => panelController.measure(containerWidth))
|
|
const panelMode = $derived(panelController.mode)
|
|
let panelModalOpen = $state(false)
|
|
const dockedPaneOpen = $derived(detailsPaneOpen && panelMode === 'docked')
|
|
$effect(() => {
|
|
if (untrack(() => panelModalOpen) && (panelMode === 'docked' || !detailsPaneOpen)) {
|
|
panelModalOpen = false
|
|
}
|
|
})
|
|
let panelDisposable: Disposable | undefined = $state(undefined)
|
|
// Disposable joins the overlay stack through its methods, not by watching `open`.
|
|
$effect(() => {
|
|
panelModalOpen
|
|
untrack(() => {
|
|
panelModalOpen ? panelDisposable?.openDrawer() : panelDisposable?.closeDrawer()
|
|
})
|
|
})
|
|
const overlayHost = getOverlayHost()
|
|
const modalHost = $derived(overlayHost?.el())
|
|
|
|
function selectableNodeAt(e: MouseEvent): HTMLElement | null {
|
|
return (e.target as HTMLElement | null)?.closest('.svelte-flow__node.selectable') ?? null
|
|
}
|
|
// Read in the capture phase: once the click bubbles, the canvas has applied
|
|
// its selection and a first click looks like a click on the selected node.
|
|
let clickStartedOnSelected = false
|
|
function noteSelectionBeforeClick(e: MouseEvent) {
|
|
clickStartedOnSelected = !!selectableNodeAt(e)?.classList.contains('selected')
|
|
}
|
|
function openPanelModalIfReselected(e: MouseEvent) {
|
|
if (clickStartedOnSelected && selectableNodeAt(e)) panelModalOpen = true
|
|
}
|
|
function openPanelModalFromGraph(e: MouseEvent) {
|
|
if (selectableNodeAt(e)) panelModalOpen = true
|
|
}
|
|
// A button in the pane that ends it closes the modal in the same update: left to
|
|
// the effect above, the modal would first render the pane without its selection.
|
|
function closePanelModal() {
|
|
if (panelMode === 'modal') panelModalOpen = false
|
|
}
|
|
function handleCanvasSelect(s: AssetGraphSelection | undefined, opts?: { open?: boolean }) {
|
|
onSelect(s)
|
|
if (opts?.open && s && panelMode === 'modal') panelModalOpen = true
|
|
}
|
|
// A data-upload node's click asks for its run form; in modal mode nothing shows
|
|
// it unless the modal opens too.
|
|
$effect(() => {
|
|
if (focusUploadSignal > 0 && untrack(() => panelMode) === 'modal') {
|
|
untrack(() => (panelModalOpen = true))
|
|
}
|
|
})
|
|
const showNodeHint = $derived(panelMode === 'modal' && !panelModalOpen)
|
|
const nodeHintText = $derived(
|
|
effectiveSelection
|
|
? 'Click the selected node to open it'
|
|
: 'Double click a node to open it'
|
|
)
|
|
|
|
let idleView = $derived(
|
|
mode !== 'edit' && editor.selection == undefined && editor.activeDraftPath == undefined
|
|
)
|
|
|
|
// Wrap the writes (and the rightPaneSize read in the else branch) in untrack so
|
|
// the effect tracks only `dockedPaneOpen` / `storedRightPaneSize` — without it,
|
|
// the Pane `bind:size` feedback loops the effect and pegs the main thread.
|
|
$effect(() => {
|
|
// Stacked (vertical) splits give the details pane more room — the run
|
|
// form + result need height more than the graph needs it.
|
|
const fallback = stacked ? 55 : 40
|
|
if (dockedPaneOpen) {
|
|
const restore = storedRightPaneSize
|
|
untrack(() => {
|
|
rightPaneSize = restore > 0 ? restore : fallback
|
|
leftPaneSize = 100 - (restore > 0 ? restore : fallback)
|
|
})
|
|
} else {
|
|
untrack(() => {
|
|
if (rightPaneSize > 0) storedRightPaneSize = rightPaneSize
|
|
leftPaneSize = 100
|
|
})
|
|
}
|
|
})
|
|
|
|
// The selected asset's producing scripts, named and iconed from the graph, for
|
|
// the pane's "Edit script".
|
|
let producerScripts = $derived(
|
|
selectionProducers
|
|
.filter((p) => p.kind === 'script')
|
|
.map((p) => {
|
|
const r = displayGraph.runnables.find((x) => x.usage_kind === 'script' && x.path === p.path)
|
|
return { path: p.path, summary: r?.summary, language: r?.language }
|
|
})
|
|
)
|
|
// The selected asset's upstream triggers that can be opened from here, each
|
|
// with the same action its trigger node has: edit (edit mode), create a
|
|
// missing one (edit mode), or the webhook / data-upload drawers.
|
|
let producerTriggers = $derived.by((): UpstreamTriggerAction[] => {
|
|
const scripts = new Set(producerScripts.map((p) => p.path))
|
|
const seen = new Set<string>()
|
|
const out: UpstreamTriggerAction[] = []
|
|
for (const t of displayGraph.triggers ?? []) {
|
|
if (t.trigger_kind === 'asset' || !scripts.has(t.runnable_path)) continue
|
|
const kind = t.trigger_kind
|
|
const rowless = kind === 'webhook' || kind === 'data_upload'
|
|
const key = `${kind}:${rowless || t.missing ? t.runnable_path : t.path}`
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
const label = TRIGGER_NODE_STYLE[kind]?.label ?? kind
|
|
const script = t.runnable_path
|
|
let action: UpstreamTriggerAction | undefined
|
|
if (kind === 'webhook') {
|
|
if (onOpenWebhook)
|
|
action = { label: 'Open webhook', detail: script, onOpen: () => onOpenWebhook(script) }
|
|
} else if (kind === 'data_upload') {
|
|
if (onOpenDataUpload)
|
|
action = { label: 'Upload data', detail: script, onOpen: () => onOpenDataUpload(script) }
|
|
} else if (t.missing && !rowless) {
|
|
if (onCreateMissingTrigger)
|
|
action = {
|
|
label: `Create ${label} trigger`,
|
|
detail: script,
|
|
onOpen: () => onCreateMissingTrigger(kind, script)
|
|
}
|
|
} else if (t.path && onEditTrigger) {
|
|
const path = t.path
|
|
action = {
|
|
label: kind === 'schedule' ? 'Edit schedule' : `Edit ${label} trigger`,
|
|
detail:
|
|
kind === 'schedule' && t.schedule
|
|
? (t.summary ?? describeSchedule(t.schedule, t.timezone) ?? path)
|
|
: path,
|
|
onOpen: () => onEditTrigger(kind, path, script)
|
|
}
|
|
}
|
|
if (action) out.push(action)
|
|
}
|
|
return out
|
|
})
|
|
|
|
function editProducerScript(path: string) {
|
|
if (mode !== 'edit') onRequestEdit?.()
|
|
onSelect({ kind: 'runnable', runnable_kind: 'script', path })
|
|
}
|
|
|
|
// ===================== Draft autosave (persistDrafts only) =====================
|
|
// All of this folder's in-flight drafts live in ONE per-user DB draft (typ
|
|
// `data_pipeline`) keyed at the folder, syncing across devices + surfacing in
|
|
// the global drafts list. localStorage is a synchronous crash mirror, READ only
|
|
// for the one-time migration below; the DB is the source of truth on load.
|
|
// FlowBuilder's autosave analogue — gated by `persistDrafts`.
|
|
let pipelineDraftPath = $derived(pipelineBundlePath(folder))
|
|
let storageKey = $derived(pipelineLocalMirrorKey(folder))
|
|
type PipelineDraftBundle = {
|
|
drafts: Array<[string, PipelineDraft]>
|
|
activeDraftPath?: string
|
|
triggerDrafts?: PipelineTriggerDraft[]
|
|
}
|
|
// Hydration is tracked on the editor instance (`editor.hydratedFromDb`), not a
|
|
// component flag: the in-session preview reuses one instance across editor
|
|
// hide/show, and it must hydrate ONCE per instance, not on every remount.
|
|
let lastPersistedBundle: string | undefined = undefined
|
|
// What decides whether the bundle needs saving: its drafts, not which one is
|
|
// open. Opening a draft is not an edit, so it rides along with the next real
|
|
// change instead of saving (and spinning the sync indicator) on a click.
|
|
const persistKey = (b: PipelineDraftBundle | undefined) =>
|
|
b ? JSON.stringify({ drafts: b.drafts, triggerDrafts: b.triggerDrafts }) : undefined
|
|
|
|
function restoreBundle(bundle: PipelineDraftBundle) {
|
|
if (Array.isArray(bundle.drafts)) {
|
|
const loaded = new Map<string, PipelineDraft>()
|
|
for (const entry of bundle.drafts) {
|
|
if (entry && typeof entry[0] === 'string' && entry[1]?.script) {
|
|
const d = entry[1] as PipelineDraft
|
|
if (typeof d.localId !== 'string' || d.localId === '')
|
|
d.localId = editor.newDraftLocalId()
|
|
loaded.set(entry[0], d)
|
|
}
|
|
}
|
|
if (loaded.size > 0) editor.drafts = loaded
|
|
}
|
|
if (typeof bundle.activeDraftPath === 'string') editor.activeDraftPath = bundle.activeDraftPath
|
|
if (Array.isArray(bundle.triggerDrafts)) {
|
|
editor.triggerDrafts = new Map(
|
|
bundle.triggerDrafts
|
|
.filter(
|
|
(d) =>
|
|
isDraftableTriggerKind(d?.kind) &&
|
|
typeof d.config?.path === 'string' &&
|
|
typeof d.config?.script_path === 'string'
|
|
)
|
|
.map((d) => [triggerDraftKey(d.kind, d.config.path), d])
|
|
)
|
|
}
|
|
}
|
|
|
|
function readLocalBundle(): PipelineDraftBundle | undefined {
|
|
if (typeof localStorage === 'undefined') return undefined
|
|
const raw = localStorage.getItem(storageKey)
|
|
if (!raw) return undefined
|
|
try {
|
|
const s = decodeState(raw)
|
|
if (s && (Array.isArray(s.drafts) || typeof s.activeDraftPath === 'string')) {
|
|
return {
|
|
drafts: Array.isArray(s.drafts) ? s.drafts : [],
|
|
activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined,
|
|
triggerDrafts: Array.isArray(s.triggerDrafts) ? s.triggerDrafts : undefined
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('failed to read local pipeline state', e)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function hydrateDrafts() {
|
|
const ws = workspace
|
|
const path = pipelineDraftPath
|
|
try {
|
|
let bundle: PipelineDraftBundle | undefined
|
|
let serverSavedAt: string | undefined
|
|
let fromServer = false
|
|
if (ws) {
|
|
const row = await DraftService.getOwnDraft({
|
|
workspace: ws,
|
|
kind: PIPELINE_DRAFT_KIND,
|
|
path
|
|
})
|
|
if (row?.value) {
|
|
bundle = row.value as PipelineDraftBundle
|
|
serverSavedAt = row.created_at
|
|
fromServer = true
|
|
}
|
|
}
|
|
// A folder switch during the await (the route page's header switcher)
|
|
// changed the target and reset hydratedFromDb. Don't
|
|
// apply this now-stale folder's bundle or mark the new folder hydrated —
|
|
// otherwise the stale result blocks the new folder's hydrate and its
|
|
// drafts bleed across folders.
|
|
if (path !== pipelineDraftPath) return
|
|
let migratedFromLocal = false
|
|
if (!bundle) {
|
|
const local = readLocalBundle()
|
|
if (local) {
|
|
bundle = local
|
|
migratedFromLocal = true
|
|
}
|
|
}
|
|
if (fromServer) editor.loadedFromDbDraft = true
|
|
if (bundle) restoreBundle(bundle)
|
|
UserDraftDbSyncer.recordRemoteSync(
|
|
{ workspace: ws ?? '', itemKind: PIPELINE_DRAFT_KIND, path },
|
|
migratedFromLocal ? undefined : serverSavedAt
|
|
)
|
|
if (!migratedFromLocal) lastPersistedBundle = persistKey(bundle)
|
|
} catch (e) {
|
|
console.warn('failed to load pipeline drafts', e)
|
|
} finally {
|
|
if (path === pipelineDraftPath) editor.hydratedFromDb = true
|
|
}
|
|
}
|
|
|
|
// Hydrate when persistence is on and this editor instance hasn't been hydrated
|
|
// for the current folder yet. An effect (not onMount) so a folder retarget —
|
|
// which resets `hydratedFromDb` — re-hydrates the new folder's draft bundle.
|
|
$effect(() => {
|
|
if (!persistDrafts || editor.hydratedFromDb) return
|
|
void hydrateDrafts()
|
|
})
|
|
|
|
$effect(() => {
|
|
if (!persistDrafts) return
|
|
// For the active draft, snapshot the latest live body writes + editor buffer
|
|
// at serialize time so edits since the last pane-transition aren't lost on reload.
|
|
const liveWritesSnapshot =
|
|
editor.liveBodyAssets.scriptPath != undefined &&
|
|
editor.drafts.has(editor.liveBodyAssets.scriptPath)
|
|
? extractWrites(editor.liveBodyAssets.assets)
|
|
: undefined
|
|
const liveReadsSnapshot =
|
|
editor.liveBodyAssets.scriptPath != undefined &&
|
|
editor.drafts.has(editor.liveBodyAssets.scriptPath)
|
|
? extractReads(editor.liveBodyAssets.assets)
|
|
: undefined
|
|
const liveWritesPath = editor.liveBodyAssets.scriptPath
|
|
const liveContentPath =
|
|
editor.liveContent.scriptPath != undefined && editor.drafts.has(editor.liveContent.scriptPath)
|
|
? editor.liveContent.scriptPath
|
|
: undefined
|
|
const liveContentValue = editor.liveContent.content
|
|
const serialized = Array.from(editor.drafts.entries()).map(([p, d]) => {
|
|
const outputAssets =
|
|
liveWritesSnapshot != undefined && liveWritesPath === p
|
|
? liveWritesSnapshot.length > 0
|
|
? liveWritesSnapshot
|
|
: undefined
|
|
: d.outputAssets
|
|
const inputAssets =
|
|
liveReadsSnapshot != undefined && liveWritesPath === p ? liveReadsSnapshot : d.inputAssets
|
|
const script =
|
|
liveContentPath === p && d.script.content !== liveContentValue
|
|
? { ...d.script, content: liveContentValue }
|
|
: d.script
|
|
if (script === d.script && outputAssets === d.outputAssets && inputAssets === d.inputAssets)
|
|
return [p, d] as [string, PipelineDraft]
|
|
return [p, { ...d, script, outputAssets, inputAssets }] as [string, PipelineDraft]
|
|
})
|
|
// A deployed script being edited is only promoted to a draft when its pane
|
|
// closes; until then its buffer is saved as the draft it will become.
|
|
const { scriptPath: livePath, content: liveBody, base } = editor.liveContent
|
|
if (
|
|
livePath != undefined &&
|
|
base &&
|
|
!editor.drafts.has(livePath) &&
|
|
liveBody !== (base.content ?? '')
|
|
) {
|
|
const live = editor.liveBodyAssets.scriptPath === livePath
|
|
const writes = live ? extractWrites(editor.liveBodyAssets.assets) : []
|
|
serialized.push([
|
|
livePath,
|
|
{
|
|
localId: editor.promotedDraftLocalId(livePath),
|
|
script: { ...base, content: liveBody },
|
|
outputAssets: writes.length > 0 ? writes : undefined,
|
|
inputAssets: live ? extractReads(editor.liveBodyAssets.assets) : undefined
|
|
}
|
|
])
|
|
}
|
|
const activePath = editor.activeDraftPath
|
|
const triggerDrafts = [...editor.triggerDrafts.values()]
|
|
const key = storageKey
|
|
const ws = workspace
|
|
const path = pipelineDraftPath
|
|
const hydrated = editor.hydratedFromDb
|
|
untrack(() => {
|
|
if (!hydrated) return
|
|
const isEmpty = serialized.length === 0 && !activePath && triggerDrafts.length === 0
|
|
const bundle: PipelineDraftBundle | undefined = isEmpty
|
|
? undefined
|
|
: {
|
|
drafts: serialized,
|
|
activeDraftPath: activePath,
|
|
...(triggerDrafts.length > 0 ? { triggerDrafts } : {})
|
|
}
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
if (isEmpty) localStorage.removeItem(key)
|
|
else localStorage.setItem(key, encodeState(bundle))
|
|
}
|
|
} catch (e) {
|
|
console.warn('failed to mirror pipeline state', e)
|
|
}
|
|
const serializedBundle = persistKey(bundle)
|
|
if (serializedBundle === lastPersistedBundle) return
|
|
lastPersistedBundle = serializedBundle
|
|
if (!ws) return
|
|
void UserDraftDbSyncer.save({
|
|
workspace: ws,
|
|
itemKind: PIPELINE_DRAFT_KIND,
|
|
path,
|
|
value: bundle ?? null,
|
|
auto: true
|
|
})
|
|
})
|
|
})
|
|
</script>
|
|
|
|
{#snippet detailsBody(ws: string)}
|
|
{#if idleView && idlePane}
|
|
{@render idlePane()}
|
|
{:else}
|
|
<AssetGraphDetailsPane
|
|
{mode}
|
|
{onRequestEdit}
|
|
{canRunByPath}
|
|
{onRunByPath}
|
|
{onRunCascadeByPath}
|
|
{runFormInitialArgs}
|
|
{onRunFormArgsChange}
|
|
{inputsOnly}
|
|
{onRunInputs}
|
|
{onShowScript}
|
|
{resolveLocalScript}
|
|
{localScriptsVersion}
|
|
selection={activeDraft ? undefined : editor.selection}
|
|
selectionProducers={activeDraft ? [] : selectionProducers}
|
|
{producerScripts}
|
|
onEditScript={editProducerScript}
|
|
{producerTriggers}
|
|
{selectionColumnGraph}
|
|
{selectionColumnLoading}
|
|
{selectionColumnTruncated}
|
|
{selectionColumnFailed}
|
|
{selectionDbt}
|
|
{schemaCanEvolve}
|
|
{selectionForkMaterialization}
|
|
{schemaContractContext}
|
|
{runsRefreshKey}
|
|
{runsPendingJobId}
|
|
{activeRunnable}
|
|
{downstreamSubscribers}
|
|
onStartBoundedRun={canBoundedRunOpenScript &&
|
|
editor.openScriptPath &&
|
|
onStartBoundedRunForOpen
|
|
? () => onStartBoundedRunForOpen?.(editor.openScriptPath!)
|
|
: undefined}
|
|
{onRunCompleted}
|
|
{onTestStateChange}
|
|
{requestRemoveSignal}
|
|
{requestRunSignal}
|
|
{requestRunCascadeSignal}
|
|
{focusUploadSignal}
|
|
draftScript={activeDraft?.script}
|
|
draftOutputAssets={activeDraft?.outputAssets}
|
|
draftInputAssets={activeDraft?.inputAssets}
|
|
{onDraftPathChange}
|
|
onDraftMetaChange={editor.setDraftMeta}
|
|
{pathPrefix}
|
|
workspace={ws}
|
|
onAnnotationsChange={editor.handleAnnotationsChange}
|
|
onAssetsChange={editor.handleAssetsChange}
|
|
onContentChange={editor.handleContentChange}
|
|
onDraftPersist={editor.handleDraftPersist}
|
|
onclose={() => {
|
|
closePanelModal()
|
|
onClose()
|
|
}}
|
|
onHide={onTogglePanelHidden &&
|
|
(() => {
|
|
closePanelModal()
|
|
onTogglePanelHidden()
|
|
})}
|
|
{onDiscard}
|
|
{onDraftSaved}
|
|
{onPersistedSaved}
|
|
{onScriptRenamed}
|
|
{onScriptRemoved}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<div class="relative h-full w-full" bind:clientWidth={containerWidth}>
|
|
<Splitpanes class="!h-full" horizontal={stacked}>
|
|
<Pane bind:size={leftPaneSize}>
|
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
<div
|
|
class="relative h-full"
|
|
ondblclick={panelMode === 'modal' ? openPanelModalFromGraph : undefined}
|
|
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
|
|
onclick={panelMode === 'modal' ? openPanelModalIfReselected : undefined}
|
|
>
|
|
<AssetGraphCanvas
|
|
graph={displayGraph}
|
|
selection={effectiveSelection}
|
|
{hoveredPaths}
|
|
{selectedRunPaths}
|
|
{activeRunnable}
|
|
{activeRunnableIds}
|
|
{runStates}
|
|
{pathPrefix}
|
|
{defaultPathSuffix}
|
|
{onCreateMissingTrigger}
|
|
{onEditTrigger}
|
|
{onDeleteTrigger}
|
|
{onOpenWebhook}
|
|
{onOpenDataUpload}
|
|
{readyDataUploadPaths}
|
|
onselect={handleCanvasSelect}
|
|
{onAddScriptForAsset}
|
|
{onAddPipelineScript}
|
|
{onRunnableMenuRemove}
|
|
{onDeleteAssetUpstream}
|
|
{nodeFixes}
|
|
{assetDeleteVerb}
|
|
{onEdgeAction}
|
|
{onAddAssetTrigger}
|
|
{onRunProducer}
|
|
{validStartPaths}
|
|
{onStartBoundedRun}
|
|
{boundPick}
|
|
{onPickEnd}
|
|
{panToNodeId}
|
|
showMinimap={!stacked}
|
|
viewportFitKey={viewportFitKey ?? folder}
|
|
assetsOnlyToggle
|
|
assetsOnlyFolder={workspace ? `${workspace}/${folder}` : undefined}
|
|
/>
|
|
{#if boundBar}{@render boundBar()}{/if}
|
|
{#if mode === 'edit'}
|
|
<PipelineEventLog events={eventLogEvents} />
|
|
{/if}
|
|
{#if prefetchingAssets}
|
|
<div
|
|
class="absolute top-2 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/95 backdrop-blur-sm border border-gray-200 dark:border-gray-700 text-2xs text-secondary shadow-sm"
|
|
title="Inferring assets for every script in this folder so the graph is complete"
|
|
>
|
|
<Loader2 size={11} class="animate-spin" />
|
|
Parsing assets…
|
|
</div>
|
|
{/if}
|
|
{#if onTogglePanelHidden && (mode !== 'edit' || editor.selection != undefined || editor.activeDraftPath != undefined)}
|
|
<div
|
|
class="absolute top-2 right-2 z-50 rounded-md bg-surface shadow-sm border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<HideButton
|
|
hidden={panelHidden}
|
|
direction={stacked ? 'bottom' : 'right'}
|
|
on:click={onTogglePanelHidden}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div></Pane
|
|
>
|
|
{#if dockedPaneOpen && workspace}
|
|
<Pane bind:size={rightPaneSize} minSize={25}>
|
|
{@render detailsBody(workspace)}
|
|
</Pane>
|
|
{/if}
|
|
</Splitpanes>
|
|
{#if showNodeHint}
|
|
<!-- Above the event log's bar, which owns the canvas's bottom edge. -->
|
|
<div
|
|
class="pointer-events-none absolute bottom-10 left-3 z-30 flex items-center gap-1.5 text-xs text-hint"
|
|
>
|
|
<MousePointerClick size={13} />
|
|
{nodeHintText}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Portalled to the host's overlay anchor (a session preview tab) so the modal covers
|
|
that tab rather than the whole page; Disposable owns its place in the overlay stack
|
|
and its Escape. -->
|
|
<Disposable bind:open={panelModalOpen} bind:this={panelDisposable}>
|
|
{#snippet children({ zIndex })}
|
|
{#if panelMode === 'modal' && panelModalOpen && workspace}
|
|
<Portal target={modalHost ?? 'body'} class="contents">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="{modalHost ? 'absolute' : 'fixed'} inset-0 flex justify-center px-2 py-6"
|
|
style="z-index: {zIndex}"
|
|
role="dialog"
|
|
>
|
|
<div class="absolute inset-0 bg-black/20" onclick={() => (panelModalOpen = false)}></div>
|
|
<div
|
|
class="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-md border bg-surface shadow-xl"
|
|
>
|
|
<div class="min-h-0 flex-1 overflow-auto">
|
|
{@render detailsBody(workspace)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
{/if}
|
|
{/snippet}
|
|
</Disposable>
|